Skip to content

Commit 7075e13

Browse files
committed
Fix custom field audit findings (WI-1038)
1 parent 616f2d5 commit 7075e13

27 files changed

Lines changed: 708 additions & 203 deletions

frontend/src/lib/dialogs/AssetReportFormModal.svelte

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,6 @@
298298
bind:checked={values[field.field_identifier]}
299299
label={getFieldLabel(field)}
300300
/>
301-
{:else if field.virtual_field_type === 'number'}
302-
<Input
303-
type="number"
304-
bind:value={values[field.field_identifier]}
305-
placeholder={getFieldLabel(field)}
306-
size="medium"
307-
/>
308301
{:else}
309302
<Input
310303
type="text"

frontend/src/lib/features/assets/AssetDynamicFieldFilter.svelte

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import Checkbox from '../../components/Checkbox.svelte';
99
import Input from '../../components/Input.svelte';
1010
import { t } from '../../stores/i18n.svelte.js';
11-
import { booleanOptions, operatorsByType, isMultiValueOperator } from '../shared/filterOperators.js';
11+
import { booleanOptions, operatorsByType, isMultiValueOperator, isNullOperator } from '../shared/filterOperators.js';
1212
1313
let {
1414
filter = { field: null, operator: '=', value: '', values: [] },
@@ -177,8 +177,10 @@
177177
onSelect={(item) => {
178178
if (item) {
179179
const newOperator = item.value;
180-
if (newOperator === 'IN' || newOperator === 'NOT IN') {
180+
if (isMultiValueOperator(newOperator)) {
181181
onChange({ ...filter, operator: newOperator, values: [], value: '' });
182+
} else if (isNullOperator(newOperator)) {
183+
onChange({ ...filter, operator: newOperator, value: '', values: [] });
182184
} else {
183185
onChange({ ...filter, operator: newOperator, values: [] });
184186
}
@@ -189,7 +191,9 @@
189191
190192
<!-- Value Input -->
191193
<div class={compact ? "flex-1 min-w-0" : "flex-1"} style={compact ? "" : "min-width: 200px;"}>
192-
{#if isMultiValueOperator(filter.operator)}
194+
{#if isNullOperator(filter.operator)}
195+
<div class="px-3 py-2 text-sm" style="color: var(--ds-text-subtle);">No value required</div>
196+
{:else if isMultiValueOperator(filter.operator)}
193197
{#if valueOptions.length > 0}
194198
<div class="border rounded p-2 max-h-32 overflow-y-auto" style="border-color: var(--ds-border); background-color: var(--ds-surface);">
195199
{#each valueOptions as option}

frontend/src/lib/features/collections/CardFieldChip.svelte

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { CalendarDays, Clock, CornerLeftUp } from '@lucide/svelte';
33
import Chip from '../../components/Chip.svelte';
44
import { formatDate, formatDateOnly, formatDateShort, formatStatusAge } from '../../utils/dateFormatter.js';
5-
import { resolveOptionLabel } from '../../utils/optionUtils.js';
5+
import { resolveOptionLabel, resolveOptionLabels } from '../../utils/optionUtils.js';
66
import { durationToString } from '../../utils/timeUtils.js';
77
import { booleanCustomFieldChecked, isBooleanCustomFieldType } from '../../utils/customFieldTypes.js';
88
import { t } from '../../stores/i18n.svelte.js';
@@ -174,10 +174,14 @@
174174
<Chip appearance="metadata" icon={CalendarDays} title={customFieldDef.name}>
175175
{formatDateOnly(customFieldValue)}
176176
</Chip>
177-
{:else if (customFieldDef.field_type === 'select' || customFieldDef.field_type === 'multiselect') && customFieldDef.options}
177+
{:else if customFieldDef.field_type === 'select' && customFieldDef.options}
178178
<Chip appearance="metadata" title={customFieldDef.name}>
179179
{resolveOptionLabel(customFieldDef.options, customFieldValue) || customFieldValue}
180180
</Chip>
181+
{:else if customFieldDef.field_type === 'multiselect' && customFieldDef.options}
182+
<Chip appearance="metadata" title={customFieldDef.name}>
183+
{resolveOptionLabels(customFieldDef.options, customFieldValue).join(', ') || customFieldValue}
184+
</Chip>
181185
{:else if isBooleanCustomFieldType(customFieldDef.field_type)}
182186
{@const checked = booleanCustomFieldChecked(customFieldValue)}
183187
<span data-testid={`board-card-custom-field-${customFieldId}`}>
@@ -186,8 +190,9 @@
186190
</Chip>
187191
</span>
188192
{:else if customFieldDef.field_type === 'number'}
193+
{@const numericValue = parseFloat(String(customFieldValue))}
189194
<Chip appearance="metadata" title={customFieldDef.name}>
190-
{parseFloat(String(customFieldValue))}
195+
{Number.isFinite(numericValue) ? numericValue : String(customFieldValue)}
191196
</Chip>
192197
{:else if customFieldDef.field_type === 'user' && customFieldUserNames.length > 0}
193198
<Chip appearance="metadata" title={customFieldDef.name}>

frontend/src/lib/features/collections/ListCustomFieldCell.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@
194194
autoOpenPickers={false}
195195
{milestones}
196196
{iterations}
197-
users={editorOptions?.users ?? users}
197+
users={editorOptions?.loaded?.users ? editorOptions.users : users}
198198
optionData={editorOptions ?? {}}
199199
optionLoading={editorOptions?.loading ?? {}}
200200
onRequestOptions={(field) => editorOptions && collectionEditorOptions.load(workspaceId, field)}

frontend/src/lib/features/forms/FormFields.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@
3131
{#if currentStepFields.some((field) => field.field_identifier === 'title')}
3232
{@const titleField = currentStepFields.find((field) => field.field_identifier === 'title')}
3333
<div>
34-
<Label for={`${idPrefix}-title`} required={titleField.is_required} class="mb-1.5" color="default">
34+
<Label for={`${idPrefix}-title`} required={true} class="mb-1.5" color="default">
3535
{titleField.display_name || t('requestForm.title')}
3636
</Label>
3737
<Input
3838
id={`${idPrefix}-title`}
3939
bind:value={formData.title}
4040
placeholder={t('requestForm.enterTitle')}
41-
required={titleField.is_required}
41+
required={true}
4242
/>
4343
{#if titleField.description}
4444
<p class="mt-1 text-xs" style="color: var(--ds-text-subtle);">{titleField.description}</p>

frontend/src/lib/features/forms/formModel.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ export function validateFormStep({
5959
const stepFields = fields.filter((field) => (field.step_number || 1) === step);
6060

6161
for (const field of stepFields) {
62-
if (!field.is_required) continue;
62+
const required =
63+
field.is_required || (field.field_type === 'default' && field.field_identifier === 'title');
64+
if (!required) continue;
6365

6466
let value;
6567
if (field.field_type === 'default') {

frontend/src/lib/features/items/CustomFieldRenderer.svelte

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,17 +174,22 @@
174174
if (typeof v === 'object' && v.name) {
175175
return v.name;
176176
}
177-
return `Customer #${v}`;
177+
return `Customer #${typeof v === 'object' ? v.id : v}`;
178178
case 'customerorganisation':
179179
if (typeof v === 'object' && v.name) {
180180
return v.name;
181181
}
182-
return `Organisation #${v}`;
182+
return `Organisation #${typeof v === 'object' ? v.id : v}`;
183183
case 'select':
184184
case 'multiselect':
185185
if (field.options) {
186186
if (field.field_type === 'multiselect') {
187-
return resolveOptionLabels(field.options, Array.isArray(v) ? v : []).join(', ');
187+
const values = Array.isArray(v)
188+
? v
189+
: typeof v === 'string' && v.includes(',')
190+
? v.split(',').map(item => item.trim()).filter(Boolean)
191+
: [v];
192+
return resolveOptionLabels(field.options, values).join(', ');
188193
}
189194
return resolveOptionLabel(field.options, v);
190195
}
@@ -202,6 +207,11 @@
202207
}
203208
}
204209
210+
function hasDisplayValue() {
211+
if (value === null || value === undefined || value === '') return false;
212+
return !(field.field_type === 'multiselect' && Array.isArray(value) && value.length === 0);
213+
}
214+
205215
// Handle keydown for text/number inputs
206216
function handleKeydown(event) {
207217
if (event.key === 'Enter' && !event.shiftKey) {
@@ -325,7 +335,7 @@
325335
onclick={handleClick}
326336
data-testid={displayTestId}
327337
>
328-
{#if value !== null && value !== undefined && value !== ''}
338+
{#if hasDisplayValue()}
329339
{#if field.field_type === 'user'}
330340
<!-- Display user with avatar -->
331341
{#if userData}
@@ -421,7 +431,7 @@
421431
class="min-w-0 {displayAlignment === 'end' ? 'text-right' : ''} {truncateDisplay ? 'whitespace-nowrap overflow-hidden' : ''} {noPadding ? '' : 'px-3'} py-2 text-sm {disabled ? 'opacity-50' : ''}"
422432
data-testid={displayTestId}
423433
>
424-
{#if value !== null && value !== undefined && value !== ''}
434+
{#if hasDisplayValue()}
425435
{#if field.field_type === 'user'}
426436
{#if userData}
427437
<div class="flex min-w-0 items-center gap-2 {displayAlignment === 'end' ? 'justify-end' : ''}">

frontend/src/lib/features/items/DynamicFieldFilter.svelte

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import Checkbox from '../../components/Checkbox.svelte';
1111
import Input from '../../components/Input.svelte';
1212
import { api } from '../../api.js';
13-
import { booleanOptions, operatorsByType, isMultiValueOperator } from '../shared/filterOperators.js';
13+
import { booleanOptions, operatorsByType, isMultiValueOperator, isNullOperator } from '../shared/filterOperators.js';
1414
1515
let {
1616
filter = {
@@ -191,14 +191,14 @@
191191
const newOperator = event.target.value;
192192
193193
// Reset value/values based on new operator
194-
if (newOperator === 'IN' || newOperator === 'NOT IN') {
194+
if (isMultiValueOperator(newOperator)) {
195195
onchange?.({
196196
...filter,
197197
operator: newOperator,
198198
values: [],
199199
value: ''
200200
});
201-
} else {
201+
} else {
202202
onchange?.({
203203
...filter,
204204
operator: newOperator,
@@ -313,8 +313,10 @@
313313
onSelect={(item) => {
314314
if (item) {
315315
const newOperator = item.value;
316-
if (newOperator === 'IN' || newOperator === 'NOT IN') {
316+
if (isMultiValueOperator(newOperator)) {
317317
onchange?.({ ...filter, operator: newOperator, values: [], value: '' });
318+
} else if (isNullOperator(newOperator)) {
319+
onchange?.({ ...filter, operator: newOperator, value: '', values: [] });
318320
} else {
319321
onchange?.({ ...filter, operator: newOperator, values: [] });
320322
}
@@ -325,7 +327,9 @@
325327
326328
<!-- Value Input -->
327329
<div data-testid={testIdPrefix ? `${testIdPrefix}-value` : undefined} class={compact ? "flex-1 min-w-0" : "flex-1"} style={compact ? "" : "min-width: 200px;"}>
328-
{#if isMultiValueOperator(filter.operator)}
330+
{#if isNullOperator(filter.operator)}
331+
<div class="px-3 py-2 text-sm" style="color: var(--ds-text-subtle);">No value required</div>
332+
{:else if isMultiValueOperator(filter.operator)}
329333
<!-- Multi-value selector for IN/NOT IN -->
330334
{#if filter.field.id === 'iteration'}
331335
<IterationCombobox

frontend/src/lib/features/shared/filterOperators.js

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,66 +5,77 @@ export const booleanOptions = [
55
{ value: 'false', label: 'False' },
66
];
77

8+
const nullOperators = [
9+
{ value: 'IS NULL', label: 'is empty' },
10+
{ value: 'IS NOT NULL', label: 'is not empty' },
11+
];
12+
13+
const withNullOperators = (operators) => [...operators, ...nullOperators];
14+
815
export const operatorsByType = {
9-
text: [
16+
text: withNullOperators([
1017
{ value: '=', label: 'equals' },
1118
{ value: '!=', label: 'does not equal' },
1219
{ value: '~', label: 'contains' },
13-
],
14-
number: [
20+
]),
21+
number: withNullOperators([
1522
{ value: '=', label: 'equals' },
1623
{ value: '!=', label: 'does not equal' },
1724
{ value: '<', label: 'less than' },
1825
{ value: '<=', label: 'less than or equal' },
1926
{ value: '>', label: 'greater than' },
2027
{ value: '>=', label: 'greater than or equal' },
21-
],
22-
date: [
28+
]),
29+
date: withNullOperators([
2330
{ value: '=', label: 'on' },
2431
{ value: '!=', label: 'not on' },
2532
{ value: '<', label: 'before' },
2633
{ value: '<=', label: 'on or before' },
2734
{ value: '>', label: 'after' },
2835
{ value: '>=', label: 'on or after' },
29-
],
30-
enum: [
36+
]),
37+
enum: withNullOperators([
3138
{ value: '=', label: 'is' },
3239
{ value: '!=', label: 'is not' },
3340
{ value: 'IN', label: 'is one of' },
3441
{ value: 'NOT IN', label: 'is not one of' },
35-
],
36-
select: [
42+
]),
43+
select: withNullOperators([
3744
{ value: '=', label: 'is' },
3845
{ value: '!=', label: 'is not' },
3946
{ value: 'IN', label: 'is one of' },
4047
{ value: 'NOT IN', label: 'is not one of' },
41-
],
42-
boolean: [{ value: '=', label: 'is' }],
43-
user: [
48+
]),
49+
boolean: withNullOperators([{ value: '=', label: 'is' }]),
50+
user: withNullOperators([
4451
{ value: '=', label: 'is' },
4552
{ value: '!=', label: 'is not' },
4653
{ value: 'IN', label: 'is one of' },
4754
{ value: 'NOT IN', label: 'is not one of' },
48-
],
49-
textarea: [
55+
]),
56+
textarea: withNullOperators([
5057
{ value: '=', label: 'equals' },
5158
{ value: '!=', label: 'does not equal' },
5259
{ value: '~', label: 'contains' },
53-
],
54-
reference: [
60+
]),
61+
reference: withNullOperators([
5562
{ value: '=', label: 'is' },
5663
{ value: '!=', label: 'is not' },
5764
{ value: 'IN', label: 'is one of' },
5865
{ value: 'NOT IN', label: 'is not one of' },
59-
],
60-
identifier: [
66+
]),
67+
identifier: withNullOperators([
6168
{ value: '=', label: 'equals' },
6269
{ value: '!=', label: 'does not equal' },
6370
{ value: 'IN', label: 'is one of' },
6471
{ value: 'NOT IN', label: 'is not one of' },
65-
],
72+
]),
6673
};
6774

6875
export function isMultiValueOperator(operator) {
6976
return operator === 'IN' || operator === 'NOT IN';
7077
}
78+
79+
export function isNullOperator(operator) {
80+
return operator === 'IS NULL' || operator === 'IS NOT NULL';
81+
}

0 commit comments

Comments
 (0)