Skip to content

Commit a4312b0

Browse files
rafaeljustoclaude
andcommitted
Fix: Decode custom field options from nested "options" object
CustomField.UnmarshalJSON decoded the type-specific options by unmarshalling the entire custom-field payload, which looked for a top-level "choices" key. The API nests options under an "options" object, so Choices (and rating/decimal options) always decoded to nil — surfacing as "options":{"choices":null} for callers that round-trip through the struct, while raw passthrough paths showed the choices correctly. Decode the nested "options" value and unmarshal that into the concrete options type instead. A missing or null options object is no longer treated as an error. Additionally, the API serializes the same "choices" shape for dropdown, multiselect and status fields, but only dropdown was decoded — multiselect and status silently lost their choices. Decode all three into the dropdown options type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3f08a70 commit a4312b0

2 files changed

Lines changed: 158 additions & 6 deletions

File tree

projects/custom_field.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,11 @@ type CustomFieldOptions interface {
143143
options()
144144
}
145145

146-
// CustomFieldOptionsDropdown store the options for a dropdown type.
146+
// CustomFieldOptionsDropdown store the options for the choice-based custom
147+
// field types. The API uses this same "choices" shape for dropdown,
148+
// multiselect and status fields.
147149
type CustomFieldOptionsDropdown struct {
148-
// Choices is the list of choices available for the dropdown.
150+
// Choices is the list of choices available for the field.
149151
Choices []CustomFieldOptionsDropdownChoice `json:"choices"`
150152
}
151153

@@ -273,22 +275,36 @@ func (c *CustomField) UnmarshalJSON(data []byte) error {
273275
}
274276
*c = CustomField{customField: customField}
275277

