From e160c9fa6ea5ba199a6ec0e65aaea600777ccdc2 Mon Sep 17 00:00:00 2001 From: carloea2 Date: Fri, 28 Aug 2026 14:03:56 -0600 Subject: [PATCH] feat(frontend): add searchable property dropdowns --- .../app/common/formly/formly-config.spec.ts | 14 ++++ .../src/app/common/formly/formly-config.ts | 3 +- .../formly/searchable-select.type.spec.ts | 65 +++++++++++++++++++ .../common/formly/searchable-select.type.ts | 63 ++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/common/formly/searchable-select.type.spec.ts create mode 100644 frontend/src/app/common/formly/searchable-select.type.ts diff --git a/frontend/src/app/common/formly/formly-config.spec.ts b/frontend/src/app/common/formly/formly-config.spec.ts index 57e8932dcac..f37aaf2f423 100644 --- a/frontend/src/app/common/formly/formly-config.spec.ts +++ b/frontend/src/app/common/formly/formly-config.spec.ts @@ -29,7 +29,9 @@ import { minlengthValidationMessage, minValidationMessage, multipleOfValidationMessage, + TEXERA_FORMLY_CONFIG, } from "./formly-config"; +import { SearchableSelectTypeComponent } from "./searchable-select.type"; // the `err` argument is unused by every message builder, so any value is fine const err = {} as any; @@ -81,3 +83,15 @@ describe("formly validation messages", () => { expect(constValidationMessage(err, field({}))).toBe('should be equal to constant "undefined"'); }); }); + +describe("formly enum type", () => { + it("uses the searchable select component for schema-backed dropdowns", () => { + const enumType = TEXERA_FORMLY_CONFIG.types.find(type => type.name === "enum"); + + expect(enumType).toEqual({ + name: "enum", + extends: "select", + component: SearchableSelectTypeComponent, + }); + }); +}); diff --git a/frontend/src/app/common/formly/formly-config.ts b/frontend/src/app/common/formly/formly-config.ts index c4fc54fd77f..0104ce3ca3c 100644 --- a/frontend/src/app/common/formly/formly-config.ts +++ b/frontend/src/app/common/formly/formly-config.ts @@ -32,6 +32,7 @@ import { DatasetVersionSelectorComponent } from "../../workspace/component/datas import { HuggingFaceImageUploadComponent } from "../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component"; import { HuggingFaceComponent } from "../../workspace/component/hugging-face/hugging-face.component"; import { HuggingFaceAudioUploadComponent } from "../../workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component"; +import { SearchableSelectTypeComponent } from "./searchable-select.type"; /** * Configuration for using Json Schema with Formly. @@ -75,7 +76,7 @@ export const TEXERA_FORMLY_CONFIG = { }, }, { name: "boolean", extends: "checkbox" }, - { name: "enum", extends: "select" }, + { name: "enum", extends: "select", component: SearchableSelectTypeComponent }, { name: "null", component: NullTypeComponent, wrappers: ["form-field"] }, { name: "array", component: ArrayTypeComponent }, { name: "object", component: ObjectTypeComponent }, diff --git a/frontend/src/app/common/formly/searchable-select.type.spec.ts b/frontend/src/app/common/formly/searchable-select.type.spec.ts new file mode 100644 index 00000000000..88ebab6082b --- /dev/null +++ b/frontend/src/app/common/formly/searchable-select.type.spec.ts @@ -0,0 +1,65 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { FormlyModule } from "@ngx-formly/core"; +import { By } from "@angular/platform-browser"; +import { NzSelectComponent, NzSelectModule } from "ng-zorro-antd/select"; +import { SearchableSelectTypeComponent } from "./searchable-select.type"; + +describe("SearchableSelectTypeComponent", () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchableSelectTypeComponent, ReactiveFormsModule, FormlyModule.forRoot(), NzSelectModule], + }).compileComponents(); + }); + + function render(options: unknown[], multiple = false): NzSelectComponent { + fixture = TestBed.createComponent(SearchableSelectTypeComponent); + fixture.componentInstance.field = { + key: "attribute", + formControl: new FormControl(), + props: { options, multiple }, + options: { showError: () => false }, + }; + fixture.detectChanges(); + return fixture.debugElement.query(By.directive(NzSelectComponent)).componentInstance as NzSelectComponent; + } + + it("searches option labels case-insensitively", () => { + const select = render(["customer_id", "order_total"]); + + expect(select.nzShowSearch).toBe(true); + expect(select.nzFilterOption("TOTAL", { nzLabel: "order_total" } as any)).toBe(true); + expect(select.nzFilterOption("missing", { nzLabel: "order_total" } as any)).toBe(false); + }); + + it("preserves default and multiple selection modes", () => { + expect(render(["customer_id"]).nzMode).toBe("default"); + fixture.destroy(); + expect(render(["customer_id"], true).nzMode).toBe("multiple"); + }); + + it("supports an empty option list", () => { + expect(render([]).nzShowSearch).toBe(true); + }); +}); diff --git a/frontend/src/app/common/formly/searchable-select.type.ts b/frontend/src/app/common/formly/searchable-select.type.ts new file mode 100644 index 00000000000..3eb1538266a --- /dev/null +++ b/frontend/src/app/common/formly/searchable-select.type.ts @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { FieldType, FieldTypeConfig, FormlyModule } from "@ngx-formly/core"; +import { FormlyFieldSelectProps, FormlySelectModule } from "@ngx-formly/core/select"; +import { NzSelectModule } from "ng-zorro-antd/select"; + +interface SearchableSelectProps extends FormlyFieldSelectProps { + multiple?: boolean; +} + +@Component({ + template: ` + + + + + + + + + + + `, + imports: [CommonModule, ReactiveFormsModule, FormlyModule, FormlySelectModule, NzSelectModule], +}) +export class SearchableSelectTypeComponent extends FieldType> {}