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()"
>
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) {
+ {{ feedbackMessage }}
+ } @if (editErrorMessage) {
+ {{ editErrorMessage }}
+ }
+
+
+
+ search
+
+
+
+ @if (loading) {
+
+ }
+
+
+
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 @@
-
+
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",