diff --git a/pkg/models/project_view.go b/pkg/models/project_view.go
index fce6798de..8b337ff90 100644
--- a/pkg/models/project_view.go
+++ b/pkg/models/project_view.go
@@ -23,6 +23,7 @@ import (
"code.vikunja.io/api/pkg/web"
+ "github.com/danielgtaylor/huma/v2"
"xorm.io/xorm"
)
@@ -66,6 +67,17 @@ func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error {
return nil
}
+// Schema lets Huma (/api/v2) reflect this type as a string enum. The custom
+// Marshal/UnmarshalJSON above serialize it as a string, but the underlying Go
+// type is an int — without this, Huma would generate an integer schema and
+// reject the string form clients actually send.
+func (*ProjectViewKind) Schema(_ huma.Registry) *huma.Schema {
+ return &huma.Schema{
+ Type: "string",
+ Enum: []any{"list", "gantt", "table", "kanban"},
+ }
+}
+
// NOTE: When adding or changing enum values for ProjectViewKind,
// make sure to update the corresponding `enums` tag in the ProjectView struct
// to keep the OpenAPI documentation in sync.
@@ -123,39 +135,48 @@ func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error {
return nil
}
+// Schema lets Huma (/api/v2) reflect this type as a string enum; see the note
+// on ProjectViewKind.Schema for why this is needed.
+func (*BucketConfigurationModeKind) Schema(_ huma.Registry) *huma.Schema {
+ return &huma.Schema{
+ Type: "string",
+ Enum: []any{"none", "manual", "filter"},
+ }
+}
+
type ProjectViewBucketConfiguration struct {
- Title string `json:"title"`
- Filter *TaskCollection `json:"filter"`
+ Title string `json:"title" doc:"The title of the bucket this configuration creates."`
+ Filter *TaskCollection `json:"filter" doc:"The filter query that decides which tasks land in this bucket. See https://vikunja.io/docs/filters."`
}
type ProjectView struct {
// The unique numeric id of this view
- ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
+ ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view" readOnly:"true" doc:"The unique, numeric id of this view. Set by the server."`
// The title of this view
- Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)"`
+ Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of this view."`
// The project this view belongs to
- ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"`
+ ProjectID int64 `xorm:"not null index" json:"project_id" param:"project" readOnly:"true" doc:"The project this view belongs to. Taken from the URL path; ignored on write."`
// The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.
- ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban"`
+ ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban" doc:"The kind of this view. One of list, gantt, table or kanban."`
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
- Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter"`
+ Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
// The position of this view in the list. The list of all views will be sorted by this parameter.
- Position float64 `xorm:"double null" json:"position"`
+ Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the project's list of views. Views are sorted ascending by this value."`
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
- BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual"`
+ BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`
// When the bucket configuration mode is not `manual`, this field holds the options of that configuration.
- BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"`
+ BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration" doc:"When the bucket configuration mode is filter, holds the title and filter of each bucket."`
// The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.
- DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"`
+ DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id" doc:"The id of the bucket new tasks without a bucket are added to. Defaults to the leftmost bucket."`
// If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.
- DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"`
+ DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id" doc:"The id of the done bucket. Tasks moved here are marked done, and tasks marked done are moved here."`
// A timestamp when this view was updated. You cannot change this value.
- Updated time.Time `xorm:"updated not null" json:"updated"`
+ Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this view was last updated. You cannot change this value."`
// A timestamp when this reaction was created. You cannot change this value.
- Created time.Time `xorm:"created not null" json:"created"`
+ Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this view was created. You cannot change this value."`
web.CRUDable `xorm:"-" json:"-"`
web.Permissions `xorm:"-" json:"-"`
diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go
index 227caaa46..22f048564 100644
--- a/pkg/models/task_collection.go
+++ b/pkg/models/task_collection.go
@@ -32,20 +32,20 @@ type TaskCollection struct {
ProjectID int64 `param:"project" json:"-"`
ProjectViewID int64 `param:"view" json:"-"`
- Search string `query:"s" json:"s"`
+ Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
// The query parameter to sort by. This is for ex. done, priority, etc.
- SortBy []string `query:"sort_by" json:"sort_by"`
+ SortBy []string `query:"sort_by" json:"sort_by" doc:"The fields to sort by, for example done or priority."`
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
- OrderBy []string `query:"order_by" json:"order_by"`
+ OrderBy []string `query:"order_by" json:"order_by" doc:"The order for each sort_by field, either asc or desc. Defaults to asc."`
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
- Filter string `query:"filter" json:"filter"`
+ Filter string `query:"filter" json:"filter" doc:"The filter query to match tasks by. See https://vikunja.io/docs/filters."`
// The time zone which should be used for date match (statements like "now" resolve to different actual times)
FilterTimezone string `query:"filter_timezone" json:"-"`
// If set to true, the result will also include null values
- FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
+ FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls" doc:"If true, the result also includes tasks whose filtered field is null."`
// If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a
// second step, will fetch all of these subtasks. This may result in more tasks than the
diff --git a/pkg/routes/api/v2/project_views.go b/pkg/routes/api/v2/project_views.go
new file mode 100644
index 000000000..907d305c9
--- /dev/null
+++ b/pkg/routes/api/v2/project_views.go
@@ -0,0 +1,177 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/danielgtaylor/huma/v2/conditional"
+)
+
+// projectViewListBody is the list-response envelope. models.ProjectView.ReadAll
+// returns []*models.ProjectView, so that's the element type.
+type projectViewListBody struct {
+ Body Paginated[*models.ProjectView]
+}
+
+// RegisterProjectViewRoutes wires the nested ProjectView CRUD onto the Huma API.
+// Every operation binds two path params: {project} → ProjectID and {view} → ID.
+// This is the reference shape every nested sub-resource copies.
+func RegisterProjectViewRoutes(api huma.API) {
+ tags := []string{"project_views"}
+
+ Register(api, huma.Operation{
+ OperationID: "project-views-list",
+ Summary: "List the views of a project",
+ Description: "Returns all views of the given project. Requires read access to the project; the list is not paginated by the server but is returned in the standard list envelope.",
+ Method: http.MethodGet,
+ Path: "/projects/{project}/views",
+ Tags: tags,
+ }, projectViewsList)
+
+ Register(api, huma.Operation{
+ OperationID: "project-views-read",
+ Summary: "Get a single view of a project",
+ Description: "Returns one view of a project. The view must belong to the project in the path. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
+ Method: http.MethodGet,
+ Path: "/projects/{project}/views/{view}",
+ Tags: tags,
+ }, projectViewsRead)
+
+ Register(api, huma.Operation{
+ OperationID: "project-views-create",
+ Summary: "Create a view in a project",
+ Description: "Creates a view in the given project. The parent project is taken from the URL, not the body. Only project admins may create a view.",
+ Method: http.MethodPost,
+ Path: "/projects/{project}/views",
+ Tags: tags,
+ }, projectViewsCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "project-views-update",
+ Summary: "Update a view of a project",
+ Description: "Replaces a project view's fields. The view must belong to the project in the path, and only project admins may update it. Use PATCH for a partial update.",
+ Method: http.MethodPut,
+ Path: "/projects/{project}/views/{view}",
+ Tags: tags,
+ }, projectViewsUpdate)
+
+ Register(api, huma.Operation{
+ OperationID: "project-views-delete",
+ Summary: "Delete a view of a project",
+ Description: "Deletes a project view along with its buckets and task positions. Only project admins may delete it.",
+ Method: http.MethodDelete,
+ Path: "/projects/{project}/views/{view}",
+ Tags: tags,
+ }, projectViewsDelete)
+}
+
+func projectViewsList(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ ListParams
+}) (*projectViewListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, _, total, err := handler.DoReadAll(ctx, &models.ProjectView{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ items, ok := result.([]*models.ProjectView)
+ if !ok {
+ return nil, fmt.Errorf("projectViews.ReadAll returned unexpected type %T (expected []*models.ProjectView)", result)
+ }
+ return &projectViewListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
+}
+
+func projectViewsRead(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ ID int64 `path:"view"`
+ conditional.Params
+}) (*singleReadBody[models.ProjectView], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ // ReadOne resolves the view via GetProjectViewByIDAndProject, which needs
+ // both ids — the parent project scopes the lookup.
+ view := &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}
+ if _, err := handler.DoReadOne(ctx, view, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ // PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
+ etag := fmt.Sprintf("%d-%d", view.ID, view.Updated.UnixNano())
+ if in.HasConditionalParams() {
+ if err := in.PreconditionFailed(etag, view.Updated); err != nil {
+ return nil, err
+ }
+ }
+ return &singleReadBody[models.ProjectView]{ETag: `"` + etag + `"`, Body: view}, nil
+}
+
+func projectViewsCreate(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ Body models.ProjectView
+}) (*singleBody[models.ProjectView], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in.Body.ProjectID = in.ProjectID // URL wins over body
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.ProjectView]{Body: &in.Body}, nil
+}
+
+func projectViewsUpdate(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ ID int64 `path:"view"`
+ Body models.ProjectView
+}) (*singleBody[models.ProjectView], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in.Body.ID = in.ID // URL wins over body
+ in.Body.ProjectID = in.ProjectID // parent from the path scopes the update
+ if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.ProjectView]{Body: &in.Body}, nil
+}
+
+func projectViewsDelete(ctx context.Context, in *struct {
+ ProjectID int64 `path:"project"`
+ ID int64 `path:"view"`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 5b00b1362..92ef73efc 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -396,6 +396,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
// Resource registrations.
apiv2.RegisterLabelRoutes(api)
apiv2.RegisterTaskDuplicateRoutes(api)
+ apiv2.RegisterProjectViewRoutes(api)
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
// synthesize their PATCH counterparts.
diff --git a/pkg/webtests/huma_project_view_test.go b/pkg/webtests/huma_project_view_test.go
new file mode 100644
index 000000000..0b1537576
--- /dev/null
+++ b/pkg/webtests/huma_project_view_test.go
@@ -0,0 +1,204 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestProjectView is the nested-path reference test for /api/v2. Views live
+// under /projects/{project}/views/{view}, so the harness binds two path params:
+// basePath carries the literal {project} and idParam picks {view}.
+//
+// Fixtures: project 1 (owned by testuser1) has views 1-4; project 2 (owned by
+// user3, no share to testuser1) has views 5-8 — used for the forbidden and
+// wrong-parent negatives.
+func TestProjectView(t *testing.T) {
+ // project 1 is owned by testuser1.
+ owned := webHandlerTestV2{
+ user: &testuser1,
+ basePath: "/api/v2/projects/1/views",
+ idParam: "view",
+ t: t,
+ }
+ require.NoError(t, owned.ensureEnv())
+ // project 2 is owned by user3; testuser1 has no access. Share owned's Echo
+ // instance: each setupTestEnv() regenerates the global JWT signing secret,
+ // so two independent harnesses would invalidate each other's tokens.
+ forbidden := webHandlerTestV2{
+ user: &testuser1,
+ basePath: "/api/v2/projects/2/views",
+ idParam: "view",
+ t: t,
+ e: owned.e,
+ }
+
+ t.Run("ReadAll", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owned.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ // project 1's four default views, none from project 2.
+ assert.Contains(t, rec.Body.String(), `"title":"List"`)
+ assert.Contains(t, rec.Body.String(), `"title":"Gantt"`)
+ assert.Contains(t, rec.Body.String(), `"title":"Table"`)
+ assert.Contains(t, rec.Body.String(), `"title":"Kanban"`)
+ assert.Contains(t, rec.Body.String(), `"project_id":1`)
+ assert.NotContains(t, rec.Body.String(), `"project_id":2`)
+ })
+ t.Run("Forbidden", func(t *testing.T) {
+ _, err := forbidden.testReadAllWithUser(nil, nil)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("ReadOne", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owned.testReadOneWithUser(nil, map[string]string{"view": "1"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"List"`)
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := owned.testReadOneWithUser(nil, map[string]string{"view": "9999"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("View from another project", func(t *testing.T) {
+ // view 5 belongs to project 2; reading it under project 1 must 404.
+ _, err := owned.testReadOneWithUser(nil, map[string]string{"view": "5"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden", func(t *testing.T) {
+ // view 5 read under its real parent (project 2) — no access.
+ _, err := forbidden.testReadOneWithUser(nil, map[string]string{"view": "5"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("Create", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owned.testCreateWithUser(nil, nil, `{"title":"New view","view_kind":"list"}`)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusCreated, rec.Code)
+ assert.Contains(t, rec.Body.String(), `"title":"New view"`)
+ // ownership: the parent project from the URL wins.
+ assert.Contains(t, rec.Body.String(), `"project_id":1`)
+ })
+ t.Run("Forbidden", func(t *testing.T) {
+ _, err := forbidden.testCreateWithUser(nil, nil, `{"title":"Nope","view_kind":"list"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("Update", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owned.testUpdateWithUser(nil, map[string]string{"view": "1"}, `{"title":"Renamed list","view_kind":"list"}`)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"Renamed list"`)
+ assert.Contains(t, rec.Body.String(), `"id":1`)
+ })
+ t.Run("View from another project", func(t *testing.T) {
+ _, err := owned.testUpdateWithUser(nil, map[string]string{"view": "5"}, `{"title":"x","view_kind":"list"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Forbidden", func(t *testing.T) {
+ _, err := forbidden.testUpdateWithUser(nil, map[string]string{"view": "5"}, `{"title":"x","view_kind":"list"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := owned.testDeleteWithUser(nil, map[string]string{"view": "2"})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ assert.Empty(t, rec.Body.String())
+ })
+ t.Run("Forbidden", func(t *testing.T) {
+ _, err := forbidden.testDeleteWithUser(nil, map[string]string{"view": "5"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ })
+}
+
+// TestProjectView_ETagReturns304 covers v2-only conditional-request behaviour.
+func TestProjectView_ETagReturns304(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/views/1", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ etag := rec.Header().Get("ETag")
+ require.NotEmpty(t, etag, "GET must return an ETag header")
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v2/projects/1/views/1", strings.NewReader(""))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("If-None-Match", etag)
+ rec = httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
+}
+
+// TestProjectView_PATCHMergePatch confirms AutoPatch synthesises a PATCH that
+// only touches supplied fields.
+func TestProjectView_PATCHMergePatch(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // Create a fresh view so we don't stomp fixtures.
+ rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/views",
+ `{"title":"before","view_kind":"table"}`, token, "")
+ require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
+ var created struct {
+ ID int64 `json:"id"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
+
+ // PATCH only title; view_kind must survive.
+ rec = humaRequest(t, e, http.MethodPatch, fmt.Sprintf("/api/v2/projects/1/views/%d", created.ID),
+ `{"title":"after"}`, token, "application/merge-patch+json")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+
+ rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/projects/1/views/%d", created.ID), "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ var after struct {
+ Title string `json:"title"`
+ ViewKind string `json:"view_kind"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
+ assert.Equal(t, "after", after.Title)
+ assert.Equal(t, "table", after.ViewKind, "view_kind must survive the PATCH")
+}