diff --git a/.changeset/tasty-apples-dance.md b/.changeset/tasty-apples-dance.md
new file mode 100644
index 000000000000..169f13eaceb2
--- /dev/null
+++ b/.changeset/tasty-apples-dance.md
@@ -0,0 +1,5 @@
+---
+"@logto/console": patch
+---
+
+refactor(console): use MultiOptionInput for DomainsInput
diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.module.scss b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.module.scss
index 89dbf3fc3b4c..59bf5f2af6f1 100644
--- a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.module.scss
+++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.module.scss
@@ -3,8 +3,10 @@
.input {
display: flex;
align-items: center;
- justify-content: space-between;
- padding: 0 _.unit(2) 0 _.unit(3);
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ gap: _.unit(2);
+ padding: _.unit(1.5) _.unit(3);
background: var(--color-layer-1);
border: 1px solid var(--color-border);
border-radius: 8px;
@@ -13,59 +15,9 @@
transition-timing-function: ease-in-out;
transition-duration: 0.2s;
font: var(--font-body-2);
- cursor: pointer;
+ cursor: text;
position: relative;
- &.multiple {
- justify-content: flex-start;
- flex-wrap: wrap;
- gap: _.unit(2);
- padding: _.unit(1.5) _.unit(3);
- cursor: text;
-
- .tag {
- cursor: auto;
- display: flex;
- align-items: center;
- gap: _.unit(1);
- position: relative;
-
- &.focused::after {
- content: '';
- position: absolute;
- inset: 0;
- background: var(--color-overlay-default-focused);
- }
-
- &.info {
- background: var(--color-error-container);
- }
- }
-
- .close {
- width: 16px;
- height: 16px;
- }
-
- .delete {
- width: 20px;
- height: 20px;
- margin-inline-end: _.unit(-0.5);
- }
-
- input {
- color: var(--color-text);
- font: var(--font-body-2);
- background: transparent;
- flex-grow: 1;
- padding: _.unit(0.5);
-
- &::placeholder {
- color: var(--color-placeholder);
- }
- }
- }
-
&:focus-within {
border-color: var(--color-primary);
outline-color: var(--color-focused-variant);
@@ -80,6 +32,25 @@
}
}
+.tag {
+ cursor: auto;
+ display: flex;
+ align-items: center;
+ gap: _.unit(1);
+ position: relative;
+
+ &.focused::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--color-overlay-default-focused);
+ }
+}
+
+.info {
+ background: var(--color-error-container);
+}
+
.errorMessage {
font: var(--font-body-2);
color: var(--color-error);
diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.test.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.test.tsx
new file mode 100644
index 000000000000..6b1d4de966bb
--- /dev/null
+++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.test.tsx
@@ -0,0 +1,138 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+
+import DomainsInput from './index';
+
+// Mock styles
+jest.mock('./index.module.scss', () => ({
+ input: 'mock-input-class',
+ tag: 'mock-tag-class',
+ info: 'mock-info-class',
+}));
+
+// Mock dependencies
+jest.mock('react-hook-form', () => ({
+ useFormContext: jest.fn(),
+}));
+
+// Mock MultiOptionInput
+jest.mock('@/components/MultiOptionInput', () => ({
+ __esModule: true,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ default: ({ validateInput, onChange, values, placeholder }: any) => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const [value, setValue] = useState('');
+ return (
+
+
{values.length}
+
{placeholder}
+
{
+ setValue(event.target.value);
+ }}
+ />
+
+
+ );
+ },
+}));
+
+// Mock utils to avoid schema dependencies
+jest.mock('./utils', () => ({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ domainOptionsParser: (values: any[]) => ({ values, errorMessage: undefined }),
+}));
+
+// Mock shared dependencies
+jest.mock(
+ '@logto/shared/universal',
+ () => ({
+ generateStandardShortId: () => 'mock-id',
+ }),
+ { virtual: true }
+);
+
+jest.mock(
+ '@silverhand/essentials',
+ () => ({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/prefer-logical-operator-over-ternary
+ conditional: (condition: any) => (condition ? condition : undefined),
+ isKeyInObject: () => true,
+ }),
+ { virtual: true }
+);
+
+// Mock useTranslation
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => (key === 'placeholder_key' ? 'Enter domain' : key),
+ }),
+}));
+
+const mockSetError = jest.fn();
+const mockClearErrors = jest.fn();
+
+describe('DomainsInput', () => {
+ const defaultProps = {
+ values: [],
+ onChange: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useFormContext as jest.Mock).mockReturnValue({
+ setError: mockSetError,
+ clearErrors: mockClearErrors,
+ });
+ });
+
+ it('renders correctly', () => {
+ render();
+ // Check for existence
+ expect(screen.getByTestId('multi-option-input')).toBeTruthy();
+ });
+
+ it('passes values correctly', () => {
+ const values = [{ id: '1', value: 'example.com' }];
+ render();
+ // Check text content
+ expect(screen.getByTestId('values-count').textContent).toBe('1');
+ });
+
+ it('handles validation and adding via mocked interaction', () => {
+ const onChange = jest.fn();
+ render();
+
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'test.com' } });
+ screen.getByTestId('add-btn').click();
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ value: 'test.com' })])
+ );
+ });
+
+ it('blocks invalid domains', () => {
+ const onChange = jest.fn();
+ render();
+
+ fireEvent.change(screen.getByTestId('input'), { target: { value: 'invalid' } });
+ screen.getByTestId('add-btn').click();
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx
index 9743f511a8d8..3b962ee72651 100644
--- a/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx
+++ b/packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.tsx
@@ -1,17 +1,11 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { generateStandardShortId } from '@logto/shared/universal';
-import { conditional, type Nullable } from '@silverhand/essentials';
-import classNames from 'classnames';
-import { useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
-import Close from '@/assets/icons/close.svg?react';
-import IconButton from '@/ds-components/IconButton';
-import Tag from '@/ds-components/Tag';
-import { onKeyDownHandler } from '@/utils/a11y';
+import MultiOptionInput from '@/components/MultiOptionInput';
-import { domainRegExp } from './consts';
+import { domainRegExp, invalidDomainFormatErrorCode } from './consts';
import styles from './index.module.scss';
import { domainOptionsParser, type Option } from './utils';
@@ -27,12 +21,9 @@ type Props = {
readonly placeholder?: AdminConsoleKey;
};
-// TODO: @Charles refactor me, use `` instead.
function DomainsInput({ className, values, onChange: rawOnChange, error, placeholder }: Props) {
- const inputRef = useRef(null);
- const [focusedValueId, setFocusedValueId] = useState>(null);
- const [currentValue, setCurrentValue] = useState('');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
+ const { t: tErrors } = useTranslation('errors');
const { setError, clearErrors } = useFormContext();
const onChange = (values: Option[]) => {
@@ -45,116 +36,33 @@ function DomainsInput({ className, values, onChange: rawOnChange, error, placeho
rawOnChange(parsedValues);
};
- const handleAdd = (value: string) => {
- const newValues: Option[] = [
- ...values,
- {
- value,
- id: generateStandardShortId(),
- ...conditional(!domainRegExp.test(value) && { status: 'info' }),
- },
- ];
- onChange(newValues);
- setCurrentValue('');
- inputRef.current?.focus();
- };
-
- const handleDelete = (option: Option) => {
- onChange(values.filter(({ id }) => id !== option.id));
- };
-
return (
- <>
- {
- inputRef.current?.focus();
- })}
- onClick={() => {
- inputRef.current?.focus();
- }}
- >
- {values.map((option) => {
- return (
- {
- inputRef.current?.focus();
- }}
- >
- {option.value}
- {
- handleDelete(option);
- }}
- onKeyDown={onKeyDownHandler(() => {
- handleDelete(option);
- })}
- >
-
-
-
- );
- })}
- {
- if (event.key === 'Backspace' && currentValue === '') {
- if (focusedValueId) {
- onChange(values.filter(({ id }) => id !== focusedValueId));
- setFocusedValueId(null);
- } else {
- setFocusedValueId(values.at(-1)?.id ?? null);
- }
- inputRef.current?.focus();
- }
- if (event.key === ' ' || event.code === 'Space' || event.key === 'Enter') {
- // Focusing on input
- if (currentValue !== '' && document.activeElement === inputRef.current) {
- handleAdd(currentValue);
- }
- // Do not react to "Enter"
- event.preventDefault();
- }
- }}
- onChange={({ currentTarget: { value } }) => {
- setCurrentValue(value);
- setFocusedValueId(null);
- }}
- onFocus={() => {
- inputRef.current?.focus();
- }}
- onBlur={() => {
- if (currentValue !== '') {
- handleAdd(currentValue);
- }
- setFocusedValueId(null);
- }}
- />
-
- {Boolean(error) && typeof error === 'string' && (
- {error}
- )}
- >
+
+ `${styles.tag} ${option.status && styles[option.status] ? styles[option.status] : ''}`
+ }
+ renderValue={(option) => option.value}
+ validateInput={(text) => {
+ if (!domainRegExp.test(text)) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ return tErrors(invalidDomainFormatErrorCode);
+ }
+
+ return {
+ value: {
+ value: text,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
+ id: generateStandardShortId(),
+ },
+ };
+ }}
+ onChange={onChange}
+ />
);
}