- `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
</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">
</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>
</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>
</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>
<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>
<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>
<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">
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,
ConfigComponent,
FileComponent,
ConfirmButtonComponent,
+ MonetaryComponent,
],
imports: [
BrowserModule,
--- /dev/null
+<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>
--- /dev/null
+.input-group-text {
+ font-size: inherit;
+}
+
+.text-muted:focus-within {
+ color: var(--bs-body-color) !important;
+}
+
+.mw-60 {
+ max-width: 60px;
+}
--- /dev/null
+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')
+ })
+})
--- /dev/null
+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)
+ }
+}
[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"
--- /dev/null
+# 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),
+ ),
+ ]
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)
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
elif field.data_type == CustomField.FieldDataType.INT:
integer_validator(data["value"])
elif field.data_type == CustomField.FieldDataType.MONETARY:
- DecimalValidator(max_digits=12, decimal_places=2)(
- Decimal(str(data["value"])),
- )
+ 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"])
from documents.tests.utils import DirectoriesMixin
-class TestCustomField(DirectoriesMixin, APITestCase):
+class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/custom_fields/"
def setUp(self):
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,
},
{
"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,
{"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):
"""
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
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)
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 ""