Looking At Different Click-To-Edit Implementations In Angular 9.1.12
At InVision, one of the user experience (UX) patterns that we employ from time-to-time is the "click-to-edit" interaction. This is where the user clicks on a piece of plain-text and, by doing so, enables an "edit form" for said text in the same screen location. Personally, I don't really like this UX pattern as I feel it has significant discoverability and usability issues. But, since it keeps coming up, I wanted to take a step back and think about different ways that a click-to-edit interaction can be implemented in Angular 9.1.12; and, what the pros and cons of each approach might be.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
To set the stage for this exploration, I've encapsulated each approach within its own component. And then, within my App component, I'm creating an array of Projects that I then pipe into each approach:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface Project {
id: string;
name: string;
}
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
template:
`
<app-approach-one [projects]="projects"></app-approach-one>
<app-approach-two [projects]="projects"></app-approach-two>
<app-approach-three [projects]="projects"></app-approach-three>
`
})
export class AppComponent {
public projects: Project[] = [
{ id: "p1", name: "My Groovy Project" },
{ id: "p2", name: "Another Cool Project" },
{ id: "p3", name: "Much Project, Such Wow" },
{ id: "p4", name: "A Good Project" }
];
}
For the sake of simplicity, my App component isn't dealing with events or worrying about a one-way data-flow - each approach can mutate the list of projects and those mutations show up in the other approach.
Approach One: A Globally-Reusable Click-To-Edit Component
The first approach to implementing a click-to-edit interaction is to create some kind of globally-reusable component that accepts a [value]
binding and emits a (valueChange)
event. Internally, this component would handle its own state, managing when to show or hide the "edit" version of the interface. But, unlike the App component above, this editable component will implement a uni-directional data-flow:
// Import the core angular services.
import { Component } from "@angular/core";
import { EventEmitter } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-editable",
inputs: [ "value" ],
outputs: [ "valueChangeEvents: valueChange" ],
styleUrls: [ "./editable.component.less" ],
template:
`
<div *ngIf="isEditing" class="editor">
<input
type="text"
name="value"
autofocus
[(ngModel)]="pendingValue"
(keydown.Enter)="processChanges()"
(keydown.Meta.Enter)="processChanges()"
(keydown.Escape)="cancel()"
/>
<button (click)="processChanges()">
Save
</button>
<a
(click)="cancel()"
(keydown.Enter)="cancel()"
tabindex="0">
Cancel
</a>
</div>
<div *ngIf="( ! isEditing )" (click)="edit()">
{{ value }}
</div>
`
})
export class EditableComponent {
public isEditing: boolean;
public pendingValue: string;
public value!: string;
public valueChangeEvents: EventEmitter<string>;
// I initialize the editable component.
constructor() {
this.isEditing = false;
this.pendingValue = "";
this.valueChangeEvents = new EventEmitter();
}
// ---
// PUBLIC METHODS.
// ---
// I cancel the editing of the value.
public cancel() : void {
this.isEditing = false;
}
// I enable the editing of the value.
public edit() : void {
this.pendingValue = this.value;
this.isEditing = true;
}
// I process changes to the pending value.
public processChanges() : void {
// If the value actually changed, emit the change but don't change the local
// value - we don't want to break unidirectional data-flow.
if ( this.pendingValue !== this.value ) {
this.valueChangeEvents.emit( this.pendingValue );
}
this.isEditing = false;
}
}
As you can see, this "Editable" component contains two different internal interfaces: one for editing the value
and one for rendering the value
. It then emits a valueChnage
event when the value is changed and relies on the calling context to pipe an updated value
input-bindings back into the component.
This approach makes it super easy to drop-in a click-to-edit feature. As you will in the code below, the only thing the calling context has to implement is the (valueChange)
handler:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { Project } from "./app.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-approach-one",
inputs: [ "projects" ],
styleUrls: [ "./approach-one.component.less" ],
template:
`
<h2>
Encapsulated Editing Approach
</h2>
<ul>
<li *ngFor="let project of projects">
<app-editable
[value]="project.name"
(valueChange)="saveProjectName( project, $event )">
</app-editable>
</li>
</ul>
`
})
export class ApproachOneComponent {
public projects!: Project[];
// ---
// PUBLIC METHODS.
// ---
// I handle the rename event, persisting the new value to the given project.
public saveProjectName( project: Project, newName: string ) : void {
// CAUTION: Normally, I would emit some sort of "rename" event to the calling
// context. But, for the sake of simplicity, I'm just mutating the project
// directly since having several sibling components that both edit project names
// is incidental and not the focus of this exploration.
project.name = newName;
}
}
As you can see, this consumption of this globally-reusable click-to-edit component is painless. And, when we run this code, we get the following browser output:
That said, this approach has significant downsides and is, in fact, my least favorite approach. The biggest problem with this approach is that by fully encapsulating the functionality as a globally-reusable component you either make it very inflexible; or, you have to make it very complicated in order to make it flexible.
For example, what if I wanted to:
- Change the styling of the form fields or form buttons?
- Change the text of the Save button?
- Change the text of the Cancel button?
- Remove the Cancel button?
- Automatically close the "edit" mode if I moused-down outside of the form?
None of this is particularly easy; and, any attempt to create a globally-reusable click-to-edit form will likely start to amass "rot" over time as more developers add little tweaks here and there until you are left with some massive dumpster fire that has a hundred different attributes.
As Sandi Metz might put it, I believe that Approach One is the "wrong abstraction". Approach One represents an irrational fear of duplication, which causes us (as developers) to try and DRY-out our code beyond the point at which it is adding value. In fact, this approach values perceived DRY'ness over usability and maintainability.
ASIDE: DRY stands for "Don't Repeat Yourself", and is one of the most misunderstood principles in computer programming. In fact, it is so misunderstood that the 20th Anniversary edition of "The Pragmatic Programmer" had to completely re-write their section on DRY in an attempt to add clarity to their original explanation.
Approach Two: Completely Inline Click-To-Edit Functionality
To overcome all of the limitations of Approach One - the globally-reusable click-to-edit component - you can simply inline all of the necessary behavior into whichever interface requires it. That's exactly what we're going to do in Approach Two. The following code takes the EditableComponent
and essentially merges it right into the logic of the calling context:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { Project } from "./app.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-approach-two",
inputs: [ "projects" ],
styleUrls: [ "./approach-two.component.less" ],
template:
`
<h2>
Inline Editing Approach
</h2>
<ul>
<li
*ngFor="let project of projects"
[ngSwitch]="( project === selectedProject )">
<div *ngSwitchCase="true" class="editor">
<input
type="text"
name="value"
autofocus
[(ngModel)]="pendingValue"
(keydown.Enter)="processChanges()"
(keydown.Meta.Enter)="processChanges()"
(keydown.Escape)="cancel()"
/>
<button (click)="processChanges()">
Save
</button>
<a
(click)="cancel()"
(keydown.Enter)="cancel()"
tabindex="0">
Cancel
</a>
</div>
<div *ngSwitchCase="false" (click)="edit( project )">
{{ project.name }}
</div>
</li>
</ul>
`
})
export class ApproachTwoComponent {
public pendingValue: string;
public projects!: Project[];
public selectedProject: Project | null;
// I initialize the approach-two component.
constructor() {
this.pendingValue = "";
this.selectedProject = null;
}
// ---
// PUBLIC METHODS.
// ---
// I cancel editing of the selected project.
public cancel() : void {
this.selectedProject = null;
}
// I enable editing of the given project.
public edit( project: Project ) : void {
this.pendingValue = project.name;
this.selectedProject = project;
}
// I process changes to the selected project's name.
public processChanges() : void {
if ( this.pendingValue !== this.selectedProject!.name ) {
// CAUTION: Normally, I would emit some sort of "rename" event to the calling
// context. But, for the sake of simplicity, I'm just mutating the project
// directly since having several sibling components that both edit project
// names is incidental and not the focus of this exploration.
this.selectedProject!.name = this.pendingValue;
}
this.selectedProject = null;
}
}
With this approach, we inline the entire edit form right where it is needed. This gives us complete access to the HTML and JavaScript which, in turn, gives us complete control over the look-and-feel and interactions of the form.
Need to change the styles? No problem, we own the HTML markup and the LESS CSS. Need to change the wording of the buttons? No problem, we own the HTML markup. Need to change the minutia of the interactions? No problem, we own the HTML markup and the code-behind.
And, when we run this code, we get the following output:
Of course, with this flexibility comes additional complexity. Whereas Approach One had a simple drop-in component, Approach Two requires additional HTML and additional event-handlers. That said, for simple interfaces, the added complexity can still be quite manageable and pays dividends when you need to update the interactions in the future.
Approach Three: Partially Encapsulated But Wholly-Owned Click-To-Edit Functionality
I really like that Approach Two allows my calling context to "own" all of the click-to-edit functionality. But, I don't love how much more complicated the HTML becomes. And, I don't really want to have to manage the "pending" state of the click-to-edit form. As such, in Approach Three, I want to find a happy-medium: using a partially encapsulated click-to-edit form.
In this approach, we're going to move the "edit form" to its own component. But, unlike with Approach One, this component is not meant to be reused - it is forever coupled to this particular calling context which continues to give us complete control over how it looks, feels, and behaves. We're only breaking it out into its own component for enhanced readability and maintainability.
To drive home this "forever coupled" concept, I'm going to keep both components (Approach Three and its Edit Form) in the same file, though, in reality, they would still live in different files:
// Import the core angular services.
import { Component } from "@angular/core";
import { EventEmitter } from "@angular/core";
// Import the application components and services.
import { Project } from "./app.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-approach-three",
inputs: [ "projects" ],
styleUrls: [ "./approach-three.component.less" ],
template:
`
<h2>
Mixed Editing Approach
</h2>
<ul>
<li
*ngFor="let project of projects"
[ngSwitch]="( project === selectedProject )">
<app-approach-three-editor
*ngSwitchCase="true"
[value]="project.name"
(valueChange)="saveProjectName( project, $event )"
(cancel)="cancel()">
</app-approach-three-editor>
<div *ngSwitchCase="false" (click)="edit( project )">
{{ project.name }}
</div>
</li>
</ul>
`
})
export class ApproachThreeComponent {
public projects!: Project[];
public selectedProject: Project | null;
// I initialize the approach-three component.
constructor() {
this.selectedProject = null;
}
// ---
// PUBLIC METHODS.
// ---
// I cancel editing of the selected project.
public cancel() : void {
this.selectedProject = null;
}
// I enable editing of the given project.
public edit( project: Project ) : void {
this.selectedProject = project;
}
// I handle the rename event, persisting the new value to the given project.
public saveProjectName( project: Project, newName: string ) : void {
// CAUTION: Normally, I would emit some sort of "rename" event to the calling
// context. But, for the sake of simplicity, I'm just mutating the project
// directly since having several sibling components that both edit project names
// is incidental and not the focus of this exploration.
project.name = newName;
this.selectedProject = null;
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// FOR THE SAKE OF THE DEMO I'm keeping this component in the same file as the approach
// three component above in order to drive-home the intention that they are coupled
// together with intent. In reality, this component would be in a sibling file.
@Component({
selector: "app-approach-three-editor",
inputs: [ "value" ],
outputs: [
"cancelEvents: cancel",
"valueChangeEvents: valueChange"
],
styleUrls: [ "./approach-three-editor.component.less" ],
template:
`
<input
type="text"
name="value"
autofocus
[(ngModel)]="pendingValue"
(keydown.Enter)="processChanges()"
(keydown.Meta.Enter)="processChanges()"
(keydown.Escape)="cancel()"
/>
<button (click)="processChanges()">
Save
</button>
<a
(click)="cancel()"
(keydown.Enter)="cancel()"
tabindex="0">
Cancel
</a>
`
})
export class ApproachThreeEditorComponent {
public cancelEvents: EventEmitter<void>;
public pendingValue: string;
public value!: string;
public valueChangeEvents: EventEmitter<string>;
// I initialize the approach-three editable component.
constructor() {
this.cancelEvents = new EventEmitter();
this.pendingValue = "";
this.valueChangeEvents = new EventEmitter();
}
// ---
// PUBLIC METHODS.
// ---
// I cancel the editing of the value.
public cancel() : void {
this.cancelEvents.emit();
}
// I get called after the inputs are bound for the first time.
public ngOnInit() : void {
this.pendingValue = this.value;
}
// I process changes to the pending value.
public processChanges() : void {
// If the value hasn't changed, treat it like a cancel action.
if ( this.pendingValue === this.value ) {
this.cancelEvents.emit();
} else {
this.valueChangeEvents.emit( this.pendingValue );
}
}
}
At first blush, this approach may seem like the most complicated since it has the most moving parts. But, this is my preferred approach. The HTML markup in the calling context is still very simple, much like it was in Approach One; and, the only added logic is that the Approach Three component has to keep track of which Project is being edited - selectedProject
- and how to handle the (cancel)
event. But, the Approach Three component doesn't have to manage the low-level interactions with the form - that's all handled by the ApproachThreeEditorComponent
component.
And, when we run this code, we get the following output:
As you can see, it works quite nicely.
I am sure that there are other ways to implement this click-to-edit functionality in Angular 9.1.12; but, these are the three ways that come to my mind. To me, Approach Two and Approach Three are both feasible. I prefer Approach Three in more complex situations; but, would happily use Approach Two in a simpler context. Really, the only approach that I would strongly object to is Approach One, which attempts to "solve all the problems!" with a single implementation. Such over-reaching solutions will almost always come back to haunt you.
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben
In Approach 2, what does this operator do:
As in:
@Charles,
It's TypeScript's Non-Null Assertion. It essentially says that the expression up to that point will not be
null
. The reason I had it in this case was because thethis.selectedProject
was set tonull | Project
, so TypeScript would complain that it could benull
. But, my method only gets called when it's not.I could have also just put a null-check in the start of the method, like:
But, that just is frustrating because it's there just to calm the compiler.
Thanks Ben.
Does the Non-Null Assertion operator work in Vanilla JS, as well?
I have never seen this before!
@Charles,
It's TypeScript specific; but, you wouldn't need it in vanilla JavaScript since it's just the compiler complaining about the "possibility" of a
null
value, which you don't have to worry about in plain JS.Of course. Thanks...
I can't see how the issues of approach one solved in approach 3? Like still cancel/save button text/styles can't be removed or changed right?
@RaTech,
In Approach 3, the "edit form" component is not intended for general re-use - it is wholly owned by the parent interface. It's only factored-out for a better separation of concerns. So, if you needed to change the button styles or text, you would just edit the component. And, because the component is owned by a single user-interface, you don't have to worry about it causing any unexpected issues elsewhere in the Angular app.
Essentially, Approach 3 is Approach 2, but with a factored-out form.