Enhancement: render document notes as markdown (#14626)

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Johannes Hagerandshamoon authored and GitHub committed 2026-11-13 09:08:20 -08:00
1 parent bfd5628dcc
commit b63ea77543
13 files changed
+270 -30

No files matched your search

@@ -2,26 +2,47 @@
<div>
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
<div class="form-group">
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
<div class="invalid-feedback" i18n>
Please enter a note.
</div>
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" [class.d-none]="noteView() !== NoteViewMode.Edit" [attr.aria-invalid]="newNoteError" aria-label="Note" i18n-aria-label aria-describedby="noteFeedback" rows="3" formControlName="newNote" placeholder="Enter note (Markdown is supported)" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
@if (noteView() === NoteViewMode.Preview) {
<section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
@if (noteForm.get('newNote').value) {
<div class="note-markdown text-break text-wrap" [innerHTML]="noteForm.get('newNote').value | markdown"></div>
} @else {
<span class="text-muted" i18n>Nothing to preview</span>
}
</section>
}
<div id="noteFeedback" class="invalid-feedback" i18n>
Please enter a note.
</div>
</div>
<div class="form-group mt-2 d-flex justify-content-end align-items-center">
<div class="form-group mt-2 d-flex justify-content-between align-items-center">
@if (networkActive()) {
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
}
<fieldset class="btn-group btn-group-sm" aria-label="Note input mode" i18n-aria-label>
<input type="radio" class="btn-check" id="noteViewEdit" name="noteViewMode" [checked]="noteView() === NoteViewMode.Edit" (change)="noteView.set(NoteViewMode.Edit)">
<label class="btn btn-outline-secondary" for="noteViewEdit" i18n>Edit</label>
<input type="radio" class="btn-check" id="noteViewPreview" name="noteViewMode" [checked]="noteView() === NoteViewMode.Preview" (change)="noteView.set(NoteViewMode.Preview)">
<label class="btn btn-outline-secondary" for="noteViewPreview" i18n>Preview</label>
</fieldset>
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
</div>
</form>
<hr>
@for (note of notes(); track note) {
@for (note of notes(); track note.id) {
<div class="card border mb-3">
<div class="card-body text-dark">
<p class="card-text">{{note.note}}</p>
@if (note.format === NoteFormat.Markdown) {
<div class="card-text note-markdown text-break text-wrap" [innerHTML]="note.note | markdown"></div>
} @else {
<p class="card-text">{{note.note}}</p>
}
</div>
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
<span>{{displayName(note)}} - {{ note.created | customDate}}</span>
<span>
{{displayName(note)}} - {{ note.created | customDate}}
</span>
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
<pngx-bs-icon name="trash"></pngx-bs-icon>
<span class="visually-hidden" i18n>Delete note</span>
@@ -5,3 +5,42 @@
.card:hover .fade {
opacity: 1;
}
.note-preview {
min-height: calc(1.5em * 3 + 0.5rem + var(--bs-border-width) * 2); // textfield height with 3 rows
}
.note-markdown ::ng-deep {
h1, h2, h3, h4, h5, h6 {
font-size: 1em;
font-weight: bold;
}
table {
margin-bottom: 1rem;
display: block;
overflow-x: auto;
white-space: nowrap;
}
th, td {
padding: 0.15rem 0.4rem;
border: 1px solid var(--bs-border-color);
}
pre {
padding: 0.5rem;
background-color: var(--bs-tertiary-bg);
border-radius: var(--bs-border-radius);
}
blockquote {
padding-left: 0.75rem;
border-left: 0.25rem solid var(--bs-border-color);
color: var(--bs-secondary-color);
}
> :last-child {
margin-bottom: 0;
}
}
@@ -5,7 +5,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { of, throwError } from 'rxjs'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -129,6 +129,66 @@ describe('DocumentNotesComponent', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
})
it('should render notes according to their format', () => {
component.notes.set([
{ id: 31, note: '**x**', format: NoteFormat.Markdown },
{
id: 32,
note: '<script>alert(1)</script>',
format: NoteFormat.Markdown,
},
{ id: 33, note: '**x**', format: NoteFormat.Plain },
])
fixture.detectChanges()
const noteCards = fixture.debugElement.queryAll(By.css('.card-text'))
expect(noteCards[0].query(By.css('strong')).nativeElement.textContent).toBe(
'x'
)
expect(noteCards[1].query(By.css('script'))).toBeNull()
expect(noteCards[2].query(By.css('strong'))).toBeNull()
expect(noteCards[2].nativeElement.textContent).toBe('**x**')
})
it('should toggle between edit and preview', () => {
const radios = fixture.debugElement.queryAll(
By.css('.btn-group input[type="radio"]')
)
radios[1].triggerEventHandler('change')
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('.note-preview')).nativeElement
.textContent
).toContain('Nothing to preview')
component.noteForm.get('newNote').setValue('**bold**')
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('.note-preview strong'))
).not.toBeNull()
expect(
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
).toContain('d-none')
radios[0].triggerEventHandler('change')
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.note-preview'))).toBeNull()
expect(
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
).not.toContain('d-none')
})
it('should return to edit mode after saving', () => {
component.documentId.set(12)
component.noteForm.get('newNote').setValue('Preview note')
fixture.debugElement
.queryAll(By.css('.btn-group input[type="radio"]'))[1]
.triggerEventHandler('change')
jest.spyOn(notesService, 'addNote').mockReturnValueOnce(of(notes))
component.addNote()
expect(component.noteView()).toBe('edit')
})
it('should handle note user display in all situations', () => {
expect(
component.displayName({
@@ -161,7 +221,7 @@ describe('DocumentNotesComponent', () => {
const addSpy = jest.spyOn(notesService, 'addNote')
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
const toastsSpy = jest.spyOn(toastService, 'showError')
const addButton = fixture.debugElement.query(By.css('button'))
const addButton = fixture.debugElement.query(By.css('.btn-primary'))
addButton.triggerEventHandler('click')
expect(addSpy).toHaveBeenCalledWith(12, note)
expect(toastsSpy).toHaveBeenCalled()
@@ -190,7 +250,9 @@ describe('DocumentNotesComponent', () => {
component.documentId.set(12)
component.notes.set(notes)
fixture.detectChanges()
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button
const deleteButton = fixture.debugElement.query(
By.css('.card-footer button')
)
const deleteSpy = jest.spyOn(notesService, 'deleteNote')
const toastsSpy = jest.spyOn(toastService, 'showError')
deleteSpy.mockReturnValueOnce(
@@ -14,15 +14,21 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
import { UserService } from 'src/app/services/rest/user.service'
import { ToastService } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
enum NoteViewMode {
Edit = 'edit',
Preview = 'preview',
}
@Component({
selector: 'pngx-document-notes',
templateUrl: './document-notes.component.html',
@@ -30,6 +36,7 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
imports: [
IfPermissionsDirective,
CustomDatePipe,
MarkdownPipe,
FormsModule,
ReactiveFormsModule,
BootstrapIconComponent,
@@ -43,6 +50,9 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
readonly notes = model<DocumentNote[]>([])
readonly addDisabled = input(false)
readonly networkActive = signal(false)
protected readonly NoteViewMode = NoteViewMode
protected readonly NoteFormat = NoteFormat
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
noteForm: FormGroup = new FormGroup({
newNote: new FormControl(''),
@@ -75,6 +85,7 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
next: (result) => {
this.notes.set(result)
this.noteForm.get('newNote').reset()
this.noteView.set(NoteViewMode.Edit)
this.networkActive.set(false)
this.updated.emit(this.notes())
},
+6
View File
@@ -1,8 +1,14 @@
import { ObjectWithId } from './object-with-id'
import { User } from './user'
export enum NoteFormat {
Plain = 'plain',
Markdown = 'markdown',
}
export interface DocumentNote extends ObjectWithId {
created?: Date
note?: string
user?: User
format?: NoteFormat
}
@@ -1,6 +1,7 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { NoteFormat } from 'src/app/data/document-note'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { DocumentNotesService } from './document-notes.service'
@@ -50,6 +51,7 @@ describe(`Additional service tests for DocumentNotesService`, () => {
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
note: content,
format: NoteFormat.Markdown,
})
})
@@ -1,7 +1,7 @@
import { HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { DocumentNote } from 'src/app/data/document-note'
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({
@@ -22,6 +22,7 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
addNote(id: number, note: string): Observable<DocumentNote[]> {
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
note: note,
format: NoteFormat.Markdown,
})
}