/** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ import { act } from 'react' import { adminTranslations } from '@byline/i18n/admin' import { I18nProvider } from '@byline/i18n/react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Lightweight uikit stubs — we don't care about the visual rendering, only // that the Input forwards props and the Label renders its htmlFor. vi.mock('@byline/ui/react', () => ({ Label: ({ id, htmlFor, label }: { id?: string; htmlFor?: string; label?: string }) => ( ), Input: ({ id, name, value, placeholder, onChange, helpText, ...rest }: { id?: string name?: string value?: string placeholder?: string onChange?: (e: { target: { value: string } }) => void helpText?: string [key: string]: any }) => ( <> {helpText ? {helpText} : null} ), })) // Mutable mocks controlled per-test via the setFixture helper below. const fixture: { systemPath: string | null sourceValue: unknown setSystemPath: ReturnType } = { systemPath: null, sourceValue: '', setSystemPath: vi.fn(), } vi.mock('./form-context', () => ({ useFormContext: () => ({ setSystemPath: fixture.setSystemPath }), useSystemPath: () => fixture.systemPath, useFieldValue: () => fixture.sourceValue, })) // Import AFTER the mocks so PathWidget picks them up. // biome-ignore lint/correctness/useImportExtensions: webapp TS resolves via tsconfig paths import { PathWidget } from './path-widget' ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true interface Fixture { systemPath?: string | null sourceValue?: unknown } function setFixture(next: Fixture = {}) { fixture.systemPath = next.systemPath ?? null fixture.sourceValue = next.sourceValue ?? '' fixture.setSystemPath = vi.fn() } describe('PathWidget', () => { let container: HTMLDivElement let root: Root beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) }) afterEach(() => { act(() => { root.unmount() }) container.remove() }) const render = ( props: Partial<{ useAsPath: string | undefined mode: 'create' | 'edit' activeLocale: string slugifier: (value: string, ctx: { locale: string; collectionPath: string }) => string sourceLocked: boolean lockPath: boolean }> = {} ) => { act(() => { root.render( // The real English admin bundle rather than a stubbed `t`, so the // assertions below check the strings an editor actually sees. A stub // would let a key be renamed or deleted without the test noticing. ) }) } const getInput = () => container.querySelector('#system-path') as HTMLInputElement it('shows the live-derived preview as placeholder when creating with an empty override', () => { setFixture({ systemPath: null, sourceValue: 'Hello World' }) render({ mode: 'create' }) const input = getInput() expect(input).toBeTruthy() expect(input.value).toBe('') expect(input.getAttribute('placeholder')).toBe('Will be saved as "hello-world"') }) it('shows the persisted path in edit mode (no placeholder preview)', () => { setFixture({ systemPath: 'existing-path', sourceValue: 'Hello World' }) render({ mode: 'edit' }) const input = getInput() expect(input.value).toBe('existing-path') // livePreview === 'hello-world' differs from persisted 'existing-path', // so the regenerate button is rendered. const regenerate = container.querySelector('button') expect(regenerate).toBeTruthy() expect(regenerate?.textContent).toContain('Regenerate from title') }) it('"Regenerate" writes the live preview into the systemPath slot', () => { setFixture({ systemPath: 'stale-path', sourceValue: 'Brand New Title' }) render({ mode: 'edit' }) const regenerate = container.querySelector('button') as HTMLButtonElement expect(regenerate).toBeTruthy() act(() => { regenerate.click() }) expect(fixture.setSystemPath).toHaveBeenCalledWith('brand-new-title') }) it('clearing the input reverts the slot to null (sticky-from-previous)', () => { setFixture({ systemPath: 'my-path', sourceValue: 'My Path' }) render({ mode: 'edit' }) const input = getInput() act(() => { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set setter?.call(input, '') input.dispatchEvent(new Event('input', { bubbles: true })) }) expect(fixture.setSystemPath).toHaveBeenCalledWith(null) }) it('typing a non-empty value writes a string override', () => { setFixture({ systemPath: null, sourceValue: 'Anything' }) render({ mode: 'create' }) const input = getInput() act(() => { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set setter?.call(input, 'custom-slug') input.dispatchEvent(new Event('input', { bubbles: true })) }) expect(fixture.setSystemPath).toHaveBeenCalledWith('custom-slug') }) it('links the input to an sr-only description via aria-describedby', () => { setFixture({ systemPath: null, sourceValue: 'Hi' }) render({ mode: 'create' }) const input = getInput() expect(input.getAttribute('aria-describedby')).toBe('system-path-description') const description = container.querySelector('#system-path-description') expect(description).toBeTruthy() expect(description?.textContent).toContain('System-managed URL path') }) it('does not render the Regenerate button when livePreview equals systemPath', () => { setFixture({ systemPath: 'hello-world', sourceValue: 'Hello World' }) render({ mode: 'edit' }) expect(container.querySelector('button')).toBeNull() }) it('does not render the Regenerate button when there is no useAsPath', () => { setFixture({ systemPath: 'whatever', sourceValue: 'Hello World' }) render({ mode: 'edit', useAsPath: undefined }) expect(container.querySelector('button')).toBeNull() }) it('derives the preview with the provided slugifier instead of the default', () => { // A custom slugifier that zero-pads a numeric serial to 7 digits — the // shape used when a `counter` field feeds the path. const padSerial = (value: string) => value.padStart(7, '0') setFixture({ systemPath: null, sourceValue: 1 }) render({ mode: 'create', slugifier: padSerial }) const input = getInput() // Default slugify would yield "1"; the custom slugifier yields "0000001". expect(input.getAttribute('placeholder')).toBe('Will be saved as "0000001"') }) it('shows the translation-locale hint when editing a translation unlocked', () => { setFixture({ systemPath: 'my-path', sourceValue: 'My Path' }) render({ mode: 'edit', activeLocale: 'fr' }) const input = getInput() expect(input.readOnly).toBe(true) expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe( 'Path is set in the default locale ("en") and applies across translations.' ) }) it('shows the suggested-slug hint for an un-slugified stored value', () => { setFixture({ systemPath: 'Not A Slug', sourceValue: 'My Path' }) render({ mode: 'edit' }) expect(getInput().readOnly).toBe(false) expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe( 'Suggested: "not-a-slug"' ) }) it('suppresses the create-mode placeholder preview when the source is locked', () => { setFixture({ systemPath: null, sourceValue: 1 }) render({ mode: 'create', sourceLocked: true }) expect(getInput().getAttribute('placeholder')).toBeNull() }) it('suppresses the Regenerate button when the source is locked', () => { // Persisted path differs from what a naive re-derivation would produce, // which would normally surface Regenerate — but a locked (server-assigned) // source can't be regenerated from the form, so it must stay hidden. setFixture({ systemPath: '0000001', sourceValue: 1 }) render({ mode: 'edit', sourceLocked: true }) expect(container.querySelector('button')).toBeNull() }) it('renders read-only and empty when creating in a locked collection', () => { setFixture({ systemPath: null, sourceValue: 'Hello World' }) render({ mode: 'create', lockPath: true }) const input = getInput() expect(input.readOnly).toBe(true) expect(input.value).toBe('') expect(input.getAttribute('placeholder')).toBeNull() expect(container.querySelector('button')).toBeNull() expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe( 'Assigned automatically when this document is saved.' ) }) it('renders read-only and shows the stored path when editing a locked collection', () => { setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' }) render({ mode: 'edit', lockPath: true }) const input = getInput() expect(input.readOnly).toBe(true) expect(input.value).toBe('catalogue-item') // The stored path differs from what the source would derive, so an // unlocked widget would offer Regenerate here. expect(container.querySelector('button')).toBeNull() expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe( 'This path is managed and cannot be edited here.' ) }) it('prefers the locked hint over the translation-locale hint', () => { setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' }) render({ mode: 'edit', lockPath: true, activeLocale: 'fr' }) expect(getInput().readOnly).toBe(true) expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe( 'This path is managed and cannot be edited here.' ) }) it('ignores a change event while locked — the lock is not merely visual', () => { setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' }) render({ mode: 'edit', lockPath: true }) // Drive the input through the native prototype setter, exactly as the // two typing tests above do. A direct `input.value = …` updates React's // value tracker, which then suppresses onChange on its own — the // assertion below would pass whether or not the guard exists. const input = getInput() act(() => { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' )?.set setter?.call(input, 'typed-over') input.dispatchEvent(new Event('input', { bubbles: true })) }) expect(fixture.setSystemPath).not.toHaveBeenCalled() }) it('leaves a counter-sourced collection editable when lockPath is not set', () => { // The explicit guard against reintroducing inference: a locked *source* // suppresses the preview and Regenerate, but never the input itself. setFixture({ systemPath: '0000448', sourceValue: 448 }) render({ mode: 'edit', sourceLocked: true }) const input = getInput() expect(input.readOnly).toBe(false) expect(container.querySelector('button')).toBeNull() }) })