src/app/features/conflict-resolution/compare-rev/compare-rev.component.ts
Visualize one specific conflicting document revision and offer resolution options.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-compare-rev |
| standalone | true |
| imports |
MatExpansionModule
MatTooltipModule
MatInputModule
MatButtonModule
|
| styleUrls | ./compare-rev.component.scss |
| templateUrl | ./compare-rev.component.html |
MatExpansionModule
MatTooltipModule
MatInputModule
MatButtonModule
FormsModule
Properties |
|
Methods |
|
Inputs |
constructor()
|
| doc | |
Type : DatabaseDocChange
|
|
|
document from the database in the current version |
|
| rev | |
Type : string
|
|
|
revision key (_rev) of the confliction version to be displayed |
|
| onPanelOpen |
onPanelOpen()
|
|
Trigger loading of the conflicting revision, called when expansion panel is opened.
Returns :
void
|
| Public Async resolveByDelete | ||||||||
resolveByDelete(docToDelete: DatabaseDocChange)
|
||||||||
|
Resolve the displayed conflict by deleting the conflicting revision doc and keeping the current doc.
Parameters :
Returns :
any
|
| Public Async resolveByManualEdit | ||||||||
resolveByManualEdit(diffStringToApply: string)
|
||||||||
|
Apply the given diff, save the resulting new document to the database and remove the conflicting document, thereby resolving the conflict. This method is also used to resolve the conflict to keep the conflicting version instead of the current doc. Then this simply applies the diff of the existing conflicting version instead of a user-edited diff.
Parameters :
Returns :
any
|
| stringify | ||||||||
stringify(entity: unknown)
|
||||||||
|
Generate a human-readable string of the given object.
Parameters :
Returns :
string
|
| docString |
Type : unknown
|
Default value : signal<string>("")
|
|
used in the template for a tooltip displaying the full document |
| resolution |
Type : unknown
|
Default value : signal<string | null>(null)
|
|
whether/how this conflict has been resolved |
| Readonly revDoc |
Type : unknown
|
Default value : computed(() => this.revDocResource.value() ?? null)
|
|
document from the database in the conflicting version |
import {
Component,
input,
inject,
ChangeDetectionStrategy,
signal,
computed,
linkedSignal,
resource,
effect,
} from "@angular/core";
import { diff } from "deep-object-diff";
import { ConfirmationDialogService } from "../../../core/common-components/confirmation-dialog/confirmation-dialog.service";
import { Database, DatabaseDocChange } from "../../../core/database/database";
import { MatSnackBar } from "@angular/material/snack-bar";
import { AutoResolutionService } from "../auto-resolution/auto-resolution.service";
import { merge } from "lodash-es";
import { MatExpansionModule } from "@angular/material/expansion";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatInputModule } from "@angular/material/input";
import { MatButtonModule } from "@angular/material/button";
import { FormsModule } from "@angular/forms";
import { DatabaseResolverService } from "../../../core/database/database-resolver.service";
/**
* Visualize one specific conflicting document revision and offer resolution options.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-compare-rev",
templateUrl: "./compare-rev.component.html",
styleUrls: ["./compare-rev.component.scss"],
imports: [
MatExpansionModule,
MatTooltipModule,
MatInputModule,
MatButtonModule,
FormsModule,
],
})
export class CompareRevComponent {
private confirmationDialog = inject(ConfirmationDialogService);
private snackBar = inject(MatSnackBar);
private conflictResolver = inject(AutoResolutionService);
/** revision key (_rev) of the confliction version to be displayed */
rev = input<string>();
/** document from the database in the current version */
doc = input<DatabaseDocChange>();
/** used in the template for a tooltip displaying the full document */
docString = signal<string>("");
/** whether/how this conflict has been resolved */
resolution = signal<string | null>(null);
private readonly db: Database;
private readonly panelOpened = signal(false);
private readonly revDocResource = resource({
params: () => {
if (!this.panelOpened()) return undefined;
const doc = this.doc();
const rev = this.rev();
return doc && rev ? { doc, rev } : undefined;
},
loader: async ({ params: { doc, rev } }) => {
const revDoc = (await this.db.get(doc._id, {
rev,
})) as DatabaseDocChange;
return revDoc;
},
});
/** document from the database in the conflicting version */
readonly revDoc = computed(() => this.revDocResource.value() ?? null);
/** changes the conflicting doc has compared to the current doc */
readonly diffs = computed(() => {
const doc = this.doc();
const revDoc = this.revDoc();
if (!doc || !revDoc) return "";
return this.stringify(diff(doc, revDoc));
});
/**
* changes the current doc has compared to the conflicting doc.
*
* This mirrors `diffs` but shows the things that would be added if the current doc would
* overwrite the conflicting version instead of the other way round.
*/
readonly diffsReverse = computed(() => {
const doc = this.doc();
const revDoc = this.revDoc();
if (!doc || !revDoc) return "";
return this.stringify(diff(revDoc, doc));
});
/** the user edited diff that can be applied as an alternative resolution (initialized with same value as `diffsReverse`) */
readonly diffsCustom = linkedSignal(() => this.diffsReverse());
constructor() {
const dbResolver = inject(DatabaseResolverService);
this.db = dbResolver.getDatabase();
// Handle auto-resolution of trivial conflicts after resource loads
effect(async () => {
const doc = this.doc();
const revDoc = this.revDoc();
if (!doc || !revDoc) return;
const isIrrelevantConflictingDoc =
this.conflictResolver.shouldDeleteConflictingRevision(doc, revDoc);
if (isIrrelevantConflictingDoc) {
const success = await this.deleteDoc(revDoc);
if (success) {
this.resolution.set(
$localize`automatically deleted trivial conflict`,
);
}
}
});
}
/**
* Trigger loading of the conflicting revision, called when expansion panel is opened.
*/
onPanelOpen() {
this.panelOpened.set(true);
}
/**
* Generate a human-readable string of the given object.
* @param entity Object to be stringified
*/
stringify(entity: unknown): string {
return JSON.stringify(
entity,
(k, v) => (k === "_rev" ? undefined : v), // ignore "_rev"
2,
);
}
/**
* Resolve the displayed conflict by deleting the conflicting revision doc and keeping the current doc.
* @param docToDelete Document to be deleted
*/
public async resolveByDelete(docToDelete: DatabaseDocChange) {
const confirmed = await this.confirmationDialog.getConfirmation(
$localize`Delete Conflicting Version?`,
$localize`Are you sure you want to keep the current version and delete this conflicting version? ${this.stringify(
docToDelete,
)}`,
);
if (confirmed) {
const success = await this.deleteDoc(docToDelete);
if (success) {
this.resolution.set($localize`deleted conflicting version`);
}
}
}
private async deleteDoc(docToDelete: DatabaseDocChange): Promise<boolean> {
try {
await this.db.remove(docToDelete);
return true;
} catch (e: unknown) {
const errorMessage =
e instanceof Error ? e.message : JSON.stringify(e) || String(e);
this.snackBar.open(
$localize`Error trying to delete conflicting version: ${errorMessage}`,
);
return false;
}
}
private async saveDoc(docToSave: DatabaseDocChange): Promise<boolean> {
try {
await this.db.put(docToSave);
return true;
} catch (e: unknown) {
const errorMessage =
e instanceof Error ? e.message : JSON.stringify(e) || String(e);
this.snackBar.open(
$localize`Error trying to save version: ${errorMessage}`,
);
return false;
}
}
/**
* Apply the given diff, save the resulting new document to the database
* and remove the conflicting document, thereby resolving the conflict.
*
* This method is also used to resolve the conflict to keep the conflicting version instead of the current doc.
* Then this simply applies the diff of the existing conflicting version instead of a user-edited diff.
*
* @param diffStringToApply The (user-edited) diff to be applied to the current doc
*/
public async resolveByManualEdit(diffStringToApply: string) {
const doc = this.doc();
if (!doc) {
return;
}
const originalDoc = merge({}, doc) as DatabaseDocChange;
const diffToApply = JSON.parse(diffStringToApply);
const mergedDoc = merge({}, doc, diffToApply) as DatabaseDocChange;
const newChanges = diff(originalDoc, mergedDoc);
const confirmed = await this.confirmationDialog.getConfirmation(
$localize`Save Changes for Conflict Resolution?`,
$localize`Are you sure you want to save the following changes and delete the conflicting version? ${this.stringify(
newChanges,
)}`,
);
if (confirmed) {
const successSave = await this.saveDoc(mergedDoc);
const conflictingRev = this.revDoc();
if (!conflictingRev) return;
const successDel = await this.deleteDoc(conflictingRev);
if (successSave && successDel) {
if (diffStringToApply === this.diffs()) {
this.resolution.set($localize`selected conflicting version`);
} else {
this.resolution.set($localize`resolved manually`);
}
}
}
}
}
@if (!resolution()) {
<mat-expansion-panel (opened)="onPanelOpen()">
<mat-expansion-panel-header>
<mat-panel-title>
<span
[matTooltip]="docString()"
(mouseover)="docString.set(stringify(doc()))"
>{{ rev() }}</span
>
</mat-panel-title>
</mat-expansion-panel-header>
<div class="flex-row gap-small">
<div>
<label
for="conflictingDiff"
i18n="Signals that there are conflicting database-entities"
>
Conflicting Record:
</label>
<textarea
id="conflictingDiff"
cdkTextareaAutosize
class="diffText conflicting"
disabled
>
{{ diffs() }}
</textarea>
<button
mat-raised-button
class="full-width"
(click)="resolveByManualEdit(diffs())"
i18n
>
Choose conflicting version
</button>
</div>
<div>
<label for="customDiff" i18n>Custom Resolution:</label>
<textarea
id="customDiff"
cdkTextareaAutosize
class="diffText custom"
[ngModel]="diffsCustom()"
(ngModelChange)="diffsCustom.set($event)"
>
</textarea>
<button
mat-raised-button
class="full-width"
(click)="resolveByManualEdit(diffsCustom())"
i18n
>
Save manually resolved record
</button>
</div>
<div>
<label for="currentDiff" i18n="A currently selected entity">
Current Record:
</label>
<textarea
id="currentDiff"
cdkTextareaAutosize
class="diffText current"
disabled
>
{{ diffsReverse() }}
</textarea>
<button
mat-raised-button
class="full-width"
(click)="resolveByDelete(revDoc())"
i18n="
Choose a current version between several conflicting versions of
database entries
"
>
Choose current version
</button>
</div>
</div>
</mat-expansion-panel>
} @else {
<div>
<em i18n>Resolved ({{ resolution() }})</em>
</div>
}
./compare-rev.component.scss
.diffText {
width: 100%;
background: white;
}
.conflicting {
background: rgba(255, 0, 0, 0.1);
}
.current {
background: rgba(0, 128, 0, 0.1);
}
.custom {
background: rgba(0, 0, 255, 0.1);
font-weight: bold;
}