Yikes a lot we missed

This commit is contained in:
shamoon
2026-07-08 19:20:00 -07:00
parent ca515f7e1d
commit 03fbf2f866
19 changed files with 275 additions and 358 deletions
@@ -1,7 +1,7 @@
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
<div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
<span class="text-truncate">{{title}}</span>
<span class="text-truncate">{{title()}}</span>
@if (id()) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied()) {
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { Component, Input, inject, input, signal } from '@angular/core'
import { Component, effect, inject, input, signal } from '@angular/core'
import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -21,19 +21,14 @@ export class PageHeaderComponent {
readonly info = input<string>(undefined)
readonly infoLink = input<string>(undefined)
readonly loading = input(false)
readonly title = input('')
readonly copied = signal(false)
private copyTimeout: any
private readonly titleSignal = signal('')
@Input()
set title(title: string) {
this.titleSignal.set(title)
this.titleService.setTitle(`${title} - ${environment.appTitle}`)
}
get title() {
return this.titleSignal()
constructor() {
effect(() => {
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
})
}
public copyID() {
@@ -229,9 +229,9 @@ describe('ProfileEditDialogComponent', () => {
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyAuthToken()
expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
expect(component.copied).toBeTruthy()
expect(component.copied()).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
expect(component.copied()).toBeFalsy()
}))
it('should support generate token, display error if needed', () => {
@@ -140,7 +140,7 @@ export class ProfileEditDialogComponent
onEmailChange(): void {
this.showEmailConfirm.set(this.currentEmail !== this.newEmail)
if (this.showEmailConfirm) {
if (this.showEmailConfirm()) {
this.form.get('email_confirm').enable()
if (this.newEmail !== this.emailConfirm) {
this.setFieldError('email_confirm', $localize`Emails must match`)
@@ -167,7 +167,7 @@ export class ProfileEditDialogComponent
onPasswordChange(): void {
this.showPasswordConfirm.set(this.currentPassword !== this.newPassword)
if (this.showPasswordConfirm) {
if (this.showPasswordConfirm()) {
this.form.get('password_confirm').enable()
if (this.newPassword !== this.passwordConfirm) {
this.setFieldError('password_confirm', $localize`Passwords must match`)
@@ -97,7 +97,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
expect(component.bundles).toEqual(bundles)
expect(component.loading).toBe(false)
expect(component.loading()).toBe(false)
expect(component.error).toBeNull()
tick(5000)
@@ -115,7 +115,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(component.error).toContain('Failed to load share link bundles.')
expect(toastService.showError).toHaveBeenCalled()
expect(component.loading).toBe(false)
expect(component.loading()).toBe(false)
tick(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
@@ -165,7 +165,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect.stringContaining('deleted.')
)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(component.loading).toBe(false)
expect(component.loading()).toBe(false)
}))
it('handles delete errors gracefully', fakeAsync(() => {
@@ -179,7 +179,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
tick()
expect(toastService.showError).toHaveBeenCalled()
expect(component.loading).toBe(false)
expect(component.loading()).toBe(false)
}))
it('retries bundle build and replaces existing entry', fakeAsync(() => {
@@ -3,7 +3,7 @@
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (!status) {
@if (!status()) {
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@@ -21,25 +21,25 @@
<dl class="card-text">
<dt i18n>Paperless-ngx Version</dt>
<dd>
{{status.pngx_version}}
@if (versionMismatch) {
{{status().pngx_version}}
@if (versionMismatch()) {
<button class="btn btn-sm d-inline align-items-center btn-dark text-uppercase small" [ngbPopover]="versionPopover" triggers="click mouseenter:mouseleave">
<i-bs name="exclamation-triangle-fill" class="text-danger lh-1"></i-bs>
</button>
}
<ng-template #versionPopover>
Frontend version: {{frontendVersion}}<br>
Backend version: {{status.pngx_version}}
Backend version: {{status().pngx_version}}
</ng-template>
</dd>
<dt i18n>Install Type</dt>
<dd>{{status.install_type}}</dd>
<dd>{{status().install_type}}</dd>
<dt i18n>Server OS</dt>
<dd>{{status.server_os}}</dd>
<dd>{{status().server_os}}</dd>
<dt i18n>Media Storage</dt>
<dd>
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status.storage.total" [value]="status.storage.total - status.storage.available"></ngb-progressbar>
<span class="small">{{status.storage.available | fileSize}} <ng-container i18n>available</ng-container> ({{status.storage.total | fileSize}} <ng-container i18n>total</ng-container>)</span>
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status().storage.total" [value]="status().storage.total - status().storage.available"></ngb-progressbar>
<span class="small">{{status().storage.available | fileSize}} <ng-container i18n>available</ng-container> ({{status().storage.total | fileSize}} <ng-container i18n>total</ng-container>)</span>
</dd>
</dl>
</div>
@@ -54,39 +54,39 @@
<div class="card-body">
<dl class="card-text">
<dt i18n>Type</dt>
<dd>{{status.database.type}}</dd>
<dd>{{status().database.type}}</dd>
<dt i18n>Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
{{status.database.status}}
@if (status.database.status === 'OK') {
{{status().database.status}}
@if (status().database.status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #databaseStatus>
@if (status.database.status === 'OK') {
{{status.database.url}}
@if (status().database.status === 'OK') {
{{status().database.url}}
} @else {
{{status.database.url}}: {{status.database.error}}
{{status().database.url}}: {{status().database.error}}
}
</ng-template>
</dd>
<dt i18n>Migration Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave">
@if (status.database.migration_status.unapplied_migrations.length === 0) {
@if (status().database.migration_status.unapplied_migrations.length === 0) {
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
<ng-container>{{status().database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
}
<ng-template #migrationStatus>
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
@if (status.database.migration_status.unapplied_migrations.length > 0) {
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status().database.migration_status.latest_migration}}</span>
@if (status().database.migration_status.unapplied_migrations.length > 0) {
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
<ul>
@for (migration of status.database.migration_status.unapplied_migrations; track migration) {
@for (migration of status().database.migration_status.unapplied_migrations; track migration) {
<li class="font-monospace small">{{migration}}</li>
}
</ul>
@@ -109,60 +109,60 @@
<dt i18n>Redis Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
{{status().tasks.redis_status}}
@if (status().tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</button>
<ng-template #redisStatus>
@if (status.tasks.redis_status === 'OK') {
{{status.tasks.redis_url}}
@if (status().tasks.redis_status === 'OK') {
{{status().tasks.redis_url}}
} @else {
{{status.tasks.redis_url}}: {{status.tasks.redis_error}}
{{status().tasks.redis_url}}: {{status().tasks.redis_error}}
}
</ng-template>
</dd>
<dt i18n>Celery Status</dt>
<dd>
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
{{status().tasks.celery_status}}
@if (status().tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.celery_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.celery_status === SystemStatusItemStatus.WARNING"></i-bs>
[class.text-danger]="status().tasks.celery_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.celery_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
<ng-template #celeryStatus>
@if (status.tasks.celery_status === 'OK') {
{{status.tasks.celery_url}}
@if (status().tasks.celery_status === 'OK') {
{{status().tasks.celery_url}}
} @else {
{{status.tasks.celery_error}}
{{status().tasks.celery_error}}
}
</ng-template>
</dd>
<dt i18n>Recent Task Activity <span class="small text-muted fw-light">({{status.tasks.summary.days}} days)</span></dt>
<dt i18n>Recent Task Activity <span class="small text-muted fw-light">({{status().tasks.summary.days}} days)</span></dt>
<dd class="mb-0">
@if (status.tasks.summary.total_count > 0) {
@if (status().tasks.summary.total_count > 0) {
<ul class="list-group border-light mt-2">
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Total</ng-container>:</span>
<span class="badge bg-light rounded-pill">{{status.tasks.summary.total_count}}</span>
<span class="badge bg-light rounded-pill">{{status().tasks.summary.total_count}}</span>
</li>
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Successful</ng-container>:</span>
<span class="badge bg-primary rounded-pill">{{status.tasks.summary.success_count}}</span>
<span class="badge bg-primary rounded-pill">{{status().tasks.summary.success_count}}</span>
</li>
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Failed</ng-container>:</span>
<span class="badge bg-danger rounded-pill">{{status.tasks.summary.failure_count}}</span>
<span class="badge bg-danger rounded-pill">{{status().tasks.summary.failure_count}}</span>
</li>
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span class="small"><ng-container i18n>Pending</ng-container>:</span>
<span class="badge bg-warning rounded-pill">{{status.tasks.summary.pending_count}}</span>
<span class="badge bg-warning rounded-pill">{{status().tasks.summary.pending_count}}</span>
</li>
</ul>
} @else {
@@ -184,8 +184,8 @@
<dt i18n>Search Index</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.index_status}}
@if (status.tasks.index_status === 'OK') {
{{status().tasks.index_status}}
@if (status().tasks.index_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
@@ -193,26 +193,26 @@
</button>
</dd>
<ng-template #indexStatus>
@if (status.tasks.index_status === 'OK') {
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
@if (status().tasks.index_status === 'OK') {
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.index_last_modified | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.index_error}}</span>
}
</ng-template>
<dt i18n>Classifier</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.classifier_status}}
@if (status.tasks.classifier_status === 'OK') {
@if (isStale(status.tasks.classifier_last_trained)) {
{{status().tasks.classifier_status}}
@if (status().tasks.classifier_status === 'OK') {
@if (isStale(status().tasks.classifier_last_trained)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
[class.text-danger]="status().tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@@ -227,26 +227,26 @@
}
</dd>
<ng-template #classifierStatus>
@if (status.tasks.classifier_status === 'OK') {
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
@if (status().tasks.classifier_status === 'OK') {
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.classifier_last_trained | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.classifier_error}}</span>
}
</ng-template>
<dt i18n>Sanity Checker</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.sanity_check_status}}
@if (status.tasks.sanity_check_status === 'OK') {
@if (isStale(status.tasks.sanity_check_last_run)) {
{{status().tasks.sanity_check_status}}
@if (status().tasks.sanity_check_status === 'OK') {
@if (isStale(status().tasks.sanity_check_last_run)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
[class.text-danger]="status().tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@@ -261,16 +261,16 @@
}
</dd>
<ng-template #sanityCheckerStatus>
@if (status.tasks.sanity_check_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_last_run | customDate:'medium'}}</span>
@if (status().tasks.sanity_check_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.sanity_check_last_run | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.sanity_check_error}}</span>
}
</ng-template>
<dt i18n>WebSocket Connection</dt>
<dd>
<span class="btn btn-sm pe-none align-items-center btn-dark text-uppercase small">
@if (status.websocket_connected === 'OK') {
@if (status().websocket_connected === 'OK') {
<ng-container i18n>OK</ng-container>
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
@@ -283,18 +283,18 @@
<dt i18n>AI Index</dt>
<dd class="d-flex align-items-center">
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="llmIndexStatus" triggers="click mouseenter:mouseleave">
{{status.tasks.llmindex_status}}
@if (status.tasks.llmindex_status === 'OK') {
@if (isStale(status.tasks.llmindex_last_modified)) {
{{status().tasks.llmindex_status}}
@if (status().tasks.llmindex_status === 'OK') {
@if (isStale(status().tasks.llmindex_last_modified)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.llmindex_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.llmindex_status === SystemStatusItemStatus.WARNING"
[class.text-muted]="status.tasks.llmindex_status === SystemStatusItemStatus.DISABLED"></i-bs>
[class.text-danger]="status().tasks.llmindex_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status().tasks.llmindex_status === SystemStatusItemStatus.WARNING"
[class.text-muted]="status().tasks.llmindex_status === SystemStatusItemStatus.DISABLED"></i-bs>
}
</button>
@if (currentUserIsSuperUser) {
@@ -309,10 +309,10 @@
}
</dd>
<ng-template #llmIndexStatus>
@if (status.tasks.llmindex_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_last_modified | customDate:'medium'}}</span>
@if (status().tasks.llmindex_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.llmindex_last_modified | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_error}}</span>
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status().tasks.llmindex_error}}</span>
}
</ng-template>
}
@@ -325,10 +325,10 @@
</div>
<div class="modal-footer">
<button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
@if (!copied) {
@if (!copied()) {
<i-bs name="clipboard-fill" class="me-1"></i-bs>
}
@if (copied) {
@if (copied()) {
<i-bs name="clipboard-check-fill" class="me-1"></i-bs>
}
<ng-container i18n>Copy</ng-container>
@@ -16,6 +16,7 @@ jest.mock('src/environments/environment', () => ({
import { Clipboard } from '@angular/cdk/clipboard'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { signal } from '@angular/core'
import {
ComponentFixture,
TestBed,
@@ -106,7 +107,7 @@ describe('SystemStatusDialogComponent', () => {
fixture = TestBed.createComponent(SystemStatusDialogComponent)
component = fixture.componentInstance
component.status = status
component.status = signal({ ...status })
clipboard = TestBed.inject(Clipboard)
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
@@ -130,11 +131,11 @@ describe('SystemStatusDialogComponent', () => {
jest.spyOn(clipboard, 'copy')
component.copy()
expect(clipboard.copy).toHaveBeenCalledWith(
JSON.stringify(component.status, null, 4)
JSON.stringify(component.status(), null, 4)
)
expect(component.copied).toBeTruthy()
expect(component.copied()).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
expect(component.copied()).toBeFalsy()
}))
it('should calculate if date is stale', () => {
@@ -180,25 +181,28 @@ describe('SystemStatusDialogComponent', () => {
it('shoduld handle version mismatch', () => {
component.frontendVersion = '2.4.2'
component.ngOnInit()
expect(component.versionMismatch).toBeTruthy()
expect(component.status.pngx_version).toContain('(frontend: 2.4.2)')
expect(component.versionMismatch()).toBeTruthy()
expect(component.status().pngx_version).toContain('(frontend: 2.4.2)')
component.frontendVersion = '2.4.3'
component.status.pngx_version = '2.4.3'
component.status.update((status) => ({
...status,
pngx_version: '2.4.3',
}))
component.ngOnInit()
expect(component.versionMismatch).toBeFalsy()
expect(component.versionMismatch()).toBeFalsy()
})
it('should update websocket connection status', () => {
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
expect(component.status().websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
websocketSubject.next(false)
expect(component.status.websocket_connected).toEqual(
expect(component.status().websocket_connected).toEqual(
SystemStatusItemStatus.ERROR
)
websocketSubject.next(true)
expect(component.status.websocket_connected).toEqual(
expect(component.status().websocket_connected).toEqual(
SystemStatusItemStatus.OK
)
})
@@ -1,13 +1,13 @@
@if (tag) {
@if (showParents && tag.parent) {
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
@if (tag()) {
@if (showParents() && tag().parent) {
<pngx-tag [tagID]="tag().parent" [clickable]="clickable()" [linkTitle]="linkTitle()"></pngx-tag>
&nbsp;&gt;&nbsp;
}
@if (!clickable) {
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
@if (!clickable()) {
<span class="badge" [style.background]="tag().color" [style.color]="tag().text_color">{{tag().name}}</span>
}
@if (clickable) {
<a [title]="linkTitle" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a>
@if (clickable()) {
<a [title]="linkTitle()" class="badge" [style.background]="tag().color" [style.color]="tag().text_color">{{tag().name}}</a>
}
} @else if (loading) {
<span class="placeholder-glow">
@@ -16,10 +16,10 @@
</span>
</span>
} @else {
@if (!clickable) {
@if (!clickable()) {
<span class="badge private" i18n>Private</span>
}
@if (clickable) {
<a [title]="linkTitle" class="badge private" i18n>Private</a>
@if (clickable()) {
<a [title]="linkTitle()" class="badge private" i18n>Private</a>
}
}
@@ -1,4 +1,4 @@
import { Component, inject, Input, signal } from '@angular/core'
import { Component, effect, inject, input, model } from '@angular/core'
import { Tag } from 'src/app/data/tag'
import {
PermissionAction,
@@ -16,63 +16,31 @@ export class TagComponent {
private permissionsService = inject(PermissionsService)
private tagService = inject(TagService)
private tagSignal = signal<Tag>(null)
private linkTitleSignal = signal('')
private clickableSignal = signal(false)
private showParentsSignal = signal(false)
private _tagID: number
readonly tag = model<Tag>(null)
readonly tagID = input<number>(undefined)
readonly linkTitle = input('')
readonly clickable = input(false, {
transform: (value: boolean | string) =>
value === '' || value === true || value === 'true',
})
readonly showParents = input(false)
@Input()
public set tag(tag: Tag) {
this.tagSignal.set(tag)
}
public get tag(): Tag {
return this.tagSignal()
}
@Input()
set tagID(tagID: number) {
if (tagID !== this._tagID) {
this._tagID = tagID
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService.getCached(this._tagID).subscribe((tag) => {
this.tag = tag
})
constructor() {
effect(() => {
const tagID = this.tagID()
if (tagID) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
)
) {
this.tagService.getCached(tagID).subscribe((tag) => {
this.tag.set(tag)
})
}
}
}
}
@Input()
get linkTitle(): string {
return this.linkTitleSignal()
}
set linkTitle(linkTitle: string) {
this.linkTitleSignal.set(linkTitle)
}
@Input()
get clickable(): boolean {
return this.clickableSignal()
}
set clickable(clickable: boolean) {
this.clickableSignal.set(clickable)
}
@Input()
get showParents(): boolean {
return this.showParentsSignal()
}
set showParents(showParents: boolean) {
this.showParentsSignal.set(showParents)
})
}
public get loading(): boolean {
@@ -164,11 +164,11 @@ describe('DashboardComponent', () => {
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
expect(settingsService.globalDropzoneEnabled()).toBeFalsy()
component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
})
it('should update saved view sorting on drag + drop, show info', () => {
@@ -36,7 +36,7 @@
@if (loading && !documents().length) {
@for (row of placeholderRows; track row) {
<tr>
@for (field of displayFields; track field; let j = $index) {
@for (field of displayFields(); track field; let j = $index) {
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': j > 1 }">
<div class="placeholder-glow text-start">
<span class="placeholder bg-secondary w-50" [ngStyle]="{ opacity: 1 - (row * 1/placeholderRows.length) }"></span>
@@ -229,7 +229,7 @@ describe('SavedViewWidgetComponent', () => {
}
)
fixture.detectChanges()
expect(component.documents).toEqual(documentResults)
expect(component.documents()).toEqual(documentResults)
})
it('should reload on document consumption finished', () => {
@@ -182,7 +182,7 @@ export class SavedViewWidgetComponent
}
reload() {
this.loading.set(this.documents.length == 0)
this.loading.set(this.documents().length == 0)
this.show.set(true)
this.documentService
.listFiltered(
@@ -4,7 +4,7 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { EventEmitter } from '@angular/core'
import { EventEmitter, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
@@ -1635,7 +1635,7 @@ describe('BulkEditorComponent', () => {
file_version: 'archive',
expiration_days: 7,
},
loading: false,
loading: signal(false),
buttonsEnabled: true,
copied: false,
},
@@ -1667,7 +1667,7 @@ describe('BulkEditorComponent', () => {
file_version: 'archive',
expiration_days: 7,
})
expect(dialogInstance.loading).toBe(false)
expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled).toBe(false)
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
expect(typeof dialogInstance.onOpenManage).toBe('function')
@@ -1704,7 +1704,7 @@ describe('BulkEditorComponent', () => {
file_version: 'original',
expiration_days: null,
},
loading: false,
loading: signal(false),
buttonsEnabled: true,
},
}
@@ -1726,7 +1726,7 @@ describe('BulkEditorComponent', () => {
$localize`Share link bundle creation is not available yet.`,
expect.any(Error)
)
expect(dialogInstance.loading).toBe(false)
expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled).toBe(true)
openSpy.mockRestore()
})
@@ -1,13 +1,13 @@
<div class="card document-card-large mb-3 shadow-sm bg-light placeholder-glow fade" [class.show]="show" [class.card-selected]="selected" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
<div class="card document-card-large mb-3 shadow-sm bg-light placeholder-glow fade" [class.show]="show()" [class.card-selected]="selected()" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
<div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document) {
@if (document()) {
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
<div class="border-end border-bottom bg-light document-card-check">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)">
<label class="form-check-label" for="smallCardCheck{{document.id}}"></label>
<input type="checkbox" class="form-check-input" id="smallCardCheck{{document().id}}" [checked]="selected()" (click)="this.toggleSelected.emit($event)">
<label class="form-check-label" for="smallCardCheck{{document().id}}"></label>
</div>
</div>
} @else {
@@ -19,20 +19,20 @@
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title w-100">
@if (document) {
@if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
@if (document()) {
@if (displayFields().includes(DisplayField.CORRESPONDENT) && document().correspondent) {
@if (clickCorrespondent.observers.length ) {
<a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{document.correspondent | correspondentName | async}}</a>
<a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document().correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{document().correspondent | correspondentName | async}}</a>
} @else {
{{document.correspondent | correspondentName | async}}
{{document().correspondent | correspondentName | async}}
}
@if (displayFields.includes(DisplayField.TITLE)) {:}
@if (displayFields().includes(DisplayField.TITLE)) {:}
}
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
@if (displayFields().includes(DisplayField.TITLE)) {
{{document().title | documentTitle}}
}
@if (displayFields.includes(DisplayField.TAGS)) {
@for (tagID of document.tags; track tagID) {
@if (displayFields().includes(DisplayField.TAGS)) {
@for (tagID of document().tags; track tagID) {
<pngx-tag [tagID]="tagID" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
}
}
@@ -42,9 +42,9 @@
</h5>
</div>
<p class="card-text">
@if (document) {
@if (document()) {
@if (hasSearchHighlights) {
<span [innerHtml]="document.__search_hit__.highlights"></span>
<span [innerHtml]="document().__search_hit__.highlights"></span>
}
@for (highlight of searchNoteHighlights; track highlight) {
<span class="d-block">
@@ -64,14 +64,14 @@
<div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group">
@if (document) {
@if (document()) {
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<i-bs name="diagram-3" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="file-earmark-richtext" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Open</span>
</a>
<pngx-preview-popup [document]="document" #popupPreview>
<pngx-preview-popup [document]="document()" #popupPreview>
<i-bs name="eye" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>View</span>
</pngx-preview-popup>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
@@ -86,76 +86,76 @@
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (document) {
@if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small>{{document.notes.length}} Notes</small>
@if (document()) {
@if (displayFields().includes(DisplayField.NOTES) && notesEnabled && document().notes.length) {
<button routerLink="/documents/{{document().id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small>{{document().notes.length}} Notes</small>
</button>
}
@if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
@if (displayFields().includes(DisplayField.DOCUMENT_TYPE) && document().document_type) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{document.document_type | documentTypeName | async}}</small>
(click)="clickDocumentType.emit(document().document_type);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{document().document_type | documentTypeName | async}}</small>
</button>
}
@if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
@if (displayFields().includes(DisplayField.STORAGE_PATH) && document().storage_path) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{document.storage_path | storagePathName | async}}</small>
(click)="clickStoragePath.emit(document().storage_path);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{document().storage_path | storagePathName | async}}</small>
</button>
}
@if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
@if (displayFields().includes(DisplayField.ASN) && document().archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document().archive_serial_number}}</small>
</div>
}
@if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
@if (displayFields().includes(DisplayField.CREATED) || displayFields().includes(DisplayField.ADDED)) {
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
<span i18n>Created: {{ document().created | customDate }}</span>
<span i18n>Added: {{ document().added | customDate }}</span>
<span i18n>Modified: {{ document().modified | customDate }}</span>
</div>
</ng-template>
@if (displayFields.includes(DisplayField.CREATED)) {
@if (displayFields().includes(DisplayField.CREATED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created | customDate:'mediumDate'}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document().created | customDate:'mediumDate'}}</small>
</div>
}
@if (displayFields.includes(DisplayField.ADDED)) {
@if (displayFields().includes(DisplayField.ADDED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.added | customDate:'mediumDate'}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document().added | customDate:'mediumDate'}}</small>
</div>
}
}
@if (displayFields.includes(DisplayField.PAGE_COUNT) && document.page_count) {
@if (displayFields().includes(DisplayField.PAGE_COUNT) && document().page_count) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="files"></i-bs>
<small i18n>{document.page_count, plural, =1 {1 page} other {{{document.page_count}} pages}}</small>
<small i18n>{document().page_count, plural, =1 {1 page} other {{{document().page_count}} pages}}</small>
</div>
}
@if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser().id) {
@if (displayFields().includes(DisplayField.OWNER) && document().owner && document().owner !== settingsService.currentUser().id) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username | async}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document().owner | username | async}}</small>
</div>
}
@if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
@if (displayFields().includes(DisplayField.SHARED) && document().is_shared_by_requester) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
</div>
}
@if (document.__search_hit__?.score) {
@if (document().__search_hit__?.score) {
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="me-2 text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
<ngb-progressbar [type]="searchScoreClass" [value]="document().__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div>
}
@for (field of document.custom_fields; track field.field) {
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
@for (field of document().custom_fields; track field.field) {
@if (displayFields().includes(DisplayField.CUSTOM_FIELD + field.field)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="ui-radios"></i-bs>
<small>
<pngx-custom-field-display [document]="document" [fieldId]="field.field" showNameIfEmpty="true"></pngx-custom-field-display>
<pngx-custom-field-display [document]="document()" [fieldId]="field.field" showNameIfEmpty="true"></pngx-custom-field-display>
</small>
</div>
}
@@ -3,11 +3,10 @@ import {
AfterViewInit,
Component,
EventEmitter,
Input,
Output,
ViewChild,
inject,
signal,
input,
} from '@angular/core'
import { RouterModule } from '@angular/router'
import {
@@ -65,32 +64,14 @@ export class DocumentCardLargeComponent
{
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
private selectedSignal = signal(false)
private displayFieldsSignal = signal<string[]>(
readonly selected = input(false)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
)
private documentSignal = signal<Document>(undefined)
readonly document = input<Document>(undefined)
DisplayField = DisplayField
@Input()
get selected(): boolean {
return this.selectedSignal()
}
set selected(selected: boolean) {
this.selectedSignal.set(selected)
}
@Input()
get displayFields(): string[] {
return this.displayFieldsSignal()
}
set displayFields(displayFields: string[]) {
this.displayFieldsSignal.set(displayFields)
}
@Output()
toggleSelected = new EventEmitter()
@@ -98,15 +79,6 @@ export class DocumentCardLargeComponent
return this.toggleSelected.observers.length > 0
}
@Input()
get document(): Document {
return this.documentSignal()
}
set document(document: Document) {
this.documentSignal.set(document)
}
@Output()
dblClickDocument = new EventEmitter()
@@ -135,10 +107,11 @@ export class DocumentCardLargeComponent
}
get searchScoreClass() {
if (this.document.__search_hit__) {
if (this.document.__search_hit__.score > 0.7) {
const document = this.document()
if (document.__search_hit__) {
if (document.__search_hit__.score > 0.7) {
return 'success'
} else if (this.document.__search_hit__.score > 0.3) {
} else if (document.__search_hit__.score > 0.3) {
return 'warning'
} else {
return 'danger'
@@ -148,12 +121,13 @@ export class DocumentCardLargeComponent
get searchNoteHighlights() {
let highlights = []
const document = this.document()
if (
this.document['__search_hit__'] &&
this.document['__search_hit__'].note_highlights
document['__search_hit__'] &&
document['__search_hit__'].note_highlights
) {
// only show notes with a match
highlights = (this.document['__search_hit__'].note_highlights as string)
highlights = (document['__search_hit__'].note_highlights as string)
.split(',')
.filter((highlight) => highlight.includes('<span'))
}
@@ -165,11 +139,11 @@ export class DocumentCardLargeComponent
}
getThumbUrl() {
return this.documentService.getThumbUrl(this.document.id)
return this.documentService.getThumbUrl(this.document().id)
}
getDownloadUrl() {
return this.documentService.getDownloadUrl(this.document.id)
return this.documentService.getDownloadUrl(this.document().id)
}
mouseLeaveCard() {
@@ -177,19 +151,21 @@ export class DocumentCardLargeComponent
}
get contentTrimmed() {
const document = this.document()
return (
this.document.content.substring(0, 500) +
(this.document.content.length > 500 ? '...' : '')
document.content.substring(0, 500) +
(document.content.length > 500 ? '...' : '')
)
}
get hasSearchHighlights() {
return Boolean(this.document?.__search_hit__?.highlights?.trim()?.length)
return Boolean(this.document()?.__search_hit__?.highlights?.trim()?.length)
}
get shouldShowContentFallback() {
const document = this.document()
return (
this.document?.__search_hit__?.score == null ||
document?.__search_hit__?.score == null ||
(!this.hasSearchHighlights && this.searchNoteHighlights.length === 0)
)
}
@@ -1,21 +1,21 @@
<div class="col p-2 h-100 fade" [class.show]="show">
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()">
<div class="col p-2 h-100 fade" [class.show]="show()">
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document) {
@if (document()) {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)">
<label class="form-check-label" for="smallCardCheck{{document.id}}"></label>
<input type="checkbox" class="form-check-input" id="smallCardCheck{{document().id}}" [checked]="selected()" (click)="this.toggleSelected.emit($event)">
<label class="form-check-label" for="smallCardCheck{{document().id}}"></label>
</div>
</div>
} @else {
<div class="placeholder bg-secondary w-100 card-img doc-img"></div>
}
@if (document && displayFields?.includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document.tags.length > 3">
@if (document() && displayFields().includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6" [class.tags-no-wrap]="document().tags.length > 3">
@for (tagID of tagIDs; track tagID) {
<pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
}
@@ -28,23 +28,23 @@
}
</div>
@if (document && displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
@if (document() && displayFields().includes(DisplayField.NOTES) && notesEnabled && document().notes.length) {
<a routerLink="/documents/{{document().id}}/notes" class="document-card-notes py-2 px-1">
<span class="badge rounded-pill bg-light border text-primary">
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
{{document.notes.length}}</span>
{{document().notes.length}}</span>
</a>
}
<div class="card-body bg-light p-2">
<p class="card-text">
@if (document) {
@if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
<a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{document.correspondent | correspondentName | async}}</a>
@if (displayFields.includes(DisplayField.TITLE)) {:}
@if (document()) {
@if (displayFields().includes(DisplayField.CORRESPONDENT) && document().correspondent) {
<a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document().correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{document().correspondent | correspondentName | async}}</a>
@if (displayFields().includes(DisplayField.TITLE)) {:}
}
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
@if (displayFields().includes(DisplayField.TITLE)) {
{{document().title | documentTitle}}
}
} @else {
<div class="placeholder bg-secondary w-100"></div>
@@ -54,82 +54,82 @@
</div>
<div class="card-footer pt-0 pb-2 px-2">
<div class="list-group list-group-flush border-0 pt-1 pb-2 card-info">
@if (document) {
@if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
@if (document()) {
@if (displayFields().includes(DisplayField.DOCUMENT_TYPE) && document().document_type) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
(click)="clickDocumentType.emit(document().document_type);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
<small>{{document.document_type | documentTypeName | async}}</small>
<small>{{document().document_type | documentTypeName | async}}</small>
</button>
}
@if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
@if (displayFields().includes(DisplayField.STORAGE_PATH) && document().storage_path) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
(click)="clickStoragePath.emit(document().storage_path);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{document.storage_path | storagePathName | async}}</small>
<small>{{document().storage_path | storagePathName | async}}</small>
</button>
}
@if (displayFields.includes(DisplayField.CREATED)) {
@if (displayFields().includes(DisplayField.CREATED)) {
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateCreatedTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
<span i18n>Created: {{ document().created | customDate }}</span>
<span i18n>Added: {{ document().added | customDate }}</span>
<span i18n>Modified: {{ document().modified | customDate }}</span>
</div>
</ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateCreatedTooltip">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
<small>{{document.created | customDate:'mediumDate'}}</small>
<small>{{document().created | customDate:'mediumDate'}}</small>
</div>
</div>
}
@if (displayFields.includes(DisplayField.ADDED)) {
@if (displayFields().includes(DisplayField.ADDED)) {
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateAddedTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
<span i18n>Created: {{ document().created | customDate }}</span>
<span i18n>Added: {{ document().added | customDate }}</span>
<span i18n>Modified: {{ document().modified | customDate }}</span>
</div>
</ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateAddedTooltip">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
<small>{{document.added | customDate:'mediumDate'}}</small>
<small>{{document().added | customDate:'mediumDate'}}</small>
</div>
</div>
}
@if (displayFields.includes(DisplayField.PAGE_COUNT) && document.page_count) {
@if (displayFields().includes(DisplayField.PAGE_COUNT) && document().page_count) {
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<div class="ps-0 p-1" placement="top">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="files"></i-bs>
<small i18n>{document.page_count, plural, =1 {1 page} other {{{document.page_count}} pages}}</small>
<small i18n>{document().page_count, plural, =1 {1 page} other {{{document().page_count}} pages}}</small>
</div>
</div>
}
@if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
@if (displayFields().includes(DisplayField.ASN) && document().archive_serial_number | isNumber) {
<div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
<small>#{{document.archive_serial_number}}</small>
<small>#{{document().archive_serial_number}}</small>
</div>
}
@if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser().id) {
@if (displayFields().includes(DisplayField.OWNER) && document().owner && document().owner !== settingsService.currentUser().id) {
<div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
<small>{{document.owner | username | async}}</small>
<small>{{document().owner | username | async}}</small>
</div>
}
@if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
@if (displayFields().includes(DisplayField.SHARED) && document().is_shared_by_requester) {
<div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
<small i18n>Shared</small>
</div>
}
@for (field of document.custom_fields; track field.field) {
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
@for (field of document().custom_fields; track field.field) {
@if (displayFields().includes(DisplayField.CUSTOM_FIELD + field.field)) {
<div class="ps-0 p-1 d-flex align-items-center overflow-hidden">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="ui-radios"></i-bs>
<small><pngx-custom-field-display [document]="document" [fieldId]="field.field" showNameIfEmpty="true"></pngx-custom-field-display></small>
<small><pngx-custom-field-display [document]="document()" [fieldId]="field.field" showNameIfEmpty="true"></pngx-custom-field-display></small>
</div>
}
}
@@ -139,11 +139,11 @@
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100">
@if (document) {
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
@if (document()) {
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
<i-bs name="file-earmark-richtext"></i-bs>
</a>
<pngx-preview-popup [document]="document" #popupPreview>
<pngx-preview-popup [document]="document()" #popupPreview>
<i-bs name="eye"></i-bs>
</pngx-preview-popup>
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
@@ -3,11 +3,10 @@ import {
AfterViewInit,
Component,
EventEmitter,
Input,
Output,
ViewChild,
inject,
signal,
input,
} from '@angular/core'
import { RouterModule } from '@angular/router'
import {
@@ -65,44 +64,17 @@ export class DocumentCardSmallComponent
{
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
private selectedSignal = signal(false)
private documentSignal = signal<Document>(undefined)
private displayFieldsSignal = signal<string[]>(
readonly selected = input(false)
readonly document = input<Document>(undefined)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
)
DisplayField = DisplayField
@Input()
get selected(): boolean {
return this.selectedSignal()
}
set selected(selected: boolean) {
this.selectedSignal.set(selected)
}
@Output()
toggleSelected = new EventEmitter()
@Input()
get document(): Document {
return this.documentSignal()
}
set document(document: Document) {
this.documentSignal.set(document)
}
@Input()
get displayFields(): string[] {
return this.displayFieldsSignal()
}
set displayFields(displayFields: string[]) {
this.displayFieldsSignal.set(displayFields)
}
@Output()
dblClickDocument = new EventEmitter()
@@ -119,9 +91,10 @@ export class DocumentCardSmallComponent
clickStoragePath = new EventEmitter<number>()
get moreTags(): number {
const limit = this.document?.notes.length > 0 ? 6 : 7
return this.document?.tags.length > limit
? this.document.tags.length - (limit - 1)
const document = this.document()
const limit = document?.notes.length > 0 ? 6 : 7
return document?.tags.length > limit
? document.tags.length - (limit - 1)
: null
}
@@ -136,19 +109,20 @@ export class DocumentCardSmallComponent
}
getThumbUrl() {
return this.documentService.getThumbUrl(this.document.id)
return this.documentService.getThumbUrl(this.document().id)
}
getDownloadUrl() {
return this.documentService.getDownloadUrl(this.document.id)
return this.documentService.getDownloadUrl(this.document().id)
}
get tagIDs() {
const limit = this.document.notes.length > 0 ? 6 : 7
if (this.document.tags.length > limit) {
return this.document.tags.slice(0, limit - 1)
const document = this.document()
const limit = document.notes.length > 0 ? 6 : 7
if (document.tags.length > limit) {
return document.tags.slice(0, limit - 1)
} else {
return this.document.tags
return document.tags
}
}
@@ -26,7 +26,7 @@ export class FileDropComponent {
public get dragDropEnabled(): boolean {
return (
this.settings.globalDropzoneEnabled &&
this.settings.globalDropzoneEnabled() &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document