feat: saved views of my work, across my projects
A person saves named views of «my work»: projects, assigned to me, that I follow, status categories, priorities, bugs only, overdue and text. They are private (Mongo my_views, no history), listed and deleted by their owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views), and run as the cross-project search, which gains project, priority, bugsOnly and overdue filters (also in MCP my_issues). The home page shows them as a bar above «my work», with a drawer to save a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1441,4 +1441,74 @@ describe('task-stream (e2e)', () => {
|
||||
expect(later.body['quality'].bugWeeks).toHaveLength(8);
|
||||
expect(later.body['fields']).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps my own views of my work, across my projects', async () => {
|
||||
const key = 'V' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
const project = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const bug = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'Late bug',
|
||||
priority: 'CRITICAL',
|
||||
dueDate: '2020-01-01',
|
||||
})
|
||||
).body['key'];
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Late task',
|
||||
priority: 'CRITICAL',
|
||||
dueDate: '2020-01-01',
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${key}-2`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
|
||||
const criteria = {
|
||||
projectIds: [project.body['id']],
|
||||
priorities: ['CRITICAL'],
|
||||
bugsOnly: true,
|
||||
overdue: true,
|
||||
};
|
||||
const saved = await rest('token-ana', 'POST', '/me/views', {
|
||||
name: ' Bugs críticos vencidos ',
|
||||
criteria,
|
||||
});
|
||||
expect(saved.status).toBe(201);
|
||||
const views = await rest('token-ana', 'GET', '/me/views');
|
||||
expect(views.body).toEqual([
|
||||
expect.objectContaining({ name: 'Bugs críticos vencidos', criteria }),
|
||||
]);
|
||||
expect((await rest('token-bob', 'GET', '/me/views')).body).toEqual([]);
|
||||
|
||||
// Running it: the same criteria on my work.
|
||||
const page = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/me/issues?assignedToMe=false&project=${project.body['id']}&priority=CRITICAL&bugsOnly=true&overdue=true`,
|
||||
);
|
||||
expect(page.body['items'].map((i: { key: string }) => i.key)).toEqual([
|
||||
bug,
|
||||
]);
|
||||
const { myIssues } = await gql(
|
||||
'token-ana',
|
||||
`query($p: [ID!]) { myIssues(assignedToMe: false, projectIds: $p, overdue: true) { total } }`,
|
||||
{ p: [project.body['id']] },
|
||||
);
|
||||
expect(myIssues.total).toBe(2);
|
||||
|
||||
// Only its owner deletes it.
|
||||
const id = saved.body['id'];
|
||||
expect((await rest('token-bob', 'DELETE', `/me/views/${id}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect((await rest('token-ana', 'DELETE', `/me/views/${id}`)).status).toBe(
|
||||
204,
|
||||
);
|
||||
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2260,6 +2260,47 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Only these of my projects (ids). Repeat or separate with commas",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "priority",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/IssuePriority"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bugsOnly",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Only types with the bug icon",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "overdue",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Only open tickets past their due date",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -7806,6 +7847,271 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/me/views": {
|
||||
"get": {
|
||||
"operationId": "SavedFiltersController_myViews",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MyViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 saved views of my work, across my projects (by name)",
|
||||
"tags": [
|
||||
"Saved filters"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…).",
|
||||
"operationId": "SavedFiltersController_saveMyView",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SaveMyViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 a view of my work under a name",
|
||||
"tags": [
|
||||
"Saved filters"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/me/views/{id}": {
|
||||
"delete": {
|
||||
"operationId": "SavedFiltersController_deleteMyView",
|
||||
"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 one of my views",
|
||||
"tags": [
|
||||
"Saved filters"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/sprints": {
|
||||
"get": {
|
||||
"description": "The one under way, the planned ones, then the closed ones.",
|
||||
@@ -14193,6 +14499,132 @@
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"MyViewCriteriaResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"assignedToMe": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"watching": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StatusCategory"
|
||||
}
|
||||
},
|
||||
"priorities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/IssuePriority"
|
||||
}
|
||||
},
|
||||
"bugsOnly": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"overdue": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"search": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"MyViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Bugs críticos de todos mis proyectos"
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/components/schemas/MyViewCriteriaResponseDto"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"criteria",
|
||||
"createdAt"
|
||||
]
|
||||
},
|
||||
"MyViewCriteriaDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectIds": {
|
||||
"description": "Only these of my projects (empty: all)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"assignedToMe": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"watching": {
|
||||
"type": "boolean",
|
||||
"description": "Only the tickets I follow"
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StatusCategory"
|
||||
}
|
||||
},
|
||||
"priorities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/IssuePriority"
|
||||
}
|
||||
},
|
||||
"bugsOnly": {
|
||||
"type": "boolean",
|
||||
"description": "Only types with the bug icon"
|
||||
},
|
||||
"overdue": {
|
||||
"type": "boolean",
|
||||
"description": "Only open tickets past due"
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveMyViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Bugs críticos de todos mis proyectos",
|
||||
"maxLength": 60
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/components/schemas/MyViewCriteriaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"criteria"
|
||||
]
|
||||
},
|
||||
"SprintStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -836,6 +836,9 @@ type Mutation {
|
||||
"""Author or project admin"""
|
||||
deleteComment(commentId: ID!): CommandResult!
|
||||
|
||||
"""Delete one of my views"""
|
||||
deleteMyView(id: ID!): Boolean!
|
||||
|
||||
"""
|
||||
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||
"""
|
||||
@@ -893,6 +896,9 @@ type Mutation {
|
||||
"""Save the current board filters under a name (members of the project)"""
|
||||
saveFilter(input: SaveFilterInput!): ID!
|
||||
|
||||
"""Save a view of my work under a name"""
|
||||
saveMyView(input: SaveMyViewInput!): 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.
|
||||
"""
|
||||
@@ -965,6 +971,45 @@ type MyNotifications {
|
||||
unread: Int!
|
||||
}
|
||||
|
||||
"""A named view of my work (private)"""
|
||||
type MyView {
|
||||
createdAt: String!
|
||||
criteria: MyViewCriteria!
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""What a view of my work narrows to; empty: every ticket of my projects"""
|
||||
type MyViewCriteria {
|
||||
assignedToMe: Boolean
|
||||
|
||||
"""Only types with the bug icon"""
|
||||
bugsOnly: Boolean
|
||||
categories: [StatusCategory!]
|
||||
|
||||
"""Only open tickets past due"""
|
||||
overdue: Boolean
|
||||
priorities: [IssuePriority!]
|
||||
|
||||
"""Empty: all my projects"""
|
||||
projectIds: [ID!]
|
||||
search: String
|
||||
watching: Boolean
|
||||
}
|
||||
|
||||
input MyViewCriteriaInput {
|
||||
assignedToMe: Boolean
|
||||
bugsOnly: Boolean
|
||||
categories: [StatusCategory!]
|
||||
overdue: Boolean
|
||||
priorities: [IssuePriority!]
|
||||
|
||||
"""Empty: all my projects"""
|
||||
projectIds: [ID!]
|
||||
search: String
|
||||
watching: Boolean
|
||||
}
|
||||
|
||||
type Notification {
|
||||
actor: UserRef!
|
||||
createdAt: String!
|
||||
@@ -1234,10 +1279,20 @@ type Query {
|
||||
"""
|
||||
myIssues(
|
||||
assignedToMe: Boolean! = true
|
||||
|
||||
"""Only types drawn with the bug icon"""
|
||||
bugsOnly: Boolean
|
||||
categories: [StatusCategory!]
|
||||
limit: Int! = 50
|
||||
offset: Int! = 0
|
||||
|
||||
"""Only open tickets past their due date"""
|
||||
overdue: Boolean
|
||||
priorities: [IssuePriority!]
|
||||
|
||||
"""Only these of my projects"""
|
||||
projectIds: [ID!]
|
||||
|
||||
"""Matches key, title or description (global search)"""
|
||||
search: String
|
||||
statuses: [String!]
|
||||
@@ -1251,6 +1306,9 @@ type Query {
|
||||
|
||||
"""Projects where I am a member"""
|
||||
myProjects: [Project!]!
|
||||
|
||||
"""My saved views of my work, across my projects"""
|
||||
myViews: [MyView!]!
|
||||
notificationSettings: NotificationSettings!
|
||||
|
||||
"""A project of mine, or any public one (no sign-in needed)"""
|
||||
@@ -1399,6 +1457,11 @@ input SaveFilterInput {
|
||||
shared: Boolean! = false
|
||||
}
|
||||
|
||||
input SaveMyViewInput {
|
||||
criteria: MyViewCriteriaInput!
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""
|
||||
A named view of the board: private to whoever saved it, or shared with the project
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user