diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html index b86673d567..13ce952790 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html @@ -4,6 +4,7 @@ [primaryActionBtnLabel]="config.primaryActionBtnLabel" [primaryActionBtnColor]="config.primaryActionBtnColor" [primaryActionBtnDisabled]="isPrimaryButtonDisabled$ | async" + contentMaxHeight="calc(100vh - 192px)" (primaryActionBtnClicked)="onPrimaryActionBtnClicked()" >
@@ -45,15 +46,6 @@ } @if (selectedListType && selectedListType !== LIST_TYPES.SEGMENT) { - Name @@ -61,10 +53,15 @@ {{ config.nameHint | translate }} - - Description (optional) - - + }
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts index c1fccbf556..0aef7fe3bd 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; -import { CommonModalComponent, CommonTagsInputComponent } from '@shared-component-lib'; +import { CommonListValuesInputComponent, CommonModalComponent } from '@shared-component-lib'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { CommonModule } from '@angular/common'; import { @@ -51,7 +51,6 @@ import { SEGMENT_TYPE } from '../../../../../../../../../../types/src'; import isEqual from 'lodash.isequal'; import { FeatureFlagsService } from '../../../../../core/feature-flags/feature-flags.service'; import { CommonModalConfig } from '@shared-component-lib/common-modal/common-modal.types'; -import { CommonTagInputType } from '../../../../../core/feature-flags/store/feature-flags.model'; import { SharedModule } from '../../../../../shared/shared.module'; @Component({ @@ -62,7 +61,7 @@ import { SharedModule } from '../../../../../shared/shared.module'; MatFormFieldModule, MatInputModule, MatAutocompleteModule, - CommonTagsInputComponent, + CommonListValuesInputComponent, CommonModule, ReactiveFormsModule, TranslateModule, @@ -73,6 +72,7 @@ import { SharedModule } from '../../../../../shared/shared.module'; }) export class UpsertPrivateSegmentListModalComponent { @ViewChild('typeSelectRef') typeSelectRef: MatSelect; + @ViewChild(CommonListValuesInputComponent) valuesInputComponent?: CommonListValuesInputComponent; listOptionTypes$: Observable<{ value: string; viewValue: string }[]>; // Disable the primary button while an add/edit is in flight in any of the three stores this // modal drives (flag/experiment/segment), to prevent double-submits. @@ -86,6 +86,7 @@ export class UpsertPrivateSegmentListModalComponent { // would send a full-replacement update that drops the unloaded members. Included in // isPrimaryButtonDisabled$ to block saving during the fetch. isLoadingMembers$ = new BehaviorSubject(false); + valuesPending$ = new BehaviorSubject(false); initialFormValues$ = new BehaviorSubject(null); subscriptions = new Subscription(); @@ -96,8 +97,6 @@ export class UpsertPrivateSegmentListModalComponent { isSegmentsListTypeDisabled$: Observable; privateSegmentListForm: FormGroup; - CommonTagInputType = CommonTagInputType; - forceValidation = false; constructor( @Inject(MAT_DIALOG_DATA) @@ -153,6 +152,17 @@ export class UpsertPrivateSegmentListModalComponent { return this.privateSegmentListForm?.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.VALUES); } + get loadingValuesCount(): number | null { + const sourceList = this.config.params.sourceList; + if (!sourceList?.segment) { + return null; + } + + return sourceList.listType?.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() + ? sourceList.segment.individualForSegmentCount ?? sourceList.segment.individualForSegment?.length ?? 0 + : sourceList.segment.groupForSegmentCount ?? sourceList.segment.groupForSegment?.length ?? 0; + } + private segmentObjectValidator(): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const value = control.value; @@ -295,10 +305,14 @@ export class UpsertPrivateSegmentListModalComponent { listenForPrimaryButtonDisabled() { this.isPrimaryButtonDisabled$ = this.isUpsertLoading$.pipe( - combineLatestWith(this.isInitialFormValueChanged$, this.isLoadingMembers$), + combineLatestWith(this.isInitialFormValueChanged$, this.isLoadingMembers$, this.valuesPending$), map( - ([isLoading, isInitialFormValueChanged, isLoadingMembers]) => - isLoading || isLoadingMembers || !isInitialFormValueChanged + ([isLoading, isInitialFormValueChanged, isLoadingMembers, valuesPending]) => + isLoading || + isLoadingMembers || + valuesPending || + this.privateSegmentListForm.invalid || + !isInitialFormValueChanged ) ); this.subscriptions.add(this.isPrimaryButtonDisabled$.subscribe()); @@ -329,6 +343,7 @@ export class UpsertPrivateSegmentListModalComponent { listenToListTypeChanges(): void { this.subscriptions.add( this.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.LIST_TYPE).valueChanges.subscribe((listType) => { + this.valuesPending$.next(false); this.resetFormExceptSelectedListType(listType); this.setValidatorsBasedOnListType(listType); }) @@ -377,7 +392,9 @@ export class UpsertPrivateSegmentListModalComponent { } onPrimaryActionBtnClicked(): void { - this.forceValidation = true; + if (this.valuesInputComponent && !this.valuesInputComponent.commitPendingChanges()) { + return; + } if (this.privateSegmentListForm.valid) { this.sendRequest(this.config.params.action); } else { @@ -386,6 +403,10 @@ export class UpsertPrivateSegmentListModalComponent { } } + onValuesPendingStateChanged(valuesPending: boolean): void { + this.valuesPending$.next(valuesPending); + } + sendRequest(action: UPSERT_PRIVATE_SEGMENT_LIST_ACTION): void { const formData = this.privateSegmentListForm.value; const listType = formData.listType; diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.html new file mode 100644 index 0000000000..4fd1ef26be --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.html @@ -0,0 +1,185 @@ +
+ @if (!showImportHelper) { + + + {{ label | translate }} + * + + + @if (hasPendingValue) { + + } @else { + + } + +

+ {{ 'lists.values.separator-hint.text' | translate }} +

+ } @else { +
+ +

+ {{ 'feature-flags.upsert-list-modal.import-csv.message.text' | translate }} + +

+
+ } @if (feedbackMessage) { + + } @if (editErrorMessage) { + + } + + + + search + + +
+ @if (loading) { + + } + + + + + + + + + + + + + + + + +
+ {{ 'lists.values.value-header.text' | translate }} ({{ displayedValueCount | number }}) + + @if (editingRowId === row.id) { + + + + } @else { + {{ row.value }} + } + +
+ {{ 'lists.values.actions-header.text' | translate }} + +
+
+ @if (editingRowId === row.id) { + + + } @else { + + + } +
+ @if (loading) { + {{ 'lists.values.loading.text' | translate }} + } @else { + {{ (rows.length ? 'lists.values.no-search-results.text' : 'lists.values.no-values.text') | translate }} + } +
+
+
diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.scss b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.scss new file mode 100644 index 0000000000..f1a64bd5f3 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.scss @@ -0,0 +1,191 @@ +.values-section { + display: flex; + flex-direction: column; + row-gap: 8px; + + .value-input { + width: 100%; + + .required-asterisk { + margin-left: -3px; + } + + ::ng-deep .mat-mdc-text-field-wrapper { + padding-right: 5px; + + .mat-mdc-form-field-infix { + position: relative; + padding-right: 40px; + } + } + } + + .field-action-button, + .export-button { + width: 32px; + height: 32px; + padding: 4px; + background: none; + border: none; + border-radius: 20px; + box-shadow: none; + outline: none; + display: inline-flex; + align-items: center; + justify-content: center; + + .material-symbols-outlined { + font-size: 20px; + color: var(--dark-grey); + } + + &:hover:not(:disabled) { + background-color: #f5f5f5; + } + + &:active:not(:disabled) { + background-color: #dfdfdf; + } + } + + .field-action-button { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + } + + .separator-hint, + .import-hint { + margin: -2px 0 0 16px; + } + + .feedback-message { + margin: 0 0 0 16px; + color: var(--red); + } + + .drag-drop-container { + display: flex; + flex-direction: column; + row-gap: 2px; + } + + .search-input { + width: 100%; + + .search-icon { + color: var(--dark-grey); + } + } + + .values-table-container { + position: relative; + width: 100%; + max-height: 272px; + overflow: auto; + overscroll-behavior-y: none; + + .loading-bar { + position: sticky; + top: 0; + z-index: 1111; + } + + /* Match the existing Material tables by adding a gap before an empty table row. */ + ::ng-deep .no-data tbody:before { + display: block; + line-height: 8px; + content: '\200C'; + } + + .values-table { + width: 100%; + + ::ng-deep thead { + tr.mat-mdc-header-row { + height: 48px; + border: 0; + + th { + padding-left: 0; + background-color: var(--zircon); + color: var(--darker-grey); + + &:first-child { + border-top-left-radius: 4px; + } + + &:last-child { + border-top-right-radius: 4px; + } + } + } + } + + ::ng-deep tbody { + tr.mat-mdc-row { + height: 56px; + + td { + min-width: 96px; + padding-left: 0; + color: var(--black-2); + } + } + + tr.mat-mdc-no-data-row { + height: 48px; + + td { + text-align: center; + border: 1.5px dashed var(--light-grey-2); + color: var(--dark-grey); + } + } + } + + .value-column { + width: 90%; + padding-left: 32px; + + .value-text { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + user-select: text; + } + + .edit-value-input { + width: 100%; + } + } + + .actions-column { + width: 10%; + min-width: 96px; + padding-right: 16px; + text-align: center; + + .actions-header { + display: flex; + align-items: center; + justify-content: flex-end; + column-gap: 4px; + } + + .table-action-button { + color: var(--dark-grey); + + &[disabled] { + .mat-icon, + .material-symbols-outlined { + opacity: 0.5; + } + } + } + } + } + } +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.spec.ts new file mode 100644 index 0000000000..f62337f692 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.spec.ts @@ -0,0 +1,382 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { CommonListValuesInputComponent } from './common-list-values-input.component'; + +describe('CommonListValuesInputComponent', () => { + let component: CommonListValuesInputComponent; + let fixture: ComponentFixture; + let onChange: jest.Mock; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [CommonListValuesInputComponent, NoopAnimationsModule, TranslateModule.forRoot()], + }); + await TestBed.compileComponents(); + + fixture = TestBed.createComponent(CommonListValuesInputComponent); + component = fixture.componentInstance; + onChange = jest.fn(); + component.registerOnChange(onChange); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + global: { + search: { text: 'Search' }, + }, + lists: { + values: { + 'duplicates-not-added': { text: '{{count}} duplicate value(s) were not added.' }, + 'edit-empty-error': { text: 'Value cannot be empty.' }, + 'edit-duplicate-error': { text: 'This value already exists in the list.' }, + 'separator-hint': { text: 'Click Add (+) or press Enter to add values.' }, + 'value-header': { text: 'Value' }, + }, + }, + }); + translate.use('en'); + fixture.detectChanges(); + }); + + it('keeps the import action available when committed values exist and pending input is empty', () => { + component.writeValue(['existing']); + fixture.detectChanges(); + + expect(component.hasPendingValue).toBe(false); + expect( + fixture.nativeElement.querySelector('.field-action-button .material-symbols-outlined').textContent.trim() + ).toBe('upload'); + }); + + it('shows the add action while pending input contains text', () => { + component.pendingValueControl.setValue('new-value'); + fixture.detectChanges(); + + expect(component.hasPendingValue).toBe(true); + expect( + fixture.nativeElement.querySelector('.field-action-button .material-symbols-outlined').textContent.trim() + ).toBe('add_circle'); + }); + + it('uses the existing search field pattern', () => { + const searchField = fixture.nativeElement.querySelector('mat-form-field.search-input'); + const searchInput = searchField.querySelector('input[matinput]'); + const searchIcon = searchField.querySelector('mat-icon.search-icon'); + + expect(searchField.getAttribute('appearance')).toBeNull(); + expect(searchInput.getAttribute('placeholder')).toBe('Search'); + expect(searchIcon.textContent.trim()).toBe('search'); + }); + + it('moves the total value count into the table header', () => { + fixture.componentRef.setInput('label', 'Values'); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.values-heading')).toBeNull(); + expect(fixture.nativeElement.querySelector('th.value-column').textContent.replace(/\s+/g, ' ').trim()).toBe( + 'Value (0)' + ); + }); + + it('uses the existing required mat-label and configured placeholder patterns', () => { + fixture.componentRef.setInput('label', 'Values'); + fixture.componentRef.setInput('placeholder', 'Values separated by commas'); + fixture.detectChanges(); + + const valueField = fixture.nativeElement.querySelector('.value-input'); + const inputLabel = valueField.querySelector('mat-label'); + const valueInput = valueField.querySelector('input[matinput]'); + + expect(inputLabel.textContent.replace(/\s+/g, ' ').trim()).toBe('Values *'); + expect(valueInput.getAttribute('placeholder')).toBe('Values separated by commas'); + }); + + it('does not show a required error after the empty input is focused and blurred', () => { + const valueInput = fixture.nativeElement.querySelector('.value-input input[matinput]'); + + valueInput.dispatchEvent(new Event('focus')); + valueInput.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain('At least one value is required'); + expect(fixture.nativeElement.querySelector('.invalid-section')).toBeNull(); + }); + + it('places the import action in the form-field infix like the existing Values input', () => { + const valueField = fixture.nativeElement.querySelector('.value-input'); + + expect(valueField.querySelector('.mat-mdc-form-field-infix > .field-action-button')).not.toBeNull(); + expect(valueField.querySelector('.mat-mdc-form-field-icon-suffix .field-action-button')).toBeNull(); + }); + + it('uses the existing download symbol in the Actions header', () => { + component.writeValue(['existing']); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.export-button .material-symbols-outlined').textContent.trim()).toBe( + 'download' + ); + }); + + it('renders an empty Material table with its standard no-data row', () => { + component.writeValue([]); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('table[mat-table].values-table')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('tr.mat-mdc-no-data-row')).not.toBeNull(); + }); + + it('adds multiple pending values in order and clears the input', () => { + component.writeValue(['existing']); + component.pendingValueControl.setValue('first,second\tthird'); + + component.commitPendingValues(); + + expect(onChange).toHaveBeenLastCalledWith(['existing', 'first', 'second', 'third']); + expect(component.pendingValueControl.value).toBe(''); + }); + + it('keeps comma-separated input pending until the Add action is selected', () => { + component.pendingValueControl.setValue('first,second'); + const commaEvent = { + key: ',', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown as KeyboardEvent; + + component.onPendingValueKeydown(commaEvent); + + expect(commaEvent.preventDefault).not.toHaveBeenCalled(); + expect(component.pendingValueControl.value).toBe('first,second'); + expect(component.rows).toEqual([]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('adds pending input when Enter is pressed', () => { + component.pendingValueControl.setValue('pending-value'); + const enterEvent = { + key: 'Enter', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown as KeyboardEvent; + + component.onPendingValueKeydown(enterEvent); + + expect(enterEvent.preventDefault).toHaveBeenCalled(); + expect(enterEvent.stopPropagation).toHaveBeenCalled(); + expect(component.pendingValueControl.value).toBe(''); + expect(component.rows.map((row) => row.value)).toEqual(['pending-value']); + expect(onChange).toHaveBeenLastCalledWith(['pending-value']); + }); + + it('does not intercept Tab so keyboard focus can move to the Add action', () => { + component.pendingValueControl.setValue('pending-value'); + const tabEvent = { + key: 'Tab', + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown as KeyboardEvent; + + component.onPendingValueKeydown(tabEvent); + + expect(tabEvent.preventDefault).not.toHaveBeenCalled(); + expect(tabEvent.stopPropagation).not.toHaveBeenCalled(); + expect(component.pendingValueControl.value).toBe('pending-value'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps pasted multi-value input pending for the Add action', () => { + const pasteEvent = { + clipboardData: { getData: () => 'first\nsecond\tthird' }, + preventDefault: jest.fn(), + target: { selectionStart: 0, selectionEnd: 0 }, + } as unknown as ClipboardEvent; + + component.onPendingValuePaste(pasteEvent); + + expect(pasteEvent.preventDefault).toHaveBeenCalled(); + expect(component.pendingValueControl.value).toBe('first,second,third'); + expect(component.rows).toEqual([]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('explains that the Add action commits pending values', () => { + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.separator-hint').textContent.trim()).toBe( + 'Click Add (+) or press Enter to add values.' + ); + }); + + it('reports pending text until the value is explicitly added', () => { + const pendingStateChanged = jest.spyOn(component.pendingStateChanged, 'emit'); + component.pendingValueControl.setValue('new-value'); + + expect(component.hasPendingChanges).toBe(true); + expect(pendingStateChanged).toHaveBeenLastCalledWith(true); + + component.commitPendingValues(); + + expect(component.hasPendingChanges).toBe(false); + expect(pendingStateChanged).toHaveBeenLastCalledWith(false); + }); + + it('reports duplicate values instead of adding them', () => { + component.writeValue(['existing']); + component.pendingValueControl.setValue('new,existing,new'); + + component.commitPendingValues(); + + expect(onChange).toHaveBeenLastCalledWith(['existing', 'new']); + expect(component.feedbackMessage).toContain('2'); + }); + + it('filters visible rows without changing the form value', () => { + component.writeValue(['Alpha', 'Beta', 'alphabet']); + onChange.mockClear(); + + component.searchControl.setValue('alpha'); + + expect(component.filteredRows.map((row) => row.value)).toEqual(['Alpha', 'alphabet']); + expect(onChange).not.toHaveBeenCalled(); + expect(component.displayedValueCount).toBe(3); + }); + + it('prevents an inline edit from creating a duplicate', () => { + component.writeValue(['first', 'second']); + const secondRow = component.rows[1]; + component.startEdit(secondRow); + component.editValueControl.setValue('first'); + + component.saveEdit(secondRow); + + expect(component.editingRowId).toBe(secondRow.id); + expect(component.editErrorMessage).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('reports an inline edit as pending until it is saved or cancelled', () => { + const pendingStateChanged = jest.spyOn(component.pendingStateChanged, 'emit'); + component.writeValue(['first']); + + component.startEdit(component.rows[0]); + expect(pendingStateChanged).toHaveBeenLastCalledWith(true); + + component.cancelEdit(); + expect(pendingStateChanged).toHaveBeenLastCalledWith(false); + }); + + it('edits and deletes values while preserving the remaining order', () => { + component.writeValue(['first', 'second', 'third']); + const secondRow = component.rows[1]; + component.startEdit(secondRow); + component.editValueControl.setValue('updated'); + component.saveEdit(secondRow); + component.deleteValue(component.rows[0]); + + expect(onChange).toHaveBeenLastCalledWith(['updated', 'third']); + }); + + it('opens and closes the import helper without changing values', () => { + const pendingStateChanged = jest.spyOn(component.pendingStateChanged, 'emit'); + component.writeValue(['existing']); + onChange.mockClear(); + + component.openImportHelper(new MouseEvent('click')); + expect(component.showImportHelper).toBe(true); + expect(pendingStateChanged).toHaveBeenLastCalledWith(true); + + component.closeImportHelper(new MouseEvent('click')); + expect(component.showImportHelper).toBe(false); + expect(pendingStateChanged).toHaveBeenLastCalledWith(false); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('loads valid CSV values into the pending input without changing the table', async () => { + component.writeValue(['existing']); + onChange.mockClear(); + component.openImportHelper(new MouseEvent('click')); + + await component.handleFilesSelected([new File(['new\nexisting\nanother'], 'values.csv', { type: 'text/csv' })]); + fixture.detectChanges(); + + expect(component.showImportHelper).toBe(false); + expect(component.pendingValueControl.value).toBe('new, existing, another'); + expect(component.rows.map((row) => row.value)).toEqual(['existing']); + expect(onChange).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('.import-review-container')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.field-action-button .material-symbols-outlined').textContent.trim() + ).toBe('add_circle'); + }); + + it('keeps imported values pending until Add or Enter explicitly commits them', async () => { + const pendingStateChanged = jest.spyOn(component.pendingStateChanged, 'emit'); + component.writeValue(['existing']); + onChange.mockClear(); + + await component.handleFilesSelected([new File(['imported'], 'values.csv', { type: 'text/csv' })]); + expect(pendingStateChanged).toHaveBeenLastCalledWith(true); + expect(onChange).not.toHaveBeenCalled(); + + component.commitPendingValues(); + + expect(onChange).toHaveBeenLastCalledWith(['existing', 'imported']); + expect(pendingStateChanged).toHaveBeenLastCalledWith(false); + }); + + it('keeps valid CSV files in file and row order until they are explicitly added', async () => { + const firstFile = new File(['first\nsecond'], 'first.csv', { type: 'text/csv' }); + const secondFile = new File(['third'], 'second.csv', { type: 'text/csv' }); + + await component.handleFilesSelected([firstFile, secondFile]); + + expect(component.pendingValueControl.value).toBe('first, second, third'); + expect(component.showImportHelper).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + + component.commitPendingValues(); + + expect(onChange).toHaveBeenLastCalledWith(['first', 'second', 'third']); + }); + + it('keeps the import helper open when CSV parsing fails', async () => { + component.openImportHelper(new MouseEvent('click')); + const invalidFile = new File(['first,second'], 'invalid.csv', { type: 'text/csv' }); + + await component.handleFilesSelected([invalidFile]); + + expect(component.importFailed).toBe(true); + expect(component.showImportHelper).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('exports the full working list rather than filtered rows', () => { + const downloadRequested = jest.spyOn(component.downloadRequested, 'emit'); + component.writeValue(['first', 'second']); + component.searchControl.setValue('first'); + + component.exportValues(); + + expect(downloadRequested).toHaveBeenCalledWith(['first', 'second']); + }); + + it('does not silently commit pending input or an inline edit when the parent form saves', () => { + component.writeValue(['first']); + component.pendingValueControl.setValue('second'); + component.startEdit(component.rows[0]); + component.editValueControl.setValue('updated'); + + expect(component.commitPendingChanges()).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + expect(component.rows.map((row) => row.value)).toEqual(['first']); + }); + + it('prevents the parent form from saving until imported input is explicitly added', async () => { + component.writeValue(['existing']); + await component.handleFilesSelected([new File(['imported'], 'values.csv', { type: 'text/csv' })]); + + expect(component.commitPendingChanges()).toBe(false); + expect(component.feedbackMessage).toBeTruthy(); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.ts new file mode 100644 index 0000000000..46984f5d1f --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.component.ts @@ -0,0 +1,336 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + forwardRef, + Input, + OnDestroy, + Output, +} from '@angular/core'; +import { FormControl, NG_VALUE_ACCESSOR, ReactiveFormsModule, ControlValueAccessor } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs'; +import { CommonImportContainerComponent } from '../common-import-container/common-import-container.component'; +import { CommonLearnMoreLinkComponent } from '../common-learn-more-link/common-learn-more-link.component'; +import { mergeUniqueListValues, parseSingleColumnCSV, splitListValues } from './common-list-values-input.helpers'; + +interface ListValueRow { + id: number; + value: string; +} + +@Component({ + selector: 'app-common-list-values-input', + templateUrl: './common-list-values-input.component.html', + styleUrl: './common-list-values-input.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CommonListValuesInputComponent), + multi: true, + }, + ], + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressBarModule, + MatTableModule, + MatTooltipModule, + TranslateModule, + CommonImportContainerComponent, + CommonLearnMoreLinkComponent, + ], +}) +export class CommonListValuesInputComponent implements ControlValueAccessor, OnDestroy { + @Input() label = ''; + @Input() placeholder = ''; + @Input() loading = false; + @Input() loadingCount: number | null = null; + @Output() downloadRequested = new EventEmitter(); + @Output() pendingStateChanged = new EventEmitter(); + + readonly displayedColumns = ['value', 'actions']; + + pendingValueControl = new FormControl('', { nonNullable: true }); + searchControl = new FormControl('', { nonNullable: true }); + editValueControl = new FormControl('', { nonNullable: true }); + + rows: ListValueRow[] = []; + filteredRows: ListValueRow[] = []; + editingRowId: number | null = null; + showImportHelper = false; + importFailed = false; + feedbackMessage = ''; + editErrorMessage = ''; + isDisabled = false; + + private nextRowId = 0; + private lastPendingState = false; + private subscriptions = new Subscription(); + private onChange: (values: string[]) => void = () => undefined; + private onTouched: () => void = () => undefined; + + constructor(private translate: TranslateService, private changeDetectorRef: ChangeDetectorRef) { + this.subscriptions.add(this.pendingValueControl.valueChanges.subscribe(() => this.emitPendingState())); + this.subscriptions.add(this.searchControl.valueChanges.subscribe(() => this.updateFilteredRows())); + } + + get hasPendingValue(): boolean { + return this.pendingValueControl.value.trim().length > 0; + } + + get displayedValueCount(): number { + return this.loading && this.loadingCount !== null ? this.loadingCount : this.rows.length; + } + + get hasPendingChanges(): boolean { + return this.hasPendingValue || this.showImportHelper || this.editingRowId !== null; + } + + writeValue(values: string[] | null): void { + this.pendingValueControl.setValue('', { emitEvent: false }); + this.showImportHelper = false; + this.importFailed = false; + this.feedbackMessage = ''; + this.rows = (values ?? []).map((value) => this.createRow(value)); + this.cancelEdit(); + this.emitPendingState(); + this.updateFilteredRows(); + } + + registerOnChange(fn: (values: string[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.isDisabled = isDisabled; + this.changeDetectorRef.markForCheck(); + } + + openImportHelper(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.feedbackMessage = ''; + this.importFailed = false; + this.showImportHelper = true; + this.emitPendingState(); + } + + closeImportHelper(event?: MouseEvent): void { + event?.preventDefault(); + this.showImportHelper = false; + this.importFailed = false; + this.emitPendingState(); + this.changeDetectorRef.markForCheck(); + } + + onPendingValueKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.commitPendingValues(); + } + + onPendingValuePaste(event: ClipboardEvent): void { + const pastedValue = event.clipboardData?.getData('text') ?? ''; + if (!/[,\t\r\n]/.test(pastedValue)) { + return; + } + + event.preventDefault(); + const input = event.target as HTMLInputElement | null; + const currentValue = this.pendingValueControl.value; + const selectionStart = input?.selectionStart ?? currentValue.length; + const selectionEnd = input?.selectionEnd ?? selectionStart; + const normalizedPastedValue = pastedValue.replace(/\r\n?/g, '\n').replace(/[\t\n]+/g, ','); + const nextValue = `${currentValue.slice(0, selectionStart)}${normalizedPastedValue}${currentValue.slice( + selectionEnd + )}`; + + this.pendingValueControl.setValue(nextValue); + } + + commitPendingValues(): void { + this.addRawValues(this.pendingValueControl.value); + this.pendingValueControl.setValue(''); + } + + commitPendingChanges(): boolean { + if (!this.hasPendingChanges) { + return true; + } + + this.feedbackMessage = this.translate.instant('lists.values.pending-action-required.text'); + this.changeDetectorRef.markForCheck(); + return false; + } + + startEdit(row: ListValueRow): void { + this.editingRowId = row.id; + this.editValueControl.setValue(row.value); + this.editErrorMessage = ''; + this.markAsTouched(); + this.emitPendingState(); + this.changeDetectorRef.markForCheck(); + } + + saveEdit(row: ListValueRow): void { + const nextValue = this.editValueControl.value.trim(); + if (!nextValue) { + this.editErrorMessage = this.translate.instant('lists.values.edit-empty-error.text'); + this.changeDetectorRef.markForCheck(); + return; + } + + if (this.rows.some((currentRow) => currentRow.id !== row.id && currentRow.value === nextValue)) { + this.editErrorMessage = this.translate.instant('lists.values.edit-duplicate-error.text'); + this.changeDetectorRef.markForCheck(); + return; + } + + this.rows = this.rows.map((currentRow) => + currentRow.id === row.id ? { ...currentRow, value: nextValue } : currentRow + ); + this.editingRowId = null; + this.editErrorMessage = ''; + this.feedbackMessage = ''; + this.emitPendingState(); + this.emitValues(); + } + + cancelEdit(): void { + this.editingRowId = null; + this.editValueControl.setValue(''); + this.editErrorMessage = ''; + this.emitPendingState(); + this.changeDetectorRef.markForCheck(); + } + + deleteValue(row: ListValueRow): void { + this.rows = this.rows.filter((currentRow) => currentRow.id !== row.id); + if (this.editingRowId === row.id) { + this.cancelEdit(); + } + this.feedbackMessage = ''; + this.markAsTouched(); + this.emitValues(); + } + + exportValues(): void { + this.downloadRequested.emit(this.getValues()); + } + + refreshSearch(): void { + this.updateFilteredRows(); + } + + async handleFilesSelected(files: File[]): Promise { + try { + const fileContents = await Promise.all(files.map((file) => this.readFile(file))); + const importedValues = fileContents.flatMap((content) => parseSingleColumnCSV(content)); + + this.pendingValueControl.setValue(importedValues.join(', ')); + this.showImportHelper = false; + this.importFailed = false; + this.feedbackMessage = ''; + this.emitPendingState(); + this.changeDetectorRef.markForCheck(); + } catch { + this.importFailed = true; + this.emitPendingState(); + this.changeDetectorRef.markForCheck(); + } + } + + trackByRowId(_index: number, row: ListValueRow): number { + return row.id; + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + private addRawValues(rawValue: string): void { + const incomingValues = splitListValues(rawValue); + if (!incomingValues.length) { + return; + } + + const result = mergeUniqueListValues(this.getValues(), incomingValues); + result.addedValues.forEach((value) => this.rows.push(this.createRow(value))); + this.setDuplicateFeedback(result.duplicateValues.length); + this.markAsTouched(); + this.emitValues(); + } + + private setDuplicateFeedback(duplicateCount: number): void { + this.feedbackMessage = duplicateCount + ? this.translate.instant('lists.values.duplicates-not-added.text', { count: duplicateCount }) + : ''; + } + + private emitValues(): void { + this.onChange(this.getValues()); + this.updateFilteredRows(); + } + + private emitPendingState(): void { + const hasPendingChanges = this.hasPendingChanges; + if (hasPendingChanges === this.lastPendingState) { + return; + } + + this.lastPendingState = hasPendingChanges; + this.pendingStateChanged.emit(hasPendingChanges); + } + + private updateFilteredRows(): void { + const searchValue = this.searchControl.value.trim().toLocaleLowerCase(); + this.filteredRows = searchValue + ? this.rows.filter((row) => row.value.toLocaleLowerCase().includes(searchValue)) + : [...this.rows]; + this.changeDetectorRef.markForCheck(); + } + + private getValues(): string[] { + return this.rows.map((row) => row.value); + } + + private createRow(value: string): ListValueRow { + return { id: this.nextRowId++, value }; + } + + markAsTouched(): void { + this.onTouched(); + } + + private readFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.form-integration.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.form-integration.spec.ts new file mode 100644 index 0000000000..e7b1d3467c --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.form-integration.spec.ts @@ -0,0 +1,88 @@ +import { FormBuilder } from '@angular/forms'; +import { BehaviorSubject } from 'rxjs'; + +jest.mock( + '@shared-component-lib', + () => ({ + CommonListValuesInputComponent: class {}, + CommonModalComponent: class {}, + }), + { virtual: true } +); +jest.mock('@shared-component-lib/common-modal/common-modal.types', () => ({}), { virtual: true }); + +import { + LIST_OPTION_TYPE, + PRIVATE_SEGMENT_LIST_FORM_FIELDS, + UPSERT_PRIVATE_SEGMENT_LIST_ACTION, +} from '../../../core/segments/store/segments.model'; +import { UpsertPrivateSegmentListModalComponent } from '../../../features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component'; + +describe('CommonListValuesInputComponent form integration', () => { + let component: UpsertPrivateSegmentListModalComponent; + + beforeEach(() => { + component = new UpsertPrivateSegmentListModalComponent( + { + title: 'Add Include List', + params: { + sourceList: null, + sourceAppContext: 'test-context', + action: UPSERT_PRIVATE_SEGMENT_LIST_ACTION.ADD_FLAG_INCLUDE_LIST, + id: 'test-id', + }, + }, + {} as never, + new FormBuilder(), + { isLoadingSegments$: new BehaviorSubject(false) } as never, + { isLoadingUpsertPrivateSegmentList$: new BehaviorSubject(false) } as never, + { isLoadingUpsertPrivateSegmentList$: new BehaviorSubject(false) } as never, + {} as never, + { markForCheck: jest.fn() } as never, + {} as never + ); + + component.createPrivateSegmentListForm(); + component.listenForIsInitialFormValueChanged(); + component.listenForPrimaryButtonDisabled(); + }); + + afterEach(() => component.subscriptions.unsubscribe()); + + it('enables Create only after every required direct-value field is valid', () => { + let disabled: boolean; + const subscription = component.isPrimaryButtonDisabled$.subscribe((value) => (disabled = value)); + + expect(disabled).toBe(true); + component.privateSegmentListForm + .get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.LIST_TYPE) + .setValue(LIST_OPTION_TYPE.INDIVIDUAL); + component.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.NAME).setValue('My list'); + expect(disabled).toBe(true); + + component.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.VALUES).setValue(['user-1']); + expect(disabled).toBe(false); + + component.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.VALUES).setValue([]); + expect(disabled).toBe(true); + subscription.unsubscribe(); + }); + + it('disables Create while a value addition, import, or edit is pending', () => { + let disabled: boolean; + const subscription = component.isPrimaryButtonDisabled$.subscribe((value) => (disabled = value)); + component.privateSegmentListForm + .get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.LIST_TYPE) + .setValue(LIST_OPTION_TYPE.INDIVIDUAL); + component.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.NAME).setValue('My list'); + component.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.VALUES).setValue(['user-1']); + expect(disabled).toBe(false); + + component.onValuesPendingStateChanged(true); + expect(disabled).toBe(true); + + component.onValuesPendingStateChanged(false); + expect(disabled).toBe(false); + subscription.unsubscribe(); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.helpers.spec.ts new file mode 100644 index 0000000000..1013f2fe7d --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.helpers.spec.ts @@ -0,0 +1,47 @@ +import { mergeUniqueListValues, parseSingleColumnCSV, splitListValues } from './common-list-values-input.helpers'; + +describe('common-list-values-input helpers', () => { + describe('splitListValues', () => { + it('splits comma, tab, and newline separated values while preserving order', () => { + expect(splitListValues(' first,second\tthird\r\nfourth\n fifth ')).toEqual([ + 'first', + 'second', + 'third', + 'fourth', + 'fifth', + ]); + }); + + it('removes empty values', () => { + expect(splitListValues('first, ,\nsecond')).toEqual(['first', 'second']); + }); + }); + + describe('mergeUniqueListValues', () => { + it('appends unique values and reports duplicates from the list and incoming batch', () => { + expect(mergeUniqueListValues(['first'], ['second', 'first', 'second', 'third'])).toEqual({ + values: ['first', 'second', 'third'], + addedValues: ['second', 'third'], + duplicateValues: ['first', 'second'], + }); + }); + + it('uses exact case-sensitive duplicate comparison', () => { + expect(mergeUniqueListValues(['Value'], ['value']).values).toEqual(['Value', 'value']); + }); + }); + + describe('parseSingleColumnCSV', () => { + it('parses one value per line', () => { + expect(parseSingleColumnCSV('first\r\nsecond\nthird')).toEqual(['first', 'second', 'third']); + }); + + it('rejects empty files', () => { + expect(() => parseSingleColumnCSV(' \n ')).toThrow('CSV file is empty'); + }); + + it('rejects files containing multiple columns', () => { + expect(() => parseSingleColumnCSV('first,second')).toThrow('CSV should contain only one column'); + }); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.helpers.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.helpers.ts new file mode 100644 index 0000000000..f167e67578 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-list-values-input/common-list-values-input.helpers.ts @@ -0,0 +1,58 @@ +export interface MergeListValuesResult { + values: string[]; + addedValues: string[]; + duplicateValues: string[]; +} + +const VALUE_SEPARATORS = /[,\t\r\n]+/; + +export function splitListValues(rawValue: string): string[] { + return rawValue + .split(VALUE_SEPARATORS) + .map((value) => value.trim()) + .filter(Boolean); +} + +export function mergeUniqueListValues(existingValues: string[], incomingValues: string[]): MergeListValuesResult { + const seenValues = new Set(existingValues); + const addedValues: string[] = []; + const duplicateValues: string[] = []; + + incomingValues.forEach((value) => { + const normalizedValue = value.trim(); + if (!normalizedValue) { + return; + } + + if (seenValues.has(normalizedValue)) { + duplicateValues.push(normalizedValue); + return; + } + + seenValues.add(normalizedValue); + addedValues.push(normalizedValue); + }); + + return { + values: [...existingValues, ...addedValues], + addedValues, + duplicateValues, + }; +} + +export function parseSingleColumnCSV(content: string): string[] { + const values = content + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + + if (!values.length) { + throw new Error('CSV file is empty'); + } + + if (values.some((value) => value.includes(','))) { + throw new Error('CSV should contain only one column'); + } + + return values; +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html index d95976d75b..19f702d489 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html @@ -7,7 +7,7 @@

{{ title | translate }}

-
+
diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts index c50fc0d8ca..381d859d0c 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts @@ -31,6 +31,7 @@ export class CommonModalComponent { @Input() primaryActionBtnColor = 'primary'; @Input() hideFooter = false; @Input() primaryActionBtnDisabled = false; + @Input() contentMaxHeight = '480px'; @Output() primaryActionBtnClicked = new EventEmitter(); onPrimaryActionBtnClicked() { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/index.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/index.ts index d7b00606d3..15f6f13f2b 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/index.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/index.ts @@ -10,6 +10,7 @@ import { CommonStatusIndicatorChipComponent } from './common-status-indicator-ch import { CommonSectionCardTitleHeaderComponent } from './common-section-card-title-header/common-section-card-title-header.component'; import { CommonSectionCardOverviewDetailsComponent } from './common-section-card-overview-details/common-section-card-overview-details.component'; import { CommonTagsInputComponent } from './common-tag-input/common-tag-input.component'; +import { CommonListValuesInputComponent } from './common-list-values-input/common-list-values-input.component'; import { CommonTagComponent } from './common-tag/common-tag.component'; import { CommonTagListComponent } from './common-tag-list/common-tag-list.component'; import { CommonLearnMoreLinkComponent } from './common-learn-more-link/common-learn-more-link.component'; @@ -30,6 +31,7 @@ export { CommonModalComponent, CommonStatusIndicatorChipComponent, CommonTagsInputComponent, + CommonListValuesInputComponent, CommonTagComponent, CommonTagListComponent, CommonLearnMoreLinkComponent, diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index cd9596a972..1122ff9dfe 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -693,7 +693,9 @@ export class DialogService { openUpsertPrivateSegmentListModal(commonModalConfig: CommonModalConfig) { const config: MatDialogConfig = { data: commonModalConfig, - width: ModalSize.STANDARD, + width: ModalSize.LARGE, + maxWidth: 'calc(100vw - 32px)', + maxHeight: 'calc(100vh - 32px)', height: 'auto', autoFocus: 'first-heading', disableClose: true, diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index 4b015c586d..d014fb5413 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -38,6 +38,24 @@ "global.app-context.text": "App Context", "global.tags.text": "TAGS", "global.search.text": "Search", + "lists.values.actions-header.text": "Actions", + "lists.values.add.tooltip.text": "Add values", + "lists.values.cancel-edit.tooltip.text": "Cancel edit", + "lists.values.confirm-edit.tooltip.text": "Save value", + "lists.values.delete.tooltip.text": "Delete value", + "lists.values.duplicates-not-added.text": "{{count}} duplicate value(s) were not added.", + "lists.values.edit.tooltip.text": "Edit value", + "lists.values.edit-duplicate-error.text": "This value already exists in the list.", + "lists.values.edit-empty-error.text": "Value cannot be empty.", + "lists.values.export.tooltip.text": "Export all values (CSV)", + "lists.values.import.tooltip.text": "Import CSV", + "lists.values.loading.text": "Loading values...", + "lists.values.no-search-results.text": "No values match your search.", + "lists.values.no-values.text": "No values added.", + "lists.values.pending-action-required.text": "Complete or cancel pending value changes before saving.", + "lists.values.separator-hint.text": "Click Add (+) or press Enter to add values.", + "lists.values.table-label.text": "List values", + "lists.values.value-header.text": "Value", "global.metrics.text": "Metrics", "global.no-metrics.text": "No Metrics", "global.queries-tabs.text": "Queries",