From 44fb5aa30edd8dad34f72b5d8095e01089627332 Mon Sep 17 00:00:00 2001 From: Tanishq S Date: Fri, 19 Dec 2025 13:28:02 +0530 Subject: [PATCH] refactor(console): use MultiOptionInput for DomainsInput --- .changeset/tasty-apples-dance.md | 5 + .../Experience/DomainsInput/index.module.scss | 77 +++------ .../Experience/DomainsInput/index.test.tsx | 138 ++++++++++++++++ .../Experience/DomainsInput/index.tsx | 150 ++++-------------- 4 files changed, 196 insertions(+), 174 deletions(-) create mode 100644 .changeset/tasty-apples-dance.md create mode 100644 packages/console/src/pages/EnterpriseSsoDetails/Experience/DomainsInput/index.test.tsx 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} + /> ); }