mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Feature: select custom field type (#7167)
This commit is contained in:
		| @@ -445,6 +445,7 @@ The following custom field types are supported: | ||||
| - `Number`: float number e.g. 12.3456 | ||||
| - `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 | ||||
| - `Select`: a pre-defined list of strings from which the user can choose | ||||
|  | ||||
| ## Share Links | ||||
|  | ||||
|   | ||||
| @@ -525,7 +525,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">337</context> | ||||
|           <context context-type="linenumber">347</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3768927257183755959" datatype="html"> | ||||
| @@ -544,7 +544,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">19</context> | ||||
|           <context context-type="linenumber">36</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context> | ||||
| @@ -584,7 +584,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">329</context> | ||||
|           <context context-type="linenumber">339</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> | ||||
| @@ -718,7 +718,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">346</context> | ||||
|           <context context-type="linenumber">356</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
| @@ -1080,7 +1080,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">305</context> | ||||
|           <context context-type="linenumber">315</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context> | ||||
| @@ -1447,6 +1447,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context> | ||||
|           <context context-type="linenumber">76</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">26</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">53</context> | ||||
| @@ -1624,7 +1628,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">18</context> | ||||
|           <context context-type="linenumber">35</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component.html</context> | ||||
| @@ -3445,18 +3449,25 @@ | ||||
|           <context context-type="linenumber">14</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4910631867841099191" datatype="html"> | ||||
|         <source>Add option</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">20</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="528950215505228201" datatype="html"> | ||||
|         <source>Create new custom field</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">36</context> | ||||
|           <context context-type="linenumber">80</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8751213029607178010" datatype="html"> | ||||
|         <source>Edit custom field</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">40</context> | ||||
|           <context context-type="linenumber">84</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6672809941092516947" datatype="html"> | ||||
| @@ -4676,7 +4687,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context> | ||||
|           <context context-type="linenumber">158</context> | ||||
|           <context context-type="linenumber">163</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1880237574877817137" datatype="html"> | ||||
| @@ -4758,7 +4769,7 @@ | ||||
|         <source>Private</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context> | ||||
|           <context context-type="linenumber">57</context> | ||||
|           <context context-type="linenumber">62</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/tag/tag.component.html</context> | ||||
| @@ -4777,7 +4788,7 @@ | ||||
|         <source>No items found</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/input/select/select.component.ts</context> | ||||
|           <context context-type="linenumber">92</context> | ||||
|           <context context-type="linenumber">97</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6541407358060244620" datatype="html"> | ||||
| @@ -5101,6 +5112,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context> | ||||
|           <context context-type="linenumber">6</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">50</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7103181924469214926" datatype="html"> | ||||
|         <source>Please select an object</source> | ||||
| @@ -5844,14 +5859,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">201</context> | ||||
|           <context context-type="linenumber">211</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">210</context> | ||||
|           <context context-type="linenumber">220</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context> | ||||
| @@ -5862,119 +5877,119 @@ | ||||
|         <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">217</context> | ||||
|           <context context-type="linenumber">227</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">221</context> | ||||
|           <context context-type="linenumber">231</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">225</context> | ||||
|           <context context-type="linenumber">235</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">229</context> | ||||
|           <context context-type="linenumber">239</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">233</context> | ||||
|           <context context-type="linenumber">243</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">237</context> | ||||
|           <context context-type="linenumber">247</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">241</context> | ||||
|           <context context-type="linenumber">251</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">246</context> | ||||
|           <context context-type="linenumber">256</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">252</context> | ||||
|           <context context-type="linenumber">262</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">261</context> | ||||
|           <context context-type="linenumber">271</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">264</context> | ||||
|           <context context-type="linenumber">274</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">271</context> | ||||
|           <context context-type="linenumber">281</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">283,286</context> | ||||
|           <context context-type="linenumber">293,296</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="186236568870281953" datatype="html"> | ||||
|         <source>History</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">294</context> | ||||
|           <context context-type="linenumber">304</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">331</context> | ||||
|           <context context-type="linenumber">341</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">334</context> | ||||
|           <context context-type="linenumber">344</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">385</context> | ||||
|           <context context-type="linenumber">395</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2218903673684131427" datatype="html"> | ||||
| @@ -7841,56 +7856,56 @@ | ||||
|         <source>Boolean</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">17</context> | ||||
|           <context context-type="linenumber">18</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3973931101896534797" datatype="html"> | ||||
|         <source>Date</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">21</context> | ||||
|           <context context-type="linenumber">22</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="362956598863566327" datatype="html"> | ||||
|         <source>Integer</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">25</context> | ||||
|           <context context-type="linenumber">26</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6370642728789544052" datatype="html"> | ||||
|         <source>Number</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">29</context> | ||||
|           <context context-type="linenumber">30</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6430409302408843009" datatype="html"> | ||||
|         <source>Monetary</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">33</context> | ||||
|           <context context-type="linenumber">34</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6162693758764653365" datatype="html"> | ||||
|         <source>Text</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">37</context> | ||||
|           <context context-type="linenumber">38</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8308045076391224954" datatype="html"> | ||||
|         <source>Url</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">41</context> | ||||
|           <context context-type="linenumber">42</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3650316326183661476" datatype="html"> | ||||
|         <source>Document Link</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/data/custom-field.ts</context> | ||||
|           <context context-type="linenumber">45</context> | ||||
|           <context context-type="linenumber">46</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3553216189604488439" datatype="html"> | ||||
|   | ||||
| @@ -30,6 +30,9 @@ | ||||
|                     <input type="checkbox" id="{{field.name}}" name="{{field.name}}" [checked]="value" value="" class="form-check-input ms-2 mt-0 pe-none"> | ||||
|                 </div> | ||||
|             } | ||||
|             @case (CustomFieldDataType.Select) { | ||||
|                 <span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span> | ||||
|             } | ||||
|             @default { | ||||
|               <span [ngbTooltip]="nameTooltip">{{value}}</span> | ||||
|             } | ||||
|   | ||||
| @@ -12,6 +12,14 @@ const customFields: CustomField[] = [ | ||||
|   { id: 1, name: 'Field 1', data_type: CustomFieldDataType.String }, | ||||
|   { id: 2, name: 'Field 2', data_type: CustomFieldDataType.Monetary }, | ||||
|   { id: 3, name: 'Field 3', data_type: CustomFieldDataType.DocumentLink }, | ||||
|   { | ||||
|     id: 4, | ||||
|     name: 'Field 4', | ||||
|     data_type: CustomFieldDataType.Select, | ||||
|     extra_data: { | ||||
|       select_options: ['Option 1', 'Option 2', 'Option 3'], | ||||
|     }, | ||||
|   }, | ||||
| ] | ||||
| const document: Document = { | ||||
|   id: 1, | ||||
| @@ -103,4 +111,8 @@ describe('CustomFieldDisplayComponent', () => { | ||||
|     expect(component.currency).toEqual('EUR') | ||||
|     expect(component.value).toEqual(100) | ||||
|   }) | ||||
|  | ||||
|   it('should show select value', () => { | ||||
|     expect(component.getSelectValue(customFields[3], 2)).toEqual('Option 3') | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -115,6 +115,10 @@ export class CustomFieldDisplayComponent implements OnInit, OnDestroy { | ||||
|     return this.docLinkDocuments?.find((d) => d.id === docId)?.title | ||||
|   } | ||||
|  | ||||
|   public getSelectValue(field: CustomField, index: number): string { | ||||
|     return field.extra_data.select_options[index] | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
|     this.unsubscribeNotifier.next(true) | ||||
|     this.unsubscribeNotifier.complete() | ||||
|   | ||||
| @@ -13,6 +13,23 @@ | ||||
|     @if (typeFieldDisabled) { | ||||
|       <small class="d-block mt-n2" i18n>Data type cannot be changed after a field is created</small> | ||||
|     } | ||||
|     <div [formGroup]="objectForm.controls.extra_data"> | ||||
|       @switch (objectForm.get('data_type').value) { | ||||
|         @case (CustomFieldDataType.Select) { | ||||
|           <button type="button" class="btn btn-sm btn-primary my-2" (click)="addSelectOption()"> | ||||
|             <span i18n>Add option</span> <i-bs name="plus-circle"></i-bs> | ||||
|           </button> | ||||
|           <div formArrayName="select_options"> | ||||
|             @for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) { | ||||
|               <div class="input-group input-group-sm my-2"> | ||||
|                 <input #selectOption type="text" class="form-control" [formControl]="option" autocomplete="off"> | ||||
|                 <button type="button" class="btn btn-outline-danger" (click)="removeSelectOption(i)" i18n>Delete</button> | ||||
|               </div> | ||||
|             } | ||||
|           </div> | ||||
|         } | ||||
|       } | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="modal-footer"> | ||||
|     <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||
|   | ||||
| @@ -13,6 +13,9 @@ import { SelectComponent } from '../../input/select/select.component' | ||||
| import { TextComponent } from '../../input/text/text.component' | ||||
| import { EditDialogMode } from '../edit-dialog.component' | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { CustomFieldDataType } from 'src/app/data/custom-field' | ||||
| import { ElementRef, QueryList } from '@angular/core' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
|  | ||||
| describe('CustomFieldEditDialogComponent', () => { | ||||
|   let component: CustomFieldEditDialogComponent | ||||
| @@ -29,7 +32,13 @@ describe('CustomFieldEditDialogComponent', () => { | ||||
|         TextComponent, | ||||
|         SafeHtmlPipe, | ||||
|       ], | ||||
|       imports: [FormsModule, ReactiveFormsModule, NgSelectModule, NgbModule], | ||||
|       imports: [ | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         NgSelectModule, | ||||
|         NgbModule, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|       ], | ||||
|       providers: [ | ||||
|         NgbActiveModal, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
| @@ -63,4 +72,55 @@ describe('CustomFieldEditDialogComponent', () => { | ||||
|     component.ngOnInit() | ||||
|     expect(component.objectForm.get('data_type').disabled).toBeTruthy() | ||||
|   }) | ||||
|  | ||||
|   it('should initialize select options on edit', () => { | ||||
|     component.dialogMode = EditDialogMode.EDIT | ||||
|     component.object = { | ||||
|       id: 1, | ||||
|       name: 'Field 1', | ||||
|       data_type: CustomFieldDataType.Select, | ||||
|       extra_data: { | ||||
|         select_options: ['Option 1', 'Option 2', 'Option 3'], | ||||
|       }, | ||||
|     } | ||||
|     fixture.detectChanges() | ||||
|     component.ngOnInit() | ||||
|     expect( | ||||
|       component.objectForm.get('extra_data').get('select_options').value.length | ||||
|     ).toBe(3) | ||||
|   }) | ||||
|  | ||||
|   it('should support add / remove select options', () => { | ||||
|     component.dialogMode = EditDialogMode.CREATE | ||||
|     fixture.detectChanges() | ||||
|     component.ngOnInit() | ||||
|     expect( | ||||
|       component.objectForm.get('extra_data').get('select_options').value.length | ||||
|     ).toBe(1) | ||||
|     component.addSelectOption() | ||||
|     expect( | ||||
|       component.objectForm.get('extra_data').get('select_options').value.length | ||||
|     ).toBe(2) | ||||
|     component.addSelectOption() | ||||
|     expect( | ||||
|       component.objectForm.get('extra_data').get('select_options').value.length | ||||
|     ).toBe(3) | ||||
|     component.removeSelectOption(0) | ||||
|     expect( | ||||
|       component.objectForm.get('extra_data').get('select_options').value.length | ||||
|     ).toBe(2) | ||||
|   }) | ||||
|  | ||||
|   it('should focus on last select option input', () => { | ||||
|     const selectOptionInputs = component[ | ||||
|       'selectOptionInputs' | ||||
|     ] as QueryList<ElementRef> | ||||
|     component.dialogMode = EditDialogMode.CREATE | ||||
|     component.objectForm.get('data_type').setValue(CustomFieldDataType.Select) | ||||
|     component.ngOnInit() | ||||
|     component.ngAfterViewInit() | ||||
|     component.addSelectOption() | ||||
|     fixture.detectChanges() | ||||
|     expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -1,11 +1,24 @@ | ||||
| import { Component, OnInit } from '@angular/core' | ||||
| import { FormGroup, FormControl } from '@angular/forms' | ||||
| import { | ||||
|   AfterViewInit, | ||||
|   Component, | ||||
|   ElementRef, | ||||
|   OnDestroy, | ||||
|   OnInit, | ||||
|   QueryList, | ||||
|   ViewChildren, | ||||
| } from '@angular/core' | ||||
| import { FormGroup, FormControl, FormArray } from '@angular/forms' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { DATA_TYPE_LABELS, CustomField } from 'src/app/data/custom-field' | ||||
| import { | ||||
|   DATA_TYPE_LABELS, | ||||
|   CustomField, | ||||
|   CustomFieldDataType, | ||||
| } from 'src/app/data/custom-field' | ||||
| import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' | ||||
| import { UserService } from 'src/app/services/rest/user.service' | ||||
| import { SettingsService } from 'src/app/services/settings.service' | ||||
| import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component' | ||||
| import { Subject, takeUntil } from 'rxjs' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-custom-field-edit-dialog', | ||||
| @@ -14,8 +27,20 @@ import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component' | ||||
| }) | ||||
| export class CustomFieldEditDialogComponent | ||||
|   extends EditDialogComponent<CustomField> | ||||
|   implements OnInit | ||||
|   implements OnInit, AfterViewInit, OnDestroy | ||||
| { | ||||
|   CustomFieldDataType = CustomFieldDataType | ||||
|  | ||||
|   @ViewChildren('selectOption') | ||||
|   private selectOptionInputs: QueryList<ElementRef> | ||||
|  | ||||
|   private unsubscribeNotifier: Subject<any> = new Subject() | ||||
|  | ||||
|   private get selectOptions(): FormArray { | ||||
|     return (this.objectForm.controls.extra_data as FormGroup).controls | ||||
|       .select_options as FormArray | ||||
|   } | ||||
|  | ||||
|   constructor( | ||||
|     service: CustomFieldsService, | ||||
|     activeModal: NgbActiveModal, | ||||
| @@ -30,6 +55,25 @@ export class CustomFieldEditDialogComponent | ||||
|     if (this.typeFieldDisabled) { | ||||
|       this.objectForm.get('data_type').disable() | ||||
|     } | ||||
|     if (this.object?.data_type === CustomFieldDataType.Select) { | ||||
|       this.selectOptions.clear() | ||||
|       this.object.extra_data.select_options.forEach((option) => | ||||
|         this.selectOptions.push(new FormControl(option)) | ||||
|       ) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ngAfterViewInit(): void { | ||||
|     this.selectOptionInputs.changes | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe(() => { | ||||
|         this.selectOptionInputs.last.nativeElement.focus() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   ngOnDestroy(): void { | ||||
|     this.unsubscribeNotifier.next(true) | ||||
|     this.unsubscribeNotifier.complete() | ||||
|   } | ||||
|  | ||||
|   getCreateTitle() { | ||||
| @@ -44,6 +88,9 @@ export class CustomFieldEditDialogComponent | ||||
|     return new FormGroup({ | ||||
|       name: new FormControl(null), | ||||
|       data_type: new FormControl(null), | ||||
|       extra_data: new FormGroup({ | ||||
|         select_options: new FormArray([new FormControl(null)]), | ||||
|       }), | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @@ -54,4 +101,12 @@ export class CustomFieldEditDialogComponent | ||||
|   get typeFieldDisabled(): boolean { | ||||
|     return this.dialogMode === EditDialogMode.EDIT | ||||
|   } | ||||
|  | ||||
|   public addSelectOption() { | ||||
|     this.selectOptions.push(new FormControl('')) | ||||
|   } | ||||
|  | ||||
|   public removeSelectOption(index: number) { | ||||
|     this.selectOptions.removeAt(index) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -132,4 +132,12 @@ describe('SelectComponent', () => { | ||||
|     const expectedTitle = `Filter documents with this ${component.title}` | ||||
|     expect(component.filterButtonTitle).toEqual(expectedTitle) | ||||
|   }) | ||||
|  | ||||
|   it('should support setting items as a plain array', () => { | ||||
|     component.itemsArray = ['foo', 'bar'] | ||||
|     expect(component.items).toEqual([ | ||||
|       { id: 0, name: 'foo' }, | ||||
|       { id: 1, name: 'bar' }, | ||||
|     ]) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -34,6 +34,11 @@ export class SelectComponent extends AbstractInputComponent<number> { | ||||
|     if (items && this.value) this.checkForPrivateItems(this.value) | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   set itemsArray(items: any[]) { | ||||
|     this._items = items.map((item, index) => ({ id: index, name: item })) | ||||
|   } | ||||
|  | ||||
|   writeValue(newValue: any): void { | ||||
|     if (newValue && this._items) { | ||||
|       this.checkForPrivateItems(newValue) | ||||
|   | ||||
| @@ -186,6 +186,16 @@ | ||||
|                       [horizontal]="true" | ||||
|                       [error]="getCustomFieldError(i)"></pngx-input-document-link> | ||||
|                     } | ||||
|                     @case (CustomFieldDataType.Select) { | ||||
|                       <pngx-input-select formControlName="value" | ||||
|                       [title]="getCustomFieldFromInstance(fieldInstance)?.name" | ||||
|                       [itemsArray]="getCustomFieldFromInstance(fieldInstance)?.extra_data.select_options" | ||||
|                       [allowNull]="true" | ||||
|                       [horizontal]="true" | ||||
|                       [removable]="userIsOwner" | ||||
|                       (removed)="removeField(fieldInstance)" | ||||
|                       [error]="getCustomFieldError(i)"></pngx-input-select> | ||||
|                     } | ||||
|                   } | ||||
|                 </div> | ||||
|               } | ||||
|   | ||||
| @@ -9,6 +9,7 @@ export enum CustomFieldDataType { | ||||
|   Float = 'float', | ||||
|   Monetary = 'monetary', | ||||
|   DocumentLink = 'documentlink', | ||||
|   Select = 'select', | ||||
| } | ||||
|  | ||||
| export const DATA_TYPE_LABELS = [ | ||||
| @@ -44,10 +45,17 @@ export const DATA_TYPE_LABELS = [ | ||||
|     id: CustomFieldDataType.DocumentLink, | ||||
|     name: $localize`Document Link`, | ||||
|   }, | ||||
|   { | ||||
|     id: CustomFieldDataType.Select, | ||||
|     name: $localize`Select`, | ||||
|   }, | ||||
| ] | ||||
|  | ||||
| export interface CustomField extends ObjectWithId { | ||||
|   data_type: CustomFieldDataType | ||||
|   name: string | ||||
|   created?: Date | ||||
|   extra_data?: { | ||||
|     select_options?: string[] | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,48 @@ | ||||
| # Generated by Django 4.2.13 on 2024-07-04 01:02 | ||||
|  | ||||
| from django.db import migrations | ||||
| from django.db import models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("documents", "1049_document_deleted_at_document_restored_at"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="customfield", | ||||
|             name="extra_data", | ||||
|             field=models.JSONField( | ||||
|                 blank=True, | ||||
|                 help_text="Extra data for the custom field, such as select options", | ||||
|                 null=True, | ||||
|                 verbose_name="extra data", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="customfieldinstance", | ||||
|             name="value_select", | ||||
|             field=models.PositiveSmallIntegerField(null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="customfield", | ||||
|             name="data_type", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("string", "String"), | ||||
|                     ("url", "URL"), | ||||
|                     ("date", "Date"), | ||||
|                     ("boolean", "Boolean"), | ||||
|                     ("integer", "Integer"), | ||||
|                     ("float", "Float"), | ||||
|                     ("monetary", "Monetary"), | ||||
|                     ("documentlink", "Document Link"), | ||||
|                     ("select", "Select"), | ||||
|                 ], | ||||
|                 editable=False, | ||||
|                 max_length=50, | ||||
|                 verbose_name="data type", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -808,6 +808,7 @@ class CustomField(models.Model): | ||||
|         FLOAT = ("float", _("Float")) | ||||
|         MONETARY = ("monetary", _("Monetary")) | ||||
|         DOCUMENTLINK = ("documentlink", _("Document Link")) | ||||
|         SELECT = ("select", _("Select")) | ||||
|  | ||||
|     created = models.DateTimeField( | ||||
|         _("created"), | ||||
| @@ -825,6 +826,15 @@ class CustomField(models.Model): | ||||
|         editable=False, | ||||
|     ) | ||||
|  | ||||
|     extra_data = models.JSONField( | ||||
|         _("extra data"), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         help_text=_( | ||||
|             "Extra data for the custom field, such as select options", | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         ordering = ("created",) | ||||
|         verbose_name = _("custom field") | ||||
| @@ -888,6 +898,8 @@ class CustomFieldInstance(models.Model): | ||||
|  | ||||
|     value_document_ids = models.JSONField(null=True) | ||||
|  | ||||
|     value_select = models.PositiveSmallIntegerField(null=True) | ||||
|  | ||||
|     class Meta: | ||||
|         ordering = ("created",) | ||||
|         verbose_name = _("custom field instance") | ||||
| @@ -900,7 +912,12 @@ class CustomFieldInstance(models.Model): | ||||
|         ] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return str(self.field.name) + f" : {self.value}" | ||||
|         value = ( | ||||
|             self.field.extra_data["select_options"][self.value_select] | ||||
|             if self.field.data_type == CustomField.FieldDataType.SELECT | ||||
|             else self.value | ||||
|         ) | ||||
|         return str(self.field.name) + f" : {value}" | ||||
|  | ||||
|     @property | ||||
|     def value(self): | ||||
| @@ -924,6 +941,8 @@ class CustomFieldInstance(models.Model): | ||||
|             return self.value_monetary | ||||
|         elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK: | ||||
|             return self.value_document_ids | ||||
|         elif self.field.data_type == CustomField.FieldDataType.SELECT: | ||||
|             return self.value_select | ||||
|         raise NotImplementedError(self.field.data_type) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -455,6 +455,7 @@ class CustomFieldSerializer(serializers.ModelSerializer): | ||||
|             "id", | ||||
|             "name", | ||||
|             "data_type", | ||||
|             "extra_data", | ||||
|         ] | ||||
|  | ||||
|     def validate(self, attrs): | ||||
| @@ -476,6 +477,23 @@ class CustomFieldSerializer(serializers.ModelSerializer): | ||||
|             raise serializers.ValidationError( | ||||
|                 {"error": "Object violates name unique constraint"}, | ||||
|             ) | ||||
|         if ( | ||||
|             "data_type" in attrs | ||||
|             and attrs["data_type"] == CustomField.FieldDataType.SELECT | ||||
|             and ( | ||||
|                 "extra_data" not in attrs | ||||
|                 or "select_options" not in attrs["extra_data"] | ||||
|                 or not isinstance(attrs["extra_data"]["select_options"], list) | ||||
|                 or len(attrs["extra_data"]["select_options"]) == 0 | ||||
|                 or not all( | ||||
|                     isinstance(option, str) and len(option) > 0 | ||||
|                     for option in attrs["extra_data"]["select_options"] | ||||
|                 ) | ||||
|             ) | ||||
|         ): | ||||
|             raise serializers.ValidationError( | ||||
|                 {"error": "extra_data.select_options must be a valid list"}, | ||||
|             ) | ||||
|         return super().validate(attrs) | ||||
|  | ||||
|  | ||||
| @@ -507,6 +525,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): | ||||
|             CustomField.FieldDataType.FLOAT: "value_float", | ||||
|             CustomField.FieldDataType.MONETARY: "value_monetary", | ||||
|             CustomField.FieldDataType.DOCUMENTLINK: "value_document_ids", | ||||
|             CustomField.FieldDataType.SELECT: "value_select", | ||||
|         } | ||||
|         # An instance is attached to a document | ||||
|         document: Document = validated_data["document"] | ||||
| @@ -563,6 +582,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): | ||||
|                     )(data["value"]) | ||||
|             elif field.data_type == CustomField.FieldDataType.STRING: | ||||
|                 MaxLengthValidator(limit_value=128)(data["value"]) | ||||
|             elif field.data_type == CustomField.FieldDataType.SELECT: | ||||
|                 select_options = field.extra_data["select_options"] | ||||
|                 try: | ||||
|                     select_options[data["value"]] | ||||
|                 except Exception: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"Value must be index of an element in {select_options}", | ||||
|                     ) | ||||
|  | ||||
|         return data | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import json | ||||
| from datetime import date | ||||
|  | ||||
| from django.contrib.auth.models import User | ||||
| @@ -49,10 +50,31 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|             data = resp.json() | ||||
|  | ||||
|             self.assertEqual(len(data), 3) | ||||
|             self.assertEqual(data["name"], name) | ||||
|             self.assertEqual(data["data_type"], field_type) | ||||
|  | ||||
|         resp = self.client.post( | ||||
|             self.ENDPOINT, | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "data_type": "select", | ||||
|                     "name": "Select Field", | ||||
|                     "extra_data": { | ||||
|                         "select_options": ["Option 1", "Option 2"], | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_201_CREATED) | ||||
|  | ||||
|         data = resp.json() | ||||
|  | ||||
|         self.assertCountEqual( | ||||
|             data["extra_data"]["select_options"], | ||||
|             ["Option 1", "Option 2"], | ||||
|         ) | ||||
|  | ||||
|     def test_create_custom_field_nonunique_name(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
| @@ -76,6 +98,45 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|     def test_create_custom_field_select_invalid_options(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Custom field does not exist | ||||
|         WHEN: | ||||
|             - API request to create custom field with invalid select options | ||||
|         THEN: | ||||
|             - HTTP 400 is returned | ||||
|         """ | ||||
|  | ||||
|         # Not a list | ||||
|         resp = self.client.post( | ||||
|             self.ENDPOINT, | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "data_type": "select", | ||||
|                     "name": "Select Field", | ||||
|                     "extra_data": { | ||||
|                         "select_options": "not a list", | ||||
|                     }, | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|         # No options | ||||
|         resp = self.client.post( | ||||
|             self.ENDPOINT, | ||||
|             json.dumps( | ||||
|                 { | ||||
|                     "data_type": "select", | ||||
|                     "name": "Select Field", | ||||
|                 }, | ||||
|             ), | ||||
|             content_type="application/json", | ||||
|         ) | ||||
|         self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|  | ||||
|     def test_create_custom_field_instance(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
| @@ -135,6 +196,13 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|             name="Test Custom Field Doc Link", | ||||
|             data_type=CustomField.FieldDataType.DOCUMENTLINK, | ||||
|         ) | ||||
|         custom_field_select = CustomField.objects.create( | ||||
|             name="Test Custom Field Select", | ||||
|             data_type=CustomField.FieldDataType.SELECT, | ||||
|             extra_data={ | ||||
|                 "select_options": ["Option 1", "Option 2"], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         date_value = date.today() | ||||
|  | ||||
| @@ -178,6 +246,10 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|                         "field": custom_field_documentlink.id, | ||||
|                         "value": [doc2.id], | ||||
|                     }, | ||||
|                     { | ||||
|                         "field": custom_field_select.id, | ||||
|                         "value": 0, | ||||
|                     }, | ||||
|                 ], | ||||
|             }, | ||||
|             format="json", | ||||
| @@ -199,11 +271,12 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|                 {"field": custom_field_monetary.id, "value": "EUR11.10"}, | ||||
|                 {"field": custom_field_monetary2.id, "value": "11.1"}, | ||||
|                 {"field": custom_field_documentlink.id, "value": [doc2.id]}, | ||||
|                 {"field": custom_field_select.id, "value": 0}, | ||||
|             ], | ||||
|         ) | ||||
|  | ||||
|         doc.refresh_from_db() | ||||
|         self.assertEqual(len(doc.custom_fields.all()), 9) | ||||
|         self.assertEqual(len(doc.custom_fields.all()), 10) | ||||
|  | ||||
|     def test_change_custom_field_instance_value(self): | ||||
|         """ | ||||
| @@ -568,6 +641,44 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(CustomFieldInstance.objects.count(), 0) | ||||
|         self.assertEqual(len(doc.custom_fields.all()), 0) | ||||
|  | ||||
|     def test_custom_field_value_select_validation(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Document & custom field exist | ||||
|         WHEN: | ||||
|             - API request to set a field value to something not in the select options | ||||
|         THEN: | ||||
|             - HTTP 400 is returned | ||||
|             - No field instance is created or attached to the document | ||||
|         """ | ||||
|         doc = Document.objects.create( | ||||
|             title="WOW", | ||||
|             content="the content", | ||||
|             checksum="123", | ||||
|             mime_type="application/pdf", | ||||
|         ) | ||||
|         custom_field_select = CustomField.objects.create( | ||||
|             name="Test Custom Field SELECT", | ||||
|             data_type=CustomField.FieldDataType.SELECT, | ||||
|             extra_data={ | ||||
|                 "select_options": ["Option 1", "Option 2"], | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         resp = self.client.patch( | ||||
|             f"/api/documents/{doc.id}/", | ||||
|             data={ | ||||
|                 "custom_fields": [ | ||||
|                     {"field": custom_field_select.id, "value": 3}, | ||||
|                 ], | ||||
|             }, | ||||
|             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) | ||||
|  | ||||
|     def test_custom_field_not_null(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon