diff --git a/cmd_board.go b/cmd_board.go new file mode 100644 index 0000000..9b83823 --- /dev/null +++ b/cmd_board.go @@ -0,0 +1,289 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/pflag" +) + +var ( + flagsBoard *pflag.FlagSet + flagBoardScaffold string + flagBoardSearchFolder string +) + +var ErrBoardUsage = ObserveError{Msg: "usage: observe board [args...]"} + +func init() { + flagsBoard = pflag.NewFlagSet("board", pflag.ContinueOnError) + flagBoardScaffold = "" + flagsBoard.StringVar(&flagBoardScaffold, "name", "", "Filter boards by name when listing, or set name when scaffolding") + flagsBoard.StringVar(&flagBoardSearchFolder, "folder", "", "Filter boards by folder ID when listing") + RegisterCommand(&Command{ + Name: "board", + Help: "Create, update, scaffold, or set/clear defaults for a board (dashboard).", + Flags: flagsBoard, + Func: cmdBoard, + }) +} + +func cmdBoard(fa FuncArgs) error { + if len(fa.args) < 2 { + return ErrBoardUsage + } + switch fa.args[1] { + case "create": + return cmdBoardCreate(fa) + case "update": + return cmdBoardUpdate(fa) + case "scaffold": + return cmdBoardScaffold(fa) + case "set-default": + return cmdBoardSetDefault(fa) + case "clear-default": + return cmdBoardClearDefault(fa) + default: + return ObserveError{Msg: fmt.Sprintf("unknown board subcommand %q; expected create, update, scaffold, set-default, or clear-default", fa.args[1])} + } +} + +var gqlSaveBoard = compileGqlQuery( +`mutation Board_Save($input: DashboardInput!) { + saveDashboard(dash: $input) { + id + name + workspaceId + } + }`, + "data", "saveDashboard", +) + +// readOnlyBoardFields are fields returned by the Observe API that are not +// accepted as input by the saveDashboard mutation. +var readOnlyBoardFields = []string{"updatedDate"} + +func readBoardInput(fa FuncArgs, filePath string) (map[string]any, error) { + data, err := fa.fs.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("board: could not read file %q: %w", filePath, err) + } + var input map[string]any + if err := json.Unmarshal(data, &input); err != nil { + return nil, fmt.Errorf("board: could not parse JSON from %q: %w", filePath, err) + } + for _, f := range readOnlyBoardFields { + delete(input, f) + } + // Normalize stages: the GraphQL DashboardStageInput type requires "input" + // to be defined. Stages with no dataset input must send [] not omit the field. + if stages, ok := input["stages"].([]any); ok { + for _, s := range stages { + if stage, ok := s.(map[string]any); ok { + if _, hasInput := stage["input"]; !hasInput { + stage["input"] = []any{} + } + } + } + } + return input, nil +} + +func cmdBoardCreate(fa FuncArgs) error { + if len(fa.args) != 3 { + return ObserveError{Msg: "usage: observe board create "} + } + input, err := readBoardInput(fa, fa.args[2]) + if err != nil { + return err + } + obj, err := gqlSaveBoard.query(fa.cfg, fa.op, fa.hc, object{"input": input}) + if err != nil { + return err + } + if obj == nil { + return fmt.Errorf("board create: no result returned") + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("board create: unexpected response type") + } + name, _ := result["name"].(string) + id, _ := result["id"].(string) + fmt.Fprintf(fa.op, "Created: %s (id: %s)\n", name, id) + return nil +} + +func cmdBoardUpdate(fa FuncArgs) error { + if len(fa.args) != 4 { + return ObserveError{Msg: "usage: observe board update "} + } + boardId := fa.args[2] + input, err := readBoardInput(fa, fa.args[3]) + if err != nil { + return err + } + input["id"] = boardId + obj, err := gqlSaveBoard.query(fa.cfg, fa.op, fa.hc, object{"input": input}) + if err != nil { + return err + } + if obj == nil { + return fmt.Errorf("board update: no result returned") + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("board update: unexpected response type") + } + name, _ := result["name"].(string) + id, _ := result["id"].(string) + fmt.Fprintf(fa.op, "Updated: %s (id: %s)\n", name, id) + return nil +} + +var boardScaffoldTemplate = map[string]any{ + "name": "My Dashboard", + "workspaceId": "42379913", + "layout": map[string]any{ + "autoPack": true, + "gridLayout": map[string]any{ + "sections": []any{ + map[string]any{ + "card": map[string]any{ + "title": "Section", + "closed": false, + "cardType": "section", + }, + "items": []any{ + map[string]any{ + "card": map[string]any{ + "stageId": "stage-abc123", + "cardType": "stage", + }, + "layout": map[string]any{ + "h": 12, + "w": 12, + "x": 0, + "y": 0, + }, + }, + }, + }, + }, + }, + "stageListLayout": map[string]any{ + "timeRange": map[string]any{ + "display": "Past 24 hours", + "millisFromCurrentTime": 86400000, + "timeRangeInfo": map[string]any{ + "key": "PRESETS", + "name": "Presets", + "presetType": "PAST_24_HOURS", + }, + }, + "isModified": false, + "parameters": []any{}, + }, + }, + "stages": []any{ + map[string]any{ + "stageID": "stage-abc123", + "pipeline": "limit 100", + "input": []any{ + map[string]any{ + "inputName": "main", + "datasetId": "42450596", + }, + }, + }, + }, +} + +func cmdBoardScaffold(fa FuncArgs) error { + tmpl := copyMap(boardScaffoldTemplate) + if flagBoardScaffold != "" { + tmpl["name"] = flagBoardScaffold + } + enc := json.NewEncoder(fa.op) + enc.SetIndent("", " ") + return enc.Encode(tmpl) +} + +var gqlSetDefaultDashboard = compileGqlQuery( +`mutation Board_SetDefault($dsid: ObjectId!, $dashid: ObjectId!) { + setDefaultDashboard(dsid: $dsid, dashid: $dashid) { + success + errorMessage + } + }`, + "data", "setDefaultDashboard", +) + +var gqlClearDefaultDashboard = compileGqlQuery( +`mutation Board_ClearDefault($dsid: ObjectId!) { + clearDefaultDashboard(dsid: $dsid) { + success + errorMessage + } + }`, + "data", "clearDefaultDashboard", +) + +func cmdBoardSetDefault(fa FuncArgs) error { + if len(fa.args) != 4 { + return ObserveError{Msg: "usage: observe board set-default "} + } + datasetId := fa.args[2] + boardId := fa.args[3] + obj, err := gqlSetDefaultDashboard.query(fa.cfg, fa.op, fa.hc, object{"dsid": datasetId, "dashid": boardId}) + if err != nil { + return err + } + if obj == nil { + return fmt.Errorf("board set-default: no result returned") + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("board set-default: unexpected response type") + } + if errMsg, ok := result["errorMessage"]; ok && errMsg != nil { + if s, ok := errMsg.(string); ok && s != "" { + return fmt.Errorf("board set-default: %s", s) + } + } + fmt.Fprintf(fa.op, "Default dashboard set successfully\n") + return nil +} + +func cmdBoardClearDefault(fa FuncArgs) error { + if len(fa.args) != 3 { + return ObserveError{Msg: "usage: observe board clear-default "} + } + datasetId := fa.args[2] + obj, err := gqlClearDefaultDashboard.query(fa.cfg, fa.op, fa.hc, object{"dsid": datasetId}) + if err != nil { + return err + } + if obj == nil { + return fmt.Errorf("board clear-default: no result returned") + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("board clear-default: unexpected response type") + } + if errMsg, ok := result["errorMessage"]; ok && errMsg != nil { + if s, ok := errMsg.(string); ok && s != "" { + return fmt.Errorf("board clear-default: %s", s) + } + } + fmt.Fprintf(fa.op, "Default dashboard cleared successfully\n") + return nil +} + +func copyMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} diff --git a/cmd_board_search_test.go b/cmd_board_search_test.go new file mode 100644 index 0000000..50a88ae --- /dev/null +++ b/cmd_board_search_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestCmdListBoardNoFlags tests that `observe list board` (no search flags) uses the +// workspace-scoped plain list query. +func TestCmdListBoardNoFlags(t *testing.T) { + // Reset search flags to make sure no flags are set. + flagBoardScaffold = "" + flagBoardSearchFolder = "" + + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"dashboardSearch":{"dashboards":[{"score":1,"dashboard":{"id":"100","name":"Ops Board","workspaceId":"42379913","updatedDate":"2026-01-01"}}]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "board"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + if diff := cmp.Diff(fix.op.OutputBuf.String(), "id name \n100 Ops Board\n"); diff != "" { + t.Error("unexpected data output:", diff) + } +} + +// TestCmdListBoardWithNameFlag tests that the --name flag triggers dashboardSearch. +func TestCmdListBoardWithNameFlag(t *testing.T) { + flagBoardScaffold = "Ops" + flagBoardSearchFolder = "" + defer func() { + flagBoardScaffold = "" + }() + + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"dashboardSearch":{"results":[{"score":1,"dashboard":{"id":"100","name":"Ops Board","workspaceId":"42379913","updatedDate":"2026-01-01"}}]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "board"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + if diff := cmp.Diff(fix.op.OutputBuf.String(), "id name \n100 Ops Board\n"); diff != "" { + t.Error("unexpected data output:", diff) + } +} + +// TestCmdListBoardWithWorkspaceViaConfig tests that the global --workspace flag +// (which populates cfg.WorkspaceIdOrName) is used as the workspace filter in +// dashboardSearch when search is triggered by --name or --folder. +func TestCmdListBoardWithWorkspaceViaConfig(t *testing.T) { + flagBoardScaffold = "Fleet" + flagBoardSearchFolder = "" + defer func() { + flagBoardScaffold = "" + }() + + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"dashboardSearch":{"results":[{"score":1,"dashboard":{"id":"200","name":"Fleet Board","workspaceId":"99999","updatedDate":"2026-01-02"}}]}}}`}, + ) + // Simulate --workspace 99999 global flag setting cfg.WorkspaceIdOrName. + fix.cfg.WorkspaceIdOrName = "99999" + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "board"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + if diff := cmp.Diff(fix.op.OutputBuf.String(), "id name \n200 Fleet Board\n"); diff != "" { + t.Error("unexpected data output:", diff) + } +} + +// TestCmdListBoardWithFolderFlag tests that --folder triggers dashboardSearch. +func TestCmdListBoardWithFolderFlag(t *testing.T) { + flagBoardScaffold = "" + flagBoardSearchFolder = "folder-1" + defer func() { + flagBoardSearchFolder = "" + }() + + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"dashboardSearch":{"results":[{"score":0.8,"dashboard":{"id":"300","name":"Folder Board","workspaceId":"42379913","updatedDate":"2026-01-03"}}]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "board"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + if diff := cmp.Diff(fix.op.OutputBuf.String(), "id name \n300 Folder Board\n"); diff != "" { + t.Error("unexpected data output:", diff) + } +} + +// TestCmdListBoardSearchNoResults tests that dashboardSearch returning empty results +// outputs just the header row (no data rows). +func TestCmdListBoardSearchNoResults(t *testing.T) { + flagBoardScaffold = "NonExistent" + flagBoardSearchFolder = "" + defer func() { + flagBoardScaffold = "" + }() + + fix := startFixture(t, + testRequest{"/v1/meta", 200, `{"data":{"dashboardSearch":{"results":[]}}}`}, + ) + RunCommandWithConfig(fix.cfg, fix.fs, fix.op, []string{"list", "board"}, fix.hc) + if diff := fix.op.ErrorBuf.String(); diff != "" { + t.Error("unexpected error output:", diff) + } + // Only the header should be printed with no data rows. + out := fix.op.OutputBuf.String() + if !strings.Contains(out, "id") || !strings.Contains(out, "name") { + t.Error("expected header row in output:", out) + } + // Should not have any data lines beyond the header. + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 1 { + t.Errorf("expected 1 line (header only), got %d: %q", len(lines), out) + } +} + +// TestBoardSearchUnpackItems tests the unpackBoardItems helper directly. +func TestBoardSearchUnpackItems(t *testing.T) { + items := array{ + object{ + "score": float64(1), + "dashboard": object{ + "id": "abc", + "name": "Test Board", + "workspaceId": "42379913", + "updatedDate": "2026-01-01", + }, + }, + // Item without "dashboard" key should be skipped. + object{"score": float64(0)}, + // Non-object item should be skipped. + "not-an-object", + } + + infos := unpackBoardItems(items) + if len(infos) != 1 { + t.Fatalf("expected 1 result, got %d", len(infos)) + } + if infos[0].Id != "abc" { + t.Errorf("expected id=abc, got %s", infos[0].Id) + } + if infos[0].Name != "Test Board" { + t.Errorf("expected name=Test Board, got %s", infos[0].Name) + } +} diff --git a/docs/board.md b/docs/board.md new file mode 100644 index 0000000..0415ceb --- /dev/null +++ b/docs/board.md @@ -0,0 +1,45 @@ +# board + + observe board create my-dashboard.json + observe board update 43092236 my-dashboard.json + observe board scaffold --name "My Dashboard" > my-dashboard.json + +The board command allows you to create, update, and scaffold boards (dashboards) +in your Observe workspace. Boards organize visualizations of data using stages +and layouts. + +To list, get, or delete boards, use the standard object commands: + + observe list board + observe get board + observe delete board + +## Subcommands + +### create + + observe board create + +Creates a new board from a JSON file containing a DashboardInput object. The +required fields are: name, workspaceId, and layout. The stages field is +optional and describes the OPAL pipelines backing the visualizations. + +### update + + observe board update + +Updates an existing board identified by its ID. Reads the same JSON format as +create, and sets the id field automatically from the command-line argument. + +### scaffold + + observe board scaffold [--name NAME] + +Prints a minimal board template JSON to stdout. Redirect it to a file to +customize and then use with create or update. Use --name to set the board name +in the template. + +## Example + + observe board scaffold --name "My Dashboard" > my-dashboard.json + observe board create my-dashboard.json diff --git a/ot_board.go b/ot_board.go new file mode 100644 index 0000000..1975cd1 --- /dev/null +++ b/ot_board.go @@ -0,0 +1,326 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +func init() { + RegisterObjectType(ObjectTypeBoard, &objectBoard{}) +} + +type objectBoard struct { + Id string + Name string + WorkspaceId string + Description string + UpdatedDate string + Layout string + Stages string +} + +var _ ObjectInstance = &objectBoard{} + +func (o *objectBoard) GetInfo() *ObjectInfo { + return &ObjectInfo{ + Id: o.Id, + Name: o.Name, + Presentation: []string{o.Id, o.Name}, + Object: o, + } +} + +func (o *objectBoard) GetValues() []PropertyInstance { + props := ObjectTypeBoard.GetProperties() + r := make([]PropertyInstance, len(props)) + for i, p := range props { + r[i] = &propertyInstance{p, o} + } + return r +} + +func (o *objectBoard) PrintToYaml(op Output, otyp ObjectType, obj ObjectInstance) error { + return printToYamlFromObjectInstance(op, otyp, obj) +} + +type objectTypeBoard struct{} + +var ObjectTypeBoard ObjectType = &objectTypeBoard{} + +var propertyDescBoard = []PropertyDesc{ + {"id", PropertyTypeString, false, true, + func(o any) any { return o.(*objectBoard).Id }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).Id = v.(string) + } + }}, + {"name", PropertyTypeString, false, false, + func(o any) any { return o.(*objectBoard).Name }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).Name = v.(string) + } + }}, + {"workspaceId", PropertyTypeString, false, false, + func(o any) any { return o.(*objectBoard).WorkspaceId }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).WorkspaceId = v.(string) + } + }}, + {"description", PropertyTypeString, false, false, + func(o any) any { return o.(*objectBoard).Description }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).Description = v.(string) + } + }}, + {"updatedDate", PropertyTypeString, true, false, + func(o any) any { return o.(*objectBoard).UpdatedDate }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).UpdatedDate = v.(string) + } + }}, + {"layout", PropertyTypeString, true, false, + func(o any) any { return o.(*objectBoard).Layout }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).Layout = v.(string) + } + }}, + {"stages", PropertyTypeString, true, false, + func(o any) any { return o.(*objectBoard).Stages }, + func(o any, v any) { + if v != nil { + o.(*objectBoard).Stages = v.(string) + } + }}, +} + +func (*objectTypeBoard) TypeName() string { return "board" } +func (*objectTypeBoard) Help() string { + return "A board (dashboard) organizes visualizations of data within a workspace." +} +func (*objectTypeBoard) CanList() bool { return true } +func (*objectTypeBoard) CanGet() bool { return true } +func (*objectTypeBoard) CanCreate() bool { return false } +func (*objectTypeBoard) CanUpdate() bool { return false } +func (*objectTypeBoard) CanDelete() bool { return true } +func (*objectTypeBoard) GetPresentationLabels() []string { return []string{"id", "name"} } +func (*objectTypeBoard) GetProperties() []PropertyDesc { return propertyDescBoard } + +var gqlListBoard = compileGqlQuery( +`query Board_List($workspaceId: [ObjectId!]!) { + dashboardSearch(terms: { workspaceId: $workspaceId }) { + dashboards { + score + dashboard { id name workspaceId updatedDate } + } + } + }`, + "data", "dashboardSearch", "dashboards", +) + +// unpackBoardItems extracts []*ObjectInfo from an array of dashboardSearch result items. +// Each item is expected to have a "dashboard" sub-object. +func unpackBoardItems(items array) []*ObjectInfo { + var ret []*ObjectInfo + for _, item := range items { + m, ok := item.(object) + if !ok { + continue + } + dash, ok := m["dashboard"] + if !ok { + continue + } + dashObj, ok := dash.(object) + if !ok { + continue + } + o := &objectBoard{} + if v, ok := dashObj["id"]; ok && v != nil { + o.Id = v.(string) + } + if v, ok := dashObj["name"]; ok && v != nil { + o.Name = v.(string) + } + if v, ok := dashObj["workspaceId"]; ok && v != nil { + o.WorkspaceId = v.(string) + } + if v, ok := dashObj["updatedDate"]; ok && v != nil { + o.UpdatedDate = v.(string) + } + ret = append(ret, o.GetInfo()) + } + return ret +} + +func (ot *objectTypeBoard) List(cfg *Config, op Output, hc httpClient) ([]*ObjectInfo, error) { + // If any search flags are set, delegate to Search with those terms. + // The global --workspace flag populates cfg.WorkspaceIdOrName for workspace filtering. + if flagBoardScaffold != "" || flagBoardSearchFolder != "" { + terms := BoardSearchTerms{ + Name: flagBoardScaffold, + WorkspaceId: cfg.WorkspaceIdOrName, + FolderId: flagBoardSearchFolder, + } + return ot.Search(cfg, op, hc, terms) + } + workspaceId := cfg.WorkspaceIdOrName + if workspaceId == "" { + workspaceId = "42379913" + } + obj, err := gqlListBoard.query(cfg, op, hc, object{"workspaceId": []string{workspaceId}}) + if err != nil || obj == nil { + return nil, err + } + items, ok := obj.(array) + if !ok { + return nil, fmt.Errorf("board list: unexpected response type") + } + return unpackBoardItems(items), nil +} + +// BoardSearchTerms holds optional search parameters for dashboardSearch. +type BoardSearchTerms struct { + Name string + WorkspaceId string + FolderId string +} + +var gqlSearchBoard = compileGqlQuery( +`query Board_Search($terms: DWSearchInput!, $maxCount: Int) { + dashboardSearch(terms: $terms, maxCount: $maxCount) { + results { + dashboard { id name workspaceId updatedDate } + score + } + } + }`, + "data", "dashboardSearch", "results", +) + +// Search queries dashboardSearch using optional name/workspace/folder filters. +// Results are presented the same way as List. +func (ot *objectTypeBoard) Search(cfg *Config, op Output, hc httpClient, terms BoardSearchTerms) ([]*ObjectInfo, error) { + termMap := object{} + if terms.Name != "" { + termMap["name"] = terms.Name + } + if terms.WorkspaceId != "" { + termMap["workspaceId"] = terms.WorkspaceId + } + if terms.FolderId != "" { + termMap["folderId"] = terms.FolderId + } + obj, err := gqlSearchBoard.query(cfg, op, hc, object{"terms": termMap}) + if err != nil || obj == nil { + return nil, err + } + items, ok := obj.(array) + if !ok { + return nil, fmt.Errorf("board search: unexpected response type") + } + return unpackBoardItems(items), nil +} + +var gqlGetBoard = compileGqlQuery( +`query Board_Get($id: ObjectId!) { + dashboard(id: $id) { + id + name + workspaceId + description + updatedDate + layout + stages { id stageID pipeline input { inputName datasetId stageId } } + } + }`, + "data", "dashboard", +) + +func (ot *objectTypeBoard) Get(cfg *Config, op Output, hc httpClient, id string) (ObjectInstance, error) { + obj, err := gqlGetBoard.query(cfg, op, hc, object{"id": id}) + if err != nil { + return nil, err + } + if obj == nil { + return nil, nil + } + raw, ok := obj.(object) + if !ok { + return nil, fmt.Errorf("board get: unexpected response type") + } + o := &objectBoard{} + if v, ok := raw["id"]; ok && v != nil { + o.Id = v.(string) + } + if v, ok := raw["name"]; ok && v != nil { + o.Name = v.(string) + } + if v, ok := raw["workspaceId"]; ok && v != nil { + o.WorkspaceId = v.(string) + } + if v, ok := raw["description"]; ok && v != nil { + o.Description = v.(string) + } + if v, ok := raw["updatedDate"]; ok && v != nil { + o.UpdatedDate = v.(string) + } + if v, ok := raw["layout"]; ok && v != nil { + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("board get: failed to marshal layout: %w", err) + } + o.Layout = string(b) + } + if v, ok := raw["stages"]; ok && v != nil { + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("board get: failed to marshal stages: %w", err) + } + o.Stages = string(b) + } + return o, nil +} + +var gqlDeleteBoard = compileGqlQuery( +`mutation Board_Delete($id: ObjectId!) { + deleteDashboard(id: $id) { + success + errorMessage + } + }`, + "data", "deleteDashboard", +) + +func (ot *objectTypeBoard) Delete(cfg *Config, op Output, hc httpClient, id string) error { + obj, err := gqlDeleteBoard.query(cfg, op, hc, object{"id": id}) + if err != nil { + return err + } + if obj == nil { + return nil + } + result, ok := obj.(object) + if !ok { + return fmt.Errorf("board delete: unexpected response type") + } + if errMsg, ok := result["errorMessage"]; ok && errMsg != nil { + if s, ok := errMsg.(string); ok && s != "" { + return fmt.Errorf("board delete: %s", s) + } + } + return nil +} + +func (ot *objectTypeBoard) Create(cfg *Config, op Output, hc httpClient, input object) (ObjectInstance, error) { + return nil, nil +} + +func (ot *objectTypeBoard) Update(cfg *Config, op Output, hc httpClient, id string, input object) (ObjectInstance, error) { + return nil, nil +}