Compare commits

..

9 Commits

Author SHA1 Message Date
Gimanh 3020e41b3a Merge pull request #28 from Gimanh/fix/issues
Fix/issues
2026-03-01 21:23:21 +01:00
Gimanh 8887d5e3e1 Merge branch 'main' into fix/issues 2026-03-01 21:22:58 +01:00
Nikolai Giman 0925547f8d chore: version 2026-03-01 21:22:13 +01:00
Nikolai Giman c6b769a476 fix: subtasks duplication 2026-03-01 21:05:55 +01:00
Nikolai Giman 3c49941693 fix: fetch root tasks for kanban 2026-03-01 18:21:25 +01:00
Gimanh 1d79dad5c8 Merge pull request #27 from Gimanh/chore/api-lib-version
chore: lib version
2026-03-01 17:58:45 +01:00
Nikolai Giman 245adf204f chore: lib version 2026-03-01 17:58:17 +01:00
Gimanh 08da93bd40 Merge pull request #25 from Gimanh/chore/readme
chore: readme
2026-02-23 09:27:24 +01:00
Nikolai Giman 317b32edea chore: readme 2026-02-23 09:26:56 +01:00
23 changed files with 2789 additions and 78 deletions
+8 -2
View File
@@ -5,6 +5,12 @@
TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.
TaskView is built for teams that want a transparent, self-hosted alternative to SaaS task managers.
## Apps
* [Docs](https://taskview.tech/docs/)
* [Web](https://app.taskview.tech/)
* [iOS](https://apps.apple.com/lk/app/taskview-todo-list-tasks/id6499107867)
* [Android](https://play.google.com/store/apps/details?id=com.handscreamgnl.taskview.app&hl=en)
It is designed for teams and individuals who want:
- full control over their data
- transparent architecture
@@ -129,9 +135,9 @@ Make sure the image versions match the version defined in the root package.json.
## Roadmap
- Plugin / extension system
- Migrate to NuxtUI or similar ui library
- [X] Migrate to NuxtUI or similar ui library
- Enterprise SSO and identity integrations
- Redesign
- [X] Redesign
- Desktop version
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.20.4",
"version": "1.20.7",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -648,6 +648,7 @@ export class TasksRepository {
eq(TasksSchema.goalId, goalId),
columnId === null ? isNull(TasksSchema.statusId) : eq(TasksSchema.statusId, columnId),
isNotNull(TasksSchema.kanbanOrder),
isNull(TasksSchema.parentId),
];
if (cursor !== null) {
conditions.push(gt(TasksSchema.kanbanOrder, cursor));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.20.4",
"version": "1.20.7",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
+544 -33
View File
@@ -1,59 +1,570 @@
# TaskView API
Библиотека для работы с TaskView API.
TypeScript/JavaScript SDK for the [TaskView](https://taskview.tech) API. Provides a typed interface for managing goals, task lists, tasks, tags, kanban boards, team collaboration, and task dependencies.
## Установка
## Installation
```bash
npm install taskview-api
npm install taskview-api axios
```
## Использование
`axios` is a peer dependency — you provide your own configured instance.
```javascript
import { setupCounter } from 'taskview-api'
## Quick Start
// Использование функции setupCounter
const button = document.querySelector('#counter')
setupCounter(button)
```typescript
import axios from 'axios';
import { TvApi } from 'taskview-api';
const $axios = axios.create({
baseURL: 'https://api.taskview.app',
});
$axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
const api = new TvApi($axios);
```
## Разработка
You can change the base URL at any time:
### Установка зависимостей
```bash
npm install
```typescript
api.setBaseUrl('https://other-api.example.com');
```
### Запуск в режиме разработки
## Authentication
```bash
npm run dev
All API requests (except auth endpoints) require a Bearer token in the `Authorization` header. Below is how to obtain and manage tokens.
### Login
**With email or username and password:**
```typescript
const response = await $axios.post('/module/auth/login', {
login: 'user@example.com', // email or username
password: 'securePassword123',
});
const { access, refresh, userData } = response.data;
```
### Сборка библиотеки
**Passwordless login (email code):**
```bash
npm run build
```typescript
// 1. Request a login code
await $axios.post('/module/auth/send-login-code', {
email: 'user@example.com',
});
// 2. User receives a code via email, then submit it
const response = await $axios.post('/module/auth/login-by-code', {
email: 'user@example.com',
code: '123456',
});
const { access, refresh, userData } = response.data;
```
### Проверка типов
### Using Tokens
```bash
npm run type-check
Once you have the access token, pass it to `TvApi` via the axios instance:
```typescript
import axios from 'axios';
import { TvApi } from 'taskview-api';
const $axios = axios.create({
baseURL: 'https://api.taskview.tech',
});
$axios.defaults.headers.common['Authorization'] = `Bearer ${access}`;
const api = new TvApi($axios);
```
## Структура проекта
Store the refresh token securely (e.g. `httpOnly` cookie or secure storage). The access token has a short lifetime; the refresh token lives longer (up to 30 days).
- `src/index.ts` - основной файл экспорта библиотеки
- `src/counter.ts` - модуль с функцией счетчика
- `dist/` - собранные файлы библиотеки
### Refreshing Tokens
## Форматы сборки
When the access token expires the server returns `401 Unauthorized`. Use the refresh token to obtain a new pair:
Библиотека собирается в следующих форматах:
- ES Module (`.es.js`)
- CommonJS (`.cjs.js`)
- UMD (`.umd.js`)
- TypeScript типы (`.d.ts`)
```typescript
const response = await $axios.post('/module/auth/refresh/token', {
refreshToken: refresh,
});
const { access: newAccess, refresh: newRefresh } = response.data;
// Update the axios header with the new access token
$axios.defaults.headers.common['Authorization'] = `Bearer ${newAccess}`;
```
### Logout
```typescript
await $axios.post('/module/auth/logout');
// Clear stored tokens on the client side
```
### OAuth Providers
TaskView supports OAuth login via **Google**, **GitHub**, and **Apple**. Redirect the user to:
```
GET /module/auth/provider/google
GET /module/auth/provider/github
GET /module/auth/provider/apple
```
After successful authentication the user is redirected back with a login code that can be exchanged for tokens via the `login-by-code` endpoint.
### JWT Payload Structure
The decoded access token contains:
```typescript
{
exp: number; // Expiration timestamp
id: number; // Token ID
type: 'jwt';
userData: {
id: number; // User ID
email: string;
login: string;
permissions: {
[key: string]: {
id: number;
name: string;
description: string;
};
};
};
}
```
## API Modules
The `TvApi` instance exposes the following modules:
| Module | Access | Description |
|---------------------|----------------------|----------------------------------------|
| Goals | `api.goals` | Create, update, delete, fetch goals |
| Goal Lists | `api.goalLists` | Task lists within a goal |
| Tasks | `api.tasks` | Full task CRUD, history, assignments |
| Tags | `api.tags` | Tag management and task tagging |
| Kanban | `api.kanban` | Columns, task ordering, pagination |
| Graph | `api.graph` | Task dependency edges |
| Collaboration | `api.collaboration` | Users, roles, permissions |
---
### Goals
```typescript
// Fetch all goals
const goals = await api.goals.fetchGoals();
// Create a goal
const goal = await api.goals.createGoal({
name: 'Sprint 1',
description: 'First sprint tasks',
color: '#4A90D9',
});
// Update a goal
await api.goals.updateGoal({
id: goal.id,
name: 'Sprint 1 (updated)',
archive: 0,
});
// Delete a goal
await api.goals.deleteGoal(goal.id);
```
### Goal Lists
Goal lists are task lists that belong to a goal.
```typescript
// Fetch lists for a goal
const lists = await api.goalLists.fetchLists({ goalId: goal.id });
// Create a list
const list = await api.goalLists.createList({
goalId: goal.id,
name: 'Backlog',
description: 'Upcoming work',
});
// Update a list
await api.goalLists.updateList({
id: list.id,
name: 'Backlog (v2)',
});
// Delete a list
await api.goalLists.deleteList(list.id);
```
### Tasks
```typescript
// Fetch tasks with pagination and filters
const tasks = await api.tasks.fetch({
goalId: goal.id,
componentId: list.id, // list ID, or -1401 for all tasks
page: 1,
showCompleted: 0, // 0 = hide completed, 1 = show
firstNew: 0, // 0 = oldest first, 1 = newest first
searchText: 'bug', // optional text search
filters: { // optional filters
selectedUser: 5,
priority: 1,
selectedTags: { '12': true, '15': true },
},
});
// Create a task
const task = await api.tasks.createTask({
goalId: goal.id,
description: 'Fix login bug',
priorityId: 1, // 1 = low, 2 = medium, 3 = high
goalListId: list.id,
note: 'Details here',
startDate: '2025-03-01',
endDate: '2025-03-05',
});
// Update a task
await api.tasks.updateTask({
id: task.id,
description: 'Fix login bug (critical)',
complete: true,
priorityId: 3,
});
// Delete a task
await api.tasks.deleteTask(task.id);
// Fetch a single task by ID
const single = await api.tasks.fetchTaskById(task.id);
// Toggle user assignment
await api.tasks.toggleTasksAssignee({
taskId: task.id,
userId: 42,
});
// Task history
const history = await api.tasks.fetchTaskHistory(task.id);
await api.tasks.recoveryTaskHistory(history.history[0].historyId, task.id);
```
### Tags
```typescript
// Fetch all tags
const tags = await api.tags.fetchAllTagsForUser();
// Create a tag
const tag = await api.tags.createTag({
name: 'urgent',
color: '#FF0000',
goalId: goal.id,
});
// Toggle tag on a task (adds if missing, removes if present)
await api.tags.toggleTag({ tagId: tag.id, taskId: task.id });
// Update a tag
await api.tags.updateTag({ id: tag.id, name: 'critical', color: '#CC0000' });
// Delete a tag
await api.tags.deleteTag({ tagId: tag.id });
```
### Kanban
```typescript
// Fetch all columns for a goal
const columns = await api.kanban.fetchAllColumns(goal.id);
// Add a column
const column = await api.kanban.addColumn({
goalId: goal.id,
name: 'In Progress',
});
// Fetch tasks for a column (cursor-based pagination)
const result = await api.kanban.fetchTasksForColumn(goal.id, column.id, null);
// result.tasks, result.nextCursor, result.columnVersion
// Move a task between columns / reorder
await api.kanban.updateTasksOrderAndColumn({
goalId: goal.id,
columnId: column.id,
taskId: task.id,
prevTaskId: null,
nextTaskId: 10,
});
// Update a column
await api.kanban.updateColumn({ id: column.id, goalId: goal.id, name: 'Review' });
// Delete a column
await api.kanban.deleteColumn({ id: column.id, goalId: goal.id });
```
### Graph (Task Dependencies)
```typescript
// Add a dependency edge (source -> target)
const edge = await api.graph.addEdge({ source: 1, target: 2 });
// Fetch all edges for a goal
const edges = await api.graph.fetchAllEdges(goal.id);
// Delete an edge
await api.graph.deleteEdge(edge.id);
```
### Collaboration
```typescript
// Invite a user by email
await api.collaboration.inviteUserToGoal({
goalId: goal.id,
email: 'user@example.com',
});
// Fetch goal members
const members = await api.collaboration.fetchUsersForGoal(goal.id);
// Remove a user
await api.collaboration.deleteUserFromGoal({
goalId: goal.id,
userId: 5,
});
// Roles
const roles = await api.collaboration.fetchRolesForGoal(goal.id);
const newRole = await api.collaboration.createRoleForGoal({
goalId: goal.id,
name: 'Developer',
});
await api.collaboration.toggleUserRoles({
goalId: goal.id,
userId: 5,
roleId: newRole.id,
});
// Permissions
const allPermissions = await api.collaboration.fetchAllPermissions();
await api.collaboration.toggleRolePermission({
goalId: goal.id,
roleId: newRole.id,
permissionId: 3,
});
await api.collaboration.deleteRoleFromGoal({
goalId: goal.id,
roleId: newRole.id,
});
```
## Permissions
The package exports `TvPermissions` — a map of all permission constants:
```typescript
import { TvPermissions } from 'taskview-api';
TvPermissions.GOAL_CAN_DELETE // 'goal_can_delete'
TvPermissions.GOAL_CAN_EDIT // 'goal_can_edit'
TvPermissions.GOAL_CAN_MANAGE_USERS // 'goal_can_manage_users'
TvPermissions.GOAL_CAN_WATCH_CONTENT // 'goal_can_watch_content'
TvPermissions.GOAL_CAN_ADD_TASK_LIST // 'goal_can_add_task_list'
TvPermissions.COMPONENT_CAN_DELETE // 'component_can_delete'
TvPermissions.COMPONENT_CAN_EDIT // 'component_can_edit'
TvPermissions.COMPONENT_CAN_WATCH_CONTENT // 'component_can_watch_content'
TvPermissions.COMPONENT_CAN_ADD_TASKS // 'component_can_add_tasks'
TvPermissions.TASK_CAN_DELETE // 'task_can_delete'
TvPermissions.TASK_CAN_EDIT_DESCRIPTION // 'task_can_edit_description'
TvPermissions.TASK_CAN_EDIT_STATUS // 'task_can_edit_status'
TvPermissions.TASK_CAN_EDIT_NOTE // 'task_can_edit_note'
TvPermissions.TASK_CAN_EDIT_DEADLINE // 'task_can_edit_deadline'
TvPermissions.TASK_CAN_EDIT_TAGS // 'task_can_edit_tags'
TvPermissions.TASK_CAN_EDIT_PRIORITY // 'task_can_edit_priority'
TvPermissions.TASK_CAN_ASSIGN_USERS // 'task_can_assign_users'
TvPermissions.TASK_CAN_ACCESS_HISTORY // 'task_can_access_history'
// ... and more
TvPermissions.KANBAN_CAN_MANAGE // 'kanban_can_manage'
TvPermissions.KANBAN_CAN_VIEW // 'kanban_can_view'
TvPermissions.GRAPH_CAN_MANAGE // 'graph_can_manage'
TvPermissions.GRAPH_CAN_VIEW // 'graph_can_view'
```
## Build Formats
The library ships in three formats:
- **ES Module** — `taskview-api.es.js`
- **CommonJS** — `taskview-api.cjs.js`
- **UMD** — `taskview-api.umd.js`
- **TypeScript declarations** — `index.d.ts`
## Example
```typescript
import axios from 'axios';
import { TvApi } from 'taskview-api';
const BASE_URL = 'http://localhost:1401';
const $axios = axios.create({ baseURL: BASE_URL });
async function login(): Promise<{ access: string; refresh: string }> {
const { data } = await $axios.post('/module/auth/login', {
login: 'test@mail.dest',
password: 'user1!#Q',
});
return data;
}
async function main() {
// 1. Authenticate
console.log('Logging in...');
const { access, refresh } = await login();
console.log('Logged in. Access token received.');
// 2. Set auth header and create API instance
$axios.defaults.headers.common['Authorization'] = `Bearer ${access}`;
const api = new TvApi($axios);
// 3. Goals
console.log('\n--- Goals ---');
const goals = await api.goals.fetchGoals();
console.log(`Found ${goals.length} goal(s)`);
const goal = await api.goals.createGoal({
name: `Example Goal ${Date.now()}`,
description: 'Created by taskview-api example',
color: '#4A90D9',
});
console.log(`Created goal: id=${goal!.id}, name="${goal!.name}"`);
// 4. Goal Lists
console.log('\n--- Goal Lists ---');
const list = await api.goalLists.createList({
goalId: goal!.id,
name: 'Backlog',
description: 'Example task list',
});
console.log(`Created list: id=${list!.id}, name="${list!.name}"`);
const lists = await api.goalLists.fetchLists({ goalId: goal!.id });
console.log(`Goal has ${lists.length} list(s)`);
// 5. Tasks
console.log('\n--- Tasks ---');
const task1 = await api.tasks.createTask({
goalId: goal!.id,
description: 'First task',
priorityId: 1,
goalListId: list!.id,
});
console.log(`Created task: id=${task1!.id}, "${task1!.description}"`);
const task2 = await api.tasks.createTask({
goalId: goal!.id,
description: 'Second task (high priority)',
priorityId: 3,
goalListId: list!.id,
note: 'This is an important task',
});
console.log(`Created task: id=${task2!.id}, "${task2!.description}"`);
const tasks = await api.tasks.fetch({
goalId: goal!.id,
componentId: list!.id,
page: 1,
showCompleted: 0,
firstNew: 0,
});
console.log(`Fetched ${tasks.length} task(s) from list`);
// 6. Update a task
await api.tasks.updateTask({
id: task1!.id,
description: 'First task (updated)',
complete: true,
});
console.log(`Marked task ${task1!.id} as complete`);
// 7. Tags
console.log('\n--- Tags ---');
const tag = await api.tags.createTag({
name: 'example-tag',
color: '#FF5733',
goalId: goal!.id,
});
console.log(`Created tag: id=${tag!.id}, name="${tag!.name}"`);
await api.tags.toggleTag({ tagId: tag!.id, taskId: task2!.id });
console.log(`Added tag "${tag!.name}" to task ${task2!.id}`);
// 8. Kanban
console.log('\n--- Kanban ---');
const column = await api.kanban.addColumn({
goalId: goal!.id,
name: 'In Progress',
});
console.log(`Created kanban column: id=${column.id}, name="${column.name}"`);
const columns = await api.kanban.fetchAllColumns(goal!.id);
console.log(`Goal has ${columns.length} kanban column(s)`);
// 9. Graph (task dependencies)
console.log('\n--- Graph ---');
const edge = await api.graph.addEdge({
source: task1!.id,
target: task2!.id,
});
console.log(`Created dependency: task ${edge.fromTaskId} → task ${edge.toTaskId}`);
const edges = await api.graph.fetchAllEdges(goal!.id);
console.log(`Goal has ${edges.length} dependency edge(s)`);
// 10. Cleanup
console.log('\n--- Cleanup ---');
await api.graph.deleteEdge(edge.id);
console.log('Deleted dependency edge');
await api.kanban.deleteColumn({ id: column.id, goalId: goal!.id });
console.log('Deleted kanban column');
await api.tags.deleteTag({ tagId: tag!.id });
console.log('Deleted tag');
await api.tasks.deleteTask(task2!.id);
await api.tasks.deleteTask(task1!.id);
console.log('Deleted tasks');
await api.goalLists.deleteList(list!.id);
console.log('Deleted list');
await api.goals.deleteGoal(goal!.id);
console.log('Deleted goal');
console.log('\nDone! All examples completed successfully.');
}
main().catch((err) => {
console.error('Error:', err.response?.data || err.message);
process.exit(1);
});
```
@@ -0,0 +1,147 @@
import axios from 'axios';
import { TvApi } from 'taskview-api';
const BASE_URL = 'http://localhost:1401';
const $axios = axios.create({ baseURL: BASE_URL });
async function login(): Promise<{ access: string; refresh: string }> {
const { data } = await $axios.post('/module/auth/login', {
login: 'test@mail.dest',
password: 'user1!#Q',
});
return data;
}
async function main() {
// 1. Authenticate
console.log('Logging in...');
const { access, refresh } = await login();
console.log('Logged in. Access token received.');
// 2. Set auth header and create API instance
$axios.defaults.headers.common['Authorization'] = `Bearer ${access}`;
const api = new TvApi($axios);
// 3. Goals
console.log('\n--- Goals ---');
const goals = await api.goals.fetchGoals();
console.log(`Found ${goals.length} goal(s)`);
const goal = await api.goals.createGoal({
name: `Example Goal ${Date.now()}`,
description: 'Created by taskview-api example',
color: '#4A90D9',
});
console.log(`Created goal: id=${goal!.id}, name="${goal!.name}"`);
// 4. Goal Lists
console.log('\n--- Goal Lists ---');
const list = await api.goalLists.createList({
goalId: goal!.id,
name: 'Backlog',
description: 'Example task list',
});
console.log(`Created list: id=${list!.id}, name="${list!.name}"`);
const lists = await api.goalLists.fetchLists({ goalId: goal!.id });
console.log(`Goal has ${lists.length} list(s)`);
// 5. Tasks
console.log('\n--- Tasks ---');
const task1 = await api.tasks.createTask({
goalId: goal!.id,
description: 'First task',
priorityId: 1,
goalListId: list!.id,
});
console.log(`Created task: id=${task1!.id}, "${task1!.description}"`);
const task2 = await api.tasks.createTask({
goalId: goal!.id,
description: 'Second task (high priority)',
priorityId: 3,
goalListId: list!.id,
note: 'This is an important task',
});
console.log(`Created task: id=${task2!.id}, "${task2!.description}"`);
const tasks = await api.tasks.fetch({
goalId: goal!.id,
componentId: list!.id,
page: 1,
showCompleted: 0,
firstNew: 0,
});
console.log(`Fetched ${tasks.length} task(s) from list`);
// 6. Update a task
await api.tasks.updateTask({
id: task1!.id,
description: 'First task (updated)',
complete: true,
});
console.log(`Marked task ${task1!.id} as complete`);
// 7. Tags
console.log('\n--- Tags ---');
const tag = await api.tags.createTag({
name: 'example-tag',
color: '#FF5733',
goalId: goal!.id,
});
console.log(`Created tag: id=${tag!.id}, name="${tag!.name}"`);
await api.tags.toggleTag({ tagId: tag!.id, taskId: task2!.id });
console.log(`Added tag "${tag!.name}" to task ${task2!.id}`);
// 8. Kanban
console.log('\n--- Kanban ---');
const column = await api.kanban.addColumn({
goalId: goal!.id,
name: 'In Progress',
});
console.log(`Created kanban column: id=${column.id}, name="${column.name}"`);
const columns = await api.kanban.fetchAllColumns(goal!.id);
console.log(`Goal has ${columns.length} kanban column(s)`);
// 9. Graph (task dependencies)
console.log('\n--- Graph ---');
const edge = await api.graph.addEdge({
source: task1!.id,
target: task2!.id,
});
console.log(`Created dependency: task ${edge.fromTaskId} → task ${edge.toTaskId}`);
const edges = await api.graph.fetchAllEdges(goal!.id);
console.log(`Goal has ${edges.length} dependency edge(s)`);
// 10. Cleanup
console.log('\n--- Cleanup ---');
await api.graph.deleteEdge(edge.id);
console.log('Deleted dependency edge');
await api.kanban.deleteColumn({ id: column.id, goalId: goal!.id });
console.log('Deleted kanban column');
await api.tags.deleteTag({ tagId: tag!.id });
console.log('Deleted tag');
await api.tasks.deleteTask(task2!.id);
await api.tasks.deleteTask(task1!.id);
console.log('Deleted tasks');
await api.goalLists.deleteList(list!.id);
console.log('Deleted list');
await api.goals.deleteGoal(goal!.id);
console.log('Deleted goal');
console.log('\nDone! All examples completed successfully.');
}
main().catch((err) => {
console.error('Error:', err.response?.data || err.message);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
{
"name": "taskview-api-example",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx tsx index.ts"
},
"dependencies": {
"taskview-api": "latest",
"axios": "^1.2.3",
"tsx": "^4.19.0"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "taskview-api",
"private": false,
"version": "1.17.0",
"version": "1.17.2",
"type": "module",
"main": "./dist/taskview-api.umd.js",
"module": "./dist/taskview-api.es.js",
+2 -1
View File
@@ -35,4 +35,5 @@ Info.plist
e2e_logs
e2e_pgdata
e2e_updates
playwright-report
playwright-report
test-results
+6 -8
View File
@@ -1,29 +1,27 @@
import { test, expect } from '@playwright/test'
import { TEST_USER } from './fixtures/auth'
import { setEnglishLocale } from './test-helpers'
test.describe('Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await setEnglishLocale(page)
await page.reload()
})
test('shows login page with welcome message', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toContainText(/welcome|возвращением/i)
await expect(page.getByRole('heading', { level: 1 })).toContainText(/welcome/i)
})
test('user can login with password and reach dashboard', async ({ page }) => {
// Switch to Password tab (default is Magic link)
await page.getByRole('tab', { name: /password|пароль/i }).click()
await page.getByRole('tab', { name: /password/i }).click()
// Fill credentials
await page.getByTestId('login-input').fill(TEST_USER.login)
await page.getByTestId('password-input').fill(TEST_USER.password)
// Submit
await page.getByTestId('sign-in-button').click()
// Should redirect to user dashboard
await expect(page).toHaveURL(/\/user/)
// Dashboard should be visible (sidebar or main content)
await expect(page.locator('body')).not.toContainText(/invalid|неверный|error|ошибка/i)
await expect(page.locator('body')).not.toContainText(/invalid|error/i)
})
})
+20 -21
View File
@@ -1,26 +1,22 @@
import { test, expect } from '@playwright/test'
import { login } from './test-helpers'
async function addProject(page: import('@playwright/test').Page, name: string) {
const input = page.getByTestId('project-add-input').first()
await input.waitFor({ state: 'visible', timeout: 15000 })
await input.fill(name)
await input.press('Enter')
await expect(page.getByText(name).first()).toBeVisible({ timeout: 10000 })
}
async function openProjectMenu(page: import('@playwright/test').Page, projectName: string) {
const row = page.getByTestId(`project-row-${projectName}`)
await row.first().waitFor({ state: 'visible', timeout: 10000 })
await row.first().getByTestId('project-menu-trigger').click()
}
import {
setupAndLogin,
addProject,
openProjectMenu,
cleanupProjects,
} from './test-helpers'
test.describe('Projects', () => {
test.setTimeout(15_000)
test.setTimeout(30_000)
test.beforeEach(async ({ page }) => {
await page.goto('/')
await login(page)
await setupAndLogin(page)
})
test.afterAll(async ({ browser }) => {
const page = await browser.newPage()
await cleanupProjects(page)
await page.close()
})
test('user can add project', async ({ page }) => {
@@ -36,16 +32,17 @@ test.describe('Projects', () => {
await page.getByTestId('context-menu-edit').click()
await page.getByTestId('project-edit-name').fill(newName)
await page.getByTestId('project-edit-save').click()
await expect(page.getByText(newName).first()).toBeVisible({ timeout: 5000 })
})
test('user can delete project', async ({ page }) => {
const projectName = `Delete Me ${Date.now()}`
await addProject(page, projectName)
await page.waitForTimeout(1000)
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-delete').click()
await page.getByRole('button', { name: /^yes|^да$/i }).click()
await page.getByTestId('context-menu-delete').click({ force: true })
await page.getByTestId('confirm-delete-button').click()
await expect(page.getByText(projectName)).toHaveCount(0)
})
@@ -63,7 +60,9 @@ test.describe('Projects', () => {
await addProject(page, projectName)
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-move-to-archive').click()
await page.waitForTimeout(1000)
await page.getByTestId('archive-list-collapsible').click()
await page.getByTestId(`project-row-${projectName}`).first().waitFor({ state: 'visible', timeout: 5000 })
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-restore-from-archive').click({ force: true })
await expect(page.getByText(projectName).first()).toBeVisible({ timeout: 5000 })
+88
View File
@@ -0,0 +1,88 @@
import { test, expect } from '@playwright/test'
import {
setupAndLogin,
createProjectAndNavigate,
cleanupProjects,
addTaskFromList,
openTaskDetail,
addSubtask,
getSubtaskCount,
navigateToKanban,
navigateToGraph,
} from './test-helpers'
test.describe('Subtask duplication', () => {
test.setTimeout(60_000)
let projectName: string
test.beforeEach(async ({ page }) => {
await setupAndLogin(page)
})
test.afterAll(async ({ browser }) => {
const page = await browser.newPage()
await cleanupProjects(page)
await page.close()
})
test('adding subtask from task list does not duplicate', async ({ page }) => {
const project = await createProjectAndNavigate(page)
projectName = project.name
const taskName = `List Task ${Date.now()}`
await addTaskFromList(page, taskName)
await openTaskDetail(page, taskName)
const count = await addSubtask(page)
expect(count).toBe(1)
})
test('adding subtask from kanban does not duplicate', async ({ page }) => {
const project = await createProjectAndNavigate(page)
projectName = project.name
const taskName = `Kanban Task ${Date.now()}`
await addTaskFromList(page, taskName)
await navigateToKanban(page, projectName)
await expect(page.getByText(taskName).first()).toBeVisible({ timeout: 10000 })
await openTaskDetail(page, taskName)
const count = await addSubtask(page)
expect(count).toBe(1)
})
test('adding subtask from graph does not duplicate', async ({ page }) => {
const project = await createProjectAndNavigate(page)
projectName = project.name
const taskName = `Graph Task ${Date.now()}`
await addTaskFromList(page, taskName)
await navigateToGraph(page, projectName)
await expect(page.getByText(taskName).first()).toBeVisible({ timeout: 10000 })
await openTaskDetail(page, taskName)
const count = await addSubtask(page)
expect(count).toBe(1)
})
test('adding multiple subtasks does not duplicate any of them', async ({ page }) => {
const project = await createProjectAndNavigate(page)
projectName = project.name
const taskName = `Multi Subtask ${Date.now()}`
await addTaskFromList(page, taskName)
await openTaskDetail(page, taskName)
for (let i = 0; i < 3; i++) {
await addSubtask(page)
}
const count = await getSubtaskCount(page)
expect(count).toBe(3)
})
})
+154 -2
View File
@@ -1,8 +1,160 @@
import { expect, type Page } from '@playwright/test'
import { TEST_USER } from './fixtures/auth'
const API_URL = 'http://localhost:1401'
// ── Cleanup ──
const createdProjectIds: number[] = []
export function trackProjectId(id: number | null) {
if (id) createdProjectIds.push(id)
}
/**
* Deletes all tracked projects via API. Call in afterAll.
* Authenticates via API login to get a JWT token (page.request alone has no Bearer header).
*/
export async function cleanupProjects(page: Page) {
if (createdProjectIds.length === 0) return
const loginResponse = await page.request.post(`${API_URL}/module/auth/login`, {
form: { login: TEST_USER.login, password: TEST_USER.password },
})
const loginData = await loginResponse.json()
const token = loginData.access
if (!token) {
console.error('Cleanup: failed to get auth token, skipping project deletion')
return
}
for (const id of createdProjectIds) {
try {
await page.request.delete(`${API_URL}/module/goals`, {
data: { goalId: id },
headers: { Authorization: `Bearer ${token}` },
timeout: 5000,
})
} catch {
console.error(`Failed to delete project ${id} via API`)
}
}
createdProjectIds.length = 0
}
// ── Locale ──
/**
* Sets the app locale to English via localStorage before page load.
* Must be called after page.goto() so localStorage is available for the domain,
* then reload to apply.
*/
export async function setEnglishLocale(page: Page) {
await page.evaluate(() => {
localStorage.setItem('store_task_view.task_view.locale', 'en')
})
}
// ── Auth ──
export async function login(page: Page) {
await page.getByRole('tab', { name: /password|пароль/i }).click()
await page.getByRole('tab', { name: /password/i }).click()
await page.getByTestId('login-input').fill(TEST_USER.login)
await page.getByTestId('password-input').fill(TEST_USER.password)
await page.getByTestId('sign-in-button').click()
}
}
/**
* Navigates to `/`, sets English locale, reloads, and logs in.
*/
export async function setupAndLogin(page: Page) {
await page.goto('/')
await setEnglishLocale(page)
await page.reload()
await login(page)
}
// ── Projects ──
export async function addProject(page: Page, name: string): Promise<number | null> {
const urlBefore = page.url()
const input = page.getByTestId('project-add-input').first()
await input.waitFor({ state: 'visible', timeout: 15000 })
await input.fill(name)
await input.press('Enter')
await expect(page.getByText(name).first()).toBeVisible({ timeout: 10000 })
await page.waitForFunction(
(prev) => location.href !== prev && /\/user\/\d+\//.test(location.href),
urlBefore,
{ timeout: 10000 },
)
const id = extractProjectId(page.url())
trackProjectId(id)
return id
}
/**
* Creates a project, waits for auto-navigation, and tracks the ID for cleanup.
*/
export async function createProjectAndNavigate(page: Page, prefix = 'Test') {
const projectName = `${prefix} ${Date.now()}`
const id = await addProject(page, projectName)
await expect(page.getByTestId('task-search-add-input')).toBeVisible({ timeout: 10000 })
return { name: projectName, id }
}
export function extractProjectId(url: string): number | null {
const match = url.match(/\/user\/(\d+)\//)
return match ? Number(match[1]) : null
}
export async function openProjectMenu(page: Page, projectName: string) {
const row = page.getByTestId(`project-row-${projectName}`)
await row.first().waitFor({ state: 'visible', timeout: 10000 })
await row.first().getByTestId('project-menu-trigger').click({ force: true })
}
// ── Navigation ──
export async function navigateToKanban(page: Page, projectName: string) {
await openProjectMenu(page, projectName)
await page.getByRole('link', { name: /kanban/i }).click()
await page.waitForURL(/\/kanban/, { timeout: 10000 })
}
export async function navigateToGraph(page: Page, projectName: string) {
await openProjectMenu(page, projectName)
await page.getByRole('link', { name: /graph/i }).click()
await page.waitForURL(/\/graph/, { timeout: 10000 })
}
// ── Tasks ──
export async function addTaskFromList(page: Page, taskName: string) {
const input = page.getByTestId('task-search-add-input')
await input.waitFor({ state: 'visible', timeout: 10000 })
await input.fill(taskName)
await input.press('Enter')
await expect(page.getByText(taskName).first()).toBeVisible({ timeout: 10000 })
}
export async function openTaskDetail(page: Page, taskName: string) {
await page.getByText(taskName).first().click()
await expect(page.getByTestId('add-subtask-button')).toBeVisible({ timeout: 10000 })
}
// ── Subtasks ──
/**
* Clicks "Add subtask" and returns the total count of subtask items.
*/
export async function addSubtask(page: Page) {
await page.getByTestId('add-subtask-button').click()
await expect(page.getByTestId('subtasks-list')).toBeVisible({ timeout: 10000 })
await page.waitForTimeout(500)
return page.locator('[data-testid^="subtask-item-"]').count()
}
export function getSubtaskCount(page: Page) {
return page.locator('[data-testid^="subtask-item-"]').count()
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.20.4",
"version": "1.20.7",
"scripts": {
"dev": "vite",
"build": "pnpm run typecheck && vite build",
@@ -3,6 +3,7 @@
v-model="taskName"
:loading="loading"
:placeholder="t('kanban.addTask')"
data-testid="kanban-add-task-input"
:trailing-icon="inputIcon"
icon="i-lucide-plus"
size="xl"
@@ -25,6 +25,7 @@
color="error"
class="min-w-12"
:ui="{base: 'justify-center'}"
data-testid="confirm-delete-button"
@click="confirm"
/>
</div>
@@ -1,6 +1,7 @@
<template>
<div
class="flex flex-col gap-3 p-3 rounded-lg border border-default hover:bg-elevated transition-colors cursor-pointer"
:data-testid="`task-item-${task.id}`"
@click="handleOpenTask"
>
<div class="flex items-center gap-3">
@@ -7,6 +7,7 @@
:loading="loading"
variant="soft"
class="w-full"
data-testid="task-search-add-input"
:ui="{
base: 'bg-tv-ui-bg-elevated',
}"
@@ -1,5 +1,8 @@
<template>
<div class="flex items-center gap-2 group ">
<div
class="flex items-center gap-2 group"
:data-testid="`subtask-item-${subtask.id}`"
>
<UTextarea
ref="inputRef"
v-model="localDescription"
@@ -6,6 +6,7 @@
<div
v-if="subtasks.length > 0 && canViewTaskSubtasks"
class="space-y-2 mb-2"
data-testid="subtasks-list"
>
<TaskSubtaskItem
v-for="subtask in subtasks"
@@ -25,6 +26,7 @@
color="neutral"
variant="ghost"
class="w-full justify-start rounded-lg shadow-sm dark:bg-tv-ui-bg-elevated"
data-testid="add-subtask-button"
:loading="isAdding"
@click="addSubtask"
>
+1 -1
View File
@@ -3,7 +3,7 @@ import { $ls } from '@/plugins/axios'
export async function useLogout() {
const result = await $api.post<{ logout: boolean }>('/module/auth/logout').catch((err) => {
console.log(err, $api)
console.error(err, $api)
})
if (result) {
+4 -5
View File
@@ -113,12 +113,11 @@ export const useTasksStore = defineStore('tasks', {
throw new Error('Main task not found for adding subtask')
}
if (mainTask) {
mainTask.subtasks.push(task)
}
mainTask.subtasks.push(task)
if (this.selectedTask?.id === task.parentId) {
this.selectedTask.subtasks.push(task)
// Sync selectedTask if it's a separate object for the same parent
if (this.selectedTask && this.selectedTask !== mainTask && this.selectedTask.id === task.parentId) {
this.selectedTask.subtasks = mainTask.subtasks
}
} else {
this.tasks.unshift(task)