From 27ee3176609e73b2692bd565ed3759a36fa419a0 Mon Sep 17 00:00:00 2001 From: uldisrudzitis Date: Thu, 29 Oct 2026 16:11:56 +0200 Subject: [PATCH] Put the cursor in the title of a new or copied section, and of a copied site - "Create new section" and a section's "copy" open the new section (/sections/?section=): its settings, its page in the preview, and its title input focused and scrolled into view. Going from /sections to /sections/:section replaces the sections list, so the section to focus is kept on the component class. - A site's "copy" switches to the copy and focuses its title. "Create new site" still opens the setup wizard. - Empty section and site titles show a "Section title" / "Site title" placeholder, also after the text is cleared. - CloneSectionAction returns its dispatch, so the copy is in the store when the action completes. - Tests: inline text input placeholder and startEditing(), section create/copy and site copy navigation and focus. Co-Authored-By: Claude Opus 5.5 --- .../inline-text-input.component.spec.ts | 69 ++++++++ .../app/inputs/inline-text-input.component.ts | 19 ++- .../app/sites/sections/section.component.ts | 30 +++- .../sections-state/site-sections.state.ts | 2 +- .../sections/site-sections.component.spec.ts | 152 ++++++++++++++++++ .../sites/sections/site-sections.component.ts | 79 +++++++-- editor/src/app/sites/site.component.ts | 41 ++--- editor/src/app/sites/sites.component.spec.ts | 100 ++++++++++++ editor/src/app/sites/sites.component.ts | 87 +++++++--- 9 files changed, 505 insertions(+), 74 deletions(-) create mode 100644 editor/src/app/inputs/inline-text-input.component.spec.ts create mode 100644 editor/src/app/sites/sections/site-sections.component.spec.ts create mode 100644 editor/src/app/sites/sites.component.spec.ts diff --git a/editor/src/app/inputs/inline-text-input.component.spec.ts b/editor/src/app/inputs/inline-text-input.component.spec.ts new file mode 100644 index 000000000..8c4799507 --- /dev/null +++ b/editor/src/app/inputs/inline-text-input.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { InlineTextInputComponent } from './inline-text-input.component'; +import { AutofocusDirective } from '../sites/shared/autofocus.directive'; + +describe('InlineTextInputComponent', () => { + let fixture: ComponentFixture; + let component: InlineTextInputComponent; + let element: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [InlineTextInputComponent, AutofocusDirective], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(InlineTextInputComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + document.body.appendChild(element); + }); + + afterEach(() => { + element.remove(); + }); + + it('shows the value', () => { + fixture.componentRef.setInput('value', 'Work'); + fixture.componentRef.setInput('placeholder', 'Type a title'); + fixture.detectChanges(); + + const text = element.querySelector('.input-placeholder'); + expect(text.textContent.trim()).toBe('Work'); + expect(text.classList).not.toContain('is-empty'); + }); + + it('shows the placeholder when the value is empty', () => { + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('placeholder', 'Type a title'); + fixture.detectChanges(); + + const text = element.querySelector('.input-placeholder'); + expect(text.textContent.trim()).toBe('Type a title'); + expect(text.classList).toContain('is-empty'); + }); + + it('shows dots when the value and placeholder are empty', () => { + fixture.componentRef.setInput('value', ''); + fixture.detectChanges(); + + expect(element.querySelector('.input-placeholder').textContent.trim()).toBe( + '...', + ); + }); + + it('opens a focused input with the placeholder on startEditing', () => { + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('placeholder', 'Type a title'); + fixture.detectChanges(); + + component.startEditing(); + fixture.detectChanges(); + + const input = element.querySelector('input'); + expect(input).not.toBeNull(); + expect(input.placeholder).toBe('Type a title'); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/editor/src/app/inputs/inline-text-input.component.ts b/editor/src/app/inputs/inline-text-input.component.ts index 1dd498df1..04dafef5b 100644 --- a/editor/src/app/inputs/inline-text-input.component.ts +++ b/editor/src/app/inputs/inline-text-input.component.ts @@ -4,14 +4,18 @@ import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; selector: 'berta-inline-text-input', template: ` @if (!focus) { - {{ - value || '...' - }} + {{ value || placeholder || '...' }} } @if (focus) { { diff --git a/editor/src/app/sites/sections/section.component.ts b/editor/src/app/sites/sections/section.component.ts index 663ce37f4..b39f744d0 100644 --- a/editor/src/app/sites/sections/section.component.ts +++ b/editor/src/app/sites/sections/section.component.ts @@ -1,4 +1,12 @@ -import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { + Component, + ElementRef, + Input, + Output, + EventEmitter, + OnInit, + ViewChild, +} from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { combineLatest } from 'rxjs'; import { take, filter, switchMap, map } from 'rxjs/operators'; @@ -6,12 +14,10 @@ import { Store } from '@ngxs/store'; import { PopupService } from '../../popup/popup.service'; import { SiteSectionStateModel } from './sections-state/site-sections-state.model'; import { TemplateTranslationsModel } from '../template-settings/site-templates.interface'; -import { - DeleteSiteSectionAction, - CloneSectionAction, -} from './sections-state/site-sections.actions'; +import { DeleteSiteSectionAction } from './sections-state/site-sections.actions'; import { AppState } from '../../app-state/app.state'; import { SettingConfigModel } from '../../shared/interfaces'; +import { InlineTextInputComponent } from '../../inputs/inline-text-input.component'; @Component({ selector: 'berta-section', @@ -20,6 +26,7 @@ import { SettingConfigModel } from '../../shared/interfaces';

- } - @@ -120,6 +124,9 @@ export class SiteComponent implements OnInit { @Input('sites') sites: SiteStateModel[]; @Output() inputFocus = new EventEmitter(); + @Output() clone = new EventEmitter(); + + @ViewChild('titleInput') titleInput: InlineTextInputComponent; currentSiteSlug$: Observable; dialog = inject(Dialog); @@ -132,6 +139,7 @@ export class SiteComponent implements OnInit { private route: ActivatedRoute, private store: Store, private popupService: PopupService, + private elementRef: ElementRef, ) { this.currentSiteSlug$ = this.store.select(AppState.getSite); } @@ -175,30 +183,11 @@ export class SiteComponent implements OnInit { } } - cloneSite() { - this.store - .select((state) => state.sites) - .pipe( - take(1), - map((sites: SiteStateModel[]) => { - return sites.map((site) => site.name); - }), - switchMap((siteNames) => - this.store.dispatch(new CloneSiteAction(this.site)).pipe( - switchMap(() => this.store.select((state) => state.sites)), - take(1), - map((sitesState: SiteStateModel[]) => - sitesState.find((site) => siteNames.indexOf(site.name) === -1), - ), - ), - ), - ) - .subscribe((newSite) => { - if (!newSite) { - return; - } - this.router.navigate([], { queryParams: { site: newSite.name } }); - }); + focusTitle() { + this.elementRef.nativeElement + .querySelector('h3') + .scrollIntoView({ block: 'nearest' }); + this.titleInput.startEditing(); } swapContentsBetweenOtherSite() { diff --git a/editor/src/app/sites/sites.component.spec.ts b/editor/src/app/sites/sites.component.spec.ts new file mode 100644 index 000000000..a21d286f5 --- /dev/null +++ b/editor/src/app/sites/sites.component.spec.ts @@ -0,0 +1,100 @@ +import { Dialog } from '@angular/cdk/dialog'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { Store } from '@ngxs/store'; +import { BehaviorSubject, of } from 'rxjs'; + +import { AppState } from '../app-state/app.state'; +import { InlineTextInputComponent } from '../inputs/inline-text-input.component'; +import { PopupService } from '../popup/popup.service'; +import { AutofocusDirective } from './shared/autofocus.directive'; +import { SiteComponent } from './site.component'; +import { SiteStateModel } from './sites-state/site-state.model'; +import { CloneSiteAction } from './sites-state/sites.actions'; +import { SitesComponent } from './sites.component'; + +describe('SitesComponent', () => { + let harness: RouterTestingHarness; + let sites$: BehaviorSubject; + + const site = (name: string, order: number): SiteStateModel => ({ + name, + title: name, + order, + mediaUrl: '', + '@attributes': { published: 1 }, + }); + + /** Waits for the navigation, the render after it and the focus timers */ + const settle = async () => { + for (let i = 0; i < 3; i++) { + await harness.fixture.whenStable(); + harness.detectChanges(); + } + }; + + beforeEach(async () => { + sites$ = new BehaviorSubject([site('', 0), site('second', 1)]); + + const store = { + select: (selector: unknown) => + selector === AppState.getSite ? of('') : sites$, + selectOnce: () => of(sites$.value), + dispatch: (action: unknown) => { + if (action instanceof CloneSiteAction) { + sites$.next([ + ...sites$.value, + site(`clone-of-${action.site.name}`, sites$.value.length), + ]); + } + return of(undefined); + }, + } as unknown as Store; + + await TestBed.configureTestingModule({ + declarations: [ + SitesComponent, + SiteComponent, + InlineTextInputComponent, + AutofocusDirective, + ], + providers: [ + provideRouter([{ path: 'multisite', component: SitesComponent }]), + { provide: Store, useValue: store }, + { provide: PopupService, useValue: {} }, + { provide: Dialog, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + harness = await RouterTestingHarness.create(); + document.body.appendChild(harness.fixture.nativeElement); + }); + + afterEach(() => { + harness.fixture.nativeElement.remove(); + }); + + it('opens a copied site and puts the cursor in its title', async () => { + await harness.navigateByUrl('/multisite?site=second'); + + harness.routeNativeElement + .querySelector( + 'berta-site:last-of-type button[title="copy"]', + ) + .click(); + await settle(); + + expect(TestBed.inject(Router).url).toBe('/multisite?site=clone-of-second'); + // New sites are added at the end of the list + const input = harness.routeNativeElement.querySelector( + 'berta-site:last-of-type .control-line input', + ); + expect(input).toBeTruthy(); + expect(input.value).toBe('clone-of-second'); + expect(input.placeholder).toBe('Site title'); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/editor/src/app/sites/sites.component.ts b/editor/src/app/sites/sites.component.ts index 614741d5f..46d44a468 100644 --- a/editor/src/app/sites/sites.component.ts +++ b/editor/src/app/sites/sites.component.ts @@ -1,4 +1,13 @@ -import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { + afterNextRender, + Component, + DestroyRef, + inject, + Injector, + OnInit, + QueryList, + ViewChildren, +} from '@angular/core'; import { Router } from '@angular/router'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Store } from '@ngxs/store'; @@ -7,9 +16,11 @@ import { take, map, switchMap } from 'rxjs/operators'; import { SiteStateModel } from './sites-state/site-state.model'; import { UpdateInputFocus } from '../app-state/app.actions'; import { + CloneSiteAction, CreateSiteAction, ReOrderSitesAction, } from './sites-state/sites.actions'; +import { SiteComponent } from './site.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ @@ -22,6 +33,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; [site]="site" [sites]="sites$ | async" (inputFocus)="updateComponentFocus($event)" + (clone)="cloneSite(site)" > } @@ -32,7 +44,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; standalone: false, }) export class SitesComponent implements OnInit { + @ViewChildren(SiteComponent) siteComponents: QueryList; + destroyRef: DestroyRef; + private injector = inject(Injector); sites$: Observable; sitesList: SiteStateModel[]; @@ -55,29 +70,55 @@ export class SitesComponent implements OnInit { } createSite() { - this.sites$ - .pipe( - take(1), - map((sites: SiteStateModel[]) => { - return sites.map((site) => site.name); - }), - switchMap((siteNames) => - this.store.dispatch(new CreateSiteAction()).pipe( - switchMap(() => this.store.selectOnce((state) => state.sites)), - map((sitesState: SiteStateModel[]) => { - return sitesState.find( - (site) => siteNames.indexOf(site.name) === -1, - ); - }), - ), + this.addSite(new CreateSiteAction()).subscribe((newSite) => { + if (!newSite) { + return; + } + this.router.navigate([], { queryParams: { site: newSite.name } }); + }); + } + + // Opens the copy and puts the cursor in its title + cloneSite(site: SiteStateModel) { + this.addSite(new CloneSiteAction(site)).subscribe((newSite) => { + if (!newSite) { + return; + } + this.router + .navigate([], { queryParams: { site: newSite.name } }) + .then(() => { + afterNextRender( + () => { + this.siteComponents + .find((component) => component.site.name === newSite.name) + ?.focusTitle(); + }, + { injector: this.injector }, + ); + }); + }); + } + + /** Dispatches an action that adds a site and returns the added site */ + private addSite( + action: CreateSiteAction | CloneSiteAction, + ): Observable { + return this.sites$.pipe( + take(1), + map((sites: SiteStateModel[]) => { + return sites.map((site) => site.name); + }), + switchMap((siteNames) => + this.store.dispatch(action).pipe( + switchMap(() => this.store.selectOnce((state) => state.sites)), + map((sitesState: SiteStateModel[]) => { + return sitesState.find( + (site) => siteNames.indexOf(site.name) === -1, + ); + }), ), - ) - .subscribe((newSite) => { - if (!newSite) { - return; - } - this.router.navigate([], { queryParams: { site: newSite.name } }); - }); + ), + ); } onDrop(event: CdkDragDrop) {