mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Enhancement: better monetary field with currency code (#5858)
This commit is contained in:
		@@ -407,7 +407,7 @@ The following custom field types are supported:
 | 
			
		||||
- `URL`: a valid url
 | 
			
		||||
- `Integer`: integer number e.g. 12
 | 
			
		||||
- `Number`: float number e.g. 12.3456
 | 
			
		||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
 | 
			
		||||
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
 | 
			
		||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
 | 
			
		||||
 | 
			
		||||
## Share Links
 | 
			
		||||
 
 | 
			
		||||
@@ -447,7 +447,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">315</context>
 | 
			
		||||
          <context context-type="linenumber">313</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="3768927257183755959" datatype="html">
 | 
			
		||||
@@ -506,7 +506,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">307</context>
 | 
			
		||||
          <context context-type="linenumber">305</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
 | 
			
		||||
@@ -632,7 +632,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">324</context>
 | 
			
		||||
          <context context-type="linenumber">322</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
 | 
			
		||||
@@ -962,7 +962,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">283</context>
 | 
			
		||||
          <context context-type="linenumber">281</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
 | 
			
		||||
@@ -3815,6 +3815,10 @@
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/input/file/file.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">21</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/input/monetary/monetary.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">9</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/input/number/number.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">9</context>
 | 
			
		||||
@@ -4824,14 +4828,14 @@
 | 
			
		||||
        <source>Content</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">190</context>
 | 
			
		||||
          <context context-type="linenumber">188</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="218403386307979629" datatype="html">
 | 
			
		||||
        <source>Metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">199</context>
 | 
			
		||||
          <context context-type="linenumber">197</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
 | 
			
		||||
@@ -4842,112 +4846,112 @@
 | 
			
		||||
        <source>Date modified</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">206</context>
 | 
			
		||||
          <context context-type="linenumber">204</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6392918669949841614" datatype="html">
 | 
			
		||||
        <source>Date added</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">210</context>
 | 
			
		||||
          <context context-type="linenumber">208</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="146828917013192897" datatype="html">
 | 
			
		||||
        <source>Media filename</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">214</context>
 | 
			
		||||
          <context context-type="linenumber">212</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4500855521601039868" datatype="html">
 | 
			
		||||
        <source>Original filename</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">218</context>
 | 
			
		||||
          <context context-type="linenumber">216</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7985558498848210210" datatype="html">
 | 
			
		||||
        <source>Original MD5 checksum</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">222</context>
 | 
			
		||||
          <context context-type="linenumber">220</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5888243105821763422" datatype="html">
 | 
			
		||||
        <source>Original file size</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">226</context>
 | 
			
		||||
          <context context-type="linenumber">224</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2696647325713149563" datatype="html">
 | 
			
		||||
        <source>Original mime type</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">230</context>
 | 
			
		||||
          <context context-type="linenumber">228</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="342875990758166588" datatype="html">
 | 
			
		||||
        <source>Archive MD5 checksum</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">235</context>
 | 
			
		||||
          <context context-type="linenumber">233</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6033581412811562084" datatype="html">
 | 
			
		||||
        <source>Archive file size</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">241</context>
 | 
			
		||||
          <context context-type="linenumber">239</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6992781481378431874" datatype="html">
 | 
			
		||||
        <source>Original document metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">250</context>
 | 
			
		||||
          <context context-type="linenumber">248</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2846565152091361585" datatype="html">
 | 
			
		||||
        <source>Archived document metadata</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">253</context>
 | 
			
		||||
          <context context-type="linenumber">251</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="1295614462098694869" datatype="html">
 | 
			
		||||
        <source>Preview</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">260</context>
 | 
			
		||||
          <context context-type="linenumber">258</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7206723502037428235" datatype="html">
 | 
			
		||||
        <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">272,275</context>
 | 
			
		||||
          <context context-type="linenumber">270,273</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5129524307369213584" datatype="html">
 | 
			
		||||
        <source>Save & next</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">309</context>
 | 
			
		||||
          <context context-type="linenumber">307</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4910102545766233758" datatype="html">
 | 
			
		||||
        <source>Save & close</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">312</context>
 | 
			
		||||
          <context context-type="linenumber">310</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="8191371354890763172" datatype="html">
 | 
			
		||||
        <source>Enter Password</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">363</context>
 | 
			
		||||
          <context context-type="linenumber">361</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2218903673684131427" datatype="html">
 | 
			
		||||
 
 | 
			
		||||
@@ -113,6 +113,7 @@ import { ConfigComponent } from './components/admin/config/config.component'
 | 
			
		||||
import { FileComponent } from './components/common/input/file/file.component'
 | 
			
		||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
 | 
			
		||||
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
 | 
			
		||||
import {
 | 
			
		||||
  archive,
 | 
			
		||||
  arrowCounterclockwise,
 | 
			
		||||
@@ -443,6 +444,7 @@ function initializeApp(settings: SettingsService) {
 | 
			
		||||
    ConfigComponent,
 | 
			
		||||
    FileComponent,
 | 
			
		||||
    ConfirmButtonComponent,
 | 
			
		||||
    MonetaryComponent,
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    BrowserModule,
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,27 @@
 | 
			
		||||
<div class="mb-3" [class.pb-3]="error">
 | 
			
		||||
  <div class="row">
 | 
			
		||||
    <div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
 | 
			
		||||
      @if (title) {
 | 
			
		||||
        <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
 | 
			
		||||
      }
 | 
			
		||||
      @if (removable) {
 | 
			
		||||
        <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
 | 
			
		||||
          <i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
 | 
			
		||||
        </button>
 | 
			
		||||
      }
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="position-relative" [class.col-md-9]="horizontal">
 | 
			
		||||
      <div class="input-group" [class.is-invalid]="error">
 | 
			
		||||
        <span class="input-group-text fw-bold bg-light">{{monetaryValue | currency: currencyCode }}</span>
 | 
			
		||||
        <input #currencyField class="form-control text-muted mw-60" tabindex="0" [(ngModel)]="currencyCode" maxlength="3" [class.is-invalid]="error" (change)="onChange(value)" [disabled]="disabled">
 | 
			
		||||
        <input #inputField type="number" tabindex="0" class="form-control text-muted" step=".01" [id]="inputId" [(ngModel)]="monetaryValue" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="invalid-feedback position-absolute top-100">
 | 
			
		||||
        {{error}}
 | 
			
		||||
      </div>
 | 
			
		||||
      @if (hint) {
 | 
			
		||||
        <small class="form-text text-muted">{{hint}}</small>
 | 
			
		||||
      }
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -0,0 +1,11 @@
 | 
			
		||||
.input-group-text {
 | 
			
		||||
    font-size: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.text-muted:focus-within {
 | 
			
		||||
    color: var(--bs-body-color) !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mw-60 {
 | 
			
		||||
    max-width: 60px;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,59 @@
 | 
			
		||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
 | 
			
		||||
import {
 | 
			
		||||
  FormsModule,
 | 
			
		||||
  NG_VALUE_ACCESSOR,
 | 
			
		||||
  ReactiveFormsModule,
 | 
			
		||||
} from '@angular/forms'
 | 
			
		||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
 | 
			
		||||
import { CurrencyPipe } from '@angular/common'
 | 
			
		||||
import { MonetaryComponent } from './monetary.component'
 | 
			
		||||
 | 
			
		||||
describe('MonetaryComponent', () => {
 | 
			
		||||
  let component: MonetaryComponent
 | 
			
		||||
  let fixture: ComponentFixture<MonetaryComponent>
 | 
			
		||||
  let input: HTMLInputElement
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    await TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [MonetaryComponent],
 | 
			
		||||
      providers: [CurrencyPipe],
 | 
			
		||||
      imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule],
 | 
			
		||||
    }).compileComponents()
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(MonetaryComponent)
 | 
			
		||||
    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    input = component.inputField.nativeElement
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should set the currency code correctly', () => {
 | 
			
		||||
    expect(component.currencyCode).toEqual('USD') // default
 | 
			
		||||
    component.currencyCode = 'EUR'
 | 
			
		||||
    expect(component.currencyCode).toEqual('EUR')
 | 
			
		||||
 | 
			
		||||
    component.value = 'G123.4'
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(document, 'activeElement', 'get')
 | 
			
		||||
      .mockReturnValue(component.currencyField.nativeElement)
 | 
			
		||||
    expect(component.currencyCode).toEqual('G')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should parse monetary value only when out of focus', () => {
 | 
			
		||||
    component.monetaryValue = 10.5
 | 
			
		||||
    jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
 | 
			
		||||
    expect(component.monetaryValue).toEqual('10.50')
 | 
			
		||||
 | 
			
		||||
    component.value = 'GBP123.4'
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(document, 'activeElement', 'get')
 | 
			
		||||
      .mockReturnValue(component.inputField.nativeElement)
 | 
			
		||||
    expect(component.monetaryValue).toEqual('123.4')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should report value including currency code and monetary value', () => {
 | 
			
		||||
    component.currencyCode = 'EUR'
 | 
			
		||||
    component.monetaryValue = 10.5
 | 
			
		||||
    expect(component.value).toEqual('EUR10.50')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
@@ -0,0 +1,59 @@
 | 
			
		||||
import {
 | 
			
		||||
  Component,
 | 
			
		||||
  DEFAULT_CURRENCY_CODE,
 | 
			
		||||
  ElementRef,
 | 
			
		||||
  forwardRef,
 | 
			
		||||
  Inject,
 | 
			
		||||
  ViewChild,
 | 
			
		||||
} from '@angular/core'
 | 
			
		||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
 | 
			
		||||
import { AbstractInputComponent } from '../abstract-input'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  providers: [
 | 
			
		||||
    {
 | 
			
		||||
      provide: NG_VALUE_ACCESSOR,
 | 
			
		||||
      useExisting: forwardRef(() => MonetaryComponent),
 | 
			
		||||
      multi: true,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  selector: 'pngx-input-monetary',
 | 
			
		||||
  templateUrl: './monetary.component.html',
 | 
			
		||||
  styleUrls: ['./monetary.component.scss'],
 | 
			
		||||
})
 | 
			
		||||
export class MonetaryComponent extends AbstractInputComponent<string> {
 | 
			
		||||
  @ViewChild('currencyField')
 | 
			
		||||
  currencyField: ElementRef
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(DEFAULT_CURRENCY_CODE) public defaultCurrencyCode: string
 | 
			
		||||
  ) {
 | 
			
		||||
    super()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get currencyCode(): string {
 | 
			
		||||
    const focused = document.activeElement === this.currencyField?.nativeElement
 | 
			
		||||
    if (focused && this.value) return this.value.match(/^([A-Z]{0,3})/)?.[0]
 | 
			
		||||
    return (
 | 
			
		||||
      this.value
 | 
			
		||||
        ?.toString()
 | 
			
		||||
        .toUpperCase()
 | 
			
		||||
        .match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set currencyCode(value: string) {
 | 
			
		||||
    this.value = value + this.monetaryValue?.toString()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get monetaryValue(): string {
 | 
			
		||||
    if (!this.value) return null
 | 
			
		||||
    const focused = document.activeElement === this.inputField?.nativeElement
 | 
			
		||||
    const val = parseFloat(this.value.toString().replace(/[^0-9.,]+/g, ''))
 | 
			
		||||
    return focused ? val.toString() : val.toFixed(2)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set monetaryValue(value: number) {
 | 
			
		||||
    this.value = this.currencyCode + value.toFixed(2)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -142,14 +142,12 @@
 | 
			
		||||
                      [error]="getCustomFieldError(i)"></pngx-input-number>
 | 
			
		||||
                    }
 | 
			
		||||
                    @case (CustomFieldDataType.Monetary) {
 | 
			
		||||
                      <pngx-input-number formControlName="value"
 | 
			
		||||
                      <pngx-input-monetary formControlName="value"
 | 
			
		||||
                      [title]="getCustomFieldFromInstance(fieldInstance)?.name"
 | 
			
		||||
                      [removable]="userIsOwner"
 | 
			
		||||
                      (removed)="removeField(fieldInstance)"
 | 
			
		||||
                      [horizontal]="true"
 | 
			
		||||
                      [showAdd]="false"
 | 
			
		||||
                      [step]=".01"
 | 
			
		||||
                      [error]="getCustomFieldError(i)"></pngx-input-number>
 | 
			
		||||
                      [error]="getCustomFieldError(i)"></pngx-input-monetary>
 | 
			
		||||
                    }
 | 
			
		||||
                    @case (CustomFieldDataType.Boolean) {
 | 
			
		||||
                      <pngx-input-check formControlName="value"
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 4.2.10 on 2024-02-22 03:52
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
from django.db import models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("documents", "1044_workflow_workflowaction_workflowtrigger_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="customfieldinstance",
 | 
			
		||||
            name="value_monetary",
 | 
			
		||||
            field=models.CharField(max_length=128, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -838,7 +838,7 @@ class CustomFieldInstance(models.Model):
 | 
			
		||||
 | 
			
		||||
    value_float = models.FloatField(null=True)
 | 
			
		||||
 | 
			
		||||
    value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
 | 
			
		||||
    value_monetary = models.CharField(null=True, max_length=128)
 | 
			
		||||
 | 
			
		||||
    value_document_ids = models.JSONField(null=True)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ from django.contrib.auth.models import User
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.core.validators import DecimalValidator
 | 
			
		||||
from django.core.validators import MaxLengthValidator
 | 
			
		||||
from django.core.validators import RegexValidator
 | 
			
		||||
from django.core.validators import integer_validator
 | 
			
		||||
from django.utils import timezone
 | 
			
		||||
from django.utils.crypto import get_random_string
 | 
			
		||||
@@ -528,9 +529,17 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
 | 
			
		||||
            elif field.data_type == CustomField.FieldDataType.INT:
 | 
			
		||||
                integer_validator(data["value"])
 | 
			
		||||
            elif field.data_type == CustomField.FieldDataType.MONETARY:
 | 
			
		||||
                try:
 | 
			
		||||
                    # First try to validate as a number from legacy format
 | 
			
		||||
                    DecimalValidator(max_digits=12, decimal_places=2)(
 | 
			
		||||
                        Decimal(str(data["value"])),
 | 
			
		||||
                    )
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    # If that fails, try to validate as a monetary string
 | 
			
		||||
                    RegexValidator(
 | 
			
		||||
                        regex=r"^[A-Z][A-Z][A-Z]\d+(\.\d{2,2})$",
 | 
			
		||||
                        message="Must be a two-decimal number with optional currency code e.g. GBP123.45",
 | 
			
		||||
                    )(data["value"])
 | 
			
		||||
            elif field.data_type == CustomField.FieldDataType.STRING:
 | 
			
		||||
                MaxLengthValidator(limit_value=128)(data["value"])
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ from documents.models import Document
 | 
			
		||||
from documents.tests.utils import DirectoriesMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestCustomField(DirectoriesMixin, APITestCase):
 | 
			
		||||
class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
 | 
			
		||||
    ENDPOINT = "/api/custom_fields/"
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
@@ -127,6 +127,10 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
			
		||||
            name="Test Custom Field Monetary",
 | 
			
		||||
            data_type=CustomField.FieldDataType.MONETARY,
 | 
			
		||||
        )
 | 
			
		||||
        custom_field_monetary2 = CustomField.objects.create(
 | 
			
		||||
            name="Test Custom Field Monetary 2",
 | 
			
		||||
            data_type=CustomField.FieldDataType.MONETARY,
 | 
			
		||||
        )
 | 
			
		||||
        custom_field_documentlink = CustomField.objects.create(
 | 
			
		||||
            name="Test Custom Field Doc Link",
 | 
			
		||||
            data_type=CustomField.FieldDataType.DOCUMENTLINK,
 | 
			
		||||
@@ -164,7 +168,11 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        "field": custom_field_monetary.id,
 | 
			
		||||
                        "value": 11.10,
 | 
			
		||||
                        "value": "EUR11.10",
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        "field": custom_field_monetary2.id,
 | 
			
		||||
                        "value": 11.10,  # Legacy format
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        "field": custom_field_documentlink.id,
 | 
			
		||||
@@ -188,13 +196,14 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
			
		||||
                {"field": custom_field_boolean.id, "value": True},
 | 
			
		||||
                {"field": custom_field_url.id, "value": "https://example.com"},
 | 
			
		||||
                {"field": custom_field_float.id, "value": 12.3456},
 | 
			
		||||
                {"field": custom_field_monetary.id, "value": 11.10},
 | 
			
		||||
                {"field": custom_field_monetary.id, "value": "EUR11.10"},
 | 
			
		||||
                {"field": custom_field_monetary2.id, "value": "11.1"},
 | 
			
		||||
                {"field": custom_field_documentlink.id, "value": [doc2.id]},
 | 
			
		||||
            ],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        doc.refresh_from_db()
 | 
			
		||||
        self.assertEqual(len(doc.custom_fields.all()), 8)
 | 
			
		||||
        self.assertEqual(len(doc.custom_fields.all()), 9)
 | 
			
		||||
 | 
			
		||||
    def test_change_custom_field_instance_value(self):
 | 
			
		||||
        """
 | 
			
		||||
@@ -458,7 +467,7 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - Document & custom field exist
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - API request to set a field value to something not a valid monetary decimal
 | 
			
		||||
            - API request to set a field value to something not a valid monetary decimal (legacy) or not a new monetary format e.g. USD12.34
 | 
			
		||||
        THEN:
 | 
			
		||||
            - HTTP 400 is returned
 | 
			
		||||
            - No field instance is created or attached to the document
 | 
			
		||||
@@ -488,6 +497,54 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
 | 
			
		||||
        resp = self.client.patch(
 | 
			
		||||
            f"/api/documents/{doc.id}/",
 | 
			
		||||
            data={
 | 
			
		||||
                "custom_fields": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "field": custom_field_money.id,
 | 
			
		||||
                        # Too few places past decimal
 | 
			
		||||
                        "value": "GBP12.1",
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
 | 
			
		||||
        resp = self.client.patch(
 | 
			
		||||
            f"/api/documents/{doc.id}/",
 | 
			
		||||
            data={
 | 
			
		||||
                "custom_fields": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "field": custom_field_money.id,
 | 
			
		||||
                        # Too many places past decimal
 | 
			
		||||
                        "value": "GBP12.123",
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
 | 
			
		||||
        resp = self.client.patch(
 | 
			
		||||
            f"/api/documents/{doc.id}/",
 | 
			
		||||
            data={
 | 
			
		||||
                "custom_fields": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "field": custom_field_money.id,
 | 
			
		||||
                        # Not a 3-letter currency code
 | 
			
		||||
                        "value": "G12.12",
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
            format="json",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        self.assertEqual(CustomFieldInstance.objects.count(), 0)
 | 
			
		||||
        self.assertEqual(len(doc.custom_fields.all()), 0)
 | 
			
		||||
 
 | 
			
		||||
@@ -777,12 +777,12 @@ msgstr ""
 | 
			
		||||
msgid "Invalid color."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/serialisers.py:1061
 | 
			
		||||
#: documents/serialisers.py:1073
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "File type %(type)s not supported"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/serialisers.py:1164
 | 
			
		||||
#: documents/serialisers.py:1176
 | 
			
		||||
msgid "Invalid variable detected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user