feat: saved board views and richer board filters
The board now filters by text, type, priority and unassigned tickets as well. Any combination can be saved as a named view, private or shared with the project, and opened again with one click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1007,6 +1007,58 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Ticket type ids",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "priority",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/IssuePriority"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "label",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Matches key, title or description",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unassigned",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only tickets without assignee",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "perColumn",
|
"name": "perColumn",
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -5933,6 +5985,293 @@
|
|||||||
"Users"
|
"Users"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/projects/{project}/filters": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SavedFiltersController_list",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SavedFilterDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "My views of the project's board, then the shared ones",
|
||||||
|
"tags": [
|
||||||
|
"Saved filters"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"description": "Project members only. `shared` makes it visible to the whole project (not for viewers).",
|
||||||
|
"operationId": "SavedFiltersController_save",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SaveFilterDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SavedFilterIdDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Save board filters under a name",
|
||||||
|
"tags": [
|
||||||
|
"Saved filters"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/filters/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "SavedFiltersController_delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Delete a saved view (whoever saved it, or an admin if shared)",
|
||||||
|
"tags": [
|
||||||
|
"Saved filters"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
@@ -8267,6 +8606,174 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"email"
|
"email"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"SavedFilterCriteriaResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"areas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priorities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/IssuePriority"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assigneeId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"unassigned": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"releaseId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SavedFilterDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"projectId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Bugs críticos de frontend"
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Visible to the whole project"
|
||||||
|
},
|
||||||
|
"criteria": {
|
||||||
|
"$ref": "#/components/schemas/SavedFilterCriteriaResponseDto"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"projectId",
|
||||||
|
"owner",
|
||||||
|
"name",
|
||||||
|
"shared",
|
||||||
|
"criteria",
|
||||||
|
"createdAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SavedFilterCriteriaDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"types": {
|
||||||
|
"description": "Ticket type ids",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"areas": {
|
||||||
|
"description": "Area ids",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priorities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/IssuePriority"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assigneeId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Pireon user id"
|
||||||
|
},
|
||||||
|
"unassigned": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"releaseId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"maxLength": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SaveFilterDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Bugs críticos de frontend",
|
||||||
|
"maxLength": 60
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"type": "object",
|
||||||
|
"default": false,
|
||||||
|
"description": "Visible to the whole project (members, not viewers, share)"
|
||||||
|
},
|
||||||
|
"criteria": {
|
||||||
|
"$ref": "#/components/schemas/SavedFilterCriteriaDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"criteria"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SavedFilterIdDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,9 @@ type Mutation {
|
|||||||
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||||
"""
|
"""
|
||||||
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
|
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Delete a view: whoever saved it, or an admin if it is shared"""
|
||||||
|
deleteSavedFilter(id: ID!): Boolean!
|
||||||
editComment(body: String!, commentId: ID!): CommandResult!
|
editComment(body: String!, commentId: ID!): CommandResult!
|
||||||
|
|
||||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
@@ -422,6 +425,9 @@ type Mutation {
|
|||||||
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
||||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Save the current board filters under a name (members of the project)"""
|
||||||
|
saveFilter(input: SaveFilterInput!): ID!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
|
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
|
||||||
"""
|
"""
|
||||||
@@ -501,7 +507,19 @@ type Project {
|
|||||||
analytics(days: Int! = 90): ProjectAnalytics!
|
analytics(days: Int! = 90): ProjectAnalytics!
|
||||||
|
|
||||||
"""Kanban board, one column per status"""
|
"""Kanban board, one column per status"""
|
||||||
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
|
board(
|
||||||
|
area: String
|
||||||
|
assigneeId: ID
|
||||||
|
labels: [String!]
|
||||||
|
perColumn: Int! = 50
|
||||||
|
priorities: [IssuePriority!]
|
||||||
|
releaseId: ID
|
||||||
|
|
||||||
|
"""Matches key, title or description"""
|
||||||
|
search: String
|
||||||
|
types: [String!]
|
||||||
|
unassigned: Boolean
|
||||||
|
): [BoardColumn!]!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
description: String
|
description: String
|
||||||
id: ID!
|
id: ID!
|
||||||
@@ -648,6 +666,9 @@ type Query {
|
|||||||
publicProjects: [Project!]!
|
publicProjects: [Project!]!
|
||||||
release(id: ID!): Release!
|
release(id: ID!): Release!
|
||||||
|
|
||||||
|
"""My views of the project's board, then the shared ones"""
|
||||||
|
savedFilters(projectId: ID!): [SavedFilter!]!
|
||||||
|
|
||||||
"""Planned releases of all my projects, soonest target date first"""
|
"""Planned releases of all my projects, soonest target date first"""
|
||||||
upcomingReleases(limit: Int! = 10): [Release!]!
|
upcomingReleases(limit: Int! = 10): [Release!]!
|
||||||
user(id: String!): User
|
user(id: String!): User
|
||||||
@@ -718,6 +739,51 @@ enum ReleaseStatus {
|
|||||||
RELEASED
|
RELEASED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input SaveFilterInput {
|
||||||
|
criteria: SavedFilterCriteriaInput!
|
||||||
|
name: String!
|
||||||
|
projectId: ID!
|
||||||
|
|
||||||
|
"""Visible to the whole project (members, not viewers, share)"""
|
||||||
|
shared: Boolean! = false
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
A named view of the board: private to whoever saved it, or shared with the project
|
||||||
|
"""
|
||||||
|
type SavedFilter {
|
||||||
|
createdAt: String!
|
||||||
|
criteria: SavedFilterCriteria!
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
owner: UserRef!
|
||||||
|
projectId: ID!
|
||||||
|
shared: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
"""What a saved view narrows the board to; empty: everything"""
|
||||||
|
type SavedFilterCriteria {
|
||||||
|
areas: [String!]
|
||||||
|
assigneeId: ID
|
||||||
|
labels: [String!]
|
||||||
|
priorities: [IssuePriority!]
|
||||||
|
releaseId: ID
|
||||||
|
search: String
|
||||||
|
types: [String!]
|
||||||
|
unassigned: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
input SavedFilterCriteriaInput {
|
||||||
|
areas: [String!]
|
||||||
|
assigneeId: ID
|
||||||
|
labels: [String!]
|
||||||
|
priorities: [IssuePriority!]
|
||||||
|
releaseId: ID
|
||||||
|
search: String
|
||||||
|
types: [String!]
|
||||||
|
unassigned: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
|
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -871,6 +871,44 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/projects/{project}/filters": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** My views of the project's board, then the shared ones */
|
||||||
|
get: operations["SavedFiltersController_list"];
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Save board filters under a name
|
||||||
|
* @description Project members only. `shared` makes it visible to the whole project (not for viewers).
|
||||||
|
*/
|
||||||
|
post: operations["SavedFiltersController_save"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/filters/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete a saved view (whoever saved it, or an admin if shared) */
|
||||||
|
delete: operations["SavedFiltersController_delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1616,6 +1654,59 @@ export interface components {
|
|||||||
/** @description Copies by e-mail */
|
/** @description Copies by e-mail */
|
||||||
email: boolean;
|
email: boolean;
|
||||||
};
|
};
|
||||||
|
SavedFilterCriteriaResponseDto: {
|
||||||
|
types?: string[];
|
||||||
|
areas?: string[];
|
||||||
|
priorities?: components["schemas"]["IssuePriority"][];
|
||||||
|
assigneeId?: string | null;
|
||||||
|
unassigned?: boolean;
|
||||||
|
/** Format: uuid */
|
||||||
|
releaseId?: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
search?: string | null;
|
||||||
|
};
|
||||||
|
SavedFilterDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
projectId: string;
|
||||||
|
owner: components["schemas"]["UserRefDto"];
|
||||||
|
/** @example Bugs críticos de frontend */
|
||||||
|
name: string;
|
||||||
|
/** @description Visible to the whole project */
|
||||||
|
shared: boolean;
|
||||||
|
criteria: components["schemas"]["SavedFilterCriteriaResponseDto"];
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
SavedFilterCriteriaDto: {
|
||||||
|
/** @description Ticket type ids */
|
||||||
|
types?: string[];
|
||||||
|
/** @description Area ids */
|
||||||
|
areas?: string[];
|
||||||
|
priorities?: components["schemas"]["IssuePriority"][];
|
||||||
|
/** @description Pireon user id */
|
||||||
|
assigneeId?: string | null;
|
||||||
|
unassigned?: boolean;
|
||||||
|
/** Format: uuid */
|
||||||
|
releaseId?: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
search?: string | null;
|
||||||
|
};
|
||||||
|
SaveFilterDto: {
|
||||||
|
/** @example Bugs críticos de frontend */
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* @description Visible to the whole project (members, not viewers, share)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
shared: Record<string, never>;
|
||||||
|
criteria: components["schemas"]["SavedFilterCriteriaDto"];
|
||||||
|
};
|
||||||
|
SavedFilterIdDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@@ -2339,6 +2430,14 @@ export interface operations {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
/** @description An area id */
|
/** @description An area id */
|
||||||
area?: string;
|
area?: string;
|
||||||
|
/** @description Ticket type ids */
|
||||||
|
type?: string[];
|
||||||
|
priority?: components["schemas"]["IssuePriority"][];
|
||||||
|
label?: string[];
|
||||||
|
/** @description Matches key, title or description */
|
||||||
|
search?: string;
|
||||||
|
/** @description Only tickets without assignee */
|
||||||
|
unassigned?: boolean;
|
||||||
perColumn?: number;
|
perColumn?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -5702,4 +5801,215 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SavedFiltersController_list: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SavedFilterDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SavedFiltersController_save: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SaveFilterDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SavedFilterIdDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SavedFiltersController_delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ stateDiagram-v2
|
|||||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||||
|
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
|
||||||
|
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
|
||||||
|
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
|
||||||
|
y se borran con el proyecto.
|
||||||
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
||||||
|
|
||||||
### Notificaciones
|
### Notificaciones
|
||||||
@@ -225,21 +229,22 @@ que funcionan también sin token.
|
|||||||
|
|
||||||
### Queries
|
### Queries
|
||||||
|
|
||||||
| Query | Devuelve |
|
| Query | Devuelve |
|
||||||
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||||
| `user(id)` | Un usuario |
|
| `user(id)` | Un usuario |
|
||||||
| `myProjects` | Proyectos de los que soy miembro |
|
| `myProjects` | Proyectos de los que soy miembro |
|
||||||
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||||
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||||
| `publicProjects` | Proyectos públicos (sin token) |
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
||||||
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
||||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||||
|
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||||
|
|
||||||
### Mutations
|
### Mutations
|
||||||
|
|
||||||
@@ -250,6 +255,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||||
|
|
||||||
@@ -453,6 +459,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||||
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export * from './lib/changes.service';
|
|||||||
export * from './lib/workflows.service';
|
export * from './lib/workflows.service';
|
||||||
export * from './lib/notifications.service';
|
export * from './lib/notifications.service';
|
||||||
export * from './lib/notifications';
|
export * from './lib/notifications';
|
||||||
|
export * from './lib/saved-filters.service';
|
||||||
|
export * from './lib/saved-filters/saved-filters';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ export * from './tracker-streams.repository';
|
|||||||
export * from './attachment-storage';
|
export * from './attachment-storage';
|
||||||
export * from './notifications.repository';
|
export * from './notifications.repository';
|
||||||
export * from './notification-mailer';
|
export * from './notification-mailer';
|
||||||
|
export * from './saved-filters.repository';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/** Saved board views: per person (or shared with a project), no history. */
|
||||||
|
export abstract class SavedFiltersRepository {
|
||||||
|
/** Whoever's own views of the project, then the shared ones, by name. */
|
||||||
|
abstract findVisible(
|
||||||
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<SavedFilterReadModel[]>;
|
||||||
|
abstract findById(id: string): Promise<SavedFilterReadModel | null>;
|
||||||
|
abstract countOwned(projectId: string, userId: string): Promise<number>;
|
||||||
|
abstract insert(filter: SavedFilterReadModel): Promise<void>;
|
||||||
|
abstract delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
SavedFilterCriteriaReadModel,
|
||||||
|
SavedFilterReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import {
|
||||||
|
DeleteSavedFilterCommand,
|
||||||
|
GetSavedFiltersQuery,
|
||||||
|
SaveFilterCommand,
|
||||||
|
} from './saved-filters/saved-filters';
|
||||||
|
|
||||||
|
export interface SaveFilterInput {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
shared: boolean;
|
||||||
|
criteria: SavedFilterCriteriaReadModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved board views: mine and the ones shared with the project. */
|
||||||
|
@Injectable()
|
||||||
|
export class SavedFiltersService {
|
||||||
|
constructor(
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(viewerId: string, projectId: string): Promise<SavedFilterReadModel[]> {
|
||||||
|
return this.queryBus.execute(new GetSavedFiltersQuery(viewerId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new SaveFilterCommand(
|
||||||
|
actorId,
|
||||||
|
input.projectId,
|
||||||
|
input.name,
|
||||||
|
input.shared,
|
||||||
|
input.criteria,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(actorId: string, filterId: string): Promise<void> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new DeleteSavedFilterCommand(actorId, filterId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
IssuePriority,
|
||||||
|
ProjectRole,
|
||||||
|
SavedFilterReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { FakeUserDirectory } from '../../testing/in-memory';
|
||||||
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
import { SavedFiltersRepository } from '../ports/saved-filters.repository';
|
||||||
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
|
import { ReadAccess } from '../queries/read-access';
|
||||||
|
import {
|
||||||
|
DeleteSavedFilterCommand,
|
||||||
|
GetSavedFiltersQuery,
|
||||||
|
MAX_SAVED_FILTERS,
|
||||||
|
SAVED_FILTERS,
|
||||||
|
SaveFilterCommand,
|
||||||
|
normalizeCriteria,
|
||||||
|
} from './saved-filters';
|
||||||
|
|
||||||
|
class InMemorySavedFilters extends SavedFiltersRepository {
|
||||||
|
readonly rows = new Map<string, SavedFilterReadModel>();
|
||||||
|
async findVisible(projectId: string, userId: string) {
|
||||||
|
return [...this.rows.values()].filter(
|
||||||
|
(f) => f.projectId === projectId && (f.owner.id === userId || f.shared),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async findById(id: string) {
|
||||||
|
return this.rows.get(id) ?? null;
|
||||||
|
}
|
||||||
|
async countOwned(projectId: string, userId: string) {
|
||||||
|
return [...this.rows.values()].filter(
|
||||||
|
(f) => f.projectId === projectId && f.owner.id === userId,
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
async insert(filter: SavedFilterReadModel) {
|
||||||
|
this.rows.set(filter.id, filter);
|
||||||
|
}
|
||||||
|
async delete(id: string) {
|
||||||
|
this.rows.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES: Record<string, ProjectRole> = {
|
||||||
|
owner: ProjectRole.OWNER,
|
||||||
|
dev: ProjectRole.MEMBER,
|
||||||
|
qa: ProjectRole.MEMBER,
|
||||||
|
pm: ProjectRole.VIEWER,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const rows = new InMemorySavedFilters();
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [CqrsModule.forRoot()],
|
||||||
|
providers: [
|
||||||
|
...SAVED_FILTERS,
|
||||||
|
ReadAccess,
|
||||||
|
{ provide: SavedFiltersRepository, useValue: rows },
|
||||||
|
{
|
||||||
|
provide: UserDirectory,
|
||||||
|
useValue: new FakeUserDirectory().add('owner').add('dev').add('qa'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: FindProjectsRepository,
|
||||||
|
useValue: {
|
||||||
|
findRole: async (_projectId: string, userId: string) =>
|
||||||
|
ROLES[userId] ?? null,
|
||||||
|
findById: async () => ({ isPublic: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
await moduleRef.init();
|
||||||
|
const commands = moduleRef.get(CommandBus);
|
||||||
|
const queries = moduleRef.get(QueryBus);
|
||||||
|
const save = (actor: string, name: string, shared = false) =>
|
||||||
|
commands.execute(
|
||||||
|
new SaveFilterCommand(actor, 'p1', name, shared, {
|
||||||
|
priorities: [IssuePriority.HIGH, IssuePriority.HIGH],
|
||||||
|
labels: [' UI ', 'ui'],
|
||||||
|
search: ' ',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const list = (viewer: string) =>
|
||||||
|
queries.execute(new GetSavedFiltersQuery(viewer, 'p1'));
|
||||||
|
const remove = (actor: string, id: string) =>
|
||||||
|
commands.execute(new DeleteSavedFilterCommand(actor, id));
|
||||||
|
return { rows, save, list, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('saved board views', () => {
|
||||||
|
it('keeps private views private and shares the shared ones', async () => {
|
||||||
|
const { save, list } = await setup();
|
||||||
|
await save('dev', 'Mis bugs');
|
||||||
|
await save('qa', 'Para QA', true);
|
||||||
|
|
||||||
|
expect((await list('dev')).map((f) => f.name).sort()).toEqual([
|
||||||
|
'Mis bugs',
|
||||||
|
'Para QA',
|
||||||
|
]);
|
||||||
|
expect((await list('qa')).map((f) => f.name)).toEqual(['Para QA']);
|
||||||
|
const [mine] = await list('dev');
|
||||||
|
expect(mine.criteria).toEqual({
|
||||||
|
priorities: [IssuePriority.HIGH],
|
||||||
|
labels: ['ui'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets viewers keep views only for themselves, and outsiders none', async () => {
|
||||||
|
const { save } = await setup();
|
||||||
|
await expect(save('pm', 'Mía')).resolves.toHaveProperty('id');
|
||||||
|
await expect(save('pm', 'De todos', true)).rejects.toThrow(ForbiddenError);
|
||||||
|
await expect(save('stranger', 'Nope')).rejects.toThrow(ForbiddenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes: the author always, an admin a shared one, nobody else', async () => {
|
||||||
|
const { save, remove, rows } = await setup();
|
||||||
|
const { id: priv } = await save('dev', 'Privada');
|
||||||
|
const { id: shared } = await save('dev', 'Compartida', true);
|
||||||
|
|
||||||
|
await expect(remove('qa', priv)).rejects.toThrow(NotFoundError);
|
||||||
|
await expect(remove('qa', shared)).rejects.toThrow(ForbiddenError);
|
||||||
|
await remove('owner', shared);
|
||||||
|
await remove('dev', priv);
|
||||||
|
expect(rows.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps how many views a person keeps per project', async () => {
|
||||||
|
const { save } = await setup();
|
||||||
|
for (let i = 0; i < MAX_SAVED_FILTERS; i++) await save('dev', `#${i}`);
|
||||||
|
await expect(save('dev', 'Una más')).rejects.toThrow(BusinessRuleError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops empty criteria', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCriteria({ types: [], assigneeId: 'u', unassigned: true }),
|
||||||
|
).toEqual({ unassigned: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandHandler,
|
||||||
|
ICommandHandler,
|
||||||
|
IQueryHandler,
|
||||||
|
Query,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
assertThat,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
ProjectRole,
|
||||||
|
SavedFilterCriteriaReadModel,
|
||||||
|
SavedFilterReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { SavedFiltersRepository } from '../ports/saved-filters.repository';
|
||||||
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
|
import { ReadAccess } from '../queries/read-access';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Saved board views. Preferences, like notification settings: they concern
|
||||||
|
* whoever saved them (or the project, when shared) and need no history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A person keeps at most this many views per project. */
|
||||||
|
export const MAX_SAVED_FILTERS = 50;
|
||||||
|
|
||||||
|
const EDITORS = [ProjectRole.OWNER, ProjectRole.ADMIN, ProjectRole.MEMBER];
|
||||||
|
const MANAGERS = [ProjectRole.OWNER, ProjectRole.ADMIN];
|
||||||
|
|
||||||
|
export class SaveFilterCommand extends Command<{ id: string }> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly name: string,
|
||||||
|
/** Visible to the whole project, or just to whoever saved it. */
|
||||||
|
public readonly shared: boolean,
|
||||||
|
public readonly criteria: SavedFilterCriteriaReadModel,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeleteSavedFilterCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly filterId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetSavedFiltersQuery extends Query<SavedFilterReadModel[]> {
|
||||||
|
constructor(
|
||||||
|
public readonly viewerId: string,
|
||||||
|
public readonly projectId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops empty criteria, trims text and deduplicates lists. */
|
||||||
|
export function normalizeCriteria(
|
||||||
|
criteria: SavedFilterCriteriaReadModel,
|
||||||
|
): SavedFilterCriteriaReadModel {
|
||||||
|
const list = <T extends string>(values?: T[] | null) =>
|
||||||
|
values?.length ? [...new Set(values)] : undefined;
|
||||||
|
const out: SavedFilterCriteriaReadModel = {
|
||||||
|
types: list(criteria.types),
|
||||||
|
areas: list(criteria.areas),
|
||||||
|
priorities: list(criteria.priorities),
|
||||||
|
labels: list(
|
||||||
|
criteria.labels?.map((l) => l.trim().toLowerCase()).filter(Boolean),
|
||||||
|
),
|
||||||
|
assigneeId: criteria.unassigned
|
||||||
|
? undefined
|
||||||
|
: criteria.assigneeId || undefined,
|
||||||
|
unassigned: criteria.unassigned || undefined,
|
||||||
|
releaseId: criteria.releaseId || undefined,
|
||||||
|
search: criteria.search?.trim() || undefined,
|
||||||
|
};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(out).filter(([, value]) => value !== undefined),
|
||||||
|
) as SavedFilterCriteriaReadModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(SaveFilterCommand)
|
||||||
|
export class SaveFilterCommandHandler
|
||||||
|
implements ICommandHandler<SaveFilterCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly filters: SavedFiltersRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
private readonly users: UserDirectory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SaveFilterCommand): Promise<{ id: string }> {
|
||||||
|
const role = await this.access.assertCanView(
|
||||||
|
command.projectId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
if (!role)
|
||||||
|
throw new ForbiddenError('Only project members can save board views');
|
||||||
|
if (command.shared && !EDITORS.includes(role))
|
||||||
|
throw new ForbiddenError('Viewers can only save views for themselves');
|
||||||
|
const name = command.name.trim();
|
||||||
|
assertThat(
|
||||||
|
name.length > 0 && name.length <= 60,
|
||||||
|
'A view needs a name (up to 60 characters)',
|
||||||
|
);
|
||||||
|
const owned = await this.filters.countOwned(
|
||||||
|
command.projectId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
if (owned >= MAX_SAVED_FILTERS)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`You already keep ${MAX_SAVED_FILTERS} views in this project; delete one first`,
|
||||||
|
);
|
||||||
|
const users = await this.users.refs([command.actorId]);
|
||||||
|
const id = randomUUID();
|
||||||
|
await this.filters.insert({
|
||||||
|
id,
|
||||||
|
projectId: command.projectId,
|
||||||
|
owner: users.of(command.actorId),
|
||||||
|
name,
|
||||||
|
shared: command.shared,
|
||||||
|
criteria: normalizeCriteria(command.criteria),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(DeleteSavedFilterCommand)
|
||||||
|
export class DeleteSavedFilterCommandHandler
|
||||||
|
implements ICommandHandler<DeleteSavedFilterCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly filters: SavedFiltersRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: DeleteSavedFilterCommand): Promise<void> {
|
||||||
|
const filter = await this.filters.findById(command.filterId);
|
||||||
|
const mine = filter?.owner.id === command.actorId;
|
||||||
|
// Someone else's private view: as if it did not exist.
|
||||||
|
if (!filter || (!mine && !filter.shared))
|
||||||
|
throw new NotFoundError('SavedFilter', command.filterId);
|
||||||
|
if (!mine) {
|
||||||
|
const role = await this.access.assertCanView(
|
||||||
|
filter.projectId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
if (!role || !MANAGERS.includes(role))
|
||||||
|
throw new ForbiddenError(
|
||||||
|
'Only whoever saved a shared view, or a project admin, can delete it',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.filters.delete(filter.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetSavedFiltersQuery)
|
||||||
|
export class GetSavedFiltersQueryHandler
|
||||||
|
implements IQueryHandler<GetSavedFiltersQuery, SavedFilterReadModel[]>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly filters: SavedFiltersRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetSavedFiltersQuery): Promise<SavedFilterReadModel[]> {
|
||||||
|
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||||
|
return this.filters.findVisible(query.projectId, query.viewerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SAVED_FILTERS = [
|
||||||
|
SaveFilterCommandHandler,
|
||||||
|
DeleteSavedFilterCommandHandler,
|
||||||
|
GetSavedFiltersQueryHandler,
|
||||||
|
];
|
||||||
@@ -29,6 +29,9 @@ import {
|
|||||||
UpdateNotificationSettingsCommandHandler,
|
UpdateNotificationSettingsCommandHandler,
|
||||||
} from './notifications/notifications.commands';
|
} from './notifications/notifications.commands';
|
||||||
|
|
||||||
|
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||||
|
import { SavedFiltersService } from './saved-filters.service';
|
||||||
|
|
||||||
/** Notifications: derived from events, kept and dismissed per person. */
|
/** Notifications: derived from events, kept and dismissed per person. */
|
||||||
const NOTIFICATIONS = [
|
const NOTIFICATIONS = [
|
||||||
Notifier,
|
Notifier,
|
||||||
@@ -47,6 +50,7 @@ const SERVICES = [
|
|||||||
ChangesService,
|
ChangesService,
|
||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
NotificationsService,
|
NotificationsService,
|
||||||
|
SavedFiltersService,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +72,7 @@ const SERVICES = [
|
|||||||
...EVENT_HANDLERS,
|
...EVENT_HANDLERS,
|
||||||
...SAGAS,
|
...SAGAS,
|
||||||
...NOTIFICATIONS,
|
...NOTIFICATIONS,
|
||||||
|
...SAVED_FILTERS,
|
||||||
],
|
],
|
||||||
exports: SERVICES,
|
exports: SERVICES,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './activity.read-model';
|
|||||||
export * from './project-stats.read-model';
|
export * from './project-stats.read-model';
|
||||||
export * from './analytics.read-model';
|
export * from './analytics.read-model';
|
||||||
export * from './notification.read-model';
|
export * from './notification.read-model';
|
||||||
|
export * from './saved-filter.read-model';
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { IssuePriority } from '../issue/issue.enums';
|
||||||
|
import { UserRefReadModel } from './user-ref.read-model';
|
||||||
|
|
||||||
|
/** What a saved view of the board narrows the tickets to. Empty: everything. */
|
||||||
|
export class SavedFilterCriteriaReadModel {
|
||||||
|
/** Ticket type ids of the project's settings. */
|
||||||
|
types?: string[];
|
||||||
|
areas?: string[];
|
||||||
|
priorities?: IssuePriority[];
|
||||||
|
assigneeId?: string | null;
|
||||||
|
unassigned?: boolean;
|
||||||
|
releaseId?: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
search?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named set of board filters. Private to whoever saved it, or shared with
|
||||||
|
* the whole project. Preferences, not history: kept outside the event store.
|
||||||
|
*/
|
||||||
|
export class SavedFilterReadModel {
|
||||||
|
id!: string;
|
||||||
|
projectId!: string;
|
||||||
|
owner!: UserRefReadModel;
|
||||||
|
name!: string;
|
||||||
|
shared!: boolean;
|
||||||
|
criteria!: SavedFilterCriteriaReadModel;
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IssueTimelineEntry,
|
IssueTimelineEntry,
|
||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
|
SavedFilterReadModel,
|
||||||
implicitRank,
|
implicitRank,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { Schema } from 'mongoose';
|
import { Schema } from 'mongoose';
|
||||||
@@ -26,6 +27,7 @@ export type ReleaseDoc = Doc<ReleaseReadModel>;
|
|||||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||||
|
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||||
export interface NotificationSettingsDoc {
|
export interface NotificationSettingsDoc {
|
||||||
_id: string;
|
_id: string;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
@@ -40,6 +42,7 @@ export const ReadModels = {
|
|||||||
Timeline: 'IssueTimelineReadModel',
|
Timeline: 'IssueTimelineReadModel',
|
||||||
Notification: 'NotificationReadModel',
|
Notification: 'NotificationReadModel',
|
||||||
NotificationSettings: 'NotificationSettings',
|
NotificationSettings: 'NotificationSettings',
|
||||||
|
SavedFilter: 'SavedFilter',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const userRef = { _id: false, id: String, name: String };
|
const userRef = { _id: false, id: String, name: String };
|
||||||
@@ -160,6 +163,20 @@ export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
|
|||||||
{ ...loose, collection: 'notification_settings' },
|
{ ...loose, collection: 'notification_settings' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
|
||||||
|
{
|
||||||
|
_id: String,
|
||||||
|
projectId: String,
|
||||||
|
owner: userRef,
|
||||||
|
name: String,
|
||||||
|
shared: Boolean,
|
||||||
|
createdAt: String,
|
||||||
|
},
|
||||||
|
{ ...loose, collection: 'saved_filters' },
|
||||||
|
);
|
||||||
|
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||||
|
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||||
|
|
||||||
export const READ_MODEL_SCHEMAS = [
|
export const READ_MODEL_SCHEMAS = [
|
||||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||||
@@ -169,6 +186,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||||
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||||
|
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function fromDoc<T extends { _id: string }>(
|
export function fromDoc<T extends { _id: string }>(
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { SavedFiltersRepository } from '@task-stream/api/tracker/application';
|
||||||
|
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { ReadModels, SavedFilterDoc, fromDoc } from './read-model.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoSavedFiltersRepository extends SavedFiltersRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.SavedFilter)
|
||||||
|
private readonly filters: Model<SavedFilterDoc>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findVisible(
|
||||||
|
projectId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<SavedFilterReadModel[]> {
|
||||||
|
const docs = await this.filters
|
||||||
|
.find({ projectId, $or: [{ 'owner.id': userId }, { shared: true }] })
|
||||||
|
.sort({ shared: 1, name: 1 })
|
||||||
|
.lean<SavedFilterDoc[]>();
|
||||||
|
return docs.map((doc) => toFilter(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SavedFilterReadModel | null> {
|
||||||
|
const doc = await this.filters.findById(id).lean<SavedFilterDoc>();
|
||||||
|
return doc ? toFilter(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
countOwned(projectId: string, userId: string): Promise<number> {
|
||||||
|
return this.filters.countDocuments({ projectId, 'owner.id': userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async insert({ id, ...rest }: SavedFilterReadModel): Promise<void> {
|
||||||
|
await this.filters.create({ _id: id, ...rest });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.filters.deleteOne({ _id: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFilter(doc: SavedFilterDoc): SavedFilterReadModel {
|
||||||
|
const filter = fromDoc(doc) as SavedFilterReadModel;
|
||||||
|
return { ...filter, criteria: filter.criteria ?? {} };
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
ProjectDoc,
|
ProjectDoc,
|
||||||
ReadModels,
|
ReadModels,
|
||||||
ReleaseDoc,
|
ReleaseDoc,
|
||||||
|
SavedFilterDoc,
|
||||||
TimelineDoc,
|
TimelineDoc,
|
||||||
} from './read-model.schemas';
|
} from './read-model.schemas';
|
||||||
import { upsertIfNewer } from './upsert-if-newer';
|
import { upsertIfNewer } from './upsert-if-newer';
|
||||||
@@ -44,6 +45,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
|||||||
private readonly activity: Model<ActivityDoc>,
|
private readonly activity: Model<ActivityDoc>,
|
||||||
@InjectModel(ReadModels.Timeline)
|
@InjectModel(ReadModels.Timeline)
|
||||||
private readonly timeline: Model<TimelineDoc>,
|
private readonly timeline: Model<TimelineDoc>,
|
||||||
|
@InjectModel(ReadModels.SavedFilter)
|
||||||
|
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
|||||||
this.projects.deleteOne({ _id: projectId }),
|
this.projects.deleteOne({ _id: projectId }),
|
||||||
this.activity.deleteMany({ projectId }),
|
this.activity.deleteMany({ projectId }),
|
||||||
this.timeline.deleteMany({ projectId }),
|
this.timeline.deleteMany({ projectId }),
|
||||||
|
// Views of the board of a project that is gone.
|
||||||
|
this.savedFilters.deleteMany({ projectId }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ChangeFeed,
|
ChangeFeed,
|
||||||
NotificationMailer,
|
NotificationMailer,
|
||||||
NotificationsRepository,
|
NotificationsRepository,
|
||||||
|
SavedFiltersRepository,
|
||||||
FindActivityRepository,
|
FindActivityRepository,
|
||||||
FindCommentsRepository,
|
FindCommentsRepository,
|
||||||
FindIssuesRepository,
|
FindIssuesRepository,
|
||||||
@@ -56,6 +57,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
|
|||||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||||
|
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||||
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
||||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||||
|
|
||||||
@@ -107,6 +109,7 @@ const PORTS = [
|
|||||||
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
||||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||||
|
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||||
// Realtime → Redis pub/sub
|
// Realtime → Redis pub/sub
|
||||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||||
|
|||||||
@@ -181,6 +181,57 @@ export class ChangeIssueStatusInput {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class SavedFilterCriteriaInput {
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(SETTING_ID, { each: true })
|
||||||
|
types?: string[];
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(SETTING_ID, { each: true })
|
||||||
|
areas?: string[];
|
||||||
|
@Field(() => [IssuePriority], { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(IssuePriority, { each: true })
|
||||||
|
priorities?: IssuePriority[];
|
||||||
|
@Field(() => ID, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assigneeId?: string | null;
|
||||||
|
@Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean;
|
||||||
|
@Field(() => ID, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
releaseId?: string | null;
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@IsString({ each: true })
|
||||||
|
labels?: string[];
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class SaveFilterInput {
|
||||||
|
@Field(() => ID) @IsUUID() projectId!: string;
|
||||||
|
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
|
||||||
|
@Field({
|
||||||
|
defaultValue: false,
|
||||||
|
description: 'Visible to the whole project (members, not viewers, share)',
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
shared!: boolean;
|
||||||
|
@Field(() => SavedFilterCriteriaInput)
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => SavedFilterCriteriaInput)
|
||||||
|
criteria!: SavedFilterCriteriaInput;
|
||||||
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class RankIssueInput {
|
export class RankIssueInput {
|
||||||
@Field(() => ID) @IsUUID() issueId!: string;
|
@Field(() => ID) @IsUUID() issueId!: string;
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
OptionalAuth,
|
OptionalAuth,
|
||||||
} from '@task-stream/api/identity/presenters';
|
} from '@task-stream/api/identity/presenters';
|
||||||
import { ProjectsService } from '@task-stream/api/tracker/application';
|
import { ProjectsService } from '@task-stream/api/tracker/application';
|
||||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
import {
|
||||||
|
IssuePriority,
|
||||||
|
ProjectReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { viewerOf } from '../common/viewer';
|
import { viewerOf } from '../common/viewer';
|
||||||
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
|
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
|
||||||
import {
|
import {
|
||||||
@@ -108,6 +111,20 @@ export class ProjectResolver {
|
|||||||
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
|
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
|
||||||
@Args('area', { type: () => String, nullable: true }) area?: string,
|
@Args('area', { type: () => String, nullable: true }) area?: string,
|
||||||
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
|
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
|
||||||
|
@Args('types', { type: () => [String], nullable: true })
|
||||||
|
types?: string[] | null,
|
||||||
|
@Args('priorities', { type: () => [IssuePriority], nullable: true })
|
||||||
|
priorities?: IssuePriority[] | null,
|
||||||
|
@Args('labels', { type: () => [String], nullable: true })
|
||||||
|
labels?: string[] | null,
|
||||||
|
@Args('search', {
|
||||||
|
type: () => String,
|
||||||
|
nullable: true,
|
||||||
|
description: 'Matches key, title or description',
|
||||||
|
})
|
||||||
|
search?: string | null,
|
||||||
|
@Args('unassigned', { type: () => Boolean, nullable: true })
|
||||||
|
unassigned?: boolean | null,
|
||||||
) {
|
) {
|
||||||
return this.projects.board(
|
return this.projects.board(
|
||||||
viewerOf(me),
|
viewerOf(me),
|
||||||
@@ -116,6 +133,11 @@ export class ProjectResolver {
|
|||||||
releaseId,
|
releaseId,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
areas: area ? [area] : undefined,
|
areas: area ? [area] : undefined,
|
||||||
|
types: types ?? undefined,
|
||||||
|
priorities: priorities ?? undefined,
|
||||||
|
labels: labels ?? undefined,
|
||||||
|
search: search?.slice(0, 200) ?? undefined,
|
||||||
|
unassigned: unassigned ?? undefined,
|
||||||
},
|
},
|
||||||
perColumn,
|
perColumn,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||||
|
import { SavedFiltersService } from '@task-stream/api/tracker/application';
|
||||||
|
import { SaveFilterInput } from './inputs';
|
||||||
|
import { SavedFilterGql } from './types';
|
||||||
|
|
||||||
|
/** Saved views of a project's board. */
|
||||||
|
@Resolver(() => SavedFilterGql)
|
||||||
|
export class SavedFilterResolver {
|
||||||
|
constructor(private readonly filters: SavedFiltersService) {}
|
||||||
|
|
||||||
|
@Query(() => [SavedFilterGql], {
|
||||||
|
description: "My views of the project's board, then the shared ones",
|
||||||
|
})
|
||||||
|
savedFilters(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
return this.filters.list(me.id, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => ID, {
|
||||||
|
description:
|
||||||
|
'Save the current board filters under a name (members of the project)',
|
||||||
|
})
|
||||||
|
async saveFilter(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: SaveFilterInput,
|
||||||
|
) {
|
||||||
|
return (await this.filters.save(me.id, input)).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Delete a view: whoever saved it, or an admin if it is shared',
|
||||||
|
})
|
||||||
|
async deleteSavedFilter(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('id', { type: () => ID }) id: string,
|
||||||
|
) {
|
||||||
|
await this.filters.delete(me.id, id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,35 @@ export class EpicGql {
|
|||||||
@Field(() => ChildProgressGql) progress!: ChildProgressGql;
|
@Field(() => ChildProgressGql) progress!: ChildProgressGql;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('SavedFilterCriteria', {
|
||||||
|
description: 'What a saved view narrows the board to; empty: everything',
|
||||||
|
})
|
||||||
|
export class SavedFilterCriteriaGql {
|
||||||
|
@Field(() => [String], { nullable: true }) types?: string[];
|
||||||
|
@Field(() => [String], { nullable: true }) areas?: string[];
|
||||||
|
@Field(() => [IssuePriority], { nullable: true })
|
||||||
|
priorities?: IssuePriority[];
|
||||||
|
@Field(() => ID, { nullable: true }) assigneeId?: string | null;
|
||||||
|
@Field({ nullable: true }) unassigned?: boolean;
|
||||||
|
@Field(() => ID, { nullable: true }) releaseId?: string | null;
|
||||||
|
@Field(() => [String], { nullable: true }) labels?: string[];
|
||||||
|
@Field(() => String, { nullable: true }) search?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SavedFilter', {
|
||||||
|
description:
|
||||||
|
'A named view of the board: private to whoever saved it, or shared with the project',
|
||||||
|
})
|
||||||
|
export class SavedFilterGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field(() => ID) projectId!: string;
|
||||||
|
@Field(() => UserRefGql) owner!: UserRefGql;
|
||||||
|
@Field() name!: string;
|
||||||
|
@Field() shared!: boolean;
|
||||||
|
@Field(() => SavedFilterCriteriaGql) criteria!: SavedFilterCriteriaGql;
|
||||||
|
@Field() createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('IssuePage')
|
@ObjectType('IssuePage')
|
||||||
export class IssuePageGql {
|
export class IssuePageGql {
|
||||||
@Field(() => [IssueGql]) items!: IssueGql[];
|
@Field(() => [IssueGql]) items!: IssueGql[];
|
||||||
|
|||||||
@@ -251,6 +251,40 @@ export class BoardQueryDto {
|
|||||||
@Matches(SETTING_ID)
|
@Matches(SETTING_ID)
|
||||||
area?: string;
|
area?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toList)
|
||||||
|
@Matches(SETTING_ID, { each: true })
|
||||||
|
type?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: IssuePriority,
|
||||||
|
enumName: 'IssuePriority',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toList)
|
||||||
|
@IsEnum(IssuePriority, { each: true })
|
||||||
|
priority?: IssuePriority[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toList)
|
||||||
|
@IsString({ each: true })
|
||||||
|
label?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Only tickets without assignee' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
unassigned?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@@ -589,6 +623,76 @@ export class SetIssueReleaseDto {
|
|||||||
releaseId!: string | null;
|
releaseId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SavedFilterCriteriaDto {
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(SETTING_ID, { each: true })
|
||||||
|
types?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(SETTING_ID, { each: true })
|
||||||
|
areas?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: IssuePriority,
|
||||||
|
enumName: 'IssuePriority',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(IssuePriority, { each: true })
|
||||||
|
priorities?: IssuePriority[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
description: 'Pireon user id',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assigneeId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsBoolean() unassigned?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
releaseId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@IsString({ each: true })
|
||||||
|
labels?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 200 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveFilterDto {
|
||||||
|
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(60)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Visible to the whole project (members, not viewers, share)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
shared = false;
|
||||||
|
|
||||||
|
@ApiProperty({ type: SavedFilterCriteriaDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => SavedFilterCriteriaDto)
|
||||||
|
criteria!: SavedFilterCriteriaDto;
|
||||||
|
}
|
||||||
|
|
||||||
export class RankIssueDto {
|
export class RankIssueDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -191,6 +191,41 @@ export class IssueDto {
|
|||||||
@ApiProperty() version!: number;
|
@ApiProperty() version!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SavedFilterCriteriaResponseDto {
|
||||||
|
@ApiPropertyOptional({ type: [String] }) types?: string[];
|
||||||
|
@ApiPropertyOptional({ type: [String] }) areas?: string[];
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: IssuePriority,
|
||||||
|
enumName: 'IssuePriority',
|
||||||
|
isArray: true,
|
||||||
|
})
|
||||||
|
priorities?: IssuePriority[];
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
assigneeId?: string | null;
|
||||||
|
@ApiPropertyOptional() unassigned?: boolean;
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||||
|
releaseId?: string | null;
|
||||||
|
@ApiPropertyOptional({ type: [String] }) labels?: string[];
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
search?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SavedFilterDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
|
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
|
||||||
|
@ApiProperty({ example: 'Bugs críticos de frontend' }) name!: string;
|
||||||
|
@ApiProperty({ description: 'Visible to the whole project' })
|
||||||
|
shared!: boolean;
|
||||||
|
@ApiProperty({ type: SavedFilterCriteriaResponseDto })
|
||||||
|
criteria!: SavedFilterCriteriaResponseDto;
|
||||||
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SavedFilterIdDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class IssuePageDto {
|
export class IssuePageDto {
|
||||||
@ApiProperty({ type: [IssueDto] }) items!: IssueDto[];
|
@ApiProperty({ type: [IssueDto] }) items!: IssueDto[];
|
||||||
@ApiProperty({ description: 'Total matches (for pagination)' })
|
@ApiProperty({ description: 'Total matches (for pagination)' })
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ export class ProjectsController {
|
|||||||
releaseId: query.releaseId,
|
releaseId: query.releaseId,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
areas: query.area ? [query.area] : undefined,
|
areas: query.area ? [query.area] : undefined,
|
||||||
|
types: query.type,
|
||||||
|
priorities: query.priority,
|
||||||
|
labels: query.label,
|
||||||
|
search: query.search,
|
||||||
|
unassigned: query.unassigned,
|
||||||
},
|
},
|
||||||
query.perColumn,
|
query.perColumn,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import {
|
||||||
|
ApiPireonAuth,
|
||||||
|
CurrentUser,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import {
|
||||||
|
ApiCommandErrors,
|
||||||
|
ApiReadErrors,
|
||||||
|
} from '@task-stream/api/shared/presenters';
|
||||||
|
import { SavedFiltersService } from '@task-stream/api/tracker/application';
|
||||||
|
import { SaveFilterDto } from './dto/requests.dto';
|
||||||
|
import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto';
|
||||||
|
import { Refs } from './refs';
|
||||||
|
|
||||||
|
/** Saved views of a project's board: mine, and the ones shared with it. */
|
||||||
|
@ApiTags('Saved filters')
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@Controller()
|
||||||
|
export class SavedFiltersController {
|
||||||
|
constructor(
|
||||||
|
private readonly filters: SavedFiltersService,
|
||||||
|
private readonly refs: Refs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('projects/:project/filters')
|
||||||
|
@ApiParam({
|
||||||
|
name: 'project',
|
||||||
|
description: 'Project id or key',
|
||||||
|
example: 'WEB',
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "My views of the project's board, then the shared ones",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: [SavedFilterDto] })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||||
|
return this.filters.list(me.id, await this.refs.projectId(me.id, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('projects/:project/filters')
|
||||||
|
@ApiParam({
|
||||||
|
name: 'project',
|
||||||
|
description: 'Project id or key',
|
||||||
|
example: 'WEB',
|
||||||
|
})
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Save board filters under a name',
|
||||||
|
description:
|
||||||
|
'Project members only. `shared` makes it visible to the whole project (not for viewers).',
|
||||||
|
})
|
||||||
|
@ApiCreatedResponse({ type: SavedFilterIdDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async save(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Body() body: SaveFilterDto,
|
||||||
|
) {
|
||||||
|
return this.filters.save(me.id, {
|
||||||
|
...body,
|
||||||
|
projectId: await this.refs.projectId(me.id, project),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('filters/:id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiParam({ name: 'id', format: 'uuid' })
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete a saved view (whoever saved it, or an admin if shared)',
|
||||||
|
})
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async delete(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
) {
|
||||||
|
await this.filters.delete(me.id, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { ProjectsController } from './http/projects.controller';
|
|||||||
import { Refs } from './http/refs';
|
import { Refs } from './http/refs';
|
||||||
import { ReleasesController } from './http/releases.controller';
|
import { ReleasesController } from './http/releases.controller';
|
||||||
import { NotificationsController } from './http/notifications.controller';
|
import { NotificationsController } from './http/notifications.controller';
|
||||||
|
import { SavedFiltersController } from './http/saved-filters.controller';
|
||||||
|
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||||
@@ -45,6 +47,7 @@ import { NotificationsController } from './http/notifications.controller';
|
|||||||
ReleasesController,
|
ReleasesController,
|
||||||
ExploreController,
|
ExploreController,
|
||||||
NotificationsController,
|
NotificationsController,
|
||||||
|
SavedFiltersController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
Refs,
|
Refs,
|
||||||
@@ -54,6 +57,7 @@ import { NotificationsController } from './http/notifications.controller';
|
|||||||
ReleaseResolver,
|
ReleaseResolver,
|
||||||
SubscriptionResolver,
|
SubscriptionResolver,
|
||||||
NotificationResolver,
|
NotificationResolver,
|
||||||
|
SavedFilterResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrackerPresentersModule {
|
export class TrackerPresentersModule {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CommandResult,
|
CommandResult,
|
||||||
CreateIssueInput,
|
CreateIssueInput,
|
||||||
IssueChanged,
|
IssueChanged,
|
||||||
|
SavedFilter,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
export const boardPageEvents = eventGroup({
|
export const boardPageEvents = eventGroup({
|
||||||
@@ -20,6 +21,9 @@ export const boardPageEvents = eventGroup({
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}>(),
|
}>(),
|
||||||
issueCreateRequested: type<CreateIssueInput>(),
|
issueCreateRequested: type<CreateIssueInput>(),
|
||||||
|
/** Save the current filter under a name. */
|
||||||
|
viewSaveRequested: type<{ name: string; shared: boolean }>(),
|
||||||
|
viewDeleteRequested: type<{ id: string }>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +35,10 @@ export const boardApiEvents = eventGroup({
|
|||||||
issueMoveFailed: type<string>(),
|
issueMoveFailed: type<string>(),
|
||||||
issueCreated: type<CommandResult>(),
|
issueCreated: type<CommandResult>(),
|
||||||
issueCreateFailed: type<string>(),
|
issueCreateFailed: type<string>(),
|
||||||
|
viewsLoaded: type<SavedFilter[]>(),
|
||||||
|
viewSaved: type<string>(),
|
||||||
|
viewDeleted: type<string>(),
|
||||||
|
viewFailed: type<string>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { inject, Injectable, Provider } from '@angular/core';
|
import { computed, inject, Injectable, Provider } from '@angular/core';
|
||||||
import { injectDispatch } from '@ngrx/signals/events';
|
import { injectDispatch } from '@ngrx/signals/events';
|
||||||
import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain';
|
import {
|
||||||
|
BoardFilter,
|
||||||
|
criteriaOf,
|
||||||
|
CreateIssueInput,
|
||||||
|
sameCriteria,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { boardPageEvents } from '../events/board.events';
|
import { boardPageEvents } from '../events/board.events';
|
||||||
import { BoardStore } from '../stores/board.store';
|
import { BoardStore } from '../stores/board.store';
|
||||||
|
|
||||||
@@ -17,6 +22,17 @@ export class BoardFacade {
|
|||||||
readonly loading = this.store.loading;
|
readonly loading = this.store.loading;
|
||||||
readonly creating = this.store.creating;
|
readonly creating = this.store.creating;
|
||||||
readonly error = this.store.error;
|
readonly error = this.store.error;
|
||||||
|
/** Saved views of this board: mine, then the shared ones. */
|
||||||
|
readonly views = this.store.views;
|
||||||
|
/** The saved view that shows exactly what the board shows now, if any. */
|
||||||
|
readonly activeView = computed(() => {
|
||||||
|
const filter = this.store.filter();
|
||||||
|
if (!filter) return null;
|
||||||
|
const criteria = criteriaOf(filter);
|
||||||
|
return (
|
||||||
|
this.store.views().find((v) => sameCriteria(v.criteria, criteria)) ?? null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/** Shows the board with this filter: opens it for a new project, refilters otherwise. */
|
/** Shows the board with this filter: opens it for a new project, refilters otherwise. */
|
||||||
show(filter: BoardFilter): void {
|
show(filter: BoardFilter): void {
|
||||||
@@ -33,6 +49,16 @@ export class BoardFacade {
|
|||||||
createIssue(input: CreateIssueInput): void {
|
createIssue(input: CreateIssueInput): void {
|
||||||
this.dispatch.issueCreateRequested(input);
|
this.dispatch.issueCreateRequested(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Saves the current filter as a view: private, or shared with the project. */
|
||||||
|
saveView(name: string, shared: boolean): void {
|
||||||
|
if (name.trim())
|
||||||
|
this.dispatch.viewSaveRequested({ name: name.trim(), shared });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteView(id: string): void {
|
||||||
|
this.dispatch.viewDeleteRequested({ id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Store + facade of the board, for the board route's component. */
|
/** Store + facade of the board, for the board route's component. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
|
SavedFiltersGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -69,6 +70,11 @@ function gateways() {
|
|||||||
download: vi.fn(() => of(new Blob(['x']))),
|
download: vi.fn(() => of(new Blob(['x']))),
|
||||||
remove: vi.fn(() => of(ok)),
|
remove: vi.fn(() => of(ok)),
|
||||||
},
|
},
|
||||||
|
views: {
|
||||||
|
list: vi.fn(() => of([])),
|
||||||
|
save: vi.fn(() => of('v1')),
|
||||||
|
remove: vi.fn(() => of(undefined)),
|
||||||
|
},
|
||||||
realtime: {
|
realtime: {
|
||||||
issueChanges: () => NEVER,
|
issueChanges: () => NEVER,
|
||||||
commentChanges: () => NEVER,
|
commentChanges: () => NEVER,
|
||||||
@@ -106,6 +112,7 @@ function setup() {
|
|||||||
{ provide: AttachmentsGateway, useValue: fakes.attachments },
|
{ provide: AttachmentsGateway, useValue: fakes.attachments },
|
||||||
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
||||||
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
||||||
|
{ provide: SavedFiltersGateway, useValue: fakes.views },
|
||||||
{
|
{
|
||||||
provide: Notifications,
|
provide: Notifications,
|
||||||
useValue: { success: vi.fn(), error: vi.fn() },
|
useValue: { success: vi.fn(), error: vi.fn() },
|
||||||
@@ -141,6 +148,35 @@ describe('BoardFacade', () => {
|
|||||||
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
|
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
|
||||||
expect(board.filter()).toEqual({ projectId: 'p2' });
|
expect(board.filter()).toEqual({ projectId: 'p2' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves the current filter as a view and knows when it is showing one', () => {
|
||||||
|
const { board, fakes } = setup();
|
||||||
|
const view = {
|
||||||
|
id: 'v1',
|
||||||
|
projectId: 'p1',
|
||||||
|
name: 'Bugs',
|
||||||
|
shared: true,
|
||||||
|
owner: { id: 'me', name: 'Me' },
|
||||||
|
createdAt: '',
|
||||||
|
criteria: { types: ['BUG'] },
|
||||||
|
};
|
||||||
|
fakes.views.list.mockReturnValue(of([view]) as never);
|
||||||
|
|
||||||
|
board.show({ projectId: 'p1', types: ['BUG'], search: ' ' });
|
||||||
|
board.saveView(' Bugs ', true);
|
||||||
|
|
||||||
|
expect(fakes.views.save).toHaveBeenCalledWith({
|
||||||
|
projectId: 'p1',
|
||||||
|
name: 'Bugs',
|
||||||
|
shared: true,
|
||||||
|
criteria: { types: ['BUG'] },
|
||||||
|
});
|
||||||
|
expect(board.activeView()?.id).toBe('v1');
|
||||||
|
board.show({ projectId: 'p1' });
|
||||||
|
expect(board.activeView()).toBeNull();
|
||||||
|
board.deleteView('v1');
|
||||||
|
expect(fakes.views.remove).toHaveBeenCalledWith('v1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ProjectFacade', () => {
|
describe('ProjectFacade', () => {
|
||||||
|
|||||||
@@ -22,10 +22,29 @@ export function matchesFilter(
|
|||||||
filter: BoardFilter | null,
|
filter: BoardFilter | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!filter) return true;
|
if (!filter) return true;
|
||||||
if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
|
if (filter.unassigned) {
|
||||||
|
if (issue.assignee) return false;
|
||||||
|
} else if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
|
||||||
return false;
|
return false;
|
||||||
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
|
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
|
||||||
if (filter.area && issue.area !== filter.area) return false;
|
if (filter.area && issue.area !== filter.area) return false;
|
||||||
|
if (filter.types?.length && !filter.types.includes(issue.type)) return false;
|
||||||
|
if (filter.priorities?.length && !filter.priorities.includes(issue.priority))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
filter.labels?.length &&
|
||||||
|
!filter.labels.every((l) => issue.labels.includes(l.toLowerCase()))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
// Cards carry no description: a change that matched only by it drops off
|
||||||
|
// until the next load, which is the safe side.
|
||||||
|
const text = filter.search?.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
!issue.key.toLowerCase().includes(text) &&
|
||||||
|
!issue.title.toLowerCase().includes(text)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
BoardColumn,
|
BoardColumn,
|
||||||
BoardFilter,
|
BoardFilter,
|
||||||
|
criteriaOf,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
|
SavedFilter,
|
||||||
|
SavedFiltersGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
|
EMPTY,
|
||||||
exhaustMap,
|
exhaustMap,
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
@@ -36,6 +40,8 @@ interface BoardState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** Saved views of this board: mine, then the shared ones. */
|
||||||
|
views: SavedFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kanban board of a project, live. Scoped to the board route. */
|
/** Kanban board of a project, live. Scoped to the board route. */
|
||||||
@@ -46,6 +52,7 @@ export const BoardStore = signalStore(
|
|||||||
loading: false,
|
loading: false,
|
||||||
creating: false,
|
creating: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
views: [],
|
||||||
}),
|
}),
|
||||||
withComputed(({ columns }) => ({
|
withComputed(({ columns }) => ({
|
||||||
total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)),
|
total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)),
|
||||||
@@ -70,6 +77,7 @@ export const BoardStore = signalStore(
|
|||||||
on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({
|
on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({
|
||||||
columns: applyIssueChange(state.columns, payload.issue, state.filter),
|
columns: applyIssueChange(state.columns, payload.issue, state.filter),
|
||||||
})),
|
})),
|
||||||
|
on(boardApiEvents.viewsLoaded, ({ payload }) => ({ views: payload })),
|
||||||
on(boardPageEvents.issueCreateRequested, () => ({ creating: true })),
|
on(boardPageEvents.issueCreateRequested, () => ({ creating: true })),
|
||||||
on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({
|
on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({
|
||||||
creating: false,
|
creating: false,
|
||||||
@@ -82,7 +90,16 @@ export const BoardStore = signalStore(
|
|||||||
issues = inject(IssuesGateway),
|
issues = inject(IssuesGateway),
|
||||||
realtime = inject(TrackerRealtimeGateway),
|
realtime = inject(TrackerRealtimeGateway),
|
||||||
notifications = inject(Notifications),
|
notifications = inject(Notifications),
|
||||||
|
savedFilters = inject(SavedFiltersGateway),
|
||||||
) => {
|
) => {
|
||||||
|
const loadViews = (projectId: string) =>
|
||||||
|
savedFilters.list(projectId).pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: (views) => boardApiEvents.viewsLoaded(views),
|
||||||
|
// Views are a convenience: without them the board still works.
|
||||||
|
error: () => boardApiEvents.viewsLoaded([]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const load = (filter: BoardFilter) =>
|
const load = (filter: BoardFilter) =>
|
||||||
issues.board(filter).pipe(
|
issues.board(filter).pipe(
|
||||||
mapResponse({
|
mapResponse({
|
||||||
@@ -101,6 +118,50 @@ export const BoardStore = signalStore(
|
|||||||
switchMap((projectId) => realtime.issueChanges(projectId)),
|
switchMap((projectId) => realtime.issueChanges(projectId)),
|
||||||
map((change) => boardRealtimeEvents.issueChanged(change)),
|
map((change) => boardRealtimeEvents.issueChanged(change)),
|
||||||
),
|
),
|
||||||
|
views$: merge(
|
||||||
|
events.on(boardPageEvents.opened).pipe(
|
||||||
|
map(({ payload }) => payload.projectId),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(boardApiEvents.viewSaved, boardApiEvents.viewDeleted)
|
||||||
|
.pipe(map(() => store.filter()?.projectId ?? null)),
|
||||||
|
).pipe(
|
||||||
|
switchMap((projectId) => (projectId ? loadViews(projectId) : EMPTY)),
|
||||||
|
),
|
||||||
|
saveView$: events.on(boardPageEvents.viewSaveRequested).pipe(
|
||||||
|
exhaustMap(({ payload }) => {
|
||||||
|
const filter = store.filter();
|
||||||
|
if (!filter) return EMPTY;
|
||||||
|
return savedFilters
|
||||||
|
.save({
|
||||||
|
projectId: filter.projectId,
|
||||||
|
name: payload.name,
|
||||||
|
shared: payload.shared,
|
||||||
|
criteria: criteriaOf(filter),
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: () => boardApiEvents.viewSaved(payload.name),
|
||||||
|
error: (error) =>
|
||||||
|
boardApiEvents.viewFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
deleteView$: events.on(boardPageEvents.viewDeleteRequested).pipe(
|
||||||
|
mergeMap(({ payload }) => {
|
||||||
|
const name =
|
||||||
|
store.views().find((v) => v.id === payload.id)?.name ?? '';
|
||||||
|
return savedFilters.remove(payload.id).pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: () => boardApiEvents.viewDeleted(name),
|
||||||
|
error: (error) =>
|
||||||
|
boardApiEvents.viewFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
move$: events.on(boardPageEvents.issueMoved).pipe(
|
move$: events.on(boardPageEvents.issueMoved).pipe(
|
||||||
mergeMap(({ payload }) =>
|
mergeMap(({ payload }) =>
|
||||||
issues
|
issues
|
||||||
@@ -146,7 +207,25 @@ export const BoardStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
events
|
events
|
||||||
.on(boardApiEvents.issueCreateFailed, boardApiEvents.loadFailed)
|
.on(boardApiEvents.viewSaved)
|
||||||
|
.pipe(
|
||||||
|
tap(({ payload }) =>
|
||||||
|
notifications.success(`Vista «${payload}» guardada`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(boardApiEvents.viewDeleted)
|
||||||
|
.pipe(
|
||||||
|
tap(({ payload }) =>
|
||||||
|
notifications.success(`Vista «${payload}» eliminada`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(
|
||||||
|
boardApiEvents.issueCreateFailed,
|
||||||
|
boardApiEvents.loadFailed,
|
||||||
|
boardApiEvents.viewFailed,
|
||||||
|
)
|
||||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export * from './lib/models/workflow';
|
|||||||
export * from './lib/ports/gateways';
|
export * from './lib/ports/gateways';
|
||||||
export * from './lib/models/mentions';
|
export * from './lib/models/mentions';
|
||||||
export * from './lib/models/hierarchy';
|
export * from './lib/models/hierarchy';
|
||||||
|
export * from './lib/models/views';
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ export interface MyIssuesFilter {
|
|||||||
export interface BoardFilter {
|
export interface BoardFilter {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
/** Only tickets nobody has (wins over `assigneeId`). */
|
||||||
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
area?: string;
|
area?: string;
|
||||||
|
/** Ticket type ids. */
|
||||||
|
types?: string[];
|
||||||
|
priorities?: IssuePriority[];
|
||||||
|
labels?: string[];
|
||||||
|
/** Matches key, title or description. */
|
||||||
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ export interface Epic {
|
|||||||
progress: ChildProgress;
|
progress: ChildProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a saved view narrows the board to; empty: everything. */
|
||||||
|
export interface SavedFilterCriteria {
|
||||||
|
types?: string[] | null;
|
||||||
|
areas?: string[] | null;
|
||||||
|
priorities?: IssuePriority[] | null;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
unassigned?: boolean | null;
|
||||||
|
releaseId?: string | null;
|
||||||
|
labels?: string[] | null;
|
||||||
|
search?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A named view of the board: private, or shared with the project. */
|
||||||
|
export interface SavedFilter {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
owner: UserRef;
|
||||||
|
name: string;
|
||||||
|
shared: boolean;
|
||||||
|
criteria: SavedFilterCriteria;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** What boards and lists show. */
|
/** What boards and lists show. */
|
||||||
export interface IssueSummary {
|
export interface IssueSummary {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
43
libs/web/tracker/domain/src/lib/models/views.spec.ts
Normal file
43
libs/web/tracker/domain/src/lib/models/views.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { IssuePriority } from './enums';
|
||||||
|
import { boardFilterOf, criteriaOf, sameCriteria } from './views';
|
||||||
|
|
||||||
|
describe('saved views', () => {
|
||||||
|
it('turns a board filter into criteria and back', () => {
|
||||||
|
const filter = {
|
||||||
|
projectId: 'p1',
|
||||||
|
assigneeId: 'u1',
|
||||||
|
area: 'FRONTEND',
|
||||||
|
types: ['BUG'],
|
||||||
|
priorities: [IssuePriority.HIGH],
|
||||||
|
search: ' login ',
|
||||||
|
};
|
||||||
|
const criteria = criteriaOf(filter);
|
||||||
|
expect(criteria).toEqual({
|
||||||
|
assigneeId: 'u1',
|
||||||
|
areas: ['FRONTEND'],
|
||||||
|
types: ['BUG'],
|
||||||
|
priorities: [IssuePriority.HIGH],
|
||||||
|
search: 'login',
|
||||||
|
});
|
||||||
|
expect(boardFilterOf('p1', criteria)).toEqual({
|
||||||
|
...filter,
|
||||||
|
search: 'login',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers "nobody" over a person', () => {
|
||||||
|
expect(
|
||||||
|
criteriaOf({ projectId: 'p', unassigned: true, assigneeId: 'u' }),
|
||||||
|
).toEqual({ unassigned: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares criteria regardless of list order and empty values', () => {
|
||||||
|
expect(
|
||||||
|
sameCriteria(
|
||||||
|
{ types: ['BUG', 'QA'], labels: [], search: null },
|
||||||
|
{ types: ['QA', 'BUG'] },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(sameCriteria({ types: ['BUG'] }, {})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
libs/web/tracker/domain/src/lib/models/views.ts
Normal file
57
libs/web/tracker/domain/src/lib/models/views.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { BoardFilter } from './inputs';
|
||||||
|
import { SavedFilterCriteria } from './models';
|
||||||
|
|
||||||
|
const has = <T>(values?: T[] | null): values is T[] => !!values?.length;
|
||||||
|
|
||||||
|
/** The board filter as saved criteria (no project, nothing empty). */
|
||||||
|
export function criteriaOf(filter: BoardFilter): SavedFilterCriteria {
|
||||||
|
const criteria: SavedFilterCriteria = {};
|
||||||
|
if (filter.unassigned) criteria.unassigned = true;
|
||||||
|
else if (filter.assigneeId) criteria.assigneeId = filter.assigneeId;
|
||||||
|
if (filter.releaseId) criteria.releaseId = filter.releaseId;
|
||||||
|
if (filter.area) criteria.areas = [filter.area];
|
||||||
|
if (has(filter.types)) criteria.types = [...filter.types];
|
||||||
|
if (has(filter.priorities)) criteria.priorities = [...filter.priorities];
|
||||||
|
if (has(filter.labels)) criteria.labels = [...filter.labels];
|
||||||
|
if (filter.search?.trim()) criteria.search = filter.search.trim();
|
||||||
|
return criteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved criteria as the board filter of a project. */
|
||||||
|
export function boardFilterOf(
|
||||||
|
projectId: string,
|
||||||
|
criteria: SavedFilterCriteria,
|
||||||
|
): BoardFilter {
|
||||||
|
const filter: BoardFilter = { projectId };
|
||||||
|
if (criteria.unassigned) filter.unassigned = true;
|
||||||
|
else if (criteria.assigneeId) filter.assigneeId = criteria.assigneeId;
|
||||||
|
if (criteria.releaseId) filter.releaseId = criteria.releaseId;
|
||||||
|
if (has(criteria.areas)) filter.area = criteria.areas[0];
|
||||||
|
if (has(criteria.types)) filter.types = [...criteria.types];
|
||||||
|
if (has(criteria.priorities)) filter.priorities = [...criteria.priorities];
|
||||||
|
if (has(criteria.labels)) filter.labels = [...criteria.labels];
|
||||||
|
if (criteria.search?.trim()) filter.search = criteria.search.trim();
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two filters show the same tickets (order of lists aside). */
|
||||||
|
export function sameCriteria(
|
||||||
|
a: SavedFilterCriteria,
|
||||||
|
b: SavedFilterCriteria,
|
||||||
|
): boolean {
|
||||||
|
const norm = (c: SavedFilterCriteria) =>
|
||||||
|
JSON.stringify(
|
||||||
|
Object.entries(c)
|
||||||
|
.filter(
|
||||||
|
([, v]) =>
|
||||||
|
v !== null &&
|
||||||
|
v !== undefined &&
|
||||||
|
v !== '' &&
|
||||||
|
v !== false &&
|
||||||
|
!(Array.isArray(v) && !v.length),
|
||||||
|
)
|
||||||
|
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
|
||||||
|
.sort(([x], [y]) => String(x).localeCompare(String(y))),
|
||||||
|
);
|
||||||
|
return norm(a) === norm(b);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
BoardColumn,
|
BoardColumn,
|
||||||
Comment,
|
Comment,
|
||||||
Epic,
|
Epic,
|
||||||
|
SavedFilter,
|
||||||
|
SavedFilterCriteria,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
Issue,
|
Issue,
|
||||||
IssuePage,
|
IssuePage,
|
||||||
@@ -124,6 +126,18 @@ export abstract class IssuesGateway {
|
|||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Saved views of a project's board (mine and the shared ones). */
|
||||||
|
export abstract class SavedFiltersGateway {
|
||||||
|
abstract list(projectId: string): Observable<SavedFilter[]>;
|
||||||
|
abstract save(input: {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
shared: boolean;
|
||||||
|
criteria: SavedFilterCriteria;
|
||||||
|
}): Observable<string>;
|
||||||
|
abstract remove(id: string): Observable<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class CommentsGateway {
|
export abstract class CommentsGateway {
|
||||||
abstract list(issueId: string): Observable<Comment[]>;
|
abstract list(issueId: string): Observable<Comment[]>;
|
||||||
abstract add(issueId: string, body: string): Observable<CommandResult>;
|
abstract add(issueId: string, body: string): Observable<CommandResult>;
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
releaseId: filter.releaseId ?? null,
|
releaseId: filter.releaseId ?? null,
|
||||||
assigneeId: filter.assigneeId ?? null,
|
assigneeId: filter.assigneeId ?? null,
|
||||||
area: filter.area ?? null,
|
area: filter.area ?? null,
|
||||||
|
types: filter.types?.length ? filter.types : null,
|
||||||
|
priorities: filter.priorities?.length
|
||||||
|
? (filter.priorities as never)
|
||||||
|
: null,
|
||||||
|
labels: filter.labels?.length ? filter.labels : null,
|
||||||
|
search: filter.search?.trim() || null,
|
||||||
|
unassigned: filter.unassigned ?? null,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
map((d) =>
|
map((d) =>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
SavedFilter,
|
||||||
|
SavedFilterCriteria,
|
||||||
|
SavedFiltersGateway,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
DeleteSavedFilterDocument,
|
||||||
|
SavedFiltersDocument,
|
||||||
|
SaveFilterDocument,
|
||||||
|
} from '../graphql/generated';
|
||||||
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
|
||||||
|
private readonly gql = inject(GraphqlOperations);
|
||||||
|
|
||||||
|
list(projectId: string): Observable<SavedFilter[]> {
|
||||||
|
return this.gql
|
||||||
|
.query(SavedFiltersDocument, { projectId })
|
||||||
|
.pipe(map((d) => d.savedFilters as SavedFilter[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(input: {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
shared: boolean;
|
||||||
|
criteria: SavedFilterCriteria;
|
||||||
|
}): Observable<string> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(SaveFilterDocument, { input: input as never })
|
||||||
|
.pipe(map((d) => d.saveFilter));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(DeleteSavedFilterDocument, { id })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,25 @@ export type RankIssueInput = {
|
|||||||
|
|
||||||
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
|
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
|
||||||
|
|
||||||
|
export type SaveFilterInput = {
|
||||||
|
criteria: SavedFilterCriteriaInput;
|
||||||
|
name: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Visible to the whole project (members, not viewers, share) */
|
||||||
|
shared?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedFilterCriteriaInput = {
|
||||||
|
areas?: Array<string> | null | undefined;
|
||||||
|
assigneeId?: string | null | undefined;
|
||||||
|
labels?: Array<string> | null | undefined;
|
||||||
|
priorities?: Array<IssuePriority> | null | undefined;
|
||||||
|
releaseId?: string | null | undefined;
|
||||||
|
search?: string | null | undefined;
|
||||||
|
types?: Array<string> | null | undefined;
|
||||||
|
unassigned?: boolean | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */
|
/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */
|
||||||
export type StatusCategory =
|
export type StatusCategory =
|
||||||
| 'CANCELLED'
|
| 'CANCELLED'
|
||||||
@@ -335,6 +354,11 @@ export type BoardQueryVariables = Exact<{
|
|||||||
releaseId?: string | null | undefined;
|
releaseId?: string | null | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
area?: string | null | undefined;
|
area?: string | null | undefined;
|
||||||
|
types?: Array<string> | string | null | undefined;
|
||||||
|
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
|
||||||
|
labels?: Array<string> | string | null | undefined;
|
||||||
|
search?: string | null | undefined;
|
||||||
|
unassigned?: boolean | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type BoardQuery = {
|
export type BoardQuery = {
|
||||||
@@ -1138,6 +1162,62 @@ export type ReleaseBurnupQuery = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SavedFilterFieldsFragment = {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
shared: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
owner: { id: string; name: string };
|
||||||
|
criteria: {
|
||||||
|
types: Array<string> | null;
|
||||||
|
areas: Array<string> | null;
|
||||||
|
priorities: Array<IssuePriority> | null;
|
||||||
|
assigneeId: string | null;
|
||||||
|
unassigned: boolean | null;
|
||||||
|
releaseId: string | null;
|
||||||
|
labels: Array<string> | null;
|
||||||
|
search: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedFiltersQueryVariables = Exact<{
|
||||||
|
projectId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SavedFiltersQuery = {
|
||||||
|
savedFilters: Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
shared: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
owner: { id: string; name: string };
|
||||||
|
criteria: {
|
||||||
|
types: Array<string> | null;
|
||||||
|
areas: Array<string> | null;
|
||||||
|
priorities: Array<IssuePriority> | null;
|
||||||
|
assigneeId: string | null;
|
||||||
|
unassigned: boolean | null;
|
||||||
|
releaseId: string | null;
|
||||||
|
labels: Array<string> | null;
|
||||||
|
search: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveFilterMutationVariables = Exact<{
|
||||||
|
input: SaveFilterInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SaveFilterMutation = { saveFilter: string };
|
||||||
|
|
||||||
|
export type DeleteSavedFilterMutationVariables = Exact<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
|
||||||
|
|
||||||
export type IssueChangedSubscriptionVariables = Exact<{
|
export type IssueChangedSubscriptionVariables = Exact<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -1949,6 +2029,74 @@ export const NotificationFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
|
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
|
||||||
|
export const SavedFilterFieldsFragmentDoc = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'SavedFilterFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'SavedFilter' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'shared' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'owner' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'criteria' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'unassigned' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'search' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
|
||||||
export const BoardDocument = {
|
export const BoardDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -1992,6 +2140,73 @@ export const BoardDocument = {
|
|||||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } },
|
variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } },
|
||||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'types' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'ListType',
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'priorities' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'ListType',
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'IssuePriority' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'labels' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'ListType',
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'search' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'unassigned' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -2041,6 +2256,46 @@ export const BoardDocument = {
|
|||||||
name: { kind: 'Name', value: 'area' },
|
name: { kind: 'Name', value: 'area' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'types' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'types' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'priorities' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'priorities' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'labels' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'labels' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'search' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'search' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'unassigned' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'unassigned' },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
kind: 'Argument',
|
kind: 'Argument',
|
||||||
name: { kind: 'Name', value: 'perColumn' },
|
name: { kind: 'Name', value: 'perColumn' },
|
||||||
@@ -7168,6 +7423,207 @@ export const ReleaseBurnupDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>;
|
} as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>;
|
||||||
|
export const SavedFiltersDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'SavedFilters' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'savedFilters' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'SavedFilterFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'SavedFilterFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'SavedFilter' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'shared' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'owner' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'criteria' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'unassigned' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'search' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<SavedFiltersQuery, SavedFiltersQueryVariables>;
|
||||||
|
export const SaveFilterDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'SaveFilter' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'SaveFilterInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'saveFilter' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<SaveFilterMutation, SaveFilterMutationVariables>;
|
||||||
|
export const DeleteSavedFilterDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'DeleteSavedFilter' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'deleteSavedFilter' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'id' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'id' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
DeleteSavedFilterMutation,
|
||||||
|
DeleteSavedFilterMutationVariables
|
||||||
|
>;
|
||||||
export const IssueChangedDocument = {
|
export const IssueChangedDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
query Board($idOrKey: String!, $releaseId: ID, $assigneeId: ID, $area: String) {
|
query Board(
|
||||||
|
$idOrKey: String!
|
||||||
|
$releaseId: ID
|
||||||
|
$assigneeId: ID
|
||||||
|
$area: String
|
||||||
|
$types: [String!]
|
||||||
|
$priorities: [IssuePriority!]
|
||||||
|
$labels: [String!]
|
||||||
|
$search: String
|
||||||
|
$unassigned: Boolean
|
||||||
|
) {
|
||||||
project(idOrKey: $idOrKey) {
|
project(idOrKey: $idOrKey) {
|
||||||
id
|
id
|
||||||
board(
|
board(
|
||||||
releaseId: $releaseId
|
releaseId: $releaseId
|
||||||
assigneeId: $assigneeId
|
assigneeId: $assigneeId
|
||||||
area: $area
|
area: $area
|
||||||
|
types: $types
|
||||||
|
priorities: $priorities
|
||||||
|
labels: $labels
|
||||||
|
search: $search
|
||||||
|
unassigned: $unassigned
|
||||||
perColumn: 100
|
perColumn: 100
|
||||||
) {
|
) {
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
fragment SavedFilterFields on SavedFilter {
|
||||||
|
id
|
||||||
|
projectId
|
||||||
|
name
|
||||||
|
shared
|
||||||
|
createdAt
|
||||||
|
owner {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
criteria {
|
||||||
|
types
|
||||||
|
areas
|
||||||
|
priorities
|
||||||
|
assigneeId
|
||||||
|
unassigned
|
||||||
|
releaseId
|
||||||
|
labels
|
||||||
|
search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query SavedFilters($projectId: ID!) {
|
||||||
|
savedFilters(projectId: $projectId) {
|
||||||
|
...SavedFilterFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation SaveFilter($input: SaveFilterInput!) {
|
||||||
|
saveFilter(input: $input)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation DeleteSavedFilter($id: ID!) {
|
||||||
|
deleteSavedFilter(id: $id)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
NotificationsGateway,
|
NotificationsGateway,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
|
SavedFiltersGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -24,6 +25,7 @@ import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.ga
|
|||||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||||
|
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
||||||
|
|
||||||
/** Ports → GraphQL adapters (and REST for files). */
|
/** Ports → GraphQL adapters (and REST for files). */
|
||||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||||
@@ -37,5 +39,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||||
|
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-views {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ts-filter {
|
.ts-filter {
|
||||||
width: 13rem;
|
width: 13rem;
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ import {
|
|||||||
PiSegmentedControlComponent,
|
PiSegmentedControlComponent,
|
||||||
PiSelectFieldComponent,
|
PiSelectFieldComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
|
PiSlideToggleComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
type PiSelectFieldOption,
|
type PiSelectFieldOption,
|
||||||
type SegmentedControlOption,
|
type SegmentedControlOption,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import {
|
import {
|
||||||
BoardFacade,
|
BoardFacade,
|
||||||
@@ -36,14 +38,19 @@ import {
|
|||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
BoardColumn,
|
BoardColumn,
|
||||||
|
BoardFilter,
|
||||||
|
boardFilterOf,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
SavedFilter,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { IssueCardComponent } from '../ui/issue-card';
|
import { IssueCardComponent } from '../ui/issue-card';
|
||||||
import { options, PRIORITY, statusLook } from '../ui/copy';
|
import { options, PRIORITY, statusLook } from '../ui/copy';
|
||||||
|
|
||||||
const ALL = '';
|
const ALL = '';
|
||||||
|
/** "Sin asignar" in the person filter. */
|
||||||
|
const NOBODY = '__nobody__';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-board-page',
|
selector: 'ts-board-page',
|
||||||
@@ -58,6 +65,7 @@ const ALL = '';
|
|||||||
PiSelectFieldComponent,
|
PiSelectFieldComponent,
|
||||||
PiSegmentedControlComponent,
|
PiSegmentedControlComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
|
PiSlideToggleComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
@@ -65,13 +73,70 @@ const ALL = '';
|
|||||||
providers: [provideBoardFacade()],
|
providers: [provideBoardFacade()],
|
||||||
styleUrl: './board.page.scss',
|
styleUrl: './board.page.scss',
|
||||||
template: `
|
template: `
|
||||||
|
@if (project.myRole()) {
|
||||||
|
<div class="ts-views" role="group" aria-label="Vistas guardadas">
|
||||||
|
<pi-select-field
|
||||||
|
class="ts-filter"
|
||||||
|
placeholder="Vistas guardadas"
|
||||||
|
[options]="viewOptions()"
|
||||||
|
[value]="board.activeView()?.id ?? ''"
|
||||||
|
(valueChange)="applyView($event)"
|
||||||
|
/>
|
||||||
|
@if (!board.activeView() && isFiltered()) {
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
text="Guardar vista"
|
||||||
|
(execute)="openSaveView()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (board.activeView(); as view) {
|
||||||
|
@if (canDeleteView(view)) {
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
[text]="'Borrar «' + view.name + '»'"
|
||||||
|
(execute)="board.deleteView(view.id)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (isFiltered()) {
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
text="Quitar filtros"
|
||||||
|
(execute)="clearFilters()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="ts-board-toolbar">
|
<div class="ts-board-toolbar">
|
||||||
|
<pi-text-field
|
||||||
|
class="ts-filter"
|
||||||
|
placeholder="Buscar en el tablero…"
|
||||||
|
[(value)]="searchInput"
|
||||||
|
/>
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
class="ts-filter"
|
class="ts-filter"
|
||||||
placeholder="Persona"
|
placeholder="Persona"
|
||||||
[options]="assigneeOptions()"
|
[options]="assigneeFilterOptions()"
|
||||||
[(value)]="assigneeFilter"
|
[(value)]="assigneeFilter"
|
||||||
/>
|
/>
|
||||||
|
<pi-select-field
|
||||||
|
class="ts-filter"
|
||||||
|
placeholder="Tipo"
|
||||||
|
[options]="typeFilterOptions()"
|
||||||
|
[(value)]="typeFilter"
|
||||||
|
/>
|
||||||
|
<pi-select-field
|
||||||
|
class="ts-filter"
|
||||||
|
placeholder="Prioridad"
|
||||||
|
[options]="priorityFilterOptions"
|
||||||
|
[(value)]="priorityFilter"
|
||||||
|
/>
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
class="ts-filter"
|
class="ts-filter"
|
||||||
placeholder="Release"
|
placeholder="Release"
|
||||||
@@ -208,6 +273,37 @@ const ALL = '';
|
|||||||
</form>
|
</form>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #viewForm>
|
||||||
|
<form
|
||||||
|
class="ts-form"
|
||||||
|
(submit)="$event.preventDefault(); saveView()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<p class="ts-muted">
|
||||||
|
Guarda los filtros de ahora para volver a ellos con un clic.
|
||||||
|
</p>
|
||||||
|
<pi-text-field
|
||||||
|
placeholder="Bugs críticos de frontend"
|
||||||
|
[(value)]="viewName"
|
||||||
|
>
|
||||||
|
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||||
|
</pi-text-field>
|
||||||
|
@if (project.canContribute()) {
|
||||||
|
<pi-slide-toggle
|
||||||
|
label="Compartir con todo el proyecto"
|
||||||
|
[(checked)]="viewShared"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<pi-action-button
|
||||||
|
size="w100"
|
||||||
|
color="primary"
|
||||||
|
text="Guardar vista"
|
||||||
|
[disabled]="!viewName().trim()"
|
||||||
|
(execute)="saveView()"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #reasonForm>
|
<ng-template #reasonForm>
|
||||||
<form
|
<form
|
||||||
class="ts-form"
|
class="ts-form"
|
||||||
@@ -243,15 +339,72 @@ export class BoardPage {
|
|||||||
protected readonly board = inject(BoardFacade);
|
protected readonly board = inject(BoardFacade);
|
||||||
protected readonly project = inject(ProjectFacade);
|
protected readonly project = inject(ProjectFacade);
|
||||||
private readonly drawer = inject(DrawerService);
|
private readonly drawer = inject(DrawerService);
|
||||||
|
private readonly auth = inject(AuthFacade);
|
||||||
|
private readonly me = computed(() => this.auth.user()?.id ?? null);
|
||||||
private readonly createForm =
|
private readonly createForm =
|
||||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||||
private readonly reasonForm =
|
private readonly reasonForm =
|
||||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||||
|
private readonly viewForm =
|
||||||
|
viewChild.required<TemplateRef<unknown>>('viewForm');
|
||||||
|
|
||||||
/* Filters */
|
/* Filters */
|
||||||
protected readonly assigneeFilter = signal<string | null>(ALL);
|
protected readonly assigneeFilter = signal<string | null>(ALL);
|
||||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||||
protected readonly area = signal<string | null>(ALL);
|
protected readonly area = signal<string | null>(ALL);
|
||||||
|
protected readonly typeFilter = signal<string | null>(ALL);
|
||||||
|
protected readonly priorityFilter = signal<string | null>(ALL);
|
||||||
|
/** What is typed; `search` follows it once typing pauses. */
|
||||||
|
protected readonly searchInput = signal('');
|
||||||
|
private readonly search = signal('');
|
||||||
|
/** Labels only come from a saved view (no picker on the toolbar). */
|
||||||
|
private readonly labels = signal<string[]>([]);
|
||||||
|
protected readonly isFiltered = computed(
|
||||||
|
() =>
|
||||||
|
!!(
|
||||||
|
this.assigneeFilter() ||
|
||||||
|
this.releaseFilter() ||
|
||||||
|
this.area() ||
|
||||||
|
this.typeFilter() ||
|
||||||
|
this.priorityFilter() ||
|
||||||
|
this.search() ||
|
||||||
|
this.labels().length
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Saved views */
|
||||||
|
protected readonly viewName = signal('');
|
||||||
|
protected readonly viewShared = signal(false);
|
||||||
|
protected readonly viewOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
|
() => [
|
||||||
|
{ value: ALL, label: 'Sin vista guardada' },
|
||||||
|
...this.board.views().map((v) => ({
|
||||||
|
value: v.id,
|
||||||
|
label: v.shared ? `${v.name} · compartida` : v.name,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
protected readonly assigneeFilterOptions = computed<
|
||||||
|
PiSelectFieldOption<string>[]
|
||||||
|
>(() => [
|
||||||
|
{ value: ALL, label: 'Todas las personas' },
|
||||||
|
{ value: NOBODY, label: 'Sin asignar' },
|
||||||
|
...this.project
|
||||||
|
.members()
|
||||||
|
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||||
|
]);
|
||||||
|
protected readonly typeFilterOptions = computed<
|
||||||
|
PiSelectFieldOption<string>[]
|
||||||
|
>(() => [
|
||||||
|
{ value: ALL, label: 'Todos los tipos' },
|
||||||
|
...this.project
|
||||||
|
.workflow()
|
||||||
|
.settings.issueTypes.map((t) => ({ value: t.id, label: t.name })),
|
||||||
|
]);
|
||||||
|
protected readonly priorityFilterOptions: PiSelectFieldOption<string>[] = [
|
||||||
|
{ value: ALL, label: 'Todas las prioridades' },
|
||||||
|
...options(PRIORITY),
|
||||||
|
];
|
||||||
|
|
||||||
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -332,16 +485,36 @@ export class BoardPage {
|
|||||||
const assignee = this.assignee();
|
const assignee = this.assignee();
|
||||||
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
|
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
|
||||||
});
|
});
|
||||||
|
effect((onCleanup) => {
|
||||||
|
const text = this.searchInput().trim();
|
||||||
|
const timer = setTimeout(() => this.search.set(text), 300);
|
||||||
|
onCleanup(() => clearTimeout(timer));
|
||||||
|
});
|
||||||
// (Re)load whenever the project or a filter changes.
|
// (Re)load whenever the project or a filter changes.
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const projectId = this.project.project()?.id;
|
const projectId = this.project.project()?.id;
|
||||||
const filter = {
|
const assignee = this.assigneeFilter();
|
||||||
assigneeId: this.assigneeFilter() || undefined,
|
const type = this.typeFilter();
|
||||||
|
const priority = this.priorityFilter() as IssuePriority | null;
|
||||||
|
const filter: Omit<BoardFilter, 'projectId'> = {
|
||||||
|
unassigned: assignee === NOBODY || undefined,
|
||||||
|
assigneeId: (assignee !== NOBODY && assignee) || undefined,
|
||||||
releaseId: this.releaseFilter() || undefined,
|
releaseId: this.releaseFilter() || undefined,
|
||||||
area: this.area() || undefined,
|
area: this.area() || undefined,
|
||||||
|
types: type ? [type] : undefined,
|
||||||
|
priorities: priority ? [priority] : undefined,
|
||||||
|
labels: this.labels().length ? this.labels() : undefined,
|
||||||
|
search: this.search() || undefined,
|
||||||
};
|
};
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
untracked(() => this.board.show({ projectId, ...filter }));
|
untracked(() =>
|
||||||
|
this.board.show({
|
||||||
|
projectId,
|
||||||
|
...(Object.fromEntries(
|
||||||
|
Object.entries(filter).filter(([, v]) => v !== undefined),
|
||||||
|
) as Omit<BoardFilter, 'projectId'>),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +556,62 @@ export class BoardPage {
|
|||||||
this.drawer.closeAll();
|
this.drawer.closeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shows what a saved view shows (the toolbar picks the first of each list). */
|
||||||
|
protected applyView(id: string | null): void {
|
||||||
|
const view = this.board.views().find((v) => v.id === id);
|
||||||
|
if (!view) {
|
||||||
|
if (!id) this.clearFilters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projectId = this.project.project()?.id ?? '';
|
||||||
|
const filter = boardFilterOf(projectId, view.criteria);
|
||||||
|
this.assigneeFilter.set(
|
||||||
|
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
|
||||||
|
);
|
||||||
|
this.releaseFilter.set(filter.releaseId ?? ALL);
|
||||||
|
this.area.set(filter.area ?? ALL);
|
||||||
|
this.typeFilter.set(filter.types?.[0] ?? ALL);
|
||||||
|
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
|
||||||
|
this.labels.set(filter.labels ?? []);
|
||||||
|
this.searchInput.set(filter.search ?? '');
|
||||||
|
this.search.set(filter.search ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearFilters(): void {
|
||||||
|
for (const filter of [
|
||||||
|
this.assigneeFilter,
|
||||||
|
this.releaseFilter,
|
||||||
|
this.area,
|
||||||
|
this.typeFilter,
|
||||||
|
this.priorityFilter,
|
||||||
|
])
|
||||||
|
filter.set(ALL);
|
||||||
|
this.labels.set([]);
|
||||||
|
this.searchInput.set('');
|
||||||
|
this.search.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openSaveView(): void {
|
||||||
|
this.viewName.set('');
|
||||||
|
this.viewShared.set(false);
|
||||||
|
this.drawer.open('Guardar vista', this.viewForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected saveView(): void {
|
||||||
|
if (!this.viewName().trim()) return;
|
||||||
|
this.board.saveView(
|
||||||
|
this.viewName(),
|
||||||
|
this.viewShared() && this.project.canContribute(),
|
||||||
|
);
|
||||||
|
this.drawer.closeAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected canDeleteView(view: SavedFilter): boolean {
|
||||||
|
return (
|
||||||
|
view.owner.id === this.me() || (view.shared && this.project.canManage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected openCreate(): void {
|
protected openCreate(): void {
|
||||||
const types = this.typeOptions();
|
const types = this.typeOptions();
|
||||||
if (!types.some((t) => t.value === this.newType()))
|
if (!types.some((t) => t.value === this.newType()))
|
||||||
|
|||||||
Reference in New Issue
Block a user