Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions editor/src/app/inputs/inline-text-input.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<InlineTextInputComponent>;
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);
});
});
19 changes: 16 additions & 3 deletions editor/src/app/inputs/inline-text-input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
selector: 'berta-inline-text-input',
template: `
@if (!focus) {
<span class="input-placeholder" (click)="onTextClick()">{{
value || '...'
}}</span>
<span
class="input-placeholder"
[class.is-empty]="!value"
(click)="onTextClick()"
>{{ value || placeholder || '...' }}</span
>
}
@if (focus) {
<input
bertaAutofocus
[value]="value"
[placeholder]="placeholder || ''"
(focus)="onFocus()"
(keydown)="updateField($event)"
(blur)="onBlur($event)"
Expand Down Expand Up @@ -48,12 +52,17 @@ import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
:host.clickable-text .input-placeholder {
cursor: pointer;
}

.input-placeholder.is-empty {
opacity: 0.5;
}
`,
],
standalone: false,
})
export class InlineTextInputComponent implements OnInit {
@Input() value: string;
@Input() placeholder: string;
@Output() update = new EventEmitter();
@Output() inputFocus = new EventEmitter();
@Output() textClick = new EventEmitter();
Expand All @@ -70,6 +79,10 @@ export class InlineTextInputComponent implements OnInit {
this.textClick.emit(true);
}

startEditing() {
this.onFocus();
}

onFocus() {
this.focus = true;
setTimeout(() => {
Expand Down
30 changes: 22 additions & 8 deletions editor/src/app/sites/sections/section.component.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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';
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',
Expand All @@ -20,6 +26,7 @@ import { SettingConfigModel } from '../../shared/interfaces';
<h3>
<berta-inline-text-input
[value]="section.title"
placeholder="Section title"
[class.clickable-text]="isClickableText"
(inputFocus)="updateComponentFocus($event)"
(update)="updateTextField('title', $event)"
Expand Down Expand Up @@ -47,7 +54,7 @@ import { SettingConfigModel } from '../../shared/interfaces';
[published]="[1, '1'].includes(section['@attributes'].published)"
></berta-icon-publish>
</button>
<button title="copy" (click)="cloneSection()">
<button title="copy" (click)="clone.emit()">
<bt-icon-clone></bt-icon-clone>
</button>
<button title="delete" class="delete" (click)="deleteSection()">
Expand Down Expand Up @@ -150,6 +157,9 @@ export class SectionComponent implements OnInit {
section: string | number;
data: { [k: string]: any };
}>();
@Output() clone = new EventEmitter<void>();

@ViewChild(InlineTextInputComponent) titleInput: InlineTextInputComponent;

isClickableText: boolean;

Expand All @@ -158,6 +168,7 @@ export class SectionComponent implements OnInit {
private router: Router,
private route: ActivatedRoute,
private popupService: PopupService,
private elementRef: ElementRef<HTMLElement>,
) {}

ngOnInit() {
Expand Down Expand Up @@ -202,8 +213,11 @@ export class SectionComponent implements OnInit {
});
}

cloneSection() {
this.store.dispatch(new CloneSectionAction(this.section));
focusTitle() {
this.elementRef.nativeElement
.querySelector('h3')
.scrollIntoView({ block: 'nearest' });
this.titleInput.startEditing();
}

deleteSection() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class SiteSectionsState implements NgxsOnInit {
{ dispatch }: StateContext<SiteSectionStateModel[]>,
action: CloneSectionAction,
) {
dispatch(new CreateSectionAction(action.section));
return dispatch(new CreateSectionAction(action.section));
}

@Action(UpdateSiteSectionAction)
Expand Down
152 changes: 152 additions & 0 deletions editor/src/app/sites/sections/site-sections.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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 { SiteTemplateSettingsState } from '../template-settings/site-template-settings.state';
import { SiteTemplatesState } from '../template-settings/site-templates.state';
import { SectionComponent } from './section.component';
import { SiteSectionStateModel } from './sections-state/site-sections-state.model';
import {
CloneSectionAction,
CreateSectionAction,
} from './sections-state/site-sections.actions';
import { SiteSectionsState } from './sections-state/site-sections.state';
import { SiteSectionsComponent } from './site-sections.component';

describe('SiteSectionsComponent', () => {
let harness: RouterTestingHarness;
let sections$: BehaviorSubject<SiteSectionStateModel[]>;

const section = (name: string, order: number): SiteSectionStateModel =>
({
name,
title: name,
order,
site_name: '',
'@attributes': { type: 'default', published: '1' },
}) as SiteSectionStateModel;

/** New sections are added at the end of the list */
const lastSectionTitleInput = () =>
harness.routeNativeElement.querySelector<HTMLInputElement>(
'berta-section:last-of-type h3 input',
);

/** 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 () => {
sections$ = new BehaviorSubject([section('home', 0), section('work', 1)]);

const addSection = (name: string) => {
sections$.next([
...sections$.value,
section(name, sections$.value.length),
]);
return of(undefined);
};

const store = {
select: (selector: unknown) => {
if (selector === SiteSectionsState.getCurrentSiteSections) {
return sections$;
}
if (selector === SiteTemplatesState.getCurrentTemplateSectionTypes) {
return of({ default: { title: 'Default', params: {} } });
}
if (selector === SiteTemplateSettingsState.getIsResponsive) {
return of(false);
}
return of({});
},
selectSnapshot: (selector: unknown) => {
if (selector === AppState.getSite) {
return '';
}
return (selector as (state: unknown) => unknown)({
siteSections: sections$.value,
});
},
dispatch: (action: unknown) => {
if (action instanceof CreateSectionAction) {
return addSection('untitled-1');
}
if (action instanceof CloneSectionAction) {
return addSection(`clone-of-${action.section.name}`);
}
return of(undefined);
},
} as unknown as Store;

await TestBed.configureTestingModule({
declarations: [
SiteSectionsComponent,
SectionComponent,
InlineTextInputComponent,
AutofocusDirective,
],
providers: [
provideRouter([
{ path: 'sections/:section', component: SiteSectionsComponent },
{ path: 'sections', component: SiteSectionsComponent },
]),
{ provide: Store, useValue: store },
{ provide: PopupService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

harness = await RouterTestingHarness.create();
document.body.appendChild(harness.fixture.nativeElement);
});

afterEach(() => {
harness.fixture.nativeElement.remove();
});

it('opens a created section and puts the cursor in its title', async () => {
await harness.navigateByUrl('/sections?site=');

harness.routeNativeElement
.querySelector<HTMLButtonElement>(':scope > button')
.click();
await settle();

expect(TestBed.inject(Router).url).toBe(
'/sections/untitled-1?site=&section=untitled-1',
);
const input = lastSectionTitleInput();
expect(input).toBeTruthy();
expect(input.placeholder).toBe('Section title');
expect(document.activeElement).toBe(input);
});

it('opens a copied section and puts the cursor in its title', async () => {
await harness.navigateByUrl('/sections/home?section=home');

harness.routeNativeElement
.querySelector<HTMLButtonElement>('berta-section button[title="copy"]')
.click();
await settle();

expect(TestBed.inject(Router).url).toBe(
'/sections/clone-of-home?section=clone-of-home',
);
const input = lastSectionTitleInput();
expect(input).toBeTruthy();
expect(input.value).toBe('clone-of-home');
expect(document.activeElement).toBe(input);
});
});
Loading
Loading