From c4bddb41ccbe1b77fce7ed8dedabb7c49f8b6398 Mon Sep 17 00:00:00 2001 From: Steven Weathers Date: Wed, 2 Oct 2024 19:20:09 -0400 Subject: [PATCH] Storyboard Fractional Index sorting (#628) * Refactor storyboard story, goal, and column sortings to use fractional indexes * Add test ids to storyboard ui elements missing them * Move e2e tests into folders for clearer organization * Add apis for storyboard events to test story move changes * Add api tests for storyboard goal and column creates * Add api test for storyboard create story * Add api tests for moving story within storyboard --- docs/swagger/docs.go | 285 +++++++++++- docs/swagger/swagger.json | 285 +++++++++++- docs/swagger/swagger.yaml | 184 +++++++- e2e/tests/admin/admin.spec.ts | 4 +- e2e/tests/admin/alerts.spec.ts | 4 +- e2e/tests/admin/apikeys.spec.ts | 4 +- e2e/tests/admin/department.spec.ts | 4 +- e2e/tests/admin/organization.spec.ts | 4 +- e2e/tests/admin/organizations.spec.ts | 4 +- e2e/tests/admin/poker-games.spec.ts | 4 +- e2e/tests/admin/team.spec.ts | 4 +- e2e/tests/admin/teams.spec.ts | 4 +- e2e/tests/admin/users.spec.ts | 4 +- e2e/tests/api/storyboards.spec.ts | 429 ++++++++++++++++-- e2e/tests/{ => poker}/poker-game.spec.ts | 4 +- e2e/tests/{ => poker}/poker-games.spec.ts | 4 +- e2e/tests/{ => retro}/retro.spec.ts | 4 +- e2e/tests/{ => retro}/retros.spec.ts | 4 +- e2e/tests/{ => storyboard}/storyboard.spec.ts | 4 +- .../{ => storyboard}/storyboards.spec.ts | 4 +- e2e/tests/{ => team}/checkin.spec.ts | 4 +- e2e/tests/{ => team}/department.spec.ts | 4 +- e2e/tests/{ => team}/organization.spec.ts | 4 +- e2e/tests/{ => team}/team.spec.ts | 4 +- e2e/tests/{ => team}/teams.spec.ts | 4 +- e2e/tests/{ => user}/login.spec.ts | 6 +- e2e/tests/{ => user}/profile.spec.ts | 4 +- e2e/tests/{ => user}/register.spec.ts | 2 +- ...240925122944_update_storyboard_sorting.sql | 162 +++++++ internal/db/storyboard/column.go | 75 ++- internal/db/storyboard/goal.go | 89 +++- internal/db/storyboard/story.go | 182 +++++++- internal/http/http.go | 4 + internal/http/storyboard.go | 285 ++++++++++++ thunderdome/storyboard.go | 6 +- ui/src/pages/storyboard/Storyboard.svelte | 31 +- 36 files changed, 1978 insertions(+), 135 deletions(-) rename e2e/tests/{ => poker}/poker-game.spec.ts (99%) rename e2e/tests/{ => poker}/poker-games.spec.ts (96%) rename e2e/tests/{ => retro}/retro.spec.ts (98%) rename e2e/tests/{ => retro}/retros.spec.ts (96%) rename e2e/tests/{ => storyboard}/storyboard.spec.ts (95%) rename e2e/tests/{ => storyboard}/storyboards.spec.ts (96%) rename e2e/tests/{ => team}/checkin.spec.ts (97%) rename e2e/tests/{ => team}/department.spec.ts (90%) rename e2e/tests/{ => team}/organization.spec.ts (93%) rename e2e/tests/{ => team}/team.spec.ts (96%) rename e2e/tests/{ => team}/teams.spec.ts (96%) rename e2e/tests/{ => user}/login.spec.ts (75%) rename e2e/tests/{ => user}/profile.spec.ts (99%) rename e2e/tests/{ => user}/register.spec.ts (97%) create mode 100644 internal/db/migrations/20240925122944_update_storyboard_sorting.sql diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index ef0117d3..0ebbb27a 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -5574,6 +5574,229 @@ const docTemplate = `{ } } }, + "/storyboards/{storyboardId}/columns": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a column to a storyboard goal", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Column Add", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "description": "request body for adding a column", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardColumnAddRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, + "/storyboards/{storyboardId}/goals": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a goal to a storyboard", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Goal Add", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "description": "the goal to add", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardGoalAddRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, + "/storyboards/{storyboardId}/stories": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a story to a storyboard goal column", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Story Add", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "description": "request body for adding a story", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardStoryAddRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, + "/storyboards/{storyboardId}/stories/{storyId}/move": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Move a story in a storyboard", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Story Move", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the story ID", + "name": "storyId", + "in": "path", + "required": true + }, + { + "description": "target goal column and place before story", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardStoryMoveRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, "/subscriptions": { "get": { "security": [ @@ -10891,6 +11114,17 @@ const docTemplate = `{ } } }, + "http.storyboardColumnAddRequestBody": { + "type": "object", + "required": [ + "goalId" + ], + "properties": { + "goalId": { + "type": "string" + } + } + }, "http.storyboardCreateRequestBody": { "type": "object", "required": [ @@ -10908,6 +11142,51 @@ const docTemplate = `{ } } }, + "http.storyboardGoalAddRequestBody": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "http.storyboardStoryAddRequestBody": { + "type": "object", + "required": [ + "columnId", + "goalId" + ], + "properties": { + "columnId": { + "type": "string" + }, + "goalId": { + "type": "string" + } + } + }, + "http.storyboardStoryMoveRequestBody": { + "type": "object", + "required": [ + "columnId", + "goalId" + ], + "properties": { + "columnId": { + "type": "string" + }, + "goalId": { + "type": "string" + }, + "placeBefore": { + "type": "string" + } + } + }, "http.subscriptionAssociateRequestBody": { "type": "object", "properties": { @@ -12177,7 +12456,7 @@ const docTemplate = `{ } }, "sort_order": { - "type": "integer" + "type": "string" }, "stories": { "type": "array", @@ -12209,7 +12488,7 @@ const docTemplate = `{ } }, "sort_order": { - "type": "integer" + "type": "string" } } }, @@ -12267,7 +12546,7 @@ const docTemplate = `{ "type": "integer" }, "sort_order": { - "type": "integer" + "type": "string" } } }, diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index bdcc2dd7..d40269bb 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -5566,6 +5566,229 @@ } } }, + "/storyboards/{storyboardId}/columns": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a column to a storyboard goal", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Column Add", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "description": "request body for adding a column", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardColumnAddRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, + "/storyboards/{storyboardId}/goals": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a goal to a storyboard", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Goal Add", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "description": "the goal to add", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardGoalAddRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, + "/storyboards/{storyboardId}/stories": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Add a story to a storyboard goal column", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Story Add", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "description": "request body for adding a story", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardStoryAddRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, + "/storyboards/{storyboardId}/stories/{storyId}/move": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Move a story in a storyboard", + "produces": [ + "application/json" + ], + "tags": [ + "storyboard" + ], + "summary": "Storyboard Story Move", + "parameters": [ + { + "type": "string", + "description": "the storyboard ID", + "name": "storyboardId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the story ID", + "name": "storyId", + "in": "path", + "required": true + }, + { + "description": "target goal column and place before story", + "name": "storyboard", + "in": "body", + "schema": { + "$ref": "#/definitions/http.storyboardStoryMoveRequestBody" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/http.standardJsonResponse" + } + } + } + } + }, "/subscriptions": { "get": { "security": [ @@ -10883,6 +11106,17 @@ } } }, + "http.storyboardColumnAddRequestBody": { + "type": "object", + "required": [ + "goalId" + ], + "properties": { + "goalId": { + "type": "string" + } + } + }, "http.storyboardCreateRequestBody": { "type": "object", "required": [ @@ -10900,6 +11134,51 @@ } } }, + "http.storyboardGoalAddRequestBody": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + }, + "http.storyboardStoryAddRequestBody": { + "type": "object", + "required": [ + "columnId", + "goalId" + ], + "properties": { + "columnId": { + "type": "string" + }, + "goalId": { + "type": "string" + } + } + }, + "http.storyboardStoryMoveRequestBody": { + "type": "object", + "required": [ + "columnId", + "goalId" + ], + "properties": { + "columnId": { + "type": "string" + }, + "goalId": { + "type": "string" + }, + "placeBefore": { + "type": "string" + } + } + }, "http.subscriptionAssociateRequestBody": { "type": "object", "properties": { @@ -12169,7 +12448,7 @@ } }, "sort_order": { - "type": "integer" + "type": "string" }, "stories": { "type": "array", @@ -12201,7 +12480,7 @@ } }, "sort_order": { - "type": "integer" + "type": "string" } } }, @@ -12259,7 +12538,7 @@ "type": "integer" }, "sort_order": { - "type": "integer" + "type": "string" } } }, diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 7f2f9c02..31653c50 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -468,6 +468,13 @@ definitions: success: type: boolean type: object + http.storyboardColumnAddRequestBody: + properties: + goalId: + type: string + required: + - goalId + type: object http.storyboardCreateRequestBody: properties: facilitatorCode: @@ -479,6 +486,36 @@ definitions: required: - storyboardName type: object + http.storyboardGoalAddRequestBody: + properties: + name: + minLength: 1 + type: string + required: + - name + type: object + http.storyboardStoryAddRequestBody: + properties: + columnId: + type: string + goalId: + type: string + required: + - columnId + - goalId + type: object + http.storyboardStoryMoveRequestBody: + properties: + columnId: + type: string + goalId: + type: string + placeBefore: + type: string + required: + - columnId + - goalId + type: object http.subscriptionAssociateRequestBody: properties: organization_id: @@ -1322,7 +1359,7 @@ definitions: $ref: '#/definitions/thunderdome.StoryboardPersona' type: array sort_order: - type: integer + type: string stories: items: $ref: '#/definitions/thunderdome.StoryboardStory' @@ -1343,7 +1380,7 @@ definitions: $ref: '#/definitions/thunderdome.StoryboardPersona' type: array sort_order: - type: integer + type: string type: object thunderdome.StoryboardPersona: properties: @@ -1381,7 +1418,7 @@ definitions: points: type: integer sort_order: - type: integer + type: string type: object thunderdome.StoryboardUser: properties: @@ -5354,6 +5391,147 @@ paths: summary: Get Storyboard tags: - storyboard + /storyboards/{storyboardId}/columns: + post: + description: Add a column to a storyboard goal + parameters: + - description: the storyboard ID + in: path + name: storyboardId + required: true + type: string + - description: request body for adding a column + in: body + name: storyboard + schema: + $ref: '#/definitions/http.storyboardColumnAddRequestBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.standardJsonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.standardJsonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.standardJsonResponse' + security: + - ApiKeyAuth: [] + summary: Storyboard Column Add + tags: + - storyboard + /storyboards/{storyboardId}/goals: + post: + description: Add a goal to a storyboard + parameters: + - description: the storyboard ID + in: path + name: storyboardId + required: true + type: string + - description: the goal to add + in: body + name: storyboard + schema: + $ref: '#/definitions/http.storyboardGoalAddRequestBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.standardJsonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.standardJsonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.standardJsonResponse' + security: + - ApiKeyAuth: [] + summary: Storyboard Goal Add + tags: + - storyboard + /storyboards/{storyboardId}/stories: + post: + description: Add a story to a storyboard goal column + parameters: + - description: the storyboard ID + in: path + name: storyboardId + required: true + type: string + - description: request body for adding a story + in: body + name: storyboard + schema: + $ref: '#/definitions/http.storyboardStoryAddRequestBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.standardJsonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.standardJsonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.standardJsonResponse' + security: + - ApiKeyAuth: [] + summary: Storyboard Story Add + tags: + - storyboard + /storyboards/{storyboardId}/stories/{storyId}/move: + put: + description: Move a story in a storyboard + parameters: + - description: the storyboard ID + in: path + name: storyboardId + required: true + type: string + - description: the story ID + in: path + name: storyId + required: true + type: string + - description: target goal column and place before story + in: body + name: storyboard + schema: + $ref: '#/definitions/http.storyboardStoryMoveRequestBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/http.standardJsonResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/http.standardJsonResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/http.standardJsonResponse' + security: + - ApiKeyAuth: [] + summary: Storyboard Story Move + tags: + - storyboard /subscriptions: get: description: get list of subscriptions diff --git a/e2e/tests/admin/admin.spec.ts b/e2e/tests/admin/admin.spec.ts index e86018a4..52ba032c 100644 --- a/e2e/tests/admin/admin.spec.ts +++ b/e2e/tests/admin/admin.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminPage } from "../../fixtures/admin/admin-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminPage } from "@fixtures/admin/admin-page"; test.describe("Admin page", { tag: ["@administration"] }, () => { test.describe("Unauthenticated user", { tag: ["@unauthenticated"] }, () => { diff --git a/e2e/tests/admin/alerts.spec.ts b/e2e/tests/admin/alerts.spec.ts index c131cc5e..a2551b80 100644 --- a/e2e/tests/admin/alerts.spec.ts +++ b/e2e/tests/admin/alerts.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AlertsPage } from "../../fixtures/admin/alerts-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AlertsPage } from "@fixtures/admin/alerts-page"; test.describe( "The Admin Alerts Page", diff --git a/e2e/tests/admin/apikeys.spec.ts b/e2e/tests/admin/apikeys.spec.ts index 9361da13..f9c6057b 100644 --- a/e2e/tests/admin/apikeys.spec.ts +++ b/e2e/tests/admin/apikeys.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { APIKeysPage } from "../../fixtures/admin/apikeys-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { APIKeysPage } from "@fixtures/admin/apikeys-page"; test.describe( "The Admin API Keys Page", diff --git a/e2e/tests/admin/department.spec.ts b/e2e/tests/admin/department.spec.ts index 65554089..d0553d59 100644 --- a/e2e/tests/admin/department.spec.ts +++ b/e2e/tests/admin/department.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminDepartmentPage } from "../../fixtures/admin/department-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminDepartmentPage } from "@fixtures/admin/department-page"; test.describe( "The Admin Department Page", diff --git a/e2e/tests/admin/organization.spec.ts b/e2e/tests/admin/organization.spec.ts index 23a2e448..9d3d95a4 100644 --- a/e2e/tests/admin/organization.spec.ts +++ b/e2e/tests/admin/organization.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminOrganizationPage } from "../../fixtures/admin/organization-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminOrganizationPage } from "@fixtures/admin/organization-page"; test.describe( "The Admin Organization Page", diff --git a/e2e/tests/admin/organizations.spec.ts b/e2e/tests/admin/organizations.spec.ts index e168ad5c..f8bb1a7d 100644 --- a/e2e/tests/admin/organizations.spec.ts +++ b/e2e/tests/admin/organizations.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { OrganizationsPage } from "../../fixtures/admin/organizations-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { OrganizationsPage } from "@fixtures/admin/organizations-page"; test.describe( "The Admin Organizations Page", diff --git a/e2e/tests/admin/poker-games.spec.ts b/e2e/tests/admin/poker-games.spec.ts index 2725ada6..a9d14a62 100644 --- a/e2e/tests/admin/poker-games.spec.ts +++ b/e2e/tests/admin/poker-games.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminGamesPage } from "../../fixtures/admin/games-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminGamesPage } from "@fixtures/admin/games-page"; test.describe( "The Admin Poker Games Page", diff --git a/e2e/tests/admin/team.spec.ts b/e2e/tests/admin/team.spec.ts index 02f7a0c5..b9215e16 100644 --- a/e2e/tests/admin/team.spec.ts +++ b/e2e/tests/admin/team.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminTeamPage } from "../../fixtures/admin/team-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminTeamPage } from "@fixtures/admin/team-page"; test.describe( "The Admin Team Page", diff --git a/e2e/tests/admin/teams.spec.ts b/e2e/tests/admin/teams.spec.ts index 64d82911..81f9ff25 100644 --- a/e2e/tests/admin/teams.spec.ts +++ b/e2e/tests/admin/teams.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminTeamsPage } from "../../fixtures/admin/teams-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminTeamsPage } from "@fixtures/admin/teams-page"; test.describe( "The Admin Teams Page", diff --git a/e2e/tests/admin/users.spec.ts b/e2e/tests/admin/users.spec.ts index 46b8608e..70506072 100644 --- a/e2e/tests/admin/users.spec.ts +++ b/e2e/tests/admin/users.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../../fixtures/user-sessions"; -import { AdminUsersPage } from "../../fixtures/admin/users-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { AdminUsersPage } from "@fixtures/admin/users-page"; test.describe( "The Admin Users Page", diff --git a/e2e/tests/api/storyboards.spec.ts b/e2e/tests/api/storyboards.spec.ts index 7196cee4..3435e91b 100644 --- a/e2e/tests/api/storyboards.spec.ts +++ b/e2e/tests/api/storyboards.spec.ts @@ -61,45 +61,406 @@ test.describe("Storyboard API", { tag: ["@api", "@storyboard"] }, () => { ); }); - test("POST /teams/{teamId}/users/{userId}/storyboards creates storyboard", async ({ - request, - registeredApiUser, - }) => { - const storyboardName = "Test API Create Team Storyboard"; + test( + "POST /teams/{teamId}/users/{userId}/storyboards creates storyboard", + { tag: ["@team"] }, + async ({ request, registeredApiUser }) => { + const storyboardName = "Test API Create Team Storyboard"; - const teamResponse = await registeredApiUser.context.post( - `users/${registeredApiUser.user.id}/teams`, - { - data: { - name: "test team create storyboard", + const teamResponse = await registeredApiUser.context.post( + `users/${registeredApiUser.user.id}/teams`, + { + data: { + name: "test team create storyboard", + }, }, - }, - ); - const { data: team } = await teamResponse.json(); + ); + const { data: team } = await teamResponse.json(); - const storyboardResponse = await registeredApiUser.context.post( - `teams/${team.id}/users/${registeredApiUser.user.id}/storyboards`, - { - data: { - storyboardName, + const storyboardResponse = await registeredApiUser.context.post( + `teams/${team.id}/users/${registeredApiUser.user.id}/storyboards`, + { + data: { + storyboardName, + }, }, - }, - ); - expect(storyboardResponse.ok()).toBeTruthy(); - const storyboard = await storyboardResponse.json(); - expect(storyboard.data).toMatchObject({ - name: storyboardName, + ); + expect(storyboardResponse.ok()).toBeTruthy(); + const storyboard = await storyboardResponse.json(); + expect(storyboard.data).toMatchObject({ + name: storyboardName, + }); + + const storyboardsResponse = await registeredApiUser.context.get( + `teams/${team.id}/storyboards`, + ); + expect(storyboardsResponse.ok()).toBeTruthy(); + const storyboards = await storyboardsResponse.json(); + expect(storyboards.data).toContainEqual( + expect.objectContaining({ + name: storyboardName, + }), + ); + }, + ); + + test.describe.serial("board actions", () => { + const storyboardName = "Test API Storyboard actions"; + let storyboard; + + test.beforeAll(async ({ registeredApiUser }) => { + const response = await registeredApiUser.context.post( + `users/${registeredApiUser.user.id}/storyboards`, + { + data: { + storyboardName, + }, + }, + ); + expect(response.ok()).toBeTruthy(); + const res = await response.json(); + storyboard = res.data; }); - const storyboardsResponse = await registeredApiUser.context.get( - `teams/${team.id}/storyboards`, - ); - expect(storyboardsResponse.ok()).toBeTruthy(); - const storyboards = await storyboardsResponse.json(); - expect(storyboards.data).toContainEqual( - expect.objectContaining({ - name: storyboardName, - }), - ); + test("POST /storyboards/{storyboardId}/goals creates storyboard goal", async ({ + request, + registeredApiUser, + }) => { + const goalName = "Test API Create Goal"; + + const goalResp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/goals`, + { + data: { + name: goalName, + }, + }, + ); + expect(goalResp.ok()).toBeTruthy(); + + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithGoal = await updatedStoryboard.json(); + expect(storyboardWithGoal.data).toMatchObject( + expect.objectContaining({ + goals: expect.arrayContaining([ + expect.objectContaining({ + name: goalName, + }), + ]), + }), + ); + storyboard = storyboardWithGoal.data; + }); + + test("POST /storyboards/{storyboardId}/columns creates storyboard column", async ({ + request, + registeredApiUser, + }) => { + const columnResp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/columns`, + { + data: { + goalId: storyboard.goals[0].id, + }, + }, + ); + expect(columnResp.ok()).toBeTruthy(); + + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithCol = await updatedStoryboard.json(); + expect(storyboardWithCol.data).toMatchObject( + expect.objectContaining({ + goals: expect.arrayContaining([ + expect.objectContaining({ + name: storyboard.goals[0].name, + columns: expect.arrayContaining([ + expect.objectContaining({ + name: "", + id: expect.any(String), + }), + ]), + }), + ]), + }), + ); + + storyboard = storyboardWithCol.data; + }); + + test("POST /storyboards/{storyboardId}/stories creates storyboard story", async ({ + request, + registeredApiUser, + }) => { + const resp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/stories`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[0].id, + }, + }, + ); + expect(resp.ok()).toBeTruthy(); + + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithStory = await updatedStoryboard.json(); + expect(storyboardWithStory.data).toMatchObject( + expect.objectContaining({ + goals: expect.arrayContaining([ + expect.objectContaining({ + name: storyboard.goals[0].name, + columns: expect.arrayContaining([ + expect.objectContaining({ + name: "", + id: storyboard.goals[0].columns[0].id, + stories: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + }), + ]), + }), + ]), + }), + ]), + }), + ); + + storyboard = storyboardWithStory.data; + }); + + test("PUT /storyboards/{storyboardId}/stories/{storyId}/move moves storyboard story", async ({ + request, + registeredApiUser, + }) => { + await test.step("Create 2 more stories", async () => { + const story2resp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/stories`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[0].id, + }, + }, + ); + expect(story2resp.ok()).toBeTruthy(); + + const story3resp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/stories`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[0].id, + }, + }, + ); + expect(story3resp.ok()).toBeTruthy(); + + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithStory = await updatedStoryboard.json(); + storyboard = storyboardWithStory.data; + }); + + await test.step("Move story to begining of column", async () => { + const storyToMoveID = storyboard.goals[0].columns[0].stories[2].id; + const storyPlaceBeforeID = storyboard.goals[0].columns[0].stories[0].id; + const storyMresp = await registeredApiUser.context.put( + `storyboards/${storyboard.id}/stories/${storyToMoveID}/move`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[0].id, + placeBefore: storyPlaceBeforeID, + }, + }, + ); + expect(storyMresp.ok()).toBeTruthy(); + + const updatedStoryboard2 = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard2.ok()).toBeTruthy(); + const storyboardWithMovedStory = await updatedStoryboard2.json(); + expect( + storyboardWithMovedStory.data.goals[0].columns[0].stories[0].id, + ).toEqual(storyToMoveID); + expect( + storyboardWithMovedStory.data.goals[0].columns[0].stories[1].id, + ).toEqual(storyPlaceBeforeID); + storyboard = storyboardWithMovedStory.data; + }); + + await test.step("Move story to between 2 stories", async () => { + const storyToMoveID = storyboard.goals[0].columns[0].stories[2].id; + const storyPlaceBeforeID = storyboard.goals[0].columns[0].stories[1].id; + const storyMresp = await registeredApiUser.context.put( + `storyboards/${storyboard.id}/stories/${storyToMoveID}/move`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[0].id, + placeBefore: storyPlaceBeforeID, + }, + }, + ); + expect(storyMresp.ok()).toBeTruthy(); + + const updatedStoryboard2 = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard2.ok()).toBeTruthy(); + const storyboardWithMovedStory = await updatedStoryboard2.json(); + expect( + storyboardWithMovedStory.data.goals[0].columns[0].stories[1].id, + ).toEqual(storyToMoveID); + expect( + storyboardWithMovedStory.data.goals[0].columns[0].stories[2].id, + ).toEqual(storyPlaceBeforeID); + storyboard = storyboardWithMovedStory.data; + }); + + await test.step("Move story to end of column", async () => { + const storyToMoveID = storyboard.goals[0].columns[0].stories[0].id; + const storyPlaceBeforeID = ""; + const storyMresp = await registeredApiUser.context.put( + `storyboards/${storyboard.id}/stories/${storyToMoveID}/move`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[0].id, + placeBefore: storyPlaceBeforeID, + }, + }, + ); + expect(storyMresp.ok()).toBeTruthy(); + + const updatedStoryboard2 = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard2.ok()).toBeTruthy(); + const storyboardWithMovedStory = await updatedStoryboard2.json(); + expect( + storyboardWithMovedStory.data.goals[0].columns[0].stories[2].id, + ).toEqual(storyToMoveID); + storyboard = storyboardWithMovedStory.data; + }); + + await test.step("Create another column", async () => { + const columnResp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/columns`, + { + data: { + goalId: storyboard.goals[0].id, + }, + }, + ); + expect(columnResp.ok()).toBeTruthy(); + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithAnotherColumn = await updatedStoryboard.json(); + storyboard = storyboardWithAnotherColumn.data; + }); + + await test.step("Move story to another column", async () => { + const storyToMoveID = storyboard.goals[0].columns[0].stories[0].id; + const storyPlaceBeforeID = ""; + const storyMresp = await registeredApiUser.context.put( + `storyboards/${storyboard.id}/stories/${storyToMoveID}/move`, + { + data: { + goalId: storyboard.goals[0].id, + columnId: storyboard.goals[0].columns[1].id, + placeBefore: storyPlaceBeforeID, + }, + }, + ); + expect(storyMresp.ok()).toBeTruthy(); + + const updatedStoryboard2 = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard2.ok()).toBeTruthy(); + const storyboardWithMovedStory = await updatedStoryboard2.json(); + expect( + storyboardWithMovedStory.data.goals[0].columns[1].stories[0].id, + ).toEqual(storyToMoveID); + storyboard = storyboardWithMovedStory.data; + }); + + await test.step("Create another goal", async () => { + const columnResp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/goals`, + { + data: { + name: "second goal to move story to", + }, + }, + ); + expect(columnResp.ok()).toBeTruthy(); + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithAnotherGoal = await updatedStoryboard.json(); + storyboard = storyboardWithAnotherGoal.data; + }); + + await test.step("Create column in second goal", async () => { + const columnResp = await registeredApiUser.context.post( + `storyboards/${storyboard.id}/columns`, + { + data: { + goalId: storyboard.goals[1].id, + }, + }, + ); + expect(columnResp.ok()).toBeTruthy(); + const updatedStoryboard = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard.ok()).toBeTruthy(); + const storyboardWithAnotherColumn = await updatedStoryboard.json(); + storyboard = storyboardWithAnotherColumn.data; + }); + + await test.step("Move story to another goals column", async () => { + const storyToMoveID = storyboard.goals[0].columns[0].stories[0].id; + const storyPlaceBeforeID = ""; + const storyMresp = await registeredApiUser.context.put( + `storyboards/${storyboard.id}/stories/${storyToMoveID}/move`, + { + data: { + goalId: storyboard.goals[1].id, + columnId: storyboard.goals[1].columns[0].id, + placeBefore: storyPlaceBeforeID, + }, + }, + ); + expect(storyMresp.ok()).toBeTruthy(); + + const updatedStoryboard2 = await registeredApiUser.context.get( + `storyboards/${storyboard.id}`, + ); + expect(updatedStoryboard2.ok()).toBeTruthy(); + const storyboardWithMovedStory = await updatedStoryboard2.json(); + expect( + storyboardWithMovedStory.data.goals[1].columns[0].stories[0].id, + ).toEqual(storyToMoveID); + storyboard = storyboardWithMovedStory.data; + }); + }); }); }); diff --git a/e2e/tests/poker-game.spec.ts b/e2e/tests/poker/poker-game.spec.ts similarity index 99% rename from e2e/tests/poker-game.spec.ts rename to e2e/tests/poker/poker-game.spec.ts index 73596f8f..4b131484 100644 --- a/e2e/tests/poker-game.spec.ts +++ b/e2e/tests/poker/poker-game.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { PokerGamePage } from "../fixtures/pages/poker-game-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { PokerGamePage } from "@fixtures/pages/poker-game-page"; const allowedPointValues = ["0", "1", "2", "3", "5", "8", "13", "?"]; const pointAverageRounding = "ceil"; diff --git a/e2e/tests/poker-games.spec.ts b/e2e/tests/poker/poker-games.spec.ts similarity index 96% rename from e2e/tests/poker-games.spec.ts rename to e2e/tests/poker/poker-games.spec.ts index da4bc955..97762b15 100644 --- a/e2e/tests/poker-games.spec.ts +++ b/e2e/tests/poker/poker-games.spec.ts @@ -1,6 +1,6 @@ -import { test } from "../fixtures/user-sessions"; +import { test } from "@fixtures/user-sessions"; import { expect } from "@playwright/test"; -import { PokerGamesPage } from "../fixtures/pages/poker-games-page"; +import { PokerGamesPage } from "@fixtures/pages/poker-games-page"; const pageTitle = "My Games"; diff --git a/e2e/tests/retro.spec.ts b/e2e/tests/retro/retro.spec.ts similarity index 98% rename from e2e/tests/retro.spec.ts rename to e2e/tests/retro/retro.spec.ts index 8e9bf881..a637ca6d 100644 --- a/e2e/tests/retro.spec.ts +++ b/e2e/tests/retro/retro.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { RetroPage } from "../fixtures/pages/retro-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { RetroPage } from "@fixtures/pages/retro-page"; test.describe("Retro page", { tag: ["@retro"] }, () => { let retro = { id: "", name: "e2e retro page tests" }; diff --git a/e2e/tests/retros.spec.ts b/e2e/tests/retro/retros.spec.ts similarity index 96% rename from e2e/tests/retros.spec.ts rename to e2e/tests/retro/retros.spec.ts index be1632fe..e0a9339f 100644 --- a/e2e/tests/retros.spec.ts +++ b/e2e/tests/retro/retros.spec.ts @@ -1,6 +1,6 @@ -import { test } from "../fixtures/user-sessions"; +import { test } from "@fixtures/user-sessions"; import { expect } from "@playwright/test"; -import { RetrosPage } from "../fixtures/pages/retros-page"; +import { RetrosPage } from "@fixtures/pages/retros-page"; const pageTitle = "My Retros"; diff --git a/e2e/tests/storyboard.spec.ts b/e2e/tests/storyboard/storyboard.spec.ts similarity index 95% rename from e2e/tests/storyboard.spec.ts rename to e2e/tests/storyboard/storyboard.spec.ts index 8f480a71..94233d9b 100644 --- a/e2e/tests/storyboard.spec.ts +++ b/e2e/tests/storyboard/storyboard.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { StoryboardPage } from "../fixtures/pages/storyboard-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { StoryboardPage } from "@fixtures/pages/storyboard-page"; test.describe("Storyboard page", { tag: ["@storyboard"] }, () => { let storyboard = { id: "", name: "e2e storyboard page tests" }; diff --git a/e2e/tests/storyboards.spec.ts b/e2e/tests/storyboard/storyboards.spec.ts similarity index 96% rename from e2e/tests/storyboards.spec.ts rename to e2e/tests/storyboard/storyboards.spec.ts index 8b0a7bfa..4e4e7fd6 100644 --- a/e2e/tests/storyboards.spec.ts +++ b/e2e/tests/storyboard/storyboards.spec.ts @@ -1,6 +1,6 @@ -import { test } from "../fixtures/user-sessions"; +import { test } from "@fixtures/user-sessions"; import { expect } from "@playwright/test"; -import { StoryboardsPage } from "../fixtures/pages/storyboards-page"; +import { StoryboardsPage } from "@fixtures/pages/storyboards-page"; const pageTitle = "My Storyboards"; diff --git a/e2e/tests/checkin.spec.ts b/e2e/tests/team/checkin.spec.ts similarity index 97% rename from e2e/tests/checkin.spec.ts rename to e2e/tests/team/checkin.spec.ts index 7ed8b956..26bee019 100644 --- a/e2e/tests/checkin.spec.ts +++ b/e2e/tests/team/checkin.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { TeamCheckinPage } from "../fixtures/pages/checkin-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { TeamCheckinPage } from "@fixtures/pages/checkin-page"; test.describe("Team Checkin page", { tag: "@checkin" }, () => { test.describe("Unauthenticated user", { tag: "@unauthenticated" }, () => { diff --git a/e2e/tests/department.spec.ts b/e2e/tests/team/department.spec.ts similarity index 90% rename from e2e/tests/department.spec.ts rename to e2e/tests/team/department.spec.ts index 3f761498..3b8712fa 100644 --- a/e2e/tests/department.spec.ts +++ b/e2e/tests/team/department.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { DepartmentPage } from "../fixtures/pages/department-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { DepartmentPage } from "@fixtures/pages/department-page"; test.describe("Department page", { tag: "@department" }, () => { test.describe("Unauthenticated user", { tag: "@unauthenticated" }, () => { diff --git a/e2e/tests/organization.spec.ts b/e2e/tests/team/organization.spec.ts similarity index 93% rename from e2e/tests/organization.spec.ts rename to e2e/tests/team/organization.spec.ts index 1079e47a..36c6eb28 100644 --- a/e2e/tests/organization.spec.ts +++ b/e2e/tests/team/organization.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { OrganizationPage } from "../fixtures/pages/organization-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { OrganizationPage } from "@fixtures/pages/organization-page"; test.describe("Organization Page", { tag: "@organization" }, () => { test( diff --git a/e2e/tests/team.spec.ts b/e2e/tests/team/team.spec.ts similarity index 96% rename from e2e/tests/team.spec.ts rename to e2e/tests/team/team.spec.ts index 6087579f..3268eea9 100644 --- a/e2e/tests/team.spec.ts +++ b/e2e/tests/team/team.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { TeamPage } from "../fixtures/pages/team-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { TeamPage } from "@fixtures/pages/team-page"; test.describe("Team page", { tag: ["@team"] }, () => { test( diff --git a/e2e/tests/teams.spec.ts b/e2e/tests/team/teams.spec.ts similarity index 96% rename from e2e/tests/teams.spec.ts rename to e2e/tests/team/teams.spec.ts index 06cf91ca..be45634e 100644 --- a/e2e/tests/teams.spec.ts +++ b/e2e/tests/team/teams.spec.ts @@ -1,6 +1,6 @@ -import { test } from "../fixtures/user-sessions"; +import { test } from "@fixtures/user-sessions"; import { expect } from "@playwright/test"; -import { TeamsPage } from "../fixtures/pages/teams-page"; +import { TeamsPage } from "@fixtures/pages/teams-page"; test.describe("Teams page", { tag: ["@team"] }, () => { test.describe("Unauthenticated user", { tag: ["@unauthenticated"] }, () => { diff --git a/e2e/tests/login.spec.ts b/e2e/tests/user/login.spec.ts similarity index 75% rename from e2e/tests/login.spec.ts rename to e2e/tests/user/login.spec.ts index 95a45869..dc3f9ab6 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/user/login.spec.ts @@ -1,6 +1,6 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { LoginPage } from "../fixtures/pages/login-page"; -import { registeredUser } from "../fixtures/db/registered-user"; +import { expect, test } from "@fixtures/user-sessions"; +import { LoginPage } from "@fixtures/pages/login-page"; +import { registeredUser } from "@fixtures/db/registered-user"; test.describe("The Login Page", { tag: "@login" }, () => { test("should navigate to my games page and reflect name in header", async ({ diff --git a/e2e/tests/profile.spec.ts b/e2e/tests/user/profile.spec.ts similarity index 99% rename from e2e/tests/profile.spec.ts rename to e2e/tests/user/profile.spec.ts index 34422180..c34b84a4 100644 --- a/e2e/tests/profile.spec.ts +++ b/e2e/tests/user/profile.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "../fixtures/user-sessions"; -import { ProfilePage } from "../fixtures/pages/profile-page"; +import { expect, test } from "@fixtures/user-sessions"; +import { ProfilePage } from "@fixtures/pages/profile-page"; test.describe("User Profile page", { tag: ["@user"] }, () => { test( diff --git a/e2e/tests/register.spec.ts b/e2e/tests/user/register.spec.ts similarity index 97% rename from e2e/tests/register.spec.ts rename to e2e/tests/user/register.spec.ts index 75764dca..9bd61d5e 100644 --- a/e2e/tests/register.spec.ts +++ b/e2e/tests/user/register.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { RegisterPage } from "../fixtures/pages/register-page"; +import { RegisterPage } from "@fixtures/pages/register-page"; const registerPageTitle = "Register"; const battlesPageTitle = "My Games"; diff --git a/internal/db/migrations/20240925122944_update_storyboard_sorting.sql b/internal/db/migrations/20240925122944_update_storyboard_sorting.sql new file mode 100644 index 00000000..ace46b10 --- /dev/null +++ b/internal/db/migrations/20240925122944_update_storyboard_sorting.sql @@ -0,0 +1,162 @@ +-- +goose Up +-- +goose StatementBegin + +-- Storyboard Story +ALTER TABLE thunderdome.storyboard_story ADD COLUMN display_order text COLLATE "C"; +UPDATE thunderdome.storyboard_story SET display_order = 'a' || sort_order::text; +ALTER TABLE thunderdome.storyboard_story DROP CONSTRAINT storyboard_story_column_id_sort_order_key; +ALTER TABLE thunderdome.storyboard_story DROP COLUMN sort_order; +ALTER TABLE thunderdome.storyboard_story ADD CONSTRAINT storyboard_story_column_id_display_order_key UNIQUE (column_id, display_order); + +-- Storyboard Column +ALTER TABLE thunderdome.storyboard_column ADD COLUMN display_order text COLLATE "C"; +UPDATE thunderdome.storyboard_column SET display_order = 'a' || sort_order::text; +ALTER TABLE thunderdome.storyboard_column DROP CONSTRAINT storyboard_column_goal_id_sort_order_key; +ALTER TABLE thunderdome.storyboard_column DROP COLUMN sort_order; +ALTER TABLE thunderdome.storyboard_column ADD CONSTRAINT storyboard_column_goal_id_display_order_key UNIQUE (goal_id, display_order); + +-- Storyboard Goal +ALTER TABLE thunderdome.storyboard_goal ADD COLUMN display_order text COLLATE "C"; +UPDATE thunderdome.storyboard_goal SET display_order = 'a' || sort_order::text; +ALTER TABLE thunderdome.storyboard_goal DROP CONSTRAINT storyboard_goal_storyboard_id_sort_order_key; +ALTER TABLE thunderdome.storyboard_goal DROP COLUMN sort_order; +ALTER TABLE thunderdome.storyboard_goal ADD CONSTRAINT storyboard_goal_storyboard_id_display_order_key UNIQUE (storyboard_id, display_order); + +DROP PROCEDURE "thunderdome".sb_column_delete(IN columnid uuid); +DROP PROCEDURE "thunderdome".sb_goal_delete(IN goalid uuid); +DROP PROCEDURE "thunderdome".sb_story_delete(IN storyid uuid); +DROP PROCEDURE "thunderdome".sb_story_move(IN storyid uuid, IN goalid uuid, IN columnid uuid, IN placebefore text); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Storyboard Story +ALTER TABLE thunderdome.storyboard_story ADD COLUMN sort_order int4; +UPDATE thunderdome.storyboard_story SET sort_order = substring(display_order, 2)::int4; +ALTER TABLE thunderdome.storyboard_story DROP CONSTRAINT storyboard_story_column_id_display_order_key; +ALTER TABLE thunderdome.storyboard_story DROP COLUMN display_order; +ALTER TABLE thunderdome.storyboard_story ADD CONSTRAINT storyboard_story_column_id_sort_order_key UNIQUE (column_id, sort_order); + +-- Storyboard Column +ALTER TABLE thunderdome.storyboard_column ADD COLUMN sort_order int4; +UPDATE thunderdome.storyboard_column SET sort_order = substring(display_order, 2)::int4; +ALTER TABLE thunderdome.storyboard_column DROP CONSTRAINT storyboard_column_goal_id_display_order_key; +ALTER TABLE thunderdome.storyboard_column DROP COLUMN display_order; +ALTER TABLE thunderdome.storyboard_column ADD CONSTRAINT storyboard_column_goal_id_sort_order_key UNIQUE (goal_id, sort_order); + +-- Storyboard Goal +ALTER TABLE thunderdome.storyboard_goal ADD COLUMN sort_order int4; +UPDATE thunderdome.storyboard_goal SET sort_order = substring(display_order, 2)::int4; +ALTER TABLE thunderdome.storyboard_goal DROP CONSTRAINT storyboard_goal_storyboard_id_display_order_key; +ALTER TABLE thunderdome.storyboard_goal DROP COLUMN display_order; +ALTER TABLE thunderdome.storyboard_goal ADD CONSTRAINT storyboard_goal_storyboard_id_sort_order_key UNIQUE (storyboard_id, sort_order); + +CREATE OR REPLACE PROCEDURE thunderdome.sb_story_move(IN storyid uuid, IN goalid uuid, IN columnid uuid, IN placebefore text) + LANGUAGE plpgsql +AS $procedure$ +DECLARE storyboardId UUID; +DECLARE srcGoalId UUID; +DECLARE srcColumnId UUID; +DECLARE srcSortOrder INTEGER; +DECLARE targetSortOrder INTEGER; +BEGIN + SET CONSTRAINTS thunderdome.storyboard_story_column_id_sort_order_key DEFERRED; + -- Get Story current details + SELECT + storyboard_id, goal_id, column_id, sort_order, name, color, content, created_date + INTO + storyboardId, srcGoalId, srcColumnId, srcSortOrder + FROM thunderdome.storyboard_story WHERE id = storyId; + + -- Get target sort order + IF placeBefore = '' THEN + SELECT coalesce(max(sort_order), 0) + 1 INTO targetSortOrder FROM thunderdome.storyboard_story WHERE column_id = columnId; + ELSE + SELECT sort_order INTO targetSortOrder FROM thunderdome.storyboard_story WHERE column_id = columnId AND id = placeBefore::UUID; + END IF; + + -- Remove from source column + UPDATE thunderdome.storyboard_story SET column_id = columnId, sort_order = 9000 WHERE id = storyId; + -- Update sort order in src column + UPDATE thunderdome.storyboard_story ss SET sort_order = (t.sort_order - 1) + FROM ( + SELECT id, sort_order FROM thunderdome.storyboard_story + WHERE column_id = srcColumnId AND sort_order > srcSortOrder + ORDER BY sort_order ASC + FOR UPDATE + ) AS t + WHERE ss.id = t.id; + + -- Update sort order for any story that should come after newly moved story + UPDATE thunderdome.storyboard_story ss SET sort_order = (t.sort_order + 1) + FROM ( + SELECT id, sort_order FROM thunderdome.storyboard_story + WHERE column_id = columnId AND sort_order >= targetSortOrder + ORDER BY sort_order DESC + FOR UPDATE + ) AS t + WHERE ss.id = t.id; + + -- Finally, insert story in its ordered place + UPDATE thunderdome.storyboard_story SET sort_order = targetSortOrder WHERE id = storyId; + + COMMIT; +END; +$procedure$; + +CREATE OR REPLACE PROCEDURE thunderdome.sb_story_delete(IN storyid uuid) + LANGUAGE plpgsql +AS $procedure$ +DECLARE columnId UUID; +DECLARE sortOrder INTEGER; +DECLARE storyboardId UUID; +BEGIN + SELECT column_id, sort_order, storyboard_id INTO columnId, sortOrder, storyboardId + FROM thunderdome.storyboard_story WHERE id = storyId; + DELETE FROM thunderdome.storyboard_story WHERE id = storyId; + UPDATE thunderdome.storyboard_story ss SET sort_order = (ss.sort_order - 1) + WHERE ss.column_id = columnId AND ss.sort_order > sortOrder; + + COMMIT; +END; +$procedure$; + +CREATE OR REPLACE PROCEDURE thunderdome.sb_goal_delete(IN goalid uuid) + LANGUAGE plpgsql +AS $procedure$ +DECLARE storyboardId UUID; +DECLARE sortOrder INTEGER; +BEGIN + SELECT sort_order, storyboard_id INTO sortOrder, storyboardId FROM thunderdome.storyboard_goal WHERE id = goalId; + + DELETE FROM thunderdome.storyboard_story WHERE goal_id = goalId; + DELETE FROM thunderdome.storyboard_column WHERE goal_id = goalId; + DELETE FROM thunderdome.storyboard_goal WHERE id = goalId; + UPDATE thunderdome.storyboard_goal sg SET sort_order = (sg.sort_order - 1) + WHERE sg.storyboard_id = storyBoardId AND sg.sort_order > sortOrder; + + COMMIT; +END; +$procedure$; + +CREATE OR REPLACE PROCEDURE thunderdome.sb_column_delete(IN columnid uuid) + LANGUAGE plpgsql +AS $procedure$ +DECLARE goalId UUID; +DECLARE sortOrder INTEGER; +DECLARE storyboardId UUID; +BEGIN + SELECT goal_id, sort_order INTO goalId, sortOrder FROM thunderdome.storyboard_column WHERE id = columnId; + + DELETE FROM thunderdome.storyboard_story WHERE column_id = columnId; + DELETE FROM thunderdome.storyboard_column WHERE id = columnId RETURNING storyboard_id INTO storyboardId; + UPDATE thunderdome.storyboard_column sc SET sort_order = (sc.sort_order - 1) + WHERE sc.goal_id = goalId AND sc.sort_order > sortOrder; + + COMMIT; +END; +$procedure$; + +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/db/storyboard/column.go b/internal/db/storyboard/column.go index f4f790aa..d2c5e61c 100644 --- a/internal/db/storyboard/column.go +++ b/internal/db/storyboard/column.go @@ -1,18 +1,79 @@ package storyboard import ( + "context" + "errors" + "fmt" + + "github.com/StevenWeathers/thunderdome-planning-poker/internal/fracindex" "github.com/StevenWeathers/thunderdome-planning-poker/thunderdome" "go.uber.org/zap" ) // CreateStoryboardColumn adds a new column to a Storyboard func (d *Service) CreateStoryboardColumn(StoryboardID string, GoalID string, userID string) ([]*thunderdome.StoryboardGoal, error) { - if _, err := d.DB.Exec( - `INSERT INTO thunderdome.storyboard_column (storyboard_id, goal_id, sort_order) - VALUES ($1, $2, ((SELECT coalesce(MAX(sort_order), 0) FROM thunderdome.storyboard_column WHERE goal_id = $2) + 1));`, + var betweenAkey *string + var logger = d.Logger.With( + zap.String("user_id", userID), + zap.String("storyboard_id", StoryboardID), + zap.String("goal_id", GoalID), + ) + + tx, err := d.DB.BeginTx(context.Background(), nil) + if err != nil { + logger.Error("begin transaction error", zap.Error(err)) + return nil, err + } + defer tx.Rollback() + + if err := tx.QueryRow( + ` + SELECT + COALESCE( + (SELECT MAX(display_order) + FROM thunderdome.storyboard_column + WHERE storyboard_id = $1 AND goal_id = $2), + 'a0' + ) AS last_display_order;`, StoryboardID, GoalID, + ).Scan(&betweenAkey); err != nil { + logger.Error("get display_order between query error", + zap.Error(err), + ) + return nil, err + } + + displayOrder, err := fracindex.KeyBetween(betweenAkey, nil) + if err != nil { + logger.Error("get display_order between error", + zap.Error(err), + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, err + } + + if displayOrder == nil { + logger.Error("get display_order returned nil", + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, errors.New("display order is nil") + } + + if _, err := tx.Exec( + `INSERT INTO thunderdome.storyboard_column (storyboard_id, goal_id, display_order) + VALUES ($1, $2, $3);`, + StoryboardID, GoalID, displayOrder, ); err != nil { - d.Logger.Error("CreateStoryboardColumn error", zap.Error(err)) + logger.Error("CreateStoryboardColumn error", + zap.Error(err), + zap.Stringp("display_order", displayOrder), + ) + return nil, err + } + + if commitErr := tx.Commit(); commitErr != nil { + logger.Error("update drivers: unable to commit", zap.Error(commitErr)) + return nil, fmt.Errorf("failed to update storyboard story display_order: %v", commitErr) } goals := d.GetStoryboardGoals(StoryboardID) @@ -27,7 +88,7 @@ func (d *Service) ReviseStoryboardColumn(StoryboardID string, UserID string, Col ColumnID, ColumnName, ); err != nil { - d.Logger.Error("ReviseStoryboardColumn error", zap.Error(err)) + d.Logger.Error("revise storyboard column error", zap.Error(err)) } goals := d.GetStoryboardGoals(StoryboardID) @@ -38,8 +99,8 @@ func (d *Service) ReviseStoryboardColumn(StoryboardID string, UserID string, Col // DeleteStoryboardColumn removes a column from the current board by ID func (d *Service) DeleteStoryboardColumn(StoryboardID string, userID string, ColumnID string) ([]*thunderdome.StoryboardGoal, error) { if _, err := d.DB.Exec( - `CALL thunderdome.sb_column_delete($1);`, ColumnID); err != nil { - d.Logger.Error("CALL thunderdome.sb_column_delete error", zap.Error(err)) + `DELETE FROM thunderdome.storyboard_column WHERE id = $1;`, ColumnID); err != nil { + d.Logger.Error("delete storyboard column error", zap.Error(err)) } goals := d.GetStoryboardGoals(StoryboardID) diff --git a/internal/db/storyboard/goal.go b/internal/db/storyboard/goal.go index 301d3dcc..7b587619 100644 --- a/internal/db/storyboard/goal.go +++ b/internal/db/storyboard/goal.go @@ -1,7 +1,12 @@ package storyboard import ( + "context" "encoding/json" + "errors" + "fmt" + + "github.com/StevenWeathers/thunderdome-planning-poker/internal/fracindex" "github.com/StevenWeathers/thunderdome-planning-poker/thunderdome" @@ -10,14 +15,70 @@ import ( // CreateStoryboardGoal adds a new goal to a Storyboard func (d *Service) CreateStoryboardGoal(StoryboardID string, userID string, GoalName string) ([]*thunderdome.StoryboardGoal, error) { - if _, err := d.DB.Exec( + var betweenAkey *string + var logger = d.Logger.With( + zap.String("user_id", userID), + zap.String("storyboard_id", StoryboardID), + zap.String("goal_name", GoalName), + ) + + tx, err := d.DB.BeginTx(context.Background(), nil) + if err != nil { + logger.Error("begin transaction error", zap.Error(err)) + return nil, err + } + defer tx.Rollback() + + if err := tx.QueryRow( + ` + SELECT + COALESCE( + (SELECT MAX(display_order) + FROM thunderdome.storyboard_goal + WHERE storyboard_id = $1), + 'a0' + ) AS last_display_order;`, + StoryboardID, + ).Scan(&betweenAkey); err != nil { + logger.Error("get display_order between query error", + zap.Error(err), + ) + return nil, err + } + + displayOrder, err := fracindex.KeyBetween(betweenAkey, nil) + if err != nil { + logger.Error("get display_order between error", + zap.Error(err), + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, err + } + + if displayOrder == nil { + logger.Error("get display_order returned nil", + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, errors.New("display order is nil") + } + + if _, err := tx.Exec( `INSERT INTO thunderdome.storyboard_goal - (storyboard_id, sort_order, name) - VALUES ($1, ((SELECT coalesce(MAX(sort_order), 0) FROM thunderdome.storyboard_goal WHERE storyboard_id = $1) + 1), $2);`, - StoryboardID, GoalName, + (storyboard_id, name, display_order) + VALUES ($1, $2, $3);`, + StoryboardID, GoalName, displayOrder, ); err != nil { - d.Logger.Error("CALL thunderdome.create_storyboard_goal error", zap.Error(err)) + logger.Error("create storyboard goal error", + zap.Error(err), + zap.Stringp("display_order", displayOrder), + ) + return nil, err + } + + if commitErr := tx.Commit(); commitErr != nil { + logger.Error("update drivers: unable to commit", zap.Error(commitErr)) + return nil, fmt.Errorf("failed to update storyboard story display_order: %v", commitErr) } goals := d.GetStoryboardGoals(StoryboardID) @@ -32,7 +93,7 @@ func (d *Service) ReviseGoalName(StoryboardID string, userID string, GoalID stri GoalID, GoalName, ); err != nil { - d.Logger.Error("CALL thunderdome.update_storyboard_goal error", zap.Error(err)) + d.Logger.Error("update storyboard goal error", zap.Error(err)) } goals := d.GetStoryboardGoals(StoryboardID) @@ -43,8 +104,8 @@ func (d *Service) ReviseGoalName(StoryboardID string, userID string, GoalID stri // DeleteStoryboardGoal removes a goal from the current board by ID func (d *Service) DeleteStoryboardGoal(StoryboardID string, userID string, GoalID string) ([]*thunderdome.StoryboardGoal, error) { if _, err := d.DB.Exec( - `CALL thunderdome.sb_goal_delete($1);`, GoalID); err != nil { - d.Logger.Error("CALL thunderdome.sb_goal_delete error", zap.Error(err)) + `DELETE FROM thunderdome.storyboard_goal WHERE id = $1;`, GoalID); err != nil { + d.Logger.Error("storyboard goal delete error", zap.Error(err)) } goals := d.GetStoryboardGoals(StoryboardID) @@ -59,9 +120,9 @@ func (d *Service) GetStoryboardGoals(StoryboardID string) []*thunderdome.Storybo goalRows, goalsErr := d.DB.Query( `SELECT sg.id, - sg.sort_order, + sg.display_order, sg.name, - COALESCE(json_agg(to_jsonb(t) - 'goal_id' ORDER BY t.sort_order) FILTER (WHERE t.id IS NOT NULL), '[]') AS columns, + COALESCE(json_agg(to_jsonb(t) - 'goal_id' ORDER BY t.display_order) FILTER (WHERE t.id IS NOT NULL), '[]') AS columns, (SELECT COALESCE(json_agg(to_jsonb(sp)) FILTER (WHERE gp.goal_id IS NOT NULL), '[]') AS personas FROM thunderdome.storyboard_goal_persona gp LEFT JOIN thunderdome.storyboard_persona sp ON sp.id = gp.persona_id) as personas @@ -70,7 +131,7 @@ func (d *Service) GetStoryboardGoals(StoryboardID string) []*thunderdome.Storybo SELECT sc.*, COALESCE( - json_agg(stss ORDER BY stss.sort_order) FILTER (WHERE stss.id IS NOT NULL), '[]' + json_agg(stss ORDER BY stss.display_order) FILTER (WHERE stss.id IS NOT NULL), '[]' ) AS stories, (SELECT COALESCE( json_agg(sp) FILTER (WHERE cp.column_id IS NOT NULL), '[]' @@ -92,8 +153,8 @@ func (d *Service) GetStoryboardGoals(StoryboardID string) []*thunderdome.Storybo GROUP BY sc.id ) t ON t.goal_id = sg.id WHERE sg.storyboard_id = $1 - GROUP BY sg.id, sg.sort_order - ORDER BY sg.sort_order;`, + GROUP BY sg.id, sg.display_order + ORDER BY sg.display_order;`, StoryboardID, ) if goalsErr == nil { @@ -104,7 +165,7 @@ func (d *Service) GetStoryboardGoals(StoryboardID string) []*thunderdome.Storybo var sg = &thunderdome.StoryboardGoal{ Id: "", Name: "", - SortOrder: 0, + SortOrder: "", Columns: make([]*thunderdome.StoryboardColumn, 0), } if err := goalRows.Scan(&sg.Id, &sg.SortOrder, &sg.Name, &columns, &personas); err != nil { diff --git a/internal/db/storyboard/story.go b/internal/db/storyboard/story.go index 85c0b572..8ef28f8c 100644 --- a/internal/db/storyboard/story.go +++ b/internal/db/storyboard/story.go @@ -1,18 +1,81 @@ package storyboard import ( + "context" + "errors" + "fmt" + + "github.com/StevenWeathers/thunderdome-planning-poker/internal/fracindex" "github.com/StevenWeathers/thunderdome-planning-poker/thunderdome" "go.uber.org/zap" ) // CreateStoryboardStory adds a new story to a Storyboard func (d *Service) CreateStoryboardStory(StoryboardID string, GoalID string, ColumnID string, userID string) ([]*thunderdome.StoryboardGoal, error) { + var betweenAkey *string + var logger = d.Logger.With( + zap.String("user_id", userID), + zap.String("storyboard_id", StoryboardID), + zap.String("column_id", ColumnID), + zap.String("goal_id", GoalID), + ) + + tx, err := d.DB.BeginTx(context.Background(), nil) + if err != nil { + logger.Error("begin transaction error", zap.Error(err)) + return nil, err + } + defer tx.Rollback() + + if err := tx.QueryRow( + ` + SELECT + COALESCE( + (SELECT MAX(display_order) + FROM thunderdome.storyboard_story + WHERE column_id = $1 AND goal_id = $2 AND storyboard_id = $3), + 'a0' + ) AS last_display_order;`, + ColumnID, GoalID, StoryboardID, + ).Scan(&betweenAkey); err != nil { + logger.Error("get display_order between query error", + zap.Error(err), + ) + return nil, err + } + + displayOrder, err := fracindex.KeyBetween(betweenAkey, nil) + if err != nil { + logger.Error("get display_order between error", + zap.Error(err), + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, err + } + + if displayOrder == nil { + logger.Error("get display_order returned nil", + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, errors.New("display order is nil") + } + if _, err := d.DB.Exec( - `INSERT INTO thunderdome.storyboard_story (storyboard_id, goal_id, column_id, sort_order) - VALUES ($1, $2, $3, ((SELECT coalesce(MAX(sort_order), 0) FROM thunderdome.storyboard_story WHERE column_id = $3) + 1));`, - StoryboardID, GoalID, ColumnID, + `INSERT INTO thunderdome.storyboard_story (storyboard_id, goal_id, column_id, display_order) + VALUES ($1, $2, $3, $4);`, + StoryboardID, GoalID, ColumnID, displayOrder, ); err != nil { - d.Logger.Error("CALL thunderdome.create_storyboard_story error", zap.Error(err)) + logger.Error( + "create story error", + zap.Error(err), + zap.Stringp("display_order_a", betweenAkey), + ) + return nil, err + } + + if commitErr := tx.Commit(); commitErr != nil { + logger.Error("update drivers: unable to commit", zap.Error(commitErr)) + return nil, fmt.Errorf("failed to update storyboard story display_order: %v", commitErr) } goals := d.GetStoryboardGoals(StoryboardID) @@ -112,14 +175,107 @@ func (d *Service) ReviseStoryLink(StoryboardID string, userID string, StoryID st // MoveStoryboardStory moves the story by ID to Goal/Column by ID func (d *Service) MoveStoryboardStory(StoryboardID string, userID string, StoryID string, GoalID string, ColumnID string, PlaceBefore string) ([]*thunderdome.StoryboardGoal, error) { - if _, err := d.DB.Exec( - `CALL thunderdome.sb_story_move($1, $2, $3, $4);`, - StoryID, - GoalID, - ColumnID, - PlaceBefore, + var betweenAkey *string + var betweenBkey *string + var logger = d.Logger.With( + zap.String("user_id", userID), + zap.String("storyboard_id", StoryboardID), + zap.String("story_id", StoryID), + zap.String("place_before", PlaceBefore), + zap.String("column_id", ColumnID), + zap.String("goal_id", GoalID), + ) + + tx, err := d.DB.BeginTx(context.Background(), nil) + if err != nil { + logger.Error("begin transaction error", zap.Error(err)) + return nil, err + } + defer tx.Rollback() + + if PlaceBefore == "" { + if err := tx.QueryRow( + ` + SELECT + (SELECT MAX(display_order) + FROM thunderdome.storyboard_story + WHERE column_id = $1 AND goal_id = $2 AND storyboard_id = $3) + AS last_display_order;`, + ColumnID, GoalID, StoryboardID, + ).Scan(&betweenAkey); err != nil { + logger.Error("get display_order between query error", + zap.Error(err), + ) + return nil, err + } + } else { + if err := tx.QueryRow( + ` + WITH current_story AS ( + SELECT id, column_id, display_order + FROM thunderdome.storyboard_story + WHERE id = $1 AND column_id = $2 AND goal_id = $3 + ), + preceding_story AS ( + SELECT id, display_order + FROM thunderdome.storyboard_story + WHERE column_id = (SELECT column_id FROM current_story) + AND goal_id = (SELECT goal_id FROM current_story) + AND display_order < (SELECT display_order FROM current_story) + ORDER BY display_order DESC + LIMIT 1 + ) + SELECT + cs.display_order AS current_display_order, + ps.display_order AS preceding_display_order + FROM current_story cs + LEFT JOIN preceding_story ps ON true; + `, + PlaceBefore, ColumnID, GoalID, + ).Scan(&betweenBkey, &betweenAkey); err != nil { + logger.Error("get display_order between query error", + zap.Error(err), + ) + return nil, err + } + } + + displayOrder, err := fracindex.KeyBetween(betweenAkey, betweenBkey) + if err != nil { + logger.Error("get display_order between error", + zap.Error(err), + zap.Stringp("display_order_a", betweenAkey), + zap.Stringp("display_order_b", betweenBkey), + ) + return nil, err + } + + if displayOrder == nil { + logger.Error("get display_order returned nil", + zap.Stringp("display_order_a", betweenAkey), + zap.Stringp("display_order_b", betweenBkey), + ) + return nil, errors.New("display order is nil") + } + + if _, err := tx.Exec( + `UPDATE thunderdome.storyboard_story + SET display_order = $1, column_id = $2, goal_id = $3, updated_date = NOW() WHERE id = $4;`, + displayOrder, ColumnID, GoalID, StoryID, ); err != nil { - d.Logger.Error("CALL thunderdome.sb_story_move error", zap.Error(err)) + logger.Error( + "update story display_order", + zap.Error(err), + zap.Stringp("display_order_a", betweenAkey), + zap.Stringp("display_order_b", betweenBkey), + zap.Stringp("display_order", displayOrder), + ) + return nil, err + } + + if commitErr := tx.Commit(); commitErr != nil { + logger.Error("update drivers: unable to commit", zap.Error(commitErr)) + return nil, fmt.Errorf("failed to update storyboard story display_order: %v", commitErr) } goals := d.GetStoryboardGoals(StoryboardID) @@ -130,8 +286,8 @@ func (d *Service) MoveStoryboardStory(StoryboardID string, userID string, StoryI // DeleteStoryboardStory removes a story from the current board by ID func (d *Service) DeleteStoryboardStory(StoryboardID string, userID string, StoryID string) ([]*thunderdome.StoryboardGoal, error) { if _, err := d.DB.Exec( - `CALL thunderdome.sb_story_delete($1);`, StoryID); err != nil { - d.Logger.Error("CALL thunderdome.sb_story_delete error", zap.Error(err)) + `DELETE FROM thunderdome.storyboard_story WHERE id = $1`, StoryID); err != nil { + d.Logger.Error("storyboard story delete error", zap.Error(err)) } goals := d.GetStoryboardGoals(StoryboardID) diff --git a/internal/http/http.go b/internal/http/http.go index fda5bb4b..0430eb10 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -435,6 +435,10 @@ func New(apiService Service, FSS fs.FS, HFS http.FileSystem) *Service { apiRouter.HandleFunc("/storyboards", a.userOnly(a.adminOnly(a.handleGetStoryboards()))).Methods("GET") apiRouter.HandleFunc("/storyboards/{storyboardId}", a.userOnly(a.handleStoryboardGet())).Methods("GET") apiRouter.HandleFunc("/storyboards/{storyboardId}", a.userOnly(a.handleStoryboardDelete(storyboardSvc))).Methods("DELETE") + apiRouter.HandleFunc("/storyboards/{storyboardId}/goals", a.userOnly(a.handleStoryboardGoalAdd(storyboardSvc))).Methods("POST") + apiRouter.HandleFunc("/storyboards/{storyboardId}/columns", a.userOnly(a.handleStoryboardColumnAdd(storyboardSvc))).Methods("POST") + apiRouter.HandleFunc("/storyboards/{storyboardId}/stories", a.userOnly(a.handleStoryboardStoryAdd(storyboardSvc))).Methods("POST") + apiRouter.HandleFunc("/storyboards/{storyboardId}/stories/{storyId}/move", a.userOnly(a.handleStoryboardStoryMove(storyboardSvc))).Methods("PUT") apiRouter.HandleFunc("/storyboard/{storyboardId}", storyboardSvc.ServeWs()) } diff --git a/internal/http/storyboard.go b/internal/http/storyboard.go index 9c38358b..e48b3d56 100644 --- a/internal/http/storyboard.go +++ b/internal/http/storyboard.go @@ -274,3 +274,288 @@ func (s *Service) handleStoryboardDelete(sb *storyboard.Service) http.HandlerFun s.Success(w, r, http.StatusOK, nil, nil) } } + +type storyboardGoalAddRequestBody struct { + Name string `json:"name" validate:"required,min=1"` +} + +// handleStoryboardGoalAdd handles adding a goal to a storyboard +// @Summary Storyboard Goal Add +// @Description Add a goal to a storyboard +// @Param storyboardId path string true "the storyboard ID" +// @Param storyboard body storyboardGoalAddRequestBody false "the goal to add" +// @Tags storyboard +// @Produce json +// @Success 200 object standardJsonResponse{} +// @Success 403 object standardJsonResponse{} +// @Success 500 object standardJsonResponse{} +// @Security ApiKeyAuth +// @Router /storyboards/{storyboardId}/goals [post] +func (s *Service) handleStoryboardGoalAdd(sb *storyboard.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + Id := vars["storyboardId"] + idErr := validate.Var(Id, "required,uuid") + if idErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, idErr.Error())) + return + } + SessionUserID := r.Context().Value(contextKeyUserID).(string) + + body, bodyErr := io.ReadAll(r.Body) // check for errors + if bodyErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, bodyErr.Error())) + return + } + + var sbm = storyboardGoalAddRequestBody{} + jsonErr := json.Unmarshal(body, &sbm) + if jsonErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, jsonErr.Error())) + return + } + + inputErr := validate.Struct(sbm) + if inputErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, inputErr.Error())) + return + } + + err := sb.APIEvent(ctx, Id, SessionUserID, "add_goal", sbm.Name) + if err != nil { + s.Logger.Ctx(ctx).Error("handle storyboard goal add error", + zap.Error(err), + zap.String("storyboard_id", Id), + zap.String("session_user_id", SessionUserID)) + s.Failure(w, r, http.StatusInternalServerError, err) + return + } + + s.Success(w, r, http.StatusOK, nil, nil) + } +} + +type storyboardColumnAddRequestBody struct { + GoalID string `json:"goalId" validate:"required,uuid"` +} + +// handleStoryboardColumnAdd handles adding a column to a storyboard goal +// @Summary Storyboard Column Add +// @Description Add a column to a storyboard goal +// @Param storyboardId path string true "the storyboard ID" +// @Param storyboard body storyboardColumnAddRequestBody false "request body for adding a column" +// @Tags storyboard +// @Produce json +// @Success 200 object standardJsonResponse{} +// @Success 403 object standardJsonResponse{} +// @Success 500 object standardJsonResponse{} +// @Security ApiKeyAuth +// @Router /storyboards/{storyboardId}/columns [post] +func (s *Service) handleStoryboardColumnAdd(sb *storyboard.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + Id := vars["storyboardId"] + idErr := validate.Var(Id, "required,uuid") + if idErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, idErr.Error())) + return + } + SessionUserID := r.Context().Value(contextKeyUserID).(string) + + body, bodyErr := io.ReadAll(r.Body) // check for errors + if bodyErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, bodyErr.Error())) + return + } + + var sbm = storyboardColumnAddRequestBody{} + jsonErr := json.Unmarshal(body, &sbm) + if jsonErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, jsonErr.Error())) + return + } + + inputErr := validate.Struct(sbm) + if inputErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, inputErr.Error())) + return + } + + eventValue, err := json.Marshal(sbm) + if err != nil { + s.Failure(w, r, http.StatusInternalServerError, Errorf(EINVALID, err.Error())) + return + } + + err = sb.APIEvent(ctx, Id, SessionUserID, "add_column", string(eventValue)) + if err != nil { + s.Logger.Ctx(ctx).Error("handle storyboard column add error", + zap.Error(err), + zap.String("storyboard_id", Id), + zap.String("session_user_id", SessionUserID)) + s.Failure(w, r, http.StatusInternalServerError, err) + return + } + + s.Success(w, r, http.StatusOK, nil, nil) + } +} + +type storyboardStoryAddRequestBody struct { + GoalID string `json:"goalId" validate:"required,uuid"` + ColumnID string `json:"columnId" validate:"required,uuid"` +} + +// handleStoryboardStoryAdd handles adding a story to a storyboard goal column +// @Summary Storyboard Story Add +// @Description Add a story to a storyboard goal column +// @Param storyboardId path string true "the storyboard ID" +// @Param storyboard body storyboardStoryAddRequestBody false "request body for adding a story" +// @Tags storyboard +// @Produce json +// @Success 200 object standardJsonResponse{} +// @Success 403 object standardJsonResponse{} +// @Success 500 object standardJsonResponse{} +// @Security ApiKeyAuth +// @Router /storyboards/{storyboardId}/stories [post] +func (s *Service) handleStoryboardStoryAdd(sb *storyboard.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + Id := vars["storyboardId"] + idErr := validate.Var(Id, "required,uuid") + if idErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, idErr.Error())) + return + } + SessionUserID := r.Context().Value(contextKeyUserID).(string) + + body, bodyErr := io.ReadAll(r.Body) // check for errors + if bodyErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, bodyErr.Error())) + return + } + + var sbm = storyboardStoryAddRequestBody{} + jsonErr := json.Unmarshal(body, &sbm) + if jsonErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, jsonErr.Error())) + return + } + + inputErr := validate.Struct(sbm) + if inputErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, inputErr.Error())) + return + } + + eventValue, err := json.Marshal(sbm) + if err != nil { + s.Failure(w, r, http.StatusInternalServerError, Errorf(EINVALID, err.Error())) + return + } + + err = sb.APIEvent(ctx, Id, SessionUserID, "add_story", string(eventValue)) + if err != nil { + s.Logger.Ctx(ctx).Error("handle storyboard story add error", + zap.Error(err), + zap.String("storyboard_id", Id), + zap.String("session_user_id", SessionUserID)) + s.Failure(w, r, http.StatusInternalServerError, err) + return + } + + s.Success(w, r, http.StatusOK, nil, nil) + } +} + +type storyboardStoryMoveRequestBody struct { + PlaceBefore string `json:"placeBefore" validate:"omitempty,uuid"` + GoalID string `json:"goalId" validate:"required,uuid"` + ColumnID string `json:"columnId" validate:"required,uuid"` +} + +// handleStoryboardStoryMove handles moving a story in a storyboard +// @Summary Storyboard Story Move +// @Description Move a story in a storyboard +// @Param storyboardId path string true "the storyboard ID" +// @Param storyId path string true "the story ID" +// @Param storyboard body storyboardStoryMoveRequestBody false "target goal column and place before story" +// @Tags storyboard +// @Produce json +// @Success 200 object standardJsonResponse{} +// @Success 403 object standardJsonResponse{} +// @Success 500 object standardJsonResponse{} +// @Security ApiKeyAuth +// @Router /storyboards/{storyboardId}/stories/{storyId}/move [put] +func (s *Service) handleStoryboardStoryMove(sb *storyboard.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + vars := mux.Vars(r) + Id := vars["storyboardId"] + idErr := validate.Var(Id, "required,uuid") + if idErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, idErr.Error())) + return + } + StoryId := vars["storyId"] + idErr = validate.Var(Id, "required,uuid") + if idErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, idErr.Error())) + return + } + SessionUserID := r.Context().Value(contextKeyUserID).(string) + + body, bodyErr := io.ReadAll(r.Body) // check for errors + if bodyErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, bodyErr.Error())) + return + } + + var sbm = storyboardStoryMoveRequestBody{} + jsonErr := json.Unmarshal(body, &sbm) + if jsonErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, jsonErr.Error())) + return + } + + inputErr := validate.Struct(sbm) + if inputErr != nil { + s.Failure(w, r, http.StatusBadRequest, Errorf(EINVALID, inputErr.Error())) + return + } + + type moveEvent struct { + StoryID string `json:"storyId"` + PlaceBefore string `json:"placeBefore"` + GoalID string `json:"goalId"` + ColumnID string `json:"columnId"` + } + sbme := moveEvent{ + StoryID: StoryId, + PlaceBefore: sbm.PlaceBefore, + GoalID: sbm.GoalID, + ColumnID: sbm.ColumnID, + } + moveEventJSON, moveEventErr := json.Marshal(sbme) + if moveEventErr != nil { + s.Failure(w, r, http.StatusInternalServerError, Errorf(EINVALID, moveEventErr.Error())) + return + } + + err := sb.APIEvent(ctx, Id, SessionUserID, "move_story", string(moveEventJSON)) + if err != nil { + s.Logger.Ctx(ctx).Error("handle storyboard story move error", + zap.Error(err), + zap.String("storyboard_id", Id), + zap.String("story_id", StoryId), + zap.String("session_user_id", SessionUserID)) + s.Failure(w, r, http.StatusInternalServerError, err) + return + } + + s.Success(w, r, http.StatusOK, nil, nil) + } +} diff --git a/thunderdome/storyboard.go b/thunderdome/storyboard.go index e0a5dd08..8cbaecdd 100644 --- a/thunderdome/storyboard.go +++ b/thunderdome/storyboard.go @@ -37,7 +37,7 @@ type StoryboardGoal struct { Name string `json:"name"` Personas []*StoryboardPersona `json:"personas"` Columns []*StoryboardColumn `json:"columns"` - SortOrder int `json:"sort_order"` + SortOrder string `json:"sort_order"` } // StoryboardColumn A column in a storyboard goal @@ -46,7 +46,7 @@ type StoryboardColumn struct { Name string `json:"name"` Personas []*StoryboardPersona `json:"personas"` Stories []*StoryboardStory `json:"stories"` - SortOrder int `json:"sort_order"` + SortOrder string `json:"sort_order"` } // StoryboardStory A story in a storyboard goal column @@ -59,7 +59,7 @@ type StoryboardStory struct { Closed bool `json:"closed"` Link string `json:"link"` Annotations []string `json:"annotations"` - SortOrder int `json:"sort_order"` + SortOrder string `json:"sort_order"` Comments []*StoryComment `json:"comments"` } diff --git a/ui/src/pages/storyboard/Storyboard.svelte b/ui/src/pages/storyboard/Storyboard.svelte index c4944c5f..96f9fcaf 100644 --- a/ui/src/pages/storyboard/Storyboard.svelte +++ b/ui/src/pages/storyboard/Storyboard.svelte @@ -856,7 +856,7 @@ {#each storyboard.goals as goal, goalIndex (goal.id)} -
+

-