278+
// The API nests the type-specific options under an "options" object, so
279+
// decode that sub-object rather than the whole custom field payload.
280+
var wrapper struct {
281+
Options json.RawMessage `json:"options"`
282+
}
283+
if err := json.Unmarshal(data, &wrapper); err != nil {
284+
return err
285+
}
286+
if len(wrapper.Options) == 0 || string(wrapper.Options) == "null" {
287+
return nil
288+
}
289+
290+
// The API serializes the same "choices" shape for dropdown, multiselect
291+
// and status fields, so all three decode into the dropdown options type.
276292
switch c.Type {
277-
case CustomFieldTypeDropdown:
293+
case CustomFieldTypeDropdown, CustomFieldTypeMultiselect, CustomFieldTypeStatus:
278294
var options CustomFieldOptionsDropdown
279-
if err := json.Unmarshal(data, &options); err != nil {
295+
if err := json.Unmarshal(wrapper.Options, &options); err != nil {
280296
return fmt.Errorf("failed to decode dropdown options: %w", err)
281297
}
282298
c.Options = options
283299
case CustomFieldTypeRating:
284300
var options CustomFieldOptionsRating
285-
if err := json.Unmarshal(data, &options); err != nil {
301+
if err := json.Unmarshal(wrapper.Options, &options); err != nil {
286302
return fmt.Errorf("failed to decode rating options: %w", err)
287303
}
288304
c.Options = options
289305
case CustomFieldTypeNumberDecimal:
290306
var options CustomFieldOptionsNumberDecimal
291-
if err := json.Unmarshal(data, &options); err != nil {
307+
if err := json.Unmarshal(wrapper.Options, &options); err != nil {
292308
return fmt.Errorf("failed to decode number decimal options: %w", err)
293309
}
294310
c.Options = options

projects/custom_field_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package projects_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"math/rand"
78
"testing"
@@ -180,3 +181,138 @@ func TestCustomFieldList(t *testing.T) {
180181
})
181182
}
182183
}
184+
185+
func TestCustomFieldUnmarshalJSONOptions(t *testing.T) {
186+
tests := []struct {
187+
name string
188+
input string
189+
verify func(t *testing.T, cf projects.CustomField)
190+
}{{
191+
name: "dropdown choices nested under options",
192+
input: `{
193+
"id": 70375,
194+
"name": "TestNumbers",
195+
"type": "dropdown",
196+
"entity": "task",
197+
"options": {"choices": [
198+
{"value": "1", "color": "#4cd5e3"},
199+
{"value": "2", "color": "#e12d42"}
200+
]}
201+
}`,
202+
verify: func(t *testing.T, cf projects.CustomField) {
203+
options, ok := cf.Options.(projects.CustomFieldOptionsDropdown)
204+
if !ok {
205+
t.Fatalf("expected dropdown options, got %T", cf.Options)
206+
}
207+
if len(options.Choices) != 2 {
208+
t.Fatalf("expected 2 choices, got %d", len(options.Choices))
209+
}
210+
if options.Choices[0].Value != "1" || options.Choices[1].Value != "2" {
211+
t.Errorf("unexpected choice values: %+v", options.Choices)
212+
}
213+
},
214+
}, {
215+
name: "multiselect choices nested under options",
216+
input: `{
217+
"id": 71000,
218+
"name": "Tags",
219+
"type": "multiselect",
220+
"entity": "task",
221+
"options": {"choices": [
222+
{"value": "red", "color": "#4cd5e3"},
223+
{"value": "green", "color": "#e12d42"}
224+
]}
225+
}`,
226+
verify: func(t *testing.T, cf projects.CustomField) {
227+
options, ok := cf.Options.(projects.CustomFieldOptionsDropdown)
228+
if !ok {
229+
t.Fatalf("expected dropdown options for multiselect, got %T", cf.Options)
230+
}
231+
if len(options.Choices) != 2 {
232+
t.Fatalf("expected 2 choices, got %d", len(options.Choices))
233+
}
234+
if options.Choices[0].Value != "red" || options.Choices[1].Value != "green" {
235+
t.Errorf("unexpected choice values: %+v", options.Choices)
236+
}
237+
},
238+
}, {
239+
name: "status choices nested under options",
240+
input: `{
241+
"id": 71001,
242+
"name": "Stage",
243+
"type": "status",
244+
"entity": "task",
245+
"options": {"choices": [
246+
{"value": "open", "color": "#4cd5e3"}
247+
]}
248+
}`,
249+
verify: func(t *testing.T, cf projects.CustomField) {
250+
options, ok := cf.Options.(projects.CustomFieldOptionsDropdown)
251+
if !ok {
252+
t.Fatalf("expected dropdown options for status, got %T", cf.Options)
253+
}
254+
if len(options.Choices) != 1 || options.Choices[0].Value != "open" {
255+
t.Errorf("unexpected choices: %+v", options.Choices)
256+
}
257+
},
258+
}, {
259+
name: "rating options nested under options",
260+
input: `{
261+
"id": 1,
262+
"name": "Rating",
263+
"type": "rating",
264+
"entity": "task",
265+
"options": {"icon": "star", "color": "#ff0000"}
266+
}`,
267+
verify: func(t *testing.T, cf projects.CustomField) {
268+
options, ok := cf.Options.(projects.CustomFieldOptionsRating)
269+
if !ok {
270+
t.Fatalf("expected rating options, got %T", cf.Options)
271+
}
272+
if options.Icon != "star" {
273+
t.Errorf("expected icon 'star', got %q", options.Icon)
274+
}
275+
},
276+
}, {
277+
name: "number decimal options nested under options",
278+
input: `{
279+
"id": 2,
280+
"name": "Decimal",
281+
"type": "number-decimal",
282+
"entity": "task",
283+
"options": {"decimals": 3}
284+
}`,
285+
verify: func(t *testing.T, cf projects.CustomField) {
286+
options, ok := cf.Options.(projects.CustomFieldOptionsNumberDecimal)
287+
if !ok {
288+
t.Fatalf("expected number decimal options, got %T", cf.Options)
289+
}
290+
if options.DecimalPoints == nil || *options.DecimalPoints != 3 {
291+
t.Errorf("expected 3 decimal points, got %v", options.DecimalPoints)
292+
}
293+
},
294+
}, {
295+
name: "missing options is not an error",
296+
input: `{
297+
"id": 3,
298+
"name": "Text",
299+
"type": "text-short",
300+
"entity": "task"
301+
}`,
302+
verify: func(t *testing.T, cf projects.CustomField) {
303+
if cf.Options != nil {
304+
t.Errorf("expected nil options, got %+v", cf.Options)
305+
}
306+
},
307+
}}
308+
309+
for _, tt := range tests {
310+
t.Run(tt.name, func(t *testing.T) {
311+
var cf projects.CustomField
312+
if err := json.Unmarshal([]byte(tt.input), &cf); err != nil {
313+
t.Fatalf("failed to unmarshal custom field: %s", err)
314+
}
315+
tt.verify(t, cf)
316+
})
317+
}
318+
}

0 commit comments

Comments
 (0)