src/app/features/conflict-resolution/compare-rev/compare-rev.component.ts

Description

Visualize one specific conflicting document revision and offer resolution options.

Example

Metadata

Relationships

Depends on

  • MatExpansionModule
  • MatTooltipModule
  • MatInputModule
  • MatButtonModule
  • FormsModule

Index

Properties
Methods
Inputs

Constructor

constructor()

Inputs

doc
Type : DatabaseDocChange

document from the database in the current version

rev
Type : string

revision key (_rev) of the confliction version to be displayed

Methods

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 :
Name Type Optional Description
docToDelete DatabaseDocChange No

Document to be deleted

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 :
Name Type Optional Description
diffStringToApply string No

The (user-edited) diff to be applied to the current doc

Returns : any
stringify
stringify(entity: unknown)

Generate a human-readable string of the given object.

Parameters :
Name Type Optional Description
entity unknown No

Object to be stringified

Returns : string

Properties

Readonly diffs
Type : unknown
Default value : computed(() => { const doc = this.doc(); const revDoc = this.revDoc(); if (!doc || !revDoc) return ""; return this.stringify(diff(doc, revDoc)); })

changes the conflicting doc has compared to the current doc

Readonly diffsCustom
Type : unknown
Default value : linkedSignal(() => this.diffsReverse())

the user edited diff that can be applied as an alternative resolution (initialized with same value as diffsReverse)

Readonly diffsReverse
Type : unknown
Default value : computed(() => { const doc = this.doc(); const revDoc = this.revDoc(); if (!doc || !revDoc) return ""; return this.stringify(diff(revDoc, doc)); })

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.

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;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""