Merge branch 'cleanups' into pro/user-perms
This commit is contained in:
commit
1a500663ed
107 changed files with 2752 additions and 1048 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@
|
|||
certs/
|
||||
.DS_Store
|
||||
.cursor
|
||||
AGENTS.md
|
||||
CLAUDE.local.md
|
||||
|
||||
.byebug_history
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ app.use('/api/uploads', express.static(config.uploadPath));
|
|||
|
||||
// Authentication middleware
|
||||
const { requireAuth } = require('./middleware/auth');
|
||||
const { logError } = require('./services/logService');
|
||||
|
||||
// Health check (before auth middleware) - ensure it's completely bypassed
|
||||
app.get('/api/health', (req, res) => {
|
||||
|
|
@ -136,12 +137,15 @@ app.get('*', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
// Error handling fallback.
|
||||
// We shouldn't be here normally!
|
||||
// Each route should properly handle
|
||||
// and log its own errors.
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
logError(err);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: err.message,
|
||||
// message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ module.exports = {
|
|||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
verbose: true,
|
||||
verbose: false,
|
||||
forceExit: true,
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
'use strict';
|
||||
|
||||
const { safeAddColumns } = require('../utils/migration-utils');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await safeAddColumns(queryInterface, 'projects', [
|
||||
{
|
||||
name: 'state',
|
||||
definition: {
|
||||
type: Sequelize.ENUM(
|
||||
'idea',
|
||||
'planned',
|
||||
'in_progress',
|
||||
'blocked',
|
||||
'completed'
|
||||
),
|
||||
allowNull: false,
|
||||
defaultValue: 'idea',
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('projects', 'state');
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Update all projects: active=true -> state='in_progress', active=false -> state='idea'
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE projects
|
||||
SET state = CASE
|
||||
WHEN active = 1 THEN 'in_progress'
|
||||
ELSE 'idea'
|
||||
END
|
||||
`);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Reverse the conversion: state='in_progress' -> active=true, others -> active=false
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE projects
|
||||
SET active = CASE
|
||||
WHEN state = 'in_progress' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
`);
|
||||
},
|
||||
};
|
||||
36
backend/migrations/20250920075916-remove-active-column.js
Normal file
36
backend/migrations/20250920075916-remove-active-column.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use strict';
|
||||
|
||||
const {
|
||||
safeRemoveColumn,
|
||||
safeAddColumns,
|
||||
} = require('../utils/migration-utils');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Remove the active column from projects table
|
||||
await safeRemoveColumn(queryInterface, 'projects', 'active');
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Add the active column back
|
||||
await safeAddColumns(queryInterface, 'projects', [
|
||||
{
|
||||
name: 'active',
|
||||
definition: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Restore active values based on state
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE projects
|
||||
SET active = CASE
|
||||
WHEN state = 'in_progress' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
`);
|
||||
},
|
||||
};
|
||||
83
backend/migrations/20250924000001-add-uid-to-inbox-items.js
Normal file
83
backend/migrations/20250924000001-add-uid-to-inbox-items.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
'use strict';
|
||||
|
||||
const { uid } = require('../utils/uid');
|
||||
const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Temporarily disable foreign key constraints for SQLite
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
// 1. Add uid column to inbox_items table
|
||||
await safeAddColumns(queryInterface, 'inbox_items', [
|
||||
{
|
||||
name: 'uid',
|
||||
definition: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true, // Initially allow null during population
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. Populate uid values for existing inbox items
|
||||
const records = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM inbox_items WHERE uid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
// Generate uid for each record
|
||||
for (const record of records) {
|
||||
const uniqueId = uid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE inbox_items SET uid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [uniqueId, record.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Make uid column not null and unique
|
||||
await queryInterface.changeColumn('inbox_items', 'uid', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
});
|
||||
|
||||
// 4. Add unique index for performance
|
||||
await safeAddIndex(queryInterface, 'inbox_items', ['uid'], {
|
||||
unique: true,
|
||||
name: 'inbox_items_uid_unique_index',
|
||||
});
|
||||
} finally {
|
||||
// Re-enable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
try {
|
||||
// Remove unique index
|
||||
await queryInterface.removeIndex(
|
||||
'inbox_items',
|
||||
'inbox_items_uid_unique_index'
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'inbox_items_uid_unique_index not found, skipping removal'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove uid column
|
||||
await queryInterface.removeColumn('inbox_items', 'uid');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Error removing uid column from inbox_items:',
|
||||
error.message
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
66
backend/migrations/20250925000001-add-uid-to-users.js
Normal file
66
backend/migrations/20250925000001-add-uid-to-users.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
'use strict';
|
||||
|
||||
const { uid } = require('../utils/uid');
|
||||
const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
await safeAddColumns(queryInterface, 'users', [
|
||||
{
|
||||
name: 'uid',
|
||||
definition: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const users = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM users WHERE uid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const user of users) {
|
||||
const uniqueId = uid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE users SET uid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [uniqueId, user.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await queryInterface.changeColumn('users', 'uid', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
});
|
||||
|
||||
await safeAddIndex(queryInterface, 'users', ['uid'], {
|
||||
unique: true,
|
||||
name: 'users_uid_unique_index',
|
||||
});
|
||||
} finally {
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
try {
|
||||
await queryInterface.removeIndex('users', 'users_uid_unique_index');
|
||||
} catch (error) {
|
||||
console.log('users_uid_unique_index not found, skipping removal');
|
||||
}
|
||||
|
||||
try {
|
||||
await queryInterface.removeColumn('users', 'uid');
|
||||
} catch (error) {
|
||||
console.log('Error removing uid column from users:', error.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { uid } = require('../utils/uid');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const InboxItem = sequelize.define(
|
||||
|
|
@ -9,6 +10,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: uid,
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -24,11 +24,6 @@ module.exports = (sequelize) => {
|
|||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
pin_to_sidebar: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
|
@ -76,6 +71,17 @@ module.exports = (sequelize) => {
|
|||
allowNull: true,
|
||||
defaultValue: 'created_at:desc',
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.ENUM(
|
||||
'idea',
|
||||
'planned',
|
||||
'in_progress',
|
||||
'blocked',
|
||||
'completed'
|
||||
),
|
||||
allowNull: false,
|
||||
defaultValue: 'idea',
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'projects',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { uid } = require('../utils/uid');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const User = sequelize.define(
|
||||
|
|
@ -10,6 +11,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: uid,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
const express = require('express');
|
||||
const { Area } = require('../models');
|
||||
const { isValidUid } = require('../utils/slug-utils');
|
||||
const _ = require('lodash');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/areas
|
||||
router.get('/areas', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const areas = await Area.findAll({
|
||||
where: { user_id: req.session.userId },
|
||||
attributes: ['id', 'uid', 'name', 'description'],
|
||||
attributes: ['uid', 'name', 'description'],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
|
|
@ -22,18 +20,17 @@ router.get('/areas', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/areas/:id
|
||||
router.get('/areas/:id', async (req, res) => {
|
||||
// GET /api/areas/:uid
|
||||
router.get('/areas/:uid', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!isValidUid(req.params.uid))
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
attributes: ['uid', 'name', 'description'],
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
if (_.isEmpty(area)) {
|
||||
return res.status(404).json({
|
||||
error: "Area not found or doesn't belong to the current user.",
|
||||
});
|
||||
|
|
@ -49,13 +46,9 @@ router.get('/areas/:id', async (req, res) => {
|
|||
// POST /api/areas
|
||||
router.post('/areas', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
if (!name || _.isEmpty(name.trim())) {
|
||||
return res.status(400).json({ error: 'Area name is required.' });
|
||||
}
|
||||
|
||||
|
|
@ -65,10 +58,7 @@ router.post('/areas', async (req, res) => {
|
|||
user_id: req.session.userId,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...area.toJSON(),
|
||||
uid: area.uid, // Explicitly include uid
|
||||
});
|
||||
res.status(201).json(_.pick(area, ['uid', 'name', 'description']));
|
||||
} catch (error) {
|
||||
console.error('Error creating area:', error);
|
||||
res.status(400).json({
|
||||
|
|
@ -80,15 +70,13 @@ router.post('/areas', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// PATCH /api/areas/:id
|
||||
router.patch('/areas/:id', async (req, res) => {
|
||||
// PATCH /api/areas/:uid
|
||||
router.patch('/areas/:uid', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!isValidUid(req.params.uid))
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
|
|
@ -102,7 +90,7 @@ router.patch('/areas/:id', async (req, res) => {
|
|||
if (description !== undefined) updateData.description = description;
|
||||
|
||||
await area.update(updateData);
|
||||
res.json(area);
|
||||
res.json(_.pick(area, ['uid', 'name', 'description']));
|
||||
} catch (error) {
|
||||
console.error('Error updating area:', error);
|
||||
res.status(400).json({
|
||||
|
|
@ -114,15 +102,14 @@ router.patch('/areas/:id', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /api/areas/:id
|
||||
router.delete('/areas/:id', async (req, res) => {
|
||||
// DELETE /api/areas/:uid
|
||||
router.delete('/areas/:uid', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
if (!isValidUid(req.params.uid))
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
|
|
@ -130,7 +117,7 @@ router.delete('/areas/:id', async (req, res) => {
|
|||
}
|
||||
|
||||
await area.destroy();
|
||||
res.status(204).send();
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
res.status(400).json({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ router.get('/version', (req, res) => {
|
|||
router.get('/current_user', async (req, res) => {
|
||||
try {
|
||||
if (req.session && req.session.userId) {
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
const user = await User.findByPk(req.session.userId, {
|
||||
attributes: [
|
||||
'uid',
|
||||
'email',
|
||||
'language',
|
||||
'appearance',
|
||||
'timezone',
|
||||
],
|
||||
});
|
||||
if (user) {
|
||||
const admin = await isAdmin(user.id);
|
||||
return res.json({
|
||||
|
|
@ -70,7 +78,7 @@ router.post('/login', async (req, res) => {
|
|||
const admin = await isAdmin(user.id);
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
language: user.language,
|
||||
appearance: user.appearance,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
const express = require('express');
|
||||
const { InboxItem } = require('../models');
|
||||
const { processInboxItem } = require('../services/inboxProcessingService');
|
||||
const { isValidUid } = require('../utils/slug-utils');
|
||||
const _ = require('lodash');
|
||||
const { logError } = require('../services/logService');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/inbox
|
||||
router.get('/inbox', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Check if pagination parameters are provided
|
||||
const hasPagination =
|
||||
req.query.limit !== undefined || req.query.offset !== undefined;
|
||||
!_.isEmpty(req.query.limit) || !_.isEmpty(req.query.offset);
|
||||
|
||||
if (hasPagination) {
|
||||
// Parse pagination parameters
|
||||
const limit = parseInt(req.query.limit) || 20; // Default to 20 items
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
const limit = parseInt(req.query.limit, 10) || 20; // Default to 20 items
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
|
||||
// Get total count for pagination info
|
||||
const totalCount = await InboxItem.count({
|
||||
|
|
@ -59,7 +58,7 @@ router.get('/inbox', async (req, res) => {
|
|||
res.json(items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching inbox items:', error);
|
||||
logError('Error fetching inbox items:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
|
@ -67,13 +66,9 @@ router.get('/inbox', async (req, res) => {
|
|||
// POST /api/inbox
|
||||
router.post('/inbox', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { content, source } = req.body;
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
if (!content || _.isEmpty(content.trim())) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
|
|
@ -86,9 +81,18 @@ router.post('/inbox', async (req, res) => {
|
|||
user_id: req.session.userId,
|
||||
});
|
||||
|
||||
res.status(201).json(item);
|
||||
res.status(201).json(
|
||||
_.pick(item, [
|
||||
'uid',
|
||||
'content',
|
||||
'status',
|
||||
'source',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating inbox item:', error);
|
||||
logError('Error creating inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the inbox item.',
|
||||
details: error.errors
|
||||
|
|
@ -98,53 +102,70 @@ router.post('/inbox', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/inbox/:id
|
||||
router.get('/inbox/:id', async (req, res) => {
|
||||
// GET /api/inbox/:uid
|
||||
router.get('/inbox/:uid', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
if (!isValidUid(req.params.uid)) {
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
attributes: [
|
||||
'uid',
|
||||
'content',
|
||||
'status',
|
||||
'source',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
if (_.isEmpty(item)) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inbox item:', error);
|
||||
logError('Error fetching inbox item:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/inbox/:id
|
||||
router.patch('/inbox/:id', async (req, res) => {
|
||||
// PATCH /api/inbox/:uid
|
||||
router.patch('/inbox/:uid', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
if (!isValidUid(req.params.uid)) {
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
if (_.isEmpty(item)) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
const { content, status } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (content !== undefined) updateData.content = content;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
if (content != null) updateData.content = content;
|
||||
if (status != null) updateData.status = status;
|
||||
|
||||
await item.update(updateData);
|
||||
res.json(item);
|
||||
res.json(
|
||||
_.pick(item, [
|
||||
'uid',
|
||||
'content',
|
||||
'status',
|
||||
'source',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating inbox item:', error);
|
||||
logError('Error updating inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the inbox item.',
|
||||
details: error.errors
|
||||
|
|
@ -154,18 +175,18 @@ router.patch('/inbox/:id', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /api/inbox/:id
|
||||
router.delete('/inbox/:id', async (req, res) => {
|
||||
// DELETE /api/inbox/:uid
|
||||
router.delete('/inbox/:uid', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
if (!isValidUid(req.params.uid)) {
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
if (_.isEmpty(item)) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
|
|
@ -173,32 +194,41 @@ router.delete('/inbox/:id', async (req, res) => {
|
|||
await item.update({ status: 'deleted' });
|
||||
res.json({ message: 'Inbox item successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting inbox item:', error);
|
||||
logError('Error deleting inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the inbox item.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/inbox/:id/process
|
||||
router.patch('/inbox/:id/process', async (req, res) => {
|
||||
// PATCH /api/inbox/:uid/process
|
||||
router.patch('/inbox/:uid/process', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
if (!isValidUid(req.params.uid)) {
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { uid: req.params.uid, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
if (_.isEmpty(item)) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
await item.update({ status: 'processed' });
|
||||
res.json(item);
|
||||
res.json(
|
||||
_.pick(item, [
|
||||
'uid',
|
||||
'content',
|
||||
'status',
|
||||
'source',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error processing inbox item:', error);
|
||||
logError('Error processing inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem processing the inbox item.',
|
||||
});
|
||||
|
|
@ -208,10 +238,6 @@ router.patch('/inbox/:id/process', async (req, res) => {
|
|||
// POST /api/inbox/analyze-text
|
||||
router.post('/inbox/analyze-text', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { content } = req.body;
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
|
|
@ -225,7 +251,7 @@ router.post('/inbox/analyze-text', async (req, res) => {
|
|||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error analyzing inbox text:', error);
|
||||
logError('Error analyzing inbox text:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
const express = require('express');
|
||||
const { Note, Tag, Project, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { extractUidFromSlug } = require('../utils/slug-utils');
|
||||
const { validateTagName } = require('../utils/validation');
|
||||
const express = require("express");
|
||||
const { Note, Tag, Project } = require("../models");
|
||||
const { extractUidFromSlug } = require("../utils/slug-utils");
|
||||
const { validateTagName } = require("../services/tagsService");
|
||||
const router = express.Router();
|
||||
const permissionsService = require('../services/permissionsService');
|
||||
const { hasAccess } = require('../middleware/authorize');
|
||||
const permissionsService = require("../services/permissionsService");
|
||||
const { hasAccess } = require("../middleware/authorize");
|
||||
const _ = require("lodash");
|
||||
const { logError } = require("../services/logService");
|
||||
|
||||
// Helper function to update note tags
|
||||
async function updateNoteTags(note, tagsArray, userId) {
|
||||
if (!tagsArray || tagsArray.length === 0) {
|
||||
if (_.isEmpty(tagsArray)) {
|
||||
await note.setTags([]);
|
||||
return;
|
||||
}
|
||||
|
|
@ -31,10 +32,9 @@ async function updateNoteTags(note, tagsArray, userId) {
|
|||
}
|
||||
}
|
||||
|
||||
// If there are invalid tags, throw an error
|
||||
if (invalidTags.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
|
||||
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -42,43 +42,39 @@ async function updateNoteTags(note, tagsArray, userId) {
|
|||
validTagNames.map(async (name) => {
|
||||
const [tag] = await Tag.findOrCreate({
|
||||
where: { name, user_id: userId },
|
||||
defaults: { name, user_id: userId },
|
||||
defaults: { name, user_id: userId }
|
||||
});
|
||||
return tag;
|
||||
})
|
||||
);
|
||||
await note.setTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Failed to update tags:', error.message);
|
||||
logError("Failed to update tags:", error.message);
|
||||
throw error; // Re-throw to handle at route level
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/notes
|
||||
router.get('/notes', async (req, res) => {
|
||||
router.get("/notes", async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const orderBy = req.query.order_by || 'title:asc';
|
||||
const [orderColumn, orderDirection] = orderBy.split(':');
|
||||
const orderBy = req.query.order_by || "title:asc";
|
||||
const [orderColumn, orderDirection] = orderBy.split(":");
|
||||
|
||||
const whereClause = await permissionsService.ownershipOrPermissionWhere(
|
||||
'note',
|
||||
"note",
|
||||
req.session.userId
|
||||
);
|
||||
let includeClause = [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
through: { attributes: [] },
|
||||
attributes: ["name", "uid"],
|
||||
through: { attributes: [] }
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
},
|
||||
attributes: ["name", "uid"]
|
||||
}
|
||||
];
|
||||
|
||||
// Filter by tag
|
||||
|
|
@ -91,220 +87,176 @@ router.get('/notes', async (req, res) => {
|
|||
where: whereClause,
|
||||
include: includeClause,
|
||||
order: [[orderColumn, orderDirection.toUpperCase()]],
|
||||
distinct: true,
|
||||
distinct: true
|
||||
});
|
||||
|
||||
res.json(notes);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
logError("Error fetching notes:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/note/:id (supports both numeric ID and uid-slug)
|
||||
router.get(
|
||||
'/note/:id',
|
||||
router.get("/note/:uidSlug",
|
||||
hasAccess(
|
||||
'ro',
|
||||
'note',
|
||||
"ro",
|
||||
"note",
|
||||
async (req) => {
|
||||
const identifier = req.params.id;
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
const n = await Note.findOne({
|
||||
where: { id: parseInt(identifier) },
|
||||
attributes: ['uid'],
|
||||
});
|
||||
return n?.uid;
|
||||
}
|
||||
const uid = extractUidFromSlug(identifier);
|
||||
return uid;
|
||||
return extractUidFromSlug(req.params.uidSlug);
|
||||
},
|
||||
{ notFoundMessage: 'Note not found.' }
|
||||
{ notFoundMessage: "Note not found." }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const identifier = req.params.id;
|
||||
let whereClause;
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
whereClause = { id: parseInt(identifier) };
|
||||
} else {
|
||||
const uid = extractUidFromSlug(identifier);
|
||||
if (!uid) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid note identifier' });
|
||||
}
|
||||
whereClause = { uid };
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: whereClause,
|
||||
where: { uid: extractUidFromSlug(req.params.uidSlug) },
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
through: { attributes: [] },
|
||||
attributes: ["name", "uid"],
|
||||
through: { attributes: [] }
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
},
|
||||
],
|
||||
attributes: ["name", "uid"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
// access ensured by middleware
|
||||
|
||||
res.json(note);
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
logError("Error fetching note:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/note
|
||||
router.post('/note', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { title, content, project_id, tags } = req.body;
|
||||
|
||||
const noteAttributes = {
|
||||
title,
|
||||
content,
|
||||
user_id: req.session.userId,
|
||||
};
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: project_id },
|
||||
});
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Invalid project.' });
|
||||
}
|
||||
const projectAccess = await permissionsService.getAccess(
|
||||
req.session.userId,
|
||||
'project',
|
||||
project.uid
|
||||
);
|
||||
const isOwner = project.user_id === req.session.userId;
|
||||
const canWrite =
|
||||
isOwner || projectAccess === 'rw' || projectAccess === 'admin';
|
||||
if (!canWrite) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
noteAttributes.project_id = project_id;
|
||||
}
|
||||
|
||||
const note = await Note.create(noteAttributes);
|
||||
|
||||
// Handle tags - can be array of strings or array of objects with name property
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every((t) => typeof t === 'string')) {
|
||||
tagNames = tags;
|
||||
} else if (tags.every((t) => typeof t === 'object' && t.name)) {
|
||||
tagNames = tags.map((t) => t.name);
|
||||
}
|
||||
}
|
||||
|
||||
await updateNoteTags(note, tagNames, req.session.userId);
|
||||
|
||||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...noteWithAssociations.toJSON(),
|
||||
uid: noteWithAssociations.uid, // Explicitly include uid
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the note.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/note/:id
|
||||
router.patch(
|
||||
'/note/:id',
|
||||
router.post("/note",
|
||||
hasAccess(
|
||||
'rw',
|
||||
'note',
|
||||
"rw",
|
||||
"project",
|
||||
async (req) => {
|
||||
const n = await Note.findOne({
|
||||
where: { id: req.params.id },
|
||||
attributes: ['uid'],
|
||||
});
|
||||
return n?.uid;
|
||||
const { project_uid } = req.body;
|
||||
if (!project_uid || _.isEmpty(project_uid.toString().trim())) {
|
||||
return null;
|
||||
}
|
||||
return project_uid.toString().trim();
|
||||
},
|
||||
{ notFoundMessage: 'Note not found.' }
|
||||
{ notFoundMessage: "Note project not found" }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { title, content, project_uid, tags } = req.body;
|
||||
|
||||
const noteAttributes = {
|
||||
title,
|
||||
content,
|
||||
user_id: req.session.userId
|
||||
};
|
||||
|
||||
// project_uid is already validated by hasAccess middleware
|
||||
const project = await Project.findOne({
|
||||
where: { uid: project_uid.toString().trim() }
|
||||
});
|
||||
|
||||
noteAttributes.project_id = project.id;
|
||||
|
||||
const note = await Note.create(noteAttributes);
|
||||
|
||||
// Handle tags - can be an array of strings
|
||||
// or array of objects with name property
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every((t) => typeof t === "string")) {
|
||||
tagNames = tags;
|
||||
} else if (tags.every((t) => typeof t === "object" && t.name)) {
|
||||
tagNames = tags.map((t) => t.name);
|
||||
}
|
||||
}
|
||||
|
||||
await updateNoteTags(note, tagNames, req.session.userId);
|
||||
|
||||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ["name", "uid"],
|
||||
through: { attributes: [] }
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ["name", "uid"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
...noteWithAssociations.toJSON(),
|
||||
uid: noteWithAssociations.uid
|
||||
});
|
||||
} catch (error) {
|
||||
logError("Error creating note:", error);
|
||||
res.status(400).json({
|
||||
error: "There was a problem creating the note.",
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/note/:uid",
|
||||
hasAccess(
|
||||
"rw",
|
||||
"note",
|
||||
async (req) => {
|
||||
return extractUidFromSlug(req.params.uid);
|
||||
},
|
||||
{ notFoundMessage: "Note not found." }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id },
|
||||
where: { uid: req.params.uid }
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
// access ensured by middleware
|
||||
|
||||
const { title, content, project_id, tags } = req.body;
|
||||
const { title, content, project_uid, tags } = req.body;
|
||||
|
||||
const updateData = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (content !== undefined) updateData.content = content;
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id !== undefined) {
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
if (project_uid !== undefined) {
|
||||
if (project_uid && typeof project_uid === 'string' && project_uid.trim()) {
|
||||
const projectUidValue = project_uid.trim();
|
||||
const project = await Project.findOne({
|
||||
where: { id: project_id },
|
||||
where: { uid: projectUidValue }
|
||||
});
|
||||
if (!project) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid project.' });
|
||||
.json({ error: "Invalid project." });
|
||||
}
|
||||
const projectAccess = await permissionsService.getAccess(
|
||||
req.session.userId,
|
||||
'project',
|
||||
"project",
|
||||
project.uid
|
||||
);
|
||||
const isOwner = project.user_id === req.session.userId;
|
||||
const canWrite =
|
||||
isOwner ||
|
||||
projectAccess === 'rw' ||
|
||||
projectAccess === 'admin';
|
||||
projectAccess === "rw" ||
|
||||
projectAccess === "admin";
|
||||
if (!canWrite) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
updateData.project_id = project_id;
|
||||
updateData.project_id = project.id;
|
||||
} else {
|
||||
updateData.project_id = null;
|
||||
}
|
||||
|
|
@ -316,10 +268,10 @@ router.patch(
|
|||
if (tags !== undefined) {
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every((t) => typeof t === 'string')) {
|
||||
if (tags.every((t) => typeof t === "string")) {
|
||||
tagNames = tags;
|
||||
} else if (
|
||||
tags.every((t) => typeof t === 'object' && t.name)
|
||||
tags.every((t) => typeof t === "object" && t.name)
|
||||
) {
|
||||
tagNames = tags.map((t) => t.name);
|
||||
}
|
||||
|
|
@ -332,63 +284,51 @@ router.patch(
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
through: { attributes: [] },
|
||||
attributes: ["id", "name", "uid"],
|
||||
through: { attributes: [] }
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
},
|
||||
],
|
||||
attributes: ["id", "name", "uid"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
res.json(noteWithAssociations);
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error);
|
||||
logError("Error updating note:", error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the note.',
|
||||
error: "There was a problem updating the note.",
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
: [error.message]
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/note/:id
|
||||
router.delete(
|
||||
'/note/:id',
|
||||
"/note/:uid",
|
||||
hasAccess(
|
||||
'rw',
|
||||
'note',
|
||||
"rw",
|
||||
"note",
|
||||
async (req) => {
|
||||
const n = await Note.findOne({
|
||||
where: { id: req.params.id },
|
||||
attributes: ['uid'],
|
||||
});
|
||||
return n?.uid;
|
||||
return extractUidFromSlug(req.params.uid);
|
||||
},
|
||||
{ notFoundMessage: 'Note not found.' }
|
||||
{ notFoundMessage: "Note not found." }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id },
|
||||
where: { uid: req.params.uid }
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
// access ensured by middleware
|
||||
|
||||
await note.destroy();
|
||||
res.json({ message: 'Note deleted successfully.' });
|
||||
res.json({ message: "Note deleted successfully." });
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the note.',
|
||||
});
|
||||
logError("Error deleting note:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ const { Project, Task, Tag, Area, Note, sequelize } = require('../models');
|
|||
const permissionsService = require('../services/permissionsService');
|
||||
const { Op } = require('sequelize');
|
||||
const { extractUidFromSlug } = require('../utils/slug-utils');
|
||||
const { validateTagName } = require('../utils/validation');
|
||||
const { validateTagName } = require('../services/tagsService');
|
||||
const { uid } = require('../utils/uid');
|
||||
const { logError } = require('../services/logService');
|
||||
const router = express.Router();
|
||||
const { hasAccess } = require('../middleware/authorize');
|
||||
|
||||
|
|
@ -115,10 +116,6 @@ async function updateProjectTags(project, tagsData, userId) {
|
|||
// POST /api/upload/project-image
|
||||
router.post('/upload/project-image', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image file provided' });
|
||||
}
|
||||
|
|
@ -127,7 +124,7 @@ router.post('/upload/project-image', upload.single('image'), (req, res) => {
|
|||
const imageUrl = `/api/uploads/projects/${req.file.filename}`;
|
||||
res.json({ imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
logError('Error uploading image:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
});
|
||||
|
|
@ -135,11 +132,7 @@ router.post('/upload/project-image', upload.single('image'), (req, res) => {
|
|||
// GET /api/projects
|
||||
router.get('/projects', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { active, pin_to_sidebar, area_id, area } = req.query;
|
||||
const { state, active, pin_to_sidebar, area_id, area } = req.query;
|
||||
|
||||
// Base: owned or shared projects
|
||||
const ownedOrShared =
|
||||
|
|
@ -149,11 +142,22 @@ router.get('/projects', async (req, res) => {
|
|||
);
|
||||
let whereClause = ownedOrShared;
|
||||
|
||||
// Filter by active status
|
||||
// Filter by state (new primary filter)
|
||||
if (state && state !== 'all') {
|
||||
if (Array.isArray(state)) {
|
||||
whereClause.state = { [Op.in]: state };
|
||||
} else {
|
||||
whereClause.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy support for active filter - map to states
|
||||
if (active === 'true') {
|
||||
whereClause.active = true;
|
||||
whereClause.state = {
|
||||
[Op.in]: ['planned', 'in_progress', 'blocked'],
|
||||
};
|
||||
} else if (active === 'false') {
|
||||
whereClause.active = false;
|
||||
whereClause.state = { [Op.in]: ['idea', 'completed'] };
|
||||
}
|
||||
|
||||
// Filter by pinned status
|
||||
|
|
@ -252,7 +256,7 @@ router.get('/projects', async (req, res) => {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
logError('Error fetching projects:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
|
@ -264,19 +268,13 @@ router.get(
|
|||
'ro',
|
||||
'project',
|
||||
async (req) => {
|
||||
const uidPart = req.params.uidSlug.split('-')[0];
|
||||
const p = await Project.findOne({
|
||||
where: { uid: uidPart },
|
||||
attributes: ['uid'],
|
||||
});
|
||||
return p?.uid;
|
||||
return extractUidFromSlug(req.params.uidSlug);
|
||||
},
|
||||
{ notFoundMessage: 'Project not found' }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
// Extract UID from slug and fetch full project with associations
|
||||
const uidPart = req.params.uidSlug.split('-')[0];
|
||||
const uidPart = extractUidFromSlug(req.params.uidSlug);
|
||||
const project = await Project.findOne({
|
||||
where: { uid: uidPart },
|
||||
include: [
|
||||
|
|
@ -285,8 +283,7 @@ router.get(
|
|||
required: false,
|
||||
where: {
|
||||
parent_task_id: null,
|
||||
recurring_parent_id: null, // Exclude recurring task instances, only show templates
|
||||
// Include ALL tasks regardless of status for client-side filtering
|
||||
recurring_parent_id: null,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
|
@ -342,10 +339,6 @@ router.get(
|
|||
],
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const projectJson = project.toJSON();
|
||||
|
||||
// Normalize task data to match frontend expectations
|
||||
|
|
@ -390,7 +383,7 @@ router.get(
|
|||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching project:', error);
|
||||
logError('Error fetching project:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -399,18 +392,14 @@ router.get(
|
|||
// POST /api/project
|
||||
router.post('/project', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
area_id,
|
||||
active,
|
||||
priority,
|
||||
due_date_at,
|
||||
image_url,
|
||||
state,
|
||||
tags,
|
||||
Tags,
|
||||
} = req.body;
|
||||
|
|
@ -430,11 +419,11 @@ router.post('/project', async (req, res) => {
|
|||
name: name.trim(),
|
||||
description: description || '',
|
||||
area_id: area_id || null,
|
||||
active: active !== undefined ? active : true,
|
||||
pin_to_sidebar: false,
|
||||
priority: priority || null,
|
||||
due_date_at: due_date_at || null,
|
||||
image_url: image_url || null,
|
||||
state: state || 'idea',
|
||||
user_id: req.session.userId,
|
||||
};
|
||||
|
||||
|
|
@ -445,7 +434,7 @@ router.post('/project', async (req, res) => {
|
|||
try {
|
||||
await updateProjectTags(project, tagsData, req.session.userId);
|
||||
} catch (tagError) {
|
||||
console.warn(
|
||||
logError(
|
||||
'Tag update failed, but project created successfully:',
|
||||
tagError.message
|
||||
);
|
||||
|
|
@ -458,7 +447,7 @@ router.post('/project', async (req, res) => {
|
|||
due_date_at: formatDate(project.due_date_at),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
logError('Error creating project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the project.',
|
||||
details: error.errors
|
||||
|
|
@ -468,38 +457,32 @@ router.post('/project', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// PATCH /api/project/:id
|
||||
// PATCH /api/project/:uid
|
||||
router.patch(
|
||||
'/project/:id',
|
||||
'/project/:uid',
|
||||
hasAccess(
|
||||
'rw',
|
||||
'project',
|
||||
async (req) => {
|
||||
const p = await Project.findByPk(req.params.id, {
|
||||
attributes: ['uid'],
|
||||
});
|
||||
return p?.uid;
|
||||
return extractUidFromSlug(req.params.uid);
|
||||
},
|
||||
{ notFoundMessage: 'Project not found.' }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
// Load project and check RW access (owner/admin or shared rw)
|
||||
const project = await Project.findByPk(req.params.id);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found.' });
|
||||
}
|
||||
// access ensured by middleware
|
||||
const project = await Project.findOne({
|
||||
where: { uid: req.params.uid },
|
||||
});
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
area_id,
|
||||
active,
|
||||
pin_to_sidebar,
|
||||
priority,
|
||||
due_date_at,
|
||||
image_url,
|
||||
state,
|
||||
tags,
|
||||
Tags,
|
||||
} = req.body;
|
||||
|
|
@ -511,12 +494,12 @@ router.patch(
|
|||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (area_id !== undefined) updateData.area_id = area_id;
|
||||
if (active !== undefined) updateData.active = active;
|
||||
if (pin_to_sidebar !== undefined)
|
||||
updateData.pin_to_sidebar = pin_to_sidebar;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (due_date_at !== undefined) updateData.due_date_at = due_date_at;
|
||||
if (image_url !== undefined) updateData.image_url = image_url;
|
||||
if (state !== undefined) updateData.state = state;
|
||||
|
||||
await project.update(updateData);
|
||||
await updateProjectTags(project, tagsData, req.session.userId);
|
||||
|
|
@ -536,11 +519,11 @@ router.patch(
|
|||
|
||||
res.json({
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
tags: projectJson.Tags || [],
|
||||
due_date_at: formatDate(projectWithAssociations.due_date_at),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
logError('Error updating project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the project.',
|
||||
details: error.errors
|
||||
|
|
@ -551,34 +534,27 @@ router.patch(
|
|||
}
|
||||
);
|
||||
|
||||
// DELETE /api/project/:id
|
||||
// DELETE /api/project/:uid
|
||||
router.delete(
|
||||
'/project/:id',
|
||||
'/project/:uid',
|
||||
hasAccess(
|
||||
'rw',
|
||||
'project',
|
||||
async (req) => {
|
||||
const p = await Project.findByPk(req.params.id, {
|
||||
attributes: ['uid'],
|
||||
});
|
||||
return p?.uid;
|
||||
return extractUidFromSlug(req.params.uid);
|
||||
},
|
||||
{ notFoundMessage: 'Project not found.' }
|
||||
),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const project = await Project.findByPk(req.params.id);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found.' });
|
||||
}
|
||||
// access ensured by middleware
|
||||
const project = await Project.findOne({
|
||||
where: { uid: req.params.uid },
|
||||
});
|
||||
|
||||
// Use a transaction to ensure atomicity
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
// Disable foreign key constraints for this operation
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF', {
|
||||
transaction,
|
||||
});
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF', { transaction });
|
||||
|
||||
try {
|
||||
// First, orphan all tasks associated with this project by setting project_id to NULL
|
||||
|
|
@ -586,7 +562,7 @@ router.delete(
|
|||
{ project_id: null },
|
||||
{
|
||||
where: {
|
||||
project_id: req.params.id,
|
||||
project_id: project.id,
|
||||
user_id: req.session.userId,
|
||||
},
|
||||
transaction,
|
||||
|
|
@ -605,7 +581,7 @@ router.delete(
|
|||
|
||||
res.json({ message: 'Project successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
logError('Error deleting project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the project.',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,36 +1,35 @@
|
|||
const express = require('express');
|
||||
const { Tag, Task, Note, Project, sequelize } = require('../models');
|
||||
const { extractUidFromSlug } = require('../utils/slug-utils');
|
||||
const { validateTagName } = require('../utils/validation');
|
||||
const { validateTagName } = require('../services/tagsService');
|
||||
const router = express.Router();
|
||||
const _ = require('lodash');
|
||||
const { Op } = require('sequelize');
|
||||
const { logError } = require('../services/logService');
|
||||
|
||||
// GET /api/tags
|
||||
router.get('/tags', async (req, res) => {
|
||||
try {
|
||||
const tags = await Tag.findAll({
|
||||
where: { user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
attributes: ['name', 'uid'],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
res.json(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
logError('Error fetching tags:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tag/:identifier (supports both ID, name, and uid-slug)
|
||||
// GET /api/tag/:identifier (supports name and uid)
|
||||
router.get('/tag', async (req, res) => {
|
||||
try {
|
||||
const { id, uid, name } = req.query;
|
||||
const { uid, name } = req.query;
|
||||
|
||||
let whereClause = {
|
||||
user_id: req.currentUser.id,
|
||||
};
|
||||
if (!_.isEmpty(id)) {
|
||||
whereClause.id = parseInt(id, 10);
|
||||
}
|
||||
if (!_.isEmpty(uid)) {
|
||||
whereClause.uid = uid;
|
||||
}
|
||||
|
|
@ -43,13 +42,13 @@ router.get('/tag', async (req, res) => {
|
|||
attributes: ['name', 'uid'],
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
if (_.isEmpty(tag)) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
res.json(tag);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tag:', error);
|
||||
logError('Error fetching tag:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
|
@ -70,12 +69,11 @@ router.post('/tag', async (req, res) => {
|
|||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: tag.id,
|
||||
uid: tag.uid, // Explicitly include uid
|
||||
uid: tag.uid,
|
||||
name: tag.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating tag:', error);
|
||||
logError('Error creating tag:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the tag.',
|
||||
});
|
||||
|
|
@ -85,21 +83,13 @@ router.post('/tag', async (req, res) => {
|
|||
// PATCH /api/tag/:identifier (supports both ID and name)
|
||||
router.patch('/tag/:identifier', async (req, res) => {
|
||||
try {
|
||||
const identifier = req.params.identifier;
|
||||
let whereClause;
|
||||
|
||||
// Check if identifier is a number (ID) or string (name)
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
// It's a numeric ID
|
||||
whereClause = {
|
||||
id: parseInt(identifier),
|
||||
user_id: req.currentUser.id,
|
||||
};
|
||||
} else {
|
||||
// It's a tag name - decode URI component to handle special characters
|
||||
const tagName = decodeURIComponent(identifier);
|
||||
whereClause = { name: tagName, user_id: req.currentUser.id };
|
||||
}
|
||||
const param = decodeURIComponent(req.params.identifier);
|
||||
let whereClause = {
|
||||
[Op.or]: [
|
||||
{ name: param, user_id: req.currentUser.id },
|
||||
{ uid: param, user_id: req.currentUser.id },
|
||||
],
|
||||
};
|
||||
|
||||
const tag = await Tag.findOne({
|
||||
where: whereClause,
|
||||
|
|
@ -110,7 +100,6 @@ router.patch('/tag/:identifier', async (req, res) => {
|
|||
}
|
||||
|
||||
const { name } = req.body;
|
||||
|
||||
const validation = validateTagName(name);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
|
|
@ -123,90 +112,62 @@ router.patch('/tag/:identifier', async (req, res) => {
|
|||
name: tag.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating tag:', error);
|
||||
logError('Error updating tag:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the tag.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tag/:identifier (supports both ID and name)
|
||||
// DELETE /api/tag/:identifier (supports uid and name)
|
||||
router.delete('/tag/:identifier', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const identifier = req.params.identifier;
|
||||
let whereClause;
|
||||
|
||||
// Check if identifier is a number (ID) or string (name)
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
// It's a numeric ID
|
||||
whereClause = {
|
||||
id: parseInt(identifier),
|
||||
user_id: req.currentUser.id,
|
||||
};
|
||||
} else {
|
||||
// It's a tag name - decode URI component to handle special characters
|
||||
const tagName = decodeURIComponent(identifier);
|
||||
whereClause = { name: tagName, user_id: req.currentUser.id };
|
||||
}
|
||||
const param = decodeURIComponent(req.params.identifier);
|
||||
let whereClause = {
|
||||
[Op.or]: [
|
||||
{ name: param, user_id: req.currentUser.id },
|
||||
{ uid: param, user_id: req.currentUser.id },
|
||||
],
|
||||
};
|
||||
|
||||
const tag = await Tag.findOne({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
if (_.isEmpty(tag)) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
// Use transaction to ensure all deletions happen atomically
|
||||
// Remove all associations before deleting the tag by manually deleting from junction tables
|
||||
// Only delete from tables that exist
|
||||
try {
|
||||
await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
|
||||
await Promise.all([
|
||||
sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('tasks_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
|
||||
}),
|
||||
sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('notes_tags table not found, skipping');
|
||||
}
|
||||
}),
|
||||
sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
|
||||
try {
|
||||
await sequelize.query(
|
||||
'DELETE FROM projects_tags WHERE tag_id = ?',
|
||||
{
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('projects_tags table not found, skipping');
|
||||
}
|
||||
|
||||
// Now safely delete the tag
|
||||
await tag.destroy({ transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.json({ message: 'Tag successfully deleted' });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error deleting tag:', error);
|
||||
logError('Error deleting tag:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the tag.',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,34 +1,53 @@
|
|||
const express = require('express');
|
||||
const { TaskEvent } = require('../models');
|
||||
const { Task, TaskEvent } = require('../models');
|
||||
const { isValidUid } = require('../utils/slug-utils');
|
||||
const {
|
||||
getTaskTimeline,
|
||||
getTaskCompletionTime,
|
||||
getUserProductivityMetrics,
|
||||
getTaskActivitySummary,
|
||||
} = require('../services/taskEventService');
|
||||
const { logError } = require('../services/logService');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/task/:id/timeline - Get task event timeline
|
||||
router.get('/task/:id/timeline', async (req, res) => {
|
||||
// GET /api/task/:uid/timeline - Get task event timeline
|
||||
router.get('/task/:uid/timeline', async (req, res) => {
|
||||
try {
|
||||
const timeline = await getTaskTimeline(req.params.id);
|
||||
if (!isValidUid(req.params.uid))
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
|
||||
// Filter to only show events for tasks owned by the current user
|
||||
const userTimeline = timeline.filter(
|
||||
(event) => event.user_id === req.currentUser.id
|
||||
);
|
||||
const task = await Task.findOne({
|
||||
where: { uid: req.params.uid, user_id: req.currentUser.id },
|
||||
});
|
||||
|
||||
res.json(userTimeline);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
const timeline = await getTaskTimeline(task.id);
|
||||
|
||||
res.json(timeline);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task timeline:', error);
|
||||
logError('Error fetching task timeline:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task timeline' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/task/:id/completion-time - Get task completion analytics
|
||||
router.get('/task/:id/completion-time', async (req, res) => {
|
||||
// GET /api/task/:uid/completion-time - Get task completion analytics
|
||||
router.get('/task/:uid/completion-time', async (req, res) => {
|
||||
try {
|
||||
const completionTime = await getTaskCompletionTime(req.params.id);
|
||||
if (!isValidUid(req.params.uid))
|
||||
return res.status(400).json({ error: 'Invalid UID' });
|
||||
|
||||
const task = await Task.findOne({
|
||||
where: { uid: req.params.uid, user_id: req.currentUser.id },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
const completionTime = await getTaskCompletionTime(task.id);
|
||||
|
||||
if (!completionTime) {
|
||||
return res
|
||||
|
|
@ -38,7 +57,7 @@ router.get('/task/:id/completion-time', async (req, res) => {
|
|||
|
||||
res.json(completionTime);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task completion time:', error);
|
||||
logError('Error fetching task completion time:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task completion time' });
|
||||
}
|
||||
});
|
||||
|
|
@ -56,7 +75,7 @@ router.get('/user/productivity-metrics', async (req, res) => {
|
|||
|
||||
res.json(metrics);
|
||||
} catch (error) {
|
||||
console.error('Error fetching productivity metrics:', error);
|
||||
logError('Error fetching productivity metrics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch productivity metrics' });
|
||||
}
|
||||
});
|
||||
|
|
@ -80,7 +99,7 @@ router.get('/user/activity-summary', async (req, res) => {
|
|||
|
||||
res.json(activitySummary);
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity summary:', error);
|
||||
logError('Error fetching activity summary:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity summary' });
|
||||
}
|
||||
});
|
||||
|
|
@ -88,7 +107,7 @@ router.get('/user/activity-summary', async (req, res) => {
|
|||
// GET /api/tasks/completion-analytics - Get completion time analytics for multiple tasks
|
||||
router.get('/tasks/completion-analytics', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0, projectId } = req.query;
|
||||
const { limit = 50, offset = 0, projectUid } = req.query;
|
||||
|
||||
// Get completed tasks for the user
|
||||
const { Task, Project } = require('../models');
|
||||
|
|
@ -99,8 +118,21 @@ router.get('/tasks/completion-analytics', async (req, res) => {
|
|||
status: 2, // completed
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
whereClause.project_id = projectId;
|
||||
// If projectUid is provided, find the project and filter by its ID
|
||||
if (projectUid) {
|
||||
if (!isValidUid(projectUid)) {
|
||||
return res.status(400).json({ error: 'Invalid project UID' });
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { uid: projectUid, user_id: req.currentUser.id },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
whereClause.project_id = project.id;
|
||||
}
|
||||
|
||||
const completedTasks = await Task.findAll({
|
||||
|
|
@ -163,7 +195,7 @@ router.get('/tasks/completion-analytics', async (req, res) => {
|
|||
summary,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching completion analytics:', error);
|
||||
logError('Error fetching completion analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch completion analytics' });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const {
|
|||
logTaskUpdate,
|
||||
getTaskTodayMoveCount,
|
||||
} = require('../services/taskEventService');
|
||||
const { validateTagName } = require('../utils/validation');
|
||||
const { validateTagName } = require('../services/tagsService');
|
||||
const {
|
||||
getSafeTimezone,
|
||||
getUpcomingRangeInUTC,
|
||||
|
|
@ -532,7 +532,7 @@ async function filterTasksByParams(params, userId, userTimezone) {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
attributes: ['id', 'name', 'state', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -742,7 +742,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'state', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -787,7 +787,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'state', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -845,7 +845,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'state', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -914,7 +914,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -959,7 +959,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -1015,7 +1015,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -1069,7 +1069,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active', 'uid'],
|
||||
attributes: ['id', 'name', 'state', 'uid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ router.get('/profile', async (req, res) => {
|
|||
|
||||
const user = await User.findByPk(req.session.userId, {
|
||||
attributes: [
|
||||
'id',
|
||||
'uid',
|
||||
'email',
|
||||
'appearance',
|
||||
'language',
|
||||
|
|
@ -172,7 +172,7 @@ router.patch('/profile', async (req, res) => {
|
|||
// Return updated user with limited fields
|
||||
const updatedUser = await User.findByPk(user.id, {
|
||||
attributes: [
|
||||
'id',
|
||||
'uid',
|
||||
'email',
|
||||
'appearance',
|
||||
'language',
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ async function seedDatabase() {
|
|||
description: 'Complete overhaul of company website',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[1].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -73,14 +73,14 @@ async function seedDatabase() {
|
|||
description: 'Master mobile app development',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[3].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
},
|
||||
{
|
||||
name: 'Home Renovation',
|
||||
description: 'Kitchen and bathroom updates',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[4].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), // 60 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -88,7 +88,7 @@ async function seedDatabase() {
|
|||
description: '90-day fitness transformation',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[2].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -96,14 +96,14 @@ async function seedDatabase() {
|
|||
description: 'Launch online consulting service',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[1].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
},
|
||||
{
|
||||
name: 'Investment Portfolio',
|
||||
description: 'Build diversified investment portfolio',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[5].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 120 * 24 * 60 * 60 * 1000), // 120 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -111,7 +111,7 @@ async function seedDatabase() {
|
|||
description: 'Plan and execute 3-week European vacation',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[6].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000), // 180 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -119,14 +119,14 @@ async function seedDatabase() {
|
|||
description: 'Learn advanced photography techniques',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[7].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
},
|
||||
{
|
||||
name: 'Professional Certification',
|
||||
description: 'Get AWS Solutions Architect certification',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[9].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 150 * 24 * 60 * 60 * 1000), // 150 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -134,7 +134,7 @@ async function seedDatabase() {
|
|||
description: 'Transform backyard into productive garden',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[4].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), // 45 days from now
|
||||
},
|
||||
{
|
||||
|
|
@ -142,21 +142,21 @@ async function seedDatabase() {
|
|||
description: 'Start personal tech blog',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[0].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
},
|
||||
{
|
||||
name: 'Language Learning Spanish',
|
||||
description: 'Become conversational in Spanish',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[3].id,
|
||||
active: false, // Paused project
|
||||
state: 'blocked', // Paused project
|
||||
},
|
||||
{
|
||||
name: 'Wedding Planning',
|
||||
description: 'Plan and organize wedding ceremony',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[8].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now
|
||||
},
|
||||
{
|
||||
|
|
@ -164,14 +164,14 @@ async function seedDatabase() {
|
|||
description: 'Establish weekly meal preparation routine',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[2].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
},
|
||||
{
|
||||
name: 'Smart Home Setup',
|
||||
description: 'Install and configure smart home devices',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[4].id,
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
due_date_at: new Date(Date.now() + 21 * 24 * 60 * 60 * 1000), // 21 days from now
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
9
backend/services/logService.js
Normal file
9
backend/services/logService.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const logError = console.error;
|
||||
const logInfo = console.log;
|
||||
const logDebug = console.log;
|
||||
|
||||
module.exports = {
|
||||
logError,
|
||||
logInfo,
|
||||
logDebug,
|
||||
};
|
||||
|
|
@ -1,11 +1,5 @@
|
|||
const { TaskEvent } = require('../models');
|
||||
|
||||
// Helper function to add default source to metadata
|
||||
const addDefaultSource = (metadata) => ({
|
||||
source: 'web',
|
||||
...metadata,
|
||||
});
|
||||
|
||||
// Helper function to create value object
|
||||
const createValueObject = (fieldName, value) =>
|
||||
value ? { [fieldName || 'value']: value } : null;
|
||||
|
|
@ -31,7 +25,10 @@ const logEvent = async ({
|
|||
metadata = {},
|
||||
}) => {
|
||||
try {
|
||||
const finalMetadata = addDefaultSource(metadata);
|
||||
const finalMetadata = {
|
||||
source: 'web',
|
||||
...metadata,
|
||||
};
|
||||
|
||||
const event = await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ describe('Areas Routes', () => {
|
|||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(areaData.name);
|
||||
expect(response.body.description).toBe(areaData.description);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
expect(response.body.uid).toBeDefined();
|
||||
expect(typeof response.body.uid).toBe('string');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
|
|
@ -81,8 +82,8 @@ describe('Areas Routes', () => {
|
|||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(response.body.map((a) => a.id)).toContain(area1.id);
|
||||
expect(response.body.map((a) => a.id)).toContain(area2.id);
|
||||
expect(response.body.map((a) => a.uid)).toContain(area1.uid);
|
||||
expect(response.body.map((a) => a.uid)).toContain(area2.uid);
|
||||
});
|
||||
|
||||
it('should order areas by name', async () => {
|
||||
|
|
@ -101,7 +102,7 @@ describe('Areas Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /api/areas/:id', () => {
|
||||
describe('GET /api/areas/:uid', () => {
|
||||
let area;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -112,17 +113,24 @@ describe('Areas Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should get area by id', async () => {
|
||||
const response = await agent.get(`/api/areas/${area.id}`);
|
||||
it('should get area by uid', async () => {
|
||||
const response = await agent.get(`/api/areas/${area.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(area.id);
|
||||
expect(response.body.uid).toBe(area.uid);
|
||||
expect(response.body.name).toBe(area.name);
|
||||
expect(response.body.description).toBe(area.description);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid uid format', async () => {
|
||||
const response = await agent.get('/api/areas/invalid-uid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent.get('/api/areas/999999');
|
||||
const response = await agent.get('/api/areas/abcd1234efghijk');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe(
|
||||
|
|
@ -142,7 +150,7 @@ describe('Areas Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/areas/${otherArea.id}`);
|
||||
const response = await agent.get(`/api/areas/${otherArea.uid}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe(
|
||||
|
|
@ -151,14 +159,14 @@ describe('Areas Routes', () => {
|
|||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/areas/${area.id}`);
|
||||
const response = await request(app).get(`/api/areas/${area.uid}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/areas/:id', () => {
|
||||
describe('PATCH /api/areas/:uid', () => {
|
||||
let area;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -176,7 +184,7 @@ describe('Areas Routes', () => {
|
|||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/areas/${area.id}`)
|
||||
.patch(`/api/areas/${area.uid}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -184,9 +192,18 @@ describe('Areas Routes', () => {
|
|||
expect(response.body.description).toBe(updateData.description);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid uid format', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/areas/invalid-uid')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/areas/999999')
|
||||
.patch('/api/areas/abcd1234efghijk')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -206,7 +223,7 @@ describe('Areas Routes', () => {
|
|||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/areas/${otherArea.id}`)
|
||||
.patch(`/api/areas/${otherArea.uid}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -215,7 +232,7 @@ describe('Areas Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/areas/${area.id}`)
|
||||
.patch(`/api/areas/${area.uid}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -223,7 +240,7 @@ describe('Areas Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/areas/:id', () => {
|
||||
describe('DELETE /api/areas/:uid', () => {
|
||||
let area;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -234,17 +251,26 @@ describe('Areas Routes', () => {
|
|||
});
|
||||
|
||||
it('should delete area', async () => {
|
||||
const response = await agent.delete(`/api/areas/${area.id}`);
|
||||
const response = await agent.delete(`/api/areas/${area.uid}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Verify area is deleted
|
||||
const deletedArea = await Area.findByPk(area.id);
|
||||
const deletedArea = await Area.findOne({
|
||||
where: { uid: area.uid },
|
||||
});
|
||||
expect(deletedArea).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid uid format', async () => {
|
||||
const response = await agent.delete('/api/areas/invalid-uid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent.delete('/api/areas/999999');
|
||||
const response = await agent.delete('/api/areas/abcd1234efghijk');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
|
|
@ -262,14 +288,16 @@ describe('Areas Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/areas/${otherArea.id}`);
|
||||
const response = await agent.delete(`/api/areas/${otherArea.uid}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/areas/${area.id}`);
|
||||
const response = await request(app).delete(
|
||||
`/api/areas/${area.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ describe('Auth Routes', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeDefined();
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
expect(response.body.user.id).toBe(user.id);
|
||||
expect(response.body.user.uid).toBe(user.uid);
|
||||
expect(response.body.user).not.toHaveProperty('id');
|
||||
expect(response.body.user.language).toBe('en');
|
||||
expect(response.body.user.appearance).toBe('light');
|
||||
expect(response.body.user.timezone).toBe('UTC');
|
||||
|
|
@ -91,7 +92,8 @@ describe('Auth Routes', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeDefined();
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
expect(response.body.user.id).toBe(user.id);
|
||||
expect(response.body.user.uid).toBe(user.uid);
|
||||
expect(response.body.user).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('should return null user when not logged in', async () => {
|
||||
|
|
|
|||
|
|
@ -52,15 +52,16 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map((item) => item.id)).toContain(
|
||||
inboxItem1.id
|
||||
expect(response.body.map((item) => item.uid)).toContain(
|
||||
inboxItem1.uid
|
||||
);
|
||||
expect(response.body.map((item) => item.id)).toContain(
|
||||
inboxItem2.id
|
||||
expect(response.body.map((item) => item.uid)).toContain(
|
||||
inboxItem2.uid
|
||||
);
|
||||
expect(response.body[0].content).toBeDefined();
|
||||
expect(response.body[0].status).toBe('added');
|
||||
expect(response.body[0].user_id).toBe(user.id);
|
||||
expect(response.body[0].uid).toBeDefined();
|
||||
expect(typeof response.body[0].uid).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle mixed inbox items when no tags exist', async () => {
|
||||
|
|
@ -90,7 +91,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(1); // Only 'added' items should be returned
|
||||
expect(response.body[0].id).toBe(addedItem.id);
|
||||
expect(response.body[0].uid).toBe(addedItem.uid);
|
||||
expect(response.body[0].status).toBe('added');
|
||||
});
|
||||
});
|
||||
|
|
@ -139,7 +140,8 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
expect(response.body.content).toBe(inboxData.content);
|
||||
expect(response.body.source).toBe(inboxData.source);
|
||||
expect(response.body.status).toBe('added');
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
expect(response.body.uid).toBeDefined();
|
||||
expect(typeof response.body.uid).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle multiple inbox items creation when no tags exist', async () => {
|
||||
|
|
@ -164,7 +166,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id - No Tags Scenario', () => {
|
||||
describe('PATCH /api/inbox/:uid - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -183,7 +185,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.patch(`/api/inbox/${inboxItem.uid}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -192,7 +194,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id/process - No Tags Scenario', () => {
|
||||
describe('PATCH /api/inbox/:uid/process - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -206,7 +208,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
|
||||
it('should process inbox items when no tags exist', async () => {
|
||||
const response = await agent.patch(
|
||||
`/api/inbox/${inboxItem.id}/process`
|
||||
`/api/inbox/${inboxItem.uid}/process`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -214,7 +216,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/inbox/:id - No Tags Scenario', () => {
|
||||
describe('DELETE /api/inbox/:uid - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -227,7 +229,7 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
});
|
||||
|
||||
it('should delete inbox items when no tags exist', async () => {
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(
|
||||
|
|
@ -235,7 +237,9 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
);
|
||||
|
||||
// Verify item status is updated to deleted
|
||||
const deletedItem = await InboxItem.findByPk(inboxItem.id);
|
||||
const deletedItem = await InboxItem.findOne({
|
||||
where: { uid: inboxItem.uid },
|
||||
});
|
||||
expect(deletedItem).not.toBeNull();
|
||||
expect(deletedItem.status).toBe('deleted');
|
||||
});
|
||||
|
|
@ -253,24 +257,24 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
.post('/api/inbox')
|
||||
.send({ content: 'Complete workflow test', source: 'web' });
|
||||
expect(createResponse.status).toBe(201);
|
||||
const itemId = createResponse.body.id;
|
||||
const itemUid = createResponse.body.uid;
|
||||
|
||||
// Step 3: Retrieve inbox items
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(1);
|
||||
expect(getResponse.body[0].id).toBe(itemId);
|
||||
expect(getResponse.body[0].uid).toBe(itemUid);
|
||||
|
||||
// Step 4: Update inbox item
|
||||
const updateResponse = await agent
|
||||
.patch(`/api/inbox/${itemId}`)
|
||||
.patch(`/api/inbox/${itemUid}`)
|
||||
.send({ content: 'Updated workflow test' });
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updateResponse.body.content).toBe('Updated workflow test');
|
||||
|
||||
// Step 5: Process inbox item
|
||||
const processResponse = await agent.patch(
|
||||
`/api/inbox/${itemId}/process`
|
||||
`/api/inbox/${itemUid}/process`
|
||||
);
|
||||
expect(processResponse.status).toBe(200);
|
||||
expect(processResponse.body.status).toBe('processed');
|
||||
|
|
@ -303,9 +307,11 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
expect(getResponse.body.length).toBe(5);
|
||||
|
||||
// Process all items concurrently
|
||||
const itemIds = createResponses.map((response) => response.body.id);
|
||||
const processPromises = itemIds.map((id) =>
|
||||
agent.patch(`/api/inbox/${id}/process`)
|
||||
const itemUids = createResponses.map(
|
||||
(response) => response.body.uid
|
||||
);
|
||||
const processPromises = itemUids.map((uid) =>
|
||||
agent.patch(`/api/inbox/${uid}/process`)
|
||||
);
|
||||
|
||||
const processResponses = await Promise.all(processPromises);
|
||||
|
|
@ -326,26 +332,28 @@ describe('Inbox Routes - No Tags Scenario', () => {
|
|||
describe('Error Handling - No Tags Scenario', () => {
|
||||
it('should handle invalid inbox item operations gracefully when no tags exist', async () => {
|
||||
// Try to get non-existent item
|
||||
const getResponse = await agent.get('/api/inbox/999999');
|
||||
expect(getResponse.status).toBe(404);
|
||||
expect(getResponse.body.error).toBe('Inbox item not found.');
|
||||
const getResponse = await agent.get('/api/inbox/invalid-uid');
|
||||
expect(getResponse.status).toBe(400);
|
||||
expect(getResponse.body.error).toBe('Invalid UID');
|
||||
|
||||
// Try to update non-existent item
|
||||
const updateResponse = await agent
|
||||
.patch('/api/inbox/999999')
|
||||
.patch('/api/inbox/abcd1234efghijk')
|
||||
.send({ content: 'Updated' });
|
||||
expect(updateResponse.status).toBe(404);
|
||||
expect(updateResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to process non-existent item
|
||||
const processResponse = await agent.patch(
|
||||
'/api/inbox/999999/process'
|
||||
'/api/inbox/abcd1234efghijk/process'
|
||||
);
|
||||
expect(processResponse.status).toBe(404);
|
||||
expect(processResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to delete non-existent item
|
||||
const deleteResponse = await agent.delete('/api/inbox/999999');
|
||||
const deleteResponse = await agent.delete(
|
||||
'/api/inbox/abcd1234efghijk'
|
||||
);
|
||||
expect(deleteResponse.status).toBe(404);
|
||||
expect(deleteResponse.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ describe('Inbox Routes', () => {
|
|||
expect(response.body.content).toBe(inboxData.content);
|
||||
expect(response.body.source).toBe(inboxData.source);
|
||||
expect(response.body.status).toBe('added');
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
expect(response.body.uid).toBeDefined();
|
||||
expect(typeof response.body.uid).toBe('string');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
|
|
@ -83,7 +84,7 @@ describe('Inbox Routes', () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(1); // Only items with status 'added' are returned
|
||||
expect(response.body.map((i) => i.id)).toContain(inboxItem1.id);
|
||||
expect(response.body.map((i) => i.uid)).toContain(inboxItem1.uid);
|
||||
});
|
||||
|
||||
it('should only return items with added status', async () => {
|
||||
|
|
@ -91,7 +92,7 @@ describe('Inbox Routes', () => {
|
|||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(1);
|
||||
expect(response.body[0].id).toBe(inboxItem1.id);
|
||||
expect(response.body[0].uid).toBe(inboxItem1.uid);
|
||||
expect(response.body[0].status).toBe('added');
|
||||
});
|
||||
|
||||
|
|
@ -129,9 +130,9 @@ describe('Inbox Routes', () => {
|
|||
expect(response.body.length).toBe(4); // Including the item from beforeEach
|
||||
|
||||
// Check that items are ordered by newest first
|
||||
expect(response.body[0].id).toBe(item3.id);
|
||||
expect(response.body[1].id).toBe(item2.id);
|
||||
expect(response.body[2].id).toBe(item1.id);
|
||||
expect(response.body[0].uid).toBe(item3.uid);
|
||||
expect(response.body[1].uid).toBe(item2.uid);
|
||||
expect(response.body[2].uid).toBe(item1.uid);
|
||||
|
||||
// Verify the content matches expected order
|
||||
expect(response.body[0].content).toBe('Third item (newest)');
|
||||
|
|
@ -147,7 +148,7 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /api/inbox/:id', () => {
|
||||
describe('GET /api/inbox/:uid', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -158,16 +159,23 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should get inbox item by id', async () => {
|
||||
const response = await agent.get(`/api/inbox/${inboxItem.id}`);
|
||||
it('should get inbox item by uid', async () => {
|
||||
const response = await agent.get(`/api/inbox/${inboxItem.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(inboxItem.id);
|
||||
expect(response.body.uid).toBe(inboxItem.uid);
|
||||
expect(response.body.content).toBe(inboxItem.content);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid uid format', async () => {
|
||||
const response = await agent.get('/api/inbox/invalid-uid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.get('/api/inbox/999999');
|
||||
const response = await agent.get('/api/inbox/abcd1234efghijk');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
|
|
@ -186,7 +194,9 @@ describe('Inbox Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/inbox/${otherInboxItem.id}`);
|
||||
const response = await agent.get(
|
||||
`/api/inbox/${otherInboxItem.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
|
|
@ -194,7 +204,7 @@ describe('Inbox Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(
|
||||
`/api/inbox/${inboxItem.id}`
|
||||
`/api/inbox/${inboxItem.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -202,7 +212,7 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id', () => {
|
||||
describe('PATCH /api/inbox/:uid', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -221,7 +231,7 @@ describe('Inbox Routes', () => {
|
|||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.patch(`/api/inbox/${inboxItem.uid}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -229,9 +239,18 @@ describe('Inbox Routes', () => {
|
|||
expect(response.body.status).toBe(updateData.status);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid uid format', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/inbox/invalid-uid')
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/inbox/999999')
|
||||
.patch('/api/inbox/abcd1234efghijk')
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -240,7 +259,7 @@ describe('Inbox Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.patch(`/api/inbox/${inboxItem.uid}`)
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -248,7 +267,7 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/inbox/:id', () => {
|
||||
describe('DELETE /api/inbox/:uid', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -260,7 +279,7 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
|
||||
it('should delete inbox item', async () => {
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(
|
||||
|
|
@ -268,13 +287,22 @@ describe('Inbox Routes', () => {
|
|||
);
|
||||
|
||||
// Verify inbox item status is updated to deleted
|
||||
const deletedItem = await InboxItem.findByPk(inboxItem.id);
|
||||
const deletedItem = await InboxItem.findOne({
|
||||
where: { uid: inboxItem.uid },
|
||||
});
|
||||
expect(deletedItem).not.toBeNull();
|
||||
expect(deletedItem.status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid uid format', async () => {
|
||||
const response = await agent.delete('/api/inbox/invalid-uid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.delete('/api/inbox/999999');
|
||||
const response = await agent.delete('/api/inbox/abcd1234efghijk');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
|
|
@ -282,7 +310,7 @@ describe('Inbox Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(
|
||||
`/api/inbox/${inboxItem.id}`
|
||||
`/api/inbox/${inboxItem.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -290,7 +318,7 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id/process', () => {
|
||||
describe('PATCH /api/inbox/:uid/process', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -304,7 +332,7 @@ describe('Inbox Routes', () => {
|
|||
|
||||
it('should process inbox item', async () => {
|
||||
const response = await agent.patch(
|
||||
`/api/inbox/${inboxItem.id}/process`
|
||||
`/api/inbox/${inboxItem.uid}/process`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -312,7 +340,18 @@ describe('Inbox Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.patch('/api/inbox/999999/process');
|
||||
const response = await agent.patch(
|
||||
'/api/inbox/invalid-uid/process'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid UID');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent item', async () => {
|
||||
const response = await agent.patch(
|
||||
'/api/inbox/abcd1234efghijk/process'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
|
|
@ -320,7 +359,7 @@ describe('Inbox Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).patch(
|
||||
`/api/inbox/${inboxItem.id}/process`
|
||||
`/api/inbox/${inboxItem.uid}/process`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe('Notes Routes', () => {
|
|||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content',
|
||||
project_id: project.id,
|
||||
project_uid: project.uid,
|
||||
};
|
||||
|
||||
const response = await agent.post('/api/note').send(noteData);
|
||||
|
|
@ -140,7 +140,7 @@ describe('Notes Routes', () => {
|
|||
});
|
||||
|
||||
it('should get note by id', async () => {
|
||||
const response = await agent.get(`/api/note/${note.id}`);
|
||||
const response = await agent.get(`/api/note/${note.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(note.id);
|
||||
|
|
@ -149,7 +149,7 @@ describe('Notes Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent.get('/api/note/999999');
|
||||
const response = await agent.get('/api/note/abcd1234efghijk');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
|
|
@ -167,14 +167,14 @@ describe('Notes Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/note/${otherNote.id}`);
|
||||
const response = await agent.get(`/api/note/${otherNote.uid}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/note/${note.id}`);
|
||||
const response = await request(app).get(`/api/note/${note.uid}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
|
|
@ -196,11 +196,11 @@ describe('Notes Routes', () => {
|
|||
const updateData = {
|
||||
title: 'Updated Note',
|
||||
content: 'Updated content',
|
||||
project_id: project.id,
|
||||
project_uid: project.uid,
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/note/${note.id}`)
|
||||
.patch(`/api/note/${note.uid}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -211,7 +211,7 @@ describe('Notes Routes', () => {
|
|||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/note/999999')
|
||||
.patch('/api/note/abcd1234efghijk')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -231,7 +231,7 @@ describe('Notes Routes', () => {
|
|||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/note/${otherNote.id}`)
|
||||
.patch(`/api/note/${otherNote.uid}`)
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
|
@ -240,7 +240,7 @@ describe('Notes Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/note/${note.id}`)
|
||||
.patch(`/api/note/${note.uid}`)
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -259,7 +259,7 @@ describe('Notes Routes', () => {
|
|||
});
|
||||
|
||||
it('should delete note', async () => {
|
||||
const response = await agent.delete(`/api/note/${note.id}`);
|
||||
const response = await agent.delete(`/api/note/${note.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Note deleted successfully.');
|
||||
|
|
@ -270,7 +270,7 @@ describe('Notes Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent.delete('/api/note/999999');
|
||||
const response = await agent.delete('/api/note/abcd1234efghijk');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
|
|
@ -288,14 +288,14 @@ describe('Notes Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/note/${otherNote.id}`);
|
||||
const response = await agent.delete(`/api/note/${otherNote.uid}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/note/${note.id}`);
|
||||
const response = await request(app).delete(`/api/note/${note.uid}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe('Projects Routes', () => {
|
|||
const projectData = {
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: true,
|
||||
state: 'planned',
|
||||
pin_to_sidebar: false,
|
||||
priority: 1,
|
||||
area_id: area.id,
|
||||
|
|
@ -40,7 +40,7 @@ describe('Projects Routes', () => {
|
|||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(projectData.name);
|
||||
expect(response.body.description).toBe(projectData.description);
|
||||
expect(response.body.active).toBe(projectData.active);
|
||||
expect(response.body.state).toBe(projectData.state);
|
||||
expect(response.body.pin_to_sidebar).toBe(
|
||||
projectData.pin_to_sidebar
|
||||
);
|
||||
|
|
@ -217,7 +217,7 @@ describe('Projects Routes', () => {
|
|||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: false,
|
||||
state: 'idea',
|
||||
priority: 0,
|
||||
user_id: user.id,
|
||||
});
|
||||
|
|
@ -227,24 +227,24 @@ describe('Projects Routes', () => {
|
|||
const updateData = {
|
||||
name: 'Updated Project',
|
||||
description: 'Updated Description',
|
||||
active: true,
|
||||
state: 'in_progress',
|
||||
priority: 2,
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/project/${project.id}`)
|
||||
.patch(`/api/project/${project.uid}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.description).toBe(updateData.description);
|
||||
expect(response.body.active).toBe(updateData.active);
|
||||
expect(response.body.state).toBe(updateData.state);
|
||||
expect(response.body.priority).toBe(updateData.priority);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/project/999999')
|
||||
.patch('/api/project/nonexistentuid')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -264,7 +264,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/project/${otherProject.id}`)
|
||||
.patch(`/api/project/${otherProject.uid}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
|
@ -273,7 +273,7 @@ describe('Projects Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/project/${project.id}`)
|
||||
.patch(`/api/project/${project.uid}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -292,7 +292,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
it('should delete project', async () => {
|
||||
const response = await agent.delete(`/api/project/${project.id}`);
|
||||
const response = await agent.delete(`/api/project/${project.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Project successfully deleted');
|
||||
|
|
@ -303,7 +303,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent.delete('/api/project/999999');
|
||||
const response = await agent.delete('/api/project/nonexistentuid');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
|
|
@ -322,7 +322,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
const response = await agent.delete(
|
||||
`/api/project/${otherProject.id}`
|
||||
`/api/project/${otherProject.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
|
@ -331,7 +331,7 @@ describe('Projects Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(
|
||||
`/api/project/${project.id}`
|
||||
`/api/project/${project.uid}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -355,7 +355,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
// Delete the project
|
||||
const response = await agent.delete(`/api/project/${project.id}`);
|
||||
const response = await agent.delete(`/api/project/${project.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Project successfully deleted');
|
||||
|
|
@ -387,7 +387,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
// Delete the project
|
||||
const response = await agent.delete(`/api/project/${project.id}`);
|
||||
const response = await agent.delete(`/api/project/${project.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Project successfully deleted');
|
||||
|
|
@ -427,7 +427,7 @@ describe('Projects Routes', () => {
|
|||
});
|
||||
|
||||
// Delete the project
|
||||
const response = await agent.delete(`/api/project/${project.id}`);
|
||||
const response = await agent.delete(`/api/project/${project.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Project successfully deleted');
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe('Tags Routes', () => {
|
|||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(tagData.name);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.uid).toBeDefined();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
|
|
@ -61,7 +61,7 @@ describe('Tags Routes', () => {
|
|||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe('project:frontend');
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.uid).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow hyphen (-) in tag names', async () => {
|
||||
|
|
@ -73,7 +73,7 @@ describe('Tags Routes', () => {
|
|||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe('project-frontend');
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.uid).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject tags with invalid characters', async () => {
|
||||
|
|
@ -108,8 +108,8 @@ describe('Tags Routes', () => {
|
|||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(response.body.map((t) => t.id)).toContain(tag1.id);
|
||||
expect(response.body.map((t) => t.id)).toContain(tag2.id);
|
||||
expect(response.body.map((t) => t.uid)).toContain(tag1.uid);
|
||||
expect(response.body.map((t) => t.uid)).toContain(tag2.uid);
|
||||
});
|
||||
|
||||
it('should order tags by name', async () => {
|
||||
|
|
@ -138,15 +138,15 @@ describe('Tags Routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should get tag by id', async () => {
|
||||
const response = await agent.get(`/api/tag?id=${tag.id}`);
|
||||
it('should get tag by uid', async () => {
|
||||
const response = await agent.get(`/api/tag?uid=${tag.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(tag.name);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent.get('/api/tag?id=999999');
|
||||
const response = await agent.get('/api/tag?uid=non-existent-uid');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
|
|
@ -164,14 +164,14 @@ describe('Tags Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/tag?id=${otherTag.id}`);
|
||||
const response = await agent.get(`/api/tag?uid=${otherTag.uid}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/tag?id=${tag.id}`);
|
||||
const response = await request(app).get(`/api/tag?uid=${tag.uid}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
|
|
@ -194,7 +194,7 @@ describe('Tags Routes', () => {
|
|||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/tag/${tag.id}`)
|
||||
.patch(`/api/tag/${tag.uid}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -203,7 +203,7 @@ describe('Tags Routes', () => {
|
|||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/tag/999999')
|
||||
.patch('/api/tag/non-existent-uid')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -223,7 +223,7 @@ describe('Tags Routes', () => {
|
|||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/tag/${otherTag.id}`)
|
||||
.patch(`/api/tag/${otherTag.uid}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
@ -232,7 +232,7 @@ describe('Tags Routes', () => {
|
|||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/tag/${tag.id}`)
|
||||
.patch(`/api/tag/${tag.uid}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
|
|
@ -251,7 +251,7 @@ describe('Tags Routes', () => {
|
|||
});
|
||||
|
||||
it('should delete tag', async () => {
|
||||
const response = await agent.delete(`/api/tag/${tag.id}`);
|
||||
const response = await agent.delete(`/api/tag/${tag.uid}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Tag successfully deleted');
|
||||
|
|
@ -262,7 +262,7 @@ describe('Tags Routes', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent.delete('/api/tag/999999');
|
||||
const response = await agent.delete('/api/tag/non-existent-uid');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
|
|
@ -280,14 +280,14 @@ describe('Tags Routes', () => {
|
|||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/tag/${otherTag.id}`);
|
||||
const response = await agent.delete(`/api/tag/${otherTag.uid}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/tag/${tag.id}`);
|
||||
const response = await request(app).delete(`/api/tag/${tag.uid}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ describe('Users Routes', () => {
|
|||
const response = await agent.get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(user.id);
|
||||
expect(response.body.uid).toBe(user.uid);
|
||||
expect(response.body).not.toHaveProperty('id');
|
||||
expect(response.body.email).toBe(user.email);
|
||||
expect(response.body).toHaveProperty('appearance');
|
||||
expect(response.body).toHaveProperty('language');
|
||||
|
|
@ -66,6 +67,8 @@ describe('Users Routes', () => {
|
|||
const response = await agent.patch('/api/profile').send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.uid).toBe(user.uid);
|
||||
expect(response.body).not.toHaveProperty('id');
|
||||
expect(response.body.appearance).toBe(updateData.appearance);
|
||||
expect(response.body.language).toBe(updateData.language);
|
||||
expect(response.body.timezone).toBe(updateData.timezone);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe('Project Model', () => {
|
|||
const projectData = {
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: true,
|
||||
state: 'planned',
|
||||
pin_to_sidebar: false,
|
||||
priority: 1,
|
||||
user_id: user.id,
|
||||
|
|
@ -32,7 +32,7 @@ describe('Project Model', () => {
|
|||
|
||||
expect(project.name).toBe(projectData.name);
|
||||
expect(project.description).toBe(projectData.description);
|
||||
expect(project.active).toBe(projectData.active);
|
||||
expect(project.state).toBe(projectData.state);
|
||||
expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
|
||||
expect(project.priority).toBe(projectData.priority);
|
||||
expect(project.user_id).toBe(user.id);
|
||||
|
|
@ -85,7 +85,7 @@ describe('Project Model', () => {
|
|||
user_id: user.id,
|
||||
});
|
||||
|
||||
expect(project.active).toBe(true);
|
||||
expect(project.state).toBe('idea');
|
||||
expect(project.pin_to_sidebar).toBe(false);
|
||||
expect(project.task_show_completed).toBe(false);
|
||||
expect(project.task_sort_order).toBe('created_at:desc');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const { validateTagName } = require('../../../utils/validation');
|
||||
const { validateTagName } = require('../../../services/tagsService');
|
||||
|
||||
describe('validation utils', () => {
|
||||
describe('validateTagName', () => {
|
||||
|
|
|
|||
|
|
@ -55,8 +55,122 @@ async function safeAddIndex(queryInterface, tableName, fields, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
async function safeRemoveColumn(queryInterface, tableName, columnName) {
|
||||
try {
|
||||
const tableInfo = await queryInterface.describeTable(tableName);
|
||||
|
||||
if (!(columnName in tableInfo)) {
|
||||
console.log(
|
||||
`Column ${columnName} does not exist in ${tableName}, skipping removal`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialect = queryInterface.sequelize.getDialect();
|
||||
|
||||
// SQLite doesn't support DROP COLUMN, so we need to recreate the table
|
||||
if (dialect === 'sqlite') {
|
||||
try {
|
||||
// Get all columns except the one to remove
|
||||
const columns = Object.keys(tableInfo).filter(
|
||||
(col) => col !== columnName
|
||||
);
|
||||
|
||||
// Build column definitions for new table
|
||||
const columnDefs = columns
|
||||
.map((col) => {
|
||||
const info = tableInfo[col];
|
||||
let def = `${col} ${info.type}`;
|
||||
|
||||
if (info.primaryKey) {
|
||||
def += ' PRIMARY KEY';
|
||||
}
|
||||
if (info.autoIncrement) {
|
||||
def += ' AUTOINCREMENT';
|
||||
}
|
||||
if (!info.allowNull) {
|
||||
def += ' NOT NULL';
|
||||
}
|
||||
if (info.unique) {
|
||||
def += ' UNIQUE';
|
||||
}
|
||||
if (
|
||||
info.defaultValue !== undefined &&
|
||||
info.defaultValue !== null
|
||||
) {
|
||||
// Properly quote string defaults
|
||||
const defaultVal =
|
||||
typeof info.defaultValue === 'string'
|
||||
? `'${info.defaultValue.replace(/'/g, "''")}'`
|
||||
: info.defaultValue;
|
||||
def += ` DEFAULT ${defaultVal}`;
|
||||
}
|
||||
|
||||
return def;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
const columnList = columns.join(', ');
|
||||
|
||||
// Execute operations separately as SQLite doesn't support multiple statements
|
||||
await queryInterface.sequelize.query(
|
||||
'PRAGMA foreign_keys = OFF;'
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`CREATE TABLE ${tableName}_new (${columnDefs});`
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`INSERT INTO ${tableName}_new (${columnList}) SELECT ${columnList} FROM ${tableName};`
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`DROP TABLE ${tableName};`
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`ALTER TABLE ${tableName}_new RENAME TO ${tableName};`
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
'PRAGMA foreign_keys = ON;'
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Successfully removed column ${columnName} from ${tableName}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Ensure foreign keys are re-enabled even on error
|
||||
try {
|
||||
await queryInterface.sequelize.query(
|
||||
'PRAGMA foreign_keys = ON;'
|
||||
);
|
||||
} catch (pragmaError) {
|
||||
// Ignore pragma errors during cleanup
|
||||
}
|
||||
console.log(
|
||||
`Migration error removing column ${columnName} from ${tableName}:`,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// For other databases, use standard removeColumn
|
||||
await queryInterface.removeColumn(tableName, columnName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Migration error removing column ${columnName} from ${tableName}:`,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
safeAddColumns,
|
||||
safeCreateTable,
|
||||
safeAddIndex,
|
||||
safeRemoveColumn,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ bash -c '
|
|||
if [ "${E2E_MODE:-}" = "ui" ]; then
|
||||
npm run test:ui
|
||||
elif [ "${E2E_MODE:-}" = "headed" ]; then
|
||||
# Respect E2E_SLOWMO and run only Firefox
|
||||
npx playwright test --headed --project=Firefox
|
||||
# Respect E2E_SLOWMO and run only Firefox sequentially
|
||||
npx playwright test --headed --project=Firefox --workers=1
|
||||
else
|
||||
npm test
|
||||
npx playwright test --workers=10
|
||||
fi
|
||||
'
|
||||
|
|
|
|||
113
e2e/bin/run-single-test.sh
Executable file
113
e2e/bin/run-single-test.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: ./run-single-test.sh "test name pattern" [browser]
|
||||
# Example: ./run-single-test.sh "delete an existing project" firefox
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <test-name-pattern> [browser]"
|
||||
echo "Example: $0 'delete an existing project' firefox"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEST_PATTERN="$1"
|
||||
BROWSER="${2:-Chromium}"
|
||||
|
||||
# Config
|
||||
APP_URL_DEFAULT="http://localhost:8080"
|
||||
BACKEND_URL="http://localhost:3002"
|
||||
BACKEND_HEALTH="${BACKEND_URL}/api/health"
|
||||
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
||||
|
||||
# Colors
|
||||
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
||||
green() { printf "\033[32m%s\033[0m\n" "$*"; }
|
||||
yellow() { printf "\033[33m%s\033[0m\n" "$*"; }
|
||||
|
||||
# Setup paths
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
||||
E2E_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ROOT_DIR="$(cd "$E2E_DIR/.." && pwd)"
|
||||
|
||||
cd "$E2E_DIR"
|
||||
if [ ! -f package.json ]; then
|
||||
red "e2e/package.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install e2e deps if needed
|
||||
if [ ! -d node_modules ]; then
|
||||
yellow "Installing e2e dependencies..."
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# Start backend and frontend
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
yellow "Starting backend..."
|
||||
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||
SEQUELIZE_LOGGING=false \
|
||||
npm run backend:start >/dev/null 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
cleanup() {
|
||||
yellow "Stopping background processes..."
|
||||
# Kill by PIDs
|
||||
if [ -n "${FRONTEND_PID:-}" ]; then kill -TERM -$FRONTEND_PID >/dev/null 2>&1 || true; fi
|
||||
if [ -n "${BACKEND_PID:-}" ]; then kill -TERM -$BACKEND_PID >/dev/null 2>&1 || true; fi
|
||||
|
||||
# Kill by ports (best-effort)
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
|
||||
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
|
||||
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||
fi
|
||||
|
||||
# Fallback direct kill
|
||||
if [ -n "${FRONTEND_PID:-}" ] && ps -p $FRONTEND_PID >/dev/null 2>&1; then kill $FRONTEND_PID || true; fi
|
||||
if [ -n "${BACKEND_PID:-}" ] && ps -p $BACKEND_PID >/dev/null 2>&1; then kill $BACKEND_PID || true; fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Wait for backend health
|
||||
yellow "Waiting for backend to be ready at ${BACKEND_HEALTH}..."
|
||||
for i in {1..60}; do
|
||||
if curl -sf "$BACKEND_HEALTH" >/dev/null; then
|
||||
green "Backend is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
if [ "$i" -eq 60 ]; then
|
||||
red "Backend did not become ready in time"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
yellow "Starting frontend dev server..."
|
||||
npm run frontend:dev >/dev/null 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# Wait for frontend
|
||||
yellow "Waiting for frontend at ${FRONTEND_URL}..."
|
||||
for i in {1..60}; do
|
||||
if curl -sf "$FRONTEND_URL" >/dev/null; then
|
||||
green "Frontend is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
if [ "$i" -eq 60 ]; then
|
||||
red "Frontend did not become ready in time"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Run tests
|
||||
cd "$E2E_DIR"
|
||||
|
||||
yellow "Running Playwright tests matching: ${TEST_PATTERN} on ${BROWSER}..."
|
||||
APP_URL="$FRONTEND_URL" \
|
||||
E2E_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||
E2E_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||
npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER"
|
||||
|
|
@ -22,7 +22,7 @@ export default defineConfig({
|
|||
},
|
||||
projects: [
|
||||
{ name: 'Chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'WebKit', use: { ...devices['Desktop Safari'] } },
|
||||
// { name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
// { name: 'WebKit', use: { ...devices['Desktop Safari'] } },
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,8 +176,8 @@ test('user can create project from inbox item', async ({ page, baseURL }) => {
|
|||
const projectNameInput = page.locator('input[name="name"], input[placeholder*="project" i], input[placeholder*="name" i]').first();
|
||||
await expect(projectNameInput).toHaveValue(testContent);
|
||||
|
||||
// Save the project - Find submit button by looking for buttons in form context, force click through backdrop
|
||||
await page.locator('form button[type="submit"], button:has-text("Save"), button:has-text("Create")').first().click({ force: true });
|
||||
// Save the project - Use the specific test ID
|
||||
await page.locator('[data-testid="project-save-button"]').click();
|
||||
|
||||
// Wait for success message or modal to close
|
||||
await expect(page.locator('input[name="name"], input[placeholder*="project" i], input[placeholder*="name" i]')).not.toBeVisible({ timeout: 10000 });
|
||||
|
|
@ -191,16 +191,12 @@ test('user can create project from inbox item', async ({ page, baseURL }) => {
|
|||
// Navigate to projects page to verify the project was created there
|
||||
await page.goto(appUrl + '/projects');
|
||||
await expect(page).toHaveURL(/\/projects$/);
|
||||
|
||||
// Wait a moment for the page to load, then check if project exists (more lenient check)
|
||||
|
||||
// Wait a moment for the page to load
|
||||
await page.waitForTimeout(2000);
|
||||
const projectExists = await page.locator('*').filter({ hasText: testContent }).count() > 0;
|
||||
if (!projectExists) {
|
||||
// If exact match fails, just verify we're on projects page and there are projects
|
||||
await expect(page.locator('h1, h2, h3').filter({ hasText: /projects/i }).first()).toBeVisible();
|
||||
} else {
|
||||
await expect(page.locator('*').filter({ hasText: testContent })).toBeVisible();
|
||||
}
|
||||
|
||||
// Verify the created project appears - use a more specific selector
|
||||
await expect(page.getByRole('link', { name: new RegExp(testContent) }).first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('user can create note from inbox item', async ({ page, baseURL }) => {
|
||||
|
|
|
|||
|
|
@ -149,15 +149,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const handleSaveNote = async (noteData: Note) => {
|
||||
try {
|
||||
let result: Note;
|
||||
if (noteData.id) {
|
||||
result = await updateNote(noteData.id, noteData);
|
||||
if (noteData.uid) {
|
||||
result = await updateNote(noteData.uid, noteData);
|
||||
// Update existing note in global store
|
||||
const currentNotes = useStore.getState().notesStore.notes;
|
||||
useStore
|
||||
.getState()
|
||||
.notesStore.setNotes(
|
||||
currentNotes.map((note) =>
|
||||
note.id === result.id ? result : note
|
||||
note.uid === result.uid ? result : note
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
|
@ -223,7 +223,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
try {
|
||||
const newProject = await createProject({
|
||||
name,
|
||||
active: true,
|
||||
state: 'planned',
|
||||
});
|
||||
return newProject;
|
||||
} catch (error) {
|
||||
|
|
@ -234,8 +234,8 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
|
||||
const handleSaveProject = async (projectData: Project) => {
|
||||
try {
|
||||
if (projectData.id) {
|
||||
await updateProject(projectData.id, projectData);
|
||||
if (projectData.uid) {
|
||||
await updateProject(projectData.uid, projectData);
|
||||
} else {
|
||||
await createProject(projectData);
|
||||
}
|
||||
|
|
@ -255,15 +255,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const handleSaveArea = async (areaData: Partial<Area>) => {
|
||||
try {
|
||||
let result: Area;
|
||||
if (areaData.id) {
|
||||
result = await updateArea(areaData.id, areaData);
|
||||
if (areaData.uid) {
|
||||
result = await updateArea(areaData.uid, areaData);
|
||||
// Update existing area in global store
|
||||
const currentAreas = useStore.getState().areasStore.areas;
|
||||
useStore
|
||||
.getState()
|
||||
.areasStore.setAreas(
|
||||
currentAreas.map((area) =>
|
||||
area.id === result.id ? result : area
|
||||
area.uid === result.uid ? result : area
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
|
@ -288,15 +288,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const handleSaveTag = async (tagData: Tag) => {
|
||||
try {
|
||||
let result: Tag;
|
||||
if (tagData.id) {
|
||||
result = await updateTag(tagData.id, tagData);
|
||||
if (tagData.uid) {
|
||||
result = await updateTag(tagData.uid, tagData);
|
||||
// Update existing tag in global store
|
||||
const currentTags = useStore.getState().tagsStore.tags;
|
||||
useStore
|
||||
.getState()
|
||||
.tagsStore.setTags(
|
||||
currentTags.map((tag) =>
|
||||
tag.id === result.id ? result : tag
|
||||
tag.uid === result.uid ? result : tag
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
|
@ -474,12 +474,12 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
isOpen={isProjectModalOpen}
|
||||
onClose={closeProjectModal}
|
||||
onSave={handleSaveProject}
|
||||
onDelete={async (projectId) => {
|
||||
onDelete={async (projectUid) => {
|
||||
try {
|
||||
const { deleteProject } = await import(
|
||||
'./utils/projectsService'
|
||||
);
|
||||
await deleteProject(projectId);
|
||||
await deleteProject(projectUid);
|
||||
|
||||
// Update global projects store
|
||||
const currentProjects =
|
||||
|
|
@ -488,7 +488,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
.getState()
|
||||
.projectsStore.setProjects(
|
||||
currentProjects.filter(
|
||||
(p) => p.id !== projectId
|
||||
(p) => p.uid !== projectUid
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface AreaModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (areaData: Partial<Area>) => Promise<void>;
|
||||
onDelete?: (areaId: number) => Promise<void>;
|
||||
onDelete?: (areaUid: string) => Promise<void>;
|
||||
area?: Area | null;
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +22,7 @@ const AreaModal: React.FC<AreaModalProps> = ({
|
|||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<Area>({
|
||||
id: area?.id || 0,
|
||||
uid: area?.uid || '',
|
||||
name: area?.name || '',
|
||||
description: area?.description || '',
|
||||
});
|
||||
|
|
@ -38,6 +39,7 @@ const AreaModal: React.FC<AreaModalProps> = ({
|
|||
if (isOpen) {
|
||||
setFormData({
|
||||
id: area?.id || 0,
|
||||
uid: area?.uid || '',
|
||||
name: area?.name || '',
|
||||
description: area?.description || '',
|
||||
});
|
||||
|
|
@ -109,7 +111,7 @@ const AreaModal: React.FC<AreaModalProps> = ({
|
|||
try {
|
||||
await onSave(formData);
|
||||
showSuccessToast(
|
||||
formData.id
|
||||
formData.uid
|
||||
? t('success.areaUpdated')
|
||||
: t('success.areaCreated')
|
||||
);
|
||||
|
|
@ -131,9 +133,9 @@ const AreaModal: React.FC<AreaModalProps> = ({
|
|||
};
|
||||
|
||||
const handleDeleteArea = async () => {
|
||||
if (formData.id && formData.id !== 0 && onDelete) {
|
||||
if (formData.uid && onDelete) {
|
||||
try {
|
||||
await onDelete(formData.id);
|
||||
await onDelete(formData.uid);
|
||||
showSuccessToast(
|
||||
t('success.areaDeleted', 'Area deleted successfully!')
|
||||
);
|
||||
|
|
@ -225,22 +227,16 @@ const AreaModal: React.FC<AreaModalProps> = ({
|
|||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{area &&
|
||||
area.id &&
|
||||
area.id !== 0 &&
|
||||
onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteArea}
|
||||
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
|
||||
title={t(
|
||||
'common.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{area && area.uid && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteArea}
|
||||
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const Areas: React.FC = () => {
|
|||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
|
||||
const justOpenedRef = useRef<boolean>(false);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -78,8 +78,8 @@ const Areas: React.FC = () => {
|
|||
try {
|
||||
useStore.getState().areasStore.setLoading(true);
|
||||
let result: Area;
|
||||
if (areaData.id && areaData.id !== 0) {
|
||||
result = await updateArea(areaData.id, {
|
||||
if (areaData.uid) {
|
||||
result = await updateArea(areaData.uid, {
|
||||
name: areaData.name,
|
||||
description: areaData.description,
|
||||
});
|
||||
|
|
@ -89,7 +89,7 @@ const Areas: React.FC = () => {
|
|||
.getState()
|
||||
.areasStore.setAreas(
|
||||
currentAreas.map((area: any) =>
|
||||
area.id === result.id ? result : area
|
||||
area.uid === result.uid ? result : area
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
|
@ -131,14 +131,14 @@ const Areas: React.FC = () => {
|
|||
|
||||
useStore.getState().areasStore.setLoading(true);
|
||||
try {
|
||||
await deleteArea(areaToDelete.id!);
|
||||
await deleteArea(areaToDelete.uid!);
|
||||
// Remove the area from global state immediately
|
||||
const currentAreas = useStore.getState().areasStore.areas;
|
||||
useStore
|
||||
.getState()
|
||||
.areasStore.setAreas(
|
||||
currentAreas.filter(
|
||||
(area: any) => area.id !== areaToDelete.id
|
||||
(area: any) => area.uid !== areaToDelete.uid
|
||||
)
|
||||
);
|
||||
setIsConfirmDialogOpen(false);
|
||||
|
|
@ -174,17 +174,17 @@ const Areas: React.FC = () => {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{areas.map((area: any) => (
|
||||
<Link
|
||||
key={area.id}
|
||||
key={area.uid}
|
||||
to={
|
||||
area.uid
|
||||
? `/projects?area=${area.uid}-${area.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/projects?area_id=${area.id}`
|
||||
: `/projects?area_id=${area.uid}`
|
||||
}
|
||||
className={`bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col group hover:opacity-90 transition-opacity cursor-pointer ${
|
||||
dropdownOpen === area.id ? 'z-50' : ''
|
||||
dropdownOpen === area.uid ? 'z-50' : ''
|
||||
}`}
|
||||
style={{
|
||||
minHeight: '120px',
|
||||
|
|
@ -215,9 +215,9 @@ const Areas: React.FC = () => {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newDropdownState =
|
||||
dropdownOpen === area.id
|
||||
dropdownOpen === area.uid
|
||||
? null
|
||||
: area.id!;
|
||||
: area.uid!;
|
||||
|
||||
if (newDropdownState !== null) {
|
||||
justOpenedRef.current = true;
|
||||
|
|
@ -228,12 +228,12 @@ const Areas: React.FC = () => {
|
|||
aria-label={t(
|
||||
'areas.toggleDropdownMenu'
|
||||
)}
|
||||
data-testid={`area-dropdown-${area.id}`}
|
||||
data-testid={`area-dropdown-${area.uid}`}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen === area.id && (
|
||||
{dropdownOpen === area.uid && (
|
||||
<div className="absolute right-0 top-full mt-1 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-[60]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -243,7 +243,7 @@ const Areas: React.FC = () => {
|
|||
setDropdownOpen(null);
|
||||
}}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left rounded-t-md"
|
||||
data-testid={`area-edit-${area.id}`}
|
||||
data-testid={`area-edit-${area.uid}`}
|
||||
>
|
||||
{t('areas.edit', 'Edit')}
|
||||
</button>
|
||||
|
|
@ -255,7 +255,7 @@ const Areas: React.FC = () => {
|
|||
setDropdownOpen(null);
|
||||
}}
|
||||
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left rounded-b-md"
|
||||
data-testid={`area-delete-${area.id}`}
|
||||
data-testid={`area-delete-${area.uid}`}
|
||||
>
|
||||
{t('areas.delete', 'Delete')}
|
||||
</button>
|
||||
|
|
@ -273,9 +273,9 @@ const Areas: React.FC = () => {
|
|||
isOpen={isAreaModalOpen}
|
||||
onClose={() => setIsAreaModalOpen(false)}
|
||||
onSave={handleSaveArea}
|
||||
onDelete={async (areaId) => {
|
||||
onDelete={async (areaUid: string) => {
|
||||
try {
|
||||
await deleteArea(areaId);
|
||||
await deleteArea(areaUid);
|
||||
const updatedAreas = await fetchAreas();
|
||||
useStore
|
||||
.getState()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FolderIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
TagIcon,
|
||||
EllipsisVerticalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
|
@ -18,12 +19,12 @@ import { useStore } from '../../store/useStore';
|
|||
|
||||
interface InboxItemDetailProps {
|
||||
item: InboxItem;
|
||||
onProcess: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onUpdate?: (id: number) => Promise<void>;
|
||||
openTaskModal: (task: Task, inboxItemId?: number) => void;
|
||||
openProjectModal: (project: Project | null, inboxItemId?: number) => void;
|
||||
openNoteModal: (note: Note | null, inboxItemId?: number) => void;
|
||||
onProcess: (uid: string) => void;
|
||||
onDelete: (uid: string) => void;
|
||||
onUpdate?: (uid: string) => Promise<void>;
|
||||
openTaskModal: (task: Task, inboxItemUid?: string) => void;
|
||||
openProjectModal: (project: Project | null, inboxItemUid?: string) => void;
|
||||
openNoteModal: (note: Note | null, inboxItemUid?: string) => void;
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,52 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownId = useRef(
|
||||
`dropdown-${Math.random().toString(36).substr(2, 9)}`
|
||||
).current;
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDropdownOpen && buttonRef.current) {
|
||||
const target = event.target as Node;
|
||||
const isOutsideButton = !buttonRef.current.contains(target);
|
||||
const currentDropdown = document.querySelector(
|
||||
`[data-dropdown-id="${dropdownId}"]`
|
||||
);
|
||||
const isOutsideDropdown = !currentDropdown?.contains(target);
|
||||
|
||||
if (isOutsideButton && isOutsideDropdown) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for custom event to close this dropdown when another opens
|
||||
const handleCloseOtherDropdowns = (event: CustomEvent) => {
|
||||
if (event.detail.dropdownId !== dropdownId && isDropdownOpen) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener(
|
||||
'closeOtherDropdowns',
|
||||
handleCloseOtherDropdowns as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener(
|
||||
'closeOtherDropdowns',
|
||||
handleCloseOtherDropdowns as EventListener
|
||||
);
|
||||
};
|
||||
}, [isDropdownOpen, dropdownId]);
|
||||
|
||||
// Helper function to parse hashtags from text (consecutive groups anywhere)
|
||||
const parseHashtags = (text: string): string[] => {
|
||||
|
|
@ -250,8 +297,8 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
completed_at: null,
|
||||
};
|
||||
|
||||
if (item.id !== undefined) {
|
||||
openTaskModal(newTask, item.id);
|
||||
if (item.uid !== undefined) {
|
||||
openTaskModal(newTask, item.uid);
|
||||
} else {
|
||||
openTaskModal(newTask);
|
||||
}
|
||||
|
|
@ -275,12 +322,12 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
const newProject: Project = {
|
||||
name: cleanedContent || item.content,
|
||||
description: '',
|
||||
active: true,
|
||||
state: 'planned',
|
||||
tags: projectTags,
|
||||
};
|
||||
|
||||
if (item.id !== undefined) {
|
||||
openProjectModal(newProject, item.id);
|
||||
if (item.uid !== undefined) {
|
||||
openProjectModal(newProject, item.uid);
|
||||
} else {
|
||||
openProjectModal(newProject);
|
||||
}
|
||||
|
|
@ -372,11 +419,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
title: finalTitle,
|
||||
content: finalContent,
|
||||
tags: tagObjects,
|
||||
project_id: projectId,
|
||||
project_uid: projectId,
|
||||
};
|
||||
|
||||
if (item.id !== undefined) {
|
||||
openNoteModal(newNote, item.id);
|
||||
if (item.uid !== undefined) {
|
||||
openNoteModal(newNote, item.uid);
|
||||
} else {
|
||||
openNoteModal(newNote);
|
||||
}
|
||||
|
|
@ -387,8 +434,8 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (item.id !== undefined) {
|
||||
onDelete(item.id);
|
||||
if (item.uid !== undefined) {
|
||||
onDelete(item.uid);
|
||||
}
|
||||
setShowConfirmDialog(false);
|
||||
};
|
||||
|
|
@ -399,12 +446,12 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center px-4 py-2 gap-2">
|
||||
<div className="flex-1 w-4/5">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onUpdate && item.id !== undefined) {
|
||||
onUpdate(item.id);
|
||||
if (onUpdate && item.uid !== undefined) {
|
||||
onUpdate(item.uid);
|
||||
}
|
||||
}}
|
||||
className="text-base font-medium text-gray-900 dark:text-gray-300 break-words text-left cursor-pointer w-full"
|
||||
|
|
@ -511,14 +558,15 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start space-x-1 shrink-0">
|
||||
{/* Desktop view (md and larger) */}
|
||||
<div className="hidden md:flex items-center justify-end w-1/5 space-x-1">
|
||||
{loading && <div className="spinner" />}
|
||||
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onUpdate && item.id !== undefined) {
|
||||
onUpdate(item.id);
|
||||
if (onUpdate && item.uid !== undefined) {
|
||||
onUpdate(item.uid);
|
||||
}
|
||||
}}
|
||||
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
|
@ -563,6 +611,135 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile 3-dot dropdown menu */}
|
||||
<div className="flex md:hidden items-center justify-end w-1/5 relative">
|
||||
{loading && <div className="spinner mr-2" />}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
data-dropdown-button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newOpenState = !isDropdownOpen;
|
||||
|
||||
// Close other dropdowns when opening this one
|
||||
if (newOpenState) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('closeOtherDropdowns', {
|
||||
detail: { dropdownId },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setIsDropdownOpen(newOpenState);
|
||||
}}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu - Positioned Relatively */}
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
data-dropdown-id={dropdownId}
|
||||
className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] transform-gpu"
|
||||
style={{
|
||||
// Prevent dropdown from being cut off at the bottom of viewport
|
||||
transform:
|
||||
buttonRef.current &&
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
.bottom +
|
||||
240 >
|
||||
window.innerHeight
|
||||
? 'translateY(-100%) translateY(-8px)'
|
||||
: 'none',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="py-1">
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onUpdate) {
|
||||
const identifier =
|
||||
item.uid ??
|
||||
(item.id !== undefined
|
||||
? String(item.id)
|
||||
: null);
|
||||
|
||||
if (identifier) {
|
||||
onUpdate(identifier);
|
||||
} else {
|
||||
console.warn(
|
||||
'Inbox item is missing an identifier for update.'
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('common.edit', 'Edit')}
|
||||
</button>
|
||||
|
||||
{/* Convert to Task Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConvertToTask();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('inbox.createTask', 'Create Task')}
|
||||
</button>
|
||||
|
||||
{/* Convert to Project Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConvertToProject();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('inbox.createProject', 'Create Project')}
|
||||
</button>
|
||||
|
||||
{/* Convert to Note Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConvertToNote();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm text-left text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('inbox.createNote', 'Create Note')}
|
||||
</button>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
|
|
|
|||
|
|
@ -60,13 +60,13 @@ const InboxItems: React.FC = () => {
|
|||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
|
||||
// Track the current inbox item ID being converted (for task/project/note conversion)
|
||||
const [currentConversionItemId, setCurrentConversionItemId] = useState<
|
||||
number | null
|
||||
// Track the current inbox item UID being converted (for task/project/note conversion)
|
||||
const [currentConversionItemUid, setCurrentConversionItemUid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Track the current inbox item being edited
|
||||
const [itemToEdit, setItemToEdit] = useState<number | null>(null);
|
||||
const [itemToEdit, setItemToEdit] = useState<string | null>(null);
|
||||
|
||||
// Create stable default task object to prevent infinite re-renders
|
||||
const defaultTask = useMemo(
|
||||
|
|
@ -182,9 +182,12 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
}, [t, showSuccessToast]); // Include dependencies that are actually used
|
||||
|
||||
const handleProcessItem = async (id: number, showToast: boolean = true) => {
|
||||
const handleProcessItem = async (
|
||||
uid: string,
|
||||
showToast: boolean = true
|
||||
) => {
|
||||
try {
|
||||
await processInboxItemWithStore(id);
|
||||
await processInboxItemWithStore(uid);
|
||||
if (showToast) {
|
||||
showSuccessToast(t('inbox.itemProcessed'));
|
||||
}
|
||||
|
|
@ -194,9 +197,9 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUpdateItem = async (id: number): Promise<void> => {
|
||||
const handleUpdateItem = async (uid: string): Promise<void> => {
|
||||
// When edit button is clicked, we open the InboxModal instead of doing inline editing
|
||||
setItemToEdit(id);
|
||||
setItemToEdit(uid);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -214,9 +217,9 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id: number) => {
|
||||
const handleDeleteItem = async (uid: string) => {
|
||||
try {
|
||||
await deleteInboxItemWithStore(id);
|
||||
await deleteInboxItemWithStore(uid);
|
||||
showSuccessToast(t('inbox.itemDeleted'));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete inbox item:', error);
|
||||
|
|
@ -225,7 +228,7 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
|
||||
// Modal handlers
|
||||
const handleOpenTaskModal = async (task: Task, inboxItemId?: number) => {
|
||||
const handleOpenTaskModal = async (task: Task, inboxItemUid?: string) => {
|
||||
try {
|
||||
// Load projects first before opening the modal
|
||||
try {
|
||||
|
|
@ -242,8 +245,8 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
setTaskToEdit(task);
|
||||
|
||||
if (inboxItemId) {
|
||||
setCurrentConversionItemId(inboxItemId);
|
||||
if (inboxItemUid) {
|
||||
setCurrentConversionItemUid(inboxItemUid);
|
||||
}
|
||||
|
||||
setIsTaskModalOpen(true);
|
||||
|
|
@ -254,7 +257,7 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
const handleOpenProjectModal = async (
|
||||
project: Project | null,
|
||||
inboxItemId?: number
|
||||
inboxItemUid?: string
|
||||
) => {
|
||||
try {
|
||||
// Load areas first before opening the modal (similar to task modal)
|
||||
|
|
@ -269,8 +272,8 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
setProjectToEdit(project);
|
||||
|
||||
if (inboxItemId) {
|
||||
setCurrentConversionItemId(inboxItemId);
|
||||
if (inboxItemUid) {
|
||||
setCurrentConversionItemUid(inboxItemUid);
|
||||
}
|
||||
|
||||
setIsProjectModalOpen(true);
|
||||
|
|
@ -281,7 +284,7 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
const handleOpenNoteModal = async (
|
||||
note: Note | null,
|
||||
inboxItemId?: number
|
||||
inboxItemUid?: string
|
||||
) => {
|
||||
// Set up the note data first
|
||||
if (note && note.content && isUrl(note.content.trim())) {
|
||||
|
|
@ -294,8 +297,8 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
setNoteToEdit(note);
|
||||
|
||||
if (inboxItemId) {
|
||||
setCurrentConversionItemId(inboxItemId);
|
||||
if (inboxItemUid) {
|
||||
setCurrentConversionItemUid(inboxItemUid);
|
||||
}
|
||||
|
||||
// Projects are already available from the store
|
||||
|
|
@ -321,9 +324,9 @@ const InboxItems: React.FC = () => {
|
|||
showSuccessToast(taskLink);
|
||||
|
||||
// Process the inbox item after successful task creation
|
||||
if (currentConversionItemId !== null) {
|
||||
await handleProcessItem(currentConversionItemId, false);
|
||||
setCurrentConversionItemId(null);
|
||||
if (currentConversionItemUid !== null) {
|
||||
await handleProcessItem(currentConversionItemUid, false);
|
||||
setCurrentConversionItemUid(null);
|
||||
}
|
||||
|
||||
setIsTaskModalOpen(false);
|
||||
|
|
@ -339,12 +342,12 @@ const InboxItems: React.FC = () => {
|
|||
showSuccessToast(t('project.createSuccess'));
|
||||
|
||||
// Process the inbox item after successful project creation
|
||||
if (currentConversionItemId !== null) {
|
||||
await handleProcessItem(currentConversionItemId, false);
|
||||
setCurrentConversionItemId(null);
|
||||
if (currentConversionItemUid !== null) {
|
||||
await handleProcessItem(currentConversionItemUid, false);
|
||||
setCurrentConversionItemUid(null);
|
||||
}
|
||||
|
||||
setIsProjectModalOpen(false);
|
||||
// Don't set isProjectModalOpen here - the modal handles its own closing via handleClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
showErrorToast(t('project.createError'));
|
||||
|
|
@ -378,9 +381,9 @@ const InboxItems: React.FC = () => {
|
|||
);
|
||||
|
||||
// Process the inbox item after successful note creation
|
||||
if (currentConversionItemId !== null) {
|
||||
await handleProcessItem(currentConversionItemId, false);
|
||||
setCurrentConversionItemId(null);
|
||||
if (currentConversionItemUid !== null) {
|
||||
await handleProcessItem(currentConversionItemUid, false);
|
||||
setCurrentConversionItemUid(null);
|
||||
}
|
||||
|
||||
setIsNoteModalOpen(false);
|
||||
|
|
@ -392,7 +395,7 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const project = await createProject({ name, active: true });
|
||||
const project = await createProject({ name, state: 'planned' });
|
||||
showSuccessToast(t('project.createSuccess'));
|
||||
return project;
|
||||
} catch (error) {
|
||||
|
|
@ -510,7 +513,7 @@ const InboxItems: React.FC = () => {
|
|||
<div className="space-y-2">
|
||||
{inboxItems.map((item) => (
|
||||
<InboxItemDetail
|
||||
key={item.id}
|
||||
key={item.uid || item.id}
|
||||
item={item}
|
||||
onProcess={handleProcessItem}
|
||||
onDelete={handleDeleteItem}
|
||||
|
|
@ -677,7 +680,7 @@ const InboxItems: React.FC = () => {
|
|||
onSave={handleSaveTask}
|
||||
onSaveNote={handleSaveNote}
|
||||
initialText={
|
||||
inboxItems.find((item) => item.id === itemToEdit)
|
||||
inboxItems.find((item) => item.uid === itemToEdit)
|
||||
?.content || ''
|
||||
}
|
||||
editMode={true}
|
||||
|
|
|
|||
|
|
@ -926,7 +926,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
try {
|
||||
await createProject({
|
||||
name: projectName,
|
||||
active: true,
|
||||
state: 'planned',
|
||||
});
|
||||
// Projects are managed by the parent component through props
|
||||
// No need to update local state
|
||||
|
|
@ -1644,7 +1644,11 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
>
|
||||
{filteredTags.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id || index}
|
||||
key={
|
||||
tag.uid ||
|
||||
tag.id ||
|
||||
index
|
||||
}
|
||||
onClick={() =>
|
||||
handleTagSelect(
|
||||
tag.name
|
||||
|
|
|
|||
|
|
@ -61,11 +61,7 @@ const NoteDetails: React.FC = () => {
|
|||
const handleDeleteNote = async () => {
|
||||
if (!noteToDelete) return;
|
||||
try {
|
||||
await deleteNoteWithStoreUpdate(
|
||||
noteToDelete.id!,
|
||||
showSuccessToast,
|
||||
t
|
||||
);
|
||||
await deleteNoteWithStoreUpdate(noteToDelete, showSuccessToast, t);
|
||||
navigate('/notes');
|
||||
} catch (err) {
|
||||
console.error('Error deleting note:', err);
|
||||
|
|
@ -74,14 +70,18 @@ const NoteDetails: React.FC = () => {
|
|||
|
||||
const handleSaveNote = async (updatedNote: Note) => {
|
||||
try {
|
||||
if (updatedNote.id !== undefined) {
|
||||
const noteIdentifier =
|
||||
updatedNote.uid ??
|
||||
(updatedNote.id !== undefined ? String(updatedNote.id) : null);
|
||||
|
||||
if (noteIdentifier) {
|
||||
const savedNote = await apiUpdateNote(
|
||||
updatedNote.id,
|
||||
noteIdentifier,
|
||||
updatedNote
|
||||
);
|
||||
setNote(savedNote);
|
||||
} else {
|
||||
console.error('Error: Note ID is undefined.');
|
||||
console.error('Error: Note identifier is undefined.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving note:', err);
|
||||
|
|
@ -237,10 +237,10 @@ const NoteDetails: React.FC = () => {
|
|||
isOpen={isNoteModalOpen}
|
||||
onClose={() => setIsNoteModalOpen(false)}
|
||||
onSave={handleSaveNote}
|
||||
onDelete={async (noteId) => {
|
||||
onDelete={async (noteUid) => {
|
||||
try {
|
||||
await deleteNoteWithStoreUpdate(
|
||||
noteId,
|
||||
noteUid,
|
||||
showSuccessToast,
|
||||
t
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface NoteModalProps {
|
|||
onClose: () => void;
|
||||
note?: Note | null;
|
||||
onSave: (noteData: Note) => Promise<void>;
|
||||
onDelete?: (noteId: number) => Promise<void>;
|
||||
onDelete?: (noteUid: string) => Promise<void>;
|
||||
projects?: Project[];
|
||||
onCreateProject?: (name: string) => Promise<Project>;
|
||||
}
|
||||
|
|
@ -119,9 +119,9 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
setNewProjectName(currentProject ? currentProject.name : '');
|
||||
|
||||
// Auto-expand sections if they have content from existing note, editing existing note, or creating new note with pre-filled project
|
||||
const shouldExpandTags = tagNames.length > 0 || !!note.id; // Expand if has tags OR editing existing note
|
||||
const shouldExpandTags = tagNames.length > 0 || !!note.uid; // Expand if has tags OR editing existing note
|
||||
const shouldExpandProject =
|
||||
!!currentProject || !!note.id || !!projectIdToFind; // Expand if has project OR editing existing note OR has project_id
|
||||
!!currentProject || !!note.uid || !!projectIdToFind; // Expand if has project OR editing existing note OR has project_id
|
||||
|
||||
setExpandedSections({
|
||||
tags: shouldExpandTags,
|
||||
|
|
@ -260,8 +260,8 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
const handleProjectSelection = (project: Project) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
project: { id: project.id!, name: project.name },
|
||||
project_id: project.id,
|
||||
project: { id: project.id!, name: project.name, uid: project.uid },
|
||||
project_uid: project.uid,
|
||||
}));
|
||||
setNewProjectName(project.name);
|
||||
setDropdownOpen(false);
|
||||
|
|
@ -274,8 +274,12 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
const newProject = await onCreateProject(newProjectName.trim());
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
project: { id: newProject.id!, name: newProject.name },
|
||||
project_id: newProject.id,
|
||||
project: {
|
||||
id: newProject.id!,
|
||||
name: newProject.name,
|
||||
uid: newProject.uid,
|
||||
},
|
||||
project_uid: newProject.uid,
|
||||
}));
|
||||
setFilteredProjects([...filteredProjects, newProject]);
|
||||
setNewProjectName(newProject.name);
|
||||
|
|
@ -339,9 +343,9 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
};
|
||||
|
||||
const handleDeleteNote = async () => {
|
||||
if (formData.id && formData.id !== 0 && onDelete) {
|
||||
if (formData.uid && onDelete) {
|
||||
try {
|
||||
await onDelete(formData.id);
|
||||
await onDelete(formData.uid);
|
||||
showSuccessToast(t('success.noteDeleted'));
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
|
|
@ -636,7 +640,7 @@ const NoteModal: React.FC<NoteModalProps> = ({
|
|||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{note && note.id && onDelete && (
|
||||
{note && note.uid && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteNote}
|
||||
|
|
|
|||
|
|
@ -51,11 +51,7 @@ const Notes: React.FC = () => {
|
|||
const handleDeleteNote = async () => {
|
||||
if (!noteToDelete) return;
|
||||
try {
|
||||
await deleteNoteWithStoreUpdate(
|
||||
noteToDelete.id!,
|
||||
showSuccessToast,
|
||||
t
|
||||
);
|
||||
await deleteNoteWithStoreUpdate(noteToDelete, showSuccessToast, t);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setNoteToDelete(null);
|
||||
} catch (err) {
|
||||
|
|
@ -70,10 +66,10 @@ const Notes: React.FC = () => {
|
|||
|
||||
const handleSaveNote = async (noteData: Note) => {
|
||||
try {
|
||||
if (noteData.id) {
|
||||
const savedNote = await updateNote(noteData.id, noteData);
|
||||
if (noteData.uid) {
|
||||
const savedNote = await updateNote(noteData.uid, noteData);
|
||||
const updatedNotes = notes.map((note) =>
|
||||
note.id === noteData.id ? savedNote : note
|
||||
note.uid === noteData.uid ? savedNote : note
|
||||
);
|
||||
setNotes(updatedNotes);
|
||||
} else {
|
||||
|
|
@ -221,7 +217,7 @@ const Notes: React.FC = () => {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
key={note.uid}
|
||||
note={note}
|
||||
onEdit={handleEditNote}
|
||||
onDelete={(note) => {
|
||||
|
|
@ -243,10 +239,10 @@ const Notes: React.FC = () => {
|
|||
setIsNoteModalOpen(false);
|
||||
}}
|
||||
onSave={handleSaveNote}
|
||||
onDelete={async (noteId) => {
|
||||
onDelete={async (noteUid) => {
|
||||
try {
|
||||
await deleteNoteWithStoreUpdate(
|
||||
noteId,
|
||||
noteUid,
|
||||
showSuccessToast,
|
||||
t
|
||||
);
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
|||
// 1. Stalled Projects (no tasks/actions)
|
||||
const stalledProjects = projects.filter(
|
||||
(project) =>
|
||||
project.active &&
|
||||
(project.state === 'planned' ||
|
||||
project.state === 'in_progress') &&
|
||||
!activeTasks.some((task) => task.project_id === project.id)
|
||||
);
|
||||
|
||||
|
|
@ -123,7 +124,12 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
|||
(task.status === 'not_started' ||
|
||||
task.status === 'in_progress')
|
||||
);
|
||||
return project.active && hasCompletedTasks && !hasNextAction;
|
||||
return (
|
||||
(project.state === 'planned' ||
|
||||
project.state === 'in_progress') &&
|
||||
hasCompletedTasks &&
|
||||
!hasNextAction
|
||||
);
|
||||
});
|
||||
|
||||
if (projectsNeedingNextAction.length > 0) {
|
||||
|
|
@ -221,7 +227,13 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
|||
|
||||
// 6. Stuck projects (not updated in a month)
|
||||
const stuckProjects = projects.filter((project) => {
|
||||
if (!project.active) return false;
|
||||
if (
|
||||
!(
|
||||
project.state === 'planned' ||
|
||||
project.state === 'in_progress'
|
||||
)
|
||||
)
|
||||
return false;
|
||||
|
||||
// Projects don't have date fields in the interface, so we'll check if they have recent tasks
|
||||
const projectTasks = activeTasks.filter(
|
||||
|
|
@ -345,7 +357,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
|||
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const project = await createProject({ name, active: true });
|
||||
const project = await createProject({ name, state: 'planned' });
|
||||
setAllProjects((prev) => [...prev, project]);
|
||||
return project;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ import FirstDayOfWeekDropdown from '../Shared/FirstDayOfWeekDropdown';
|
|||
import { getLocaleFirstDayOfWeek } from '../../utils/profileService';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
currentUser: { id: number; email: string };
|
||||
currentUser: { uid: string; email: string };
|
||||
isDarkMode?: boolean;
|
||||
toggleDarkMode?: () => void;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: number;
|
||||
uid: string;
|
||||
email: string;
|
||||
appearance: 'light' | 'dark';
|
||||
language: string;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import {
|
|||
TrashIcon,
|
||||
TagIcon,
|
||||
PlusCircleIcon,
|
||||
Squares2X2Icon,
|
||||
PlayIcon,
|
||||
LightBulbIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import TaskList from '../Task/TaskList';
|
||||
import ProjectModal from '../Project/ProjectModal';
|
||||
|
|
@ -39,6 +45,7 @@ import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService';
|
|||
import AutoSuggestNextActionBox from './AutoSuggestNextActionBox';
|
||||
import SortFilterButton, { SortOption } from '../Shared/SortFilterButton';
|
||||
import LoadingSpinner from '../Shared/LoadingSpinner';
|
||||
import { usePersistedModal } from '../../hooks/usePersistedModal';
|
||||
|
||||
const ProjectDetails: React.FC = () => {
|
||||
const { uidSlug } = useParams<{ uidSlug: string }>();
|
||||
|
|
@ -64,7 +71,14 @@ const ProjectDetails: React.FC = () => {
|
|||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
// Use persisted modal state that survives component remounts
|
||||
const {
|
||||
isOpen: isModalOpen,
|
||||
openModal,
|
||||
closeModal,
|
||||
} = usePersistedModal(project?.id);
|
||||
const editButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false);
|
||||
|
|
@ -376,22 +390,41 @@ const ProjectDetails: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleEditProject = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
// Setup native event listener for edit button to avoid React event system conflicts
|
||||
useEffect(() => {
|
||||
const button = editButtonRef.current;
|
||||
if (button) {
|
||||
const handleClick = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openModal();
|
||||
};
|
||||
|
||||
button.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
button.removeEventListener('click', handleClick);
|
||||
};
|
||||
}
|
||||
}, [openModal]);
|
||||
|
||||
const handleSaveProject = async (updatedProject: Project) => {
|
||||
if (!updatedProject.id) {
|
||||
if (!updatedProject.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedProject = await updateProject(
|
||||
updatedProject.id,
|
||||
updatedProject.uid,
|
||||
updatedProject
|
||||
);
|
||||
setProject(savedProject);
|
||||
setIsModalOpen(false);
|
||||
// Merge the saved project with existing project to preserve area data
|
||||
setProject((prevProject) => ({
|
||||
...savedProject,
|
||||
// Preserve area info if it's missing from the response
|
||||
area: savedProject.area || prevProject?.area,
|
||||
Area: (savedProject as any).Area || (prevProject as any)?.Area,
|
||||
}));
|
||||
closeModal();
|
||||
} catch {
|
||||
// Error saving project - silently handled
|
||||
}
|
||||
|
|
@ -477,12 +510,12 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project?.id) {
|
||||
if (!project?.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
await deleteProject(project.uid);
|
||||
navigate('/projects');
|
||||
} catch {
|
||||
// Error deleting project - silently handled
|
||||
|
|
@ -493,7 +526,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const handleEditNote = async (note: Note) => {
|
||||
try {
|
||||
// Fetch the complete note data including tags
|
||||
const response = await fetch(`/api/note/${note.id}`, {
|
||||
const response = await fetch(`/api/note/${note.uid}`, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
|
@ -513,10 +546,17 @@ const ProjectDetails: React.FC = () => {
|
|||
setIsNoteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (noteId: number) => {
|
||||
const handleDeleteNote = async (noteIdentifier: string) => {
|
||||
try {
|
||||
await apiDeleteNote(noteId);
|
||||
setNotes(notes.filter((n) => n.id !== noteId));
|
||||
await apiDeleteNote(noteIdentifier);
|
||||
setNotes(
|
||||
notes.filter((n) => {
|
||||
const currentIdentifier =
|
||||
n.uid ??
|
||||
(n.id !== undefined ? String(n.id) : undefined);
|
||||
return currentIdentifier !== noteIdentifier;
|
||||
})
|
||||
);
|
||||
setNoteToDelete(null);
|
||||
setIsConfirmDialogOpen(false);
|
||||
} catch {
|
||||
|
|
@ -528,8 +568,15 @@ const ProjectDetails: React.FC = () => {
|
|||
const handleSaveNote = async (noteData: Note) => {
|
||||
try {
|
||||
let savedNote: Note;
|
||||
if (noteData.id) {
|
||||
savedNote = await updateNote(noteData.id, noteData);
|
||||
const noteIdentifier =
|
||||
noteData.uid ??
|
||||
(noteData.id !== undefined ? String(noteData.id) : null);
|
||||
|
||||
let isUpdate = false;
|
||||
|
||||
if (noteIdentifier) {
|
||||
savedNote = await updateNote(noteIdentifier, noteData);
|
||||
isUpdate = true;
|
||||
} else {
|
||||
savedNote = await createNote(noteData);
|
||||
}
|
||||
|
|
@ -542,9 +589,21 @@ const ProjectDetails: React.FC = () => {
|
|||
// If updated note moved to another project, remove it from this list
|
||||
if (savedNote.id && savedNote.project_id !== project?.id) {
|
||||
setNotes(notes.filter((n) => n.id !== savedNote.id));
|
||||
} else if (noteData.id) {
|
||||
} else if (isUpdate) {
|
||||
const savedIdentifier =
|
||||
savedNote.uid ??
|
||||
(savedNote.id !== undefined ? String(savedNote.id) : null);
|
||||
|
||||
setNotes(
|
||||
notes.map((n) => (n.id === savedNote.id ? savedNote : n))
|
||||
notes.map((n) => {
|
||||
const currentIdentifier =
|
||||
n.uid ??
|
||||
(n.id !== undefined ? String(n.id) : undefined);
|
||||
|
||||
return currentIdentifier === savedIdentifier
|
||||
? savedNote
|
||||
: n;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setNotes([savedNote, ...notes]);
|
||||
|
|
@ -635,6 +694,36 @@ const ProjectDetails: React.FC = () => {
|
|||
return sortedTasks;
|
||||
}, [tasks, showCompleted, orderBy]);
|
||||
|
||||
// Function to get the appropriate icon for project state
|
||||
const getStateIcon = (state: string) => {
|
||||
switch (state) {
|
||||
case 'idea':
|
||||
return (
|
||||
<LightBulbIcon className="h-3 w-3 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
case 'planned':
|
||||
return (
|
||||
<ClipboardDocumentListIcon className="h-3 w-3 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
case 'in_progress':
|
||||
return (
|
||||
<PlayIcon className="h-3 w-3 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
case 'blocked':
|
||||
return (
|
||||
<ExclamationTriangleIcon className="h-3 w-3 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
case 'completed':
|
||||
return (
|
||||
<CheckCircleIcon className="h-3 w-3 text-gray-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<PlayIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner message="Loading project details..." />;
|
||||
}
|
||||
|
|
@ -687,14 +776,31 @@ const ProjectDetails: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Display - Bottom Left */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center space-x-1">
|
||||
{/* State, Tags and Area Display - Bottom Left */}
|
||||
<div className="absolute bottom-2 left-2 flex items-center space-x-2">
|
||||
{/* Project State Display */}
|
||||
{project.state && (
|
||||
<div className="flex items-center space-x-2 bg-black bg-opacity-40 backdrop-blur-sm rounded px-2 py-1">
|
||||
{getStateIcon(project.state)}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
<span className="text-xs text-white/90 font-medium">
|
||||
{t(
|
||||
`projects.states.${project.state}`
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags Display */}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-center space-x-2 bg-black bg-opacity-40 backdrop-blur-sm rounded px-2 py-1">
|
||||
<TagIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex items-center space-x-1">
|
||||
{project.tags.map((tag, index) => (
|
||||
<span key={tag.id || index}>
|
||||
<span key={tag.uid || tag.id || index}>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Navigate to tag details page
|
||||
|
|
@ -733,22 +839,75 @@ const ProjectDetails: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Area Display */}
|
||||
{(project.area || (project as any).Area) && (
|
||||
<div className="flex items-center space-x-2 bg-black bg-opacity-40 backdrop-blur-sm rounded px-2 py-1">
|
||||
<Squares2X2Icon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Use the correct area property (Area or area)
|
||||
const projectArea =
|
||||
project.area ||
|
||||
(project as any).Area;
|
||||
|
||||
// Find the area in the areas store to get the uid
|
||||
const area = areas.find(
|
||||
(a) =>
|
||||
a.id === projectArea.id
|
||||
);
|
||||
const areaUid = area?.uid;
|
||||
|
||||
if (!areaUid) {
|
||||
console.warn(
|
||||
'Area uid not found for area id:',
|
||||
projectArea.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to projects filtered by this area (same as Areas page)
|
||||
const areaSlug =
|
||||
projectArea.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(/^-|-$/g, '');
|
||||
navigate(
|
||||
`/projects?area=${areaUid}-${areaSlug}`
|
||||
);
|
||||
}}
|
||||
className="text-xs text-white/90 hover:text-blue-200 transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
{
|
||||
(
|
||||
project.area ||
|
||||
(project as any).Area
|
||||
)?.name
|
||||
}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit/Delete Buttons - Bottom Right */}
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditProject();
|
||||
}}
|
||||
ref={editButtonRef}
|
||||
type="button"
|
||||
className="p-2 bg-black bg-opacity-50 text-blue-400 hover:text-blue-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -1010,7 +1169,7 @@ const ProjectDetails: React.FC = () => {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{notes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
key={note.uid}
|
||||
note={note}
|
||||
onEdit={handleEditNote}
|
||||
onDelete={(note) => {
|
||||
|
|
@ -1043,8 +1202,9 @@ const ProjectDetails: React.FC = () => {
|
|||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
uid: project.uid,
|
||||
},
|
||||
project_id: project.id,
|
||||
project_uid: project.uid,
|
||||
});
|
||||
setIsNoteModalOpen(true);
|
||||
}}
|
||||
|
|
@ -1059,7 +1219,7 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
<ProjectModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onClose={closeModal}
|
||||
onSave={handleSaveProject}
|
||||
project={project}
|
||||
areas={areas}
|
||||
|
|
@ -1083,7 +1243,17 @@ const ProjectDetails: React.FC = () => {
|
|||
<ConfirmDialog
|
||||
title="Delete Note"
|
||||
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
|
||||
onConfirm={() => handleDeleteNote(noteToDelete.id!)}
|
||||
onConfirm={() => {
|
||||
const identifier =
|
||||
noteToDelete?.uid ??
|
||||
(noteToDelete?.id !== undefined
|
||||
? String(noteToDelete.id)
|
||||
: null);
|
||||
|
||||
if (identifier) {
|
||||
handleDeleteNote(identifier);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsConfirmDialogOpen(false);
|
||||
setNoteToDelete(null);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||
import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { Project } from '../../entities/Project';
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
LightBulbIcon,
|
||||
DocumentTextIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Project, ProjectState } from '../../entities/Project';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
||||
|
|
@ -33,6 +41,40 @@ const getProjectInitials = (name: string, maxLetters?: number) => {
|
|||
return maxLetters ? initials.substring(0, maxLetters) : initials;
|
||||
};
|
||||
|
||||
const getStateIcon = (state: ProjectState | undefined) => {
|
||||
switch (state) {
|
||||
case 'idea':
|
||||
return { icon: LightBulbIcon };
|
||||
case 'planned':
|
||||
return { icon: DocumentTextIcon };
|
||||
case 'in_progress':
|
||||
return { icon: PlayIcon };
|
||||
case 'blocked':
|
||||
return { icon: StopIcon };
|
||||
case 'completed':
|
||||
return { icon: CheckCircleIcon };
|
||||
default:
|
||||
return { icon: LightBulbIcon };
|
||||
}
|
||||
};
|
||||
|
||||
const getStateLabel = (state: ProjectState | undefined, t: any): string => {
|
||||
switch (state) {
|
||||
case 'idea':
|
||||
return t('projects.states.idea', 'Idea');
|
||||
case 'planned':
|
||||
return t('projects.states.planned', 'Planned');
|
||||
case 'in_progress':
|
||||
return t('projects.states.in_progress', 'In Progress');
|
||||
case 'blocked':
|
||||
return t('projects.states.blocked', 'Blocked');
|
||||
case 'completed':
|
||||
return t('projects.states.completed', 'Completed');
|
||||
default:
|
||||
return t('projects.states.idea', 'Idea');
|
||||
}
|
||||
};
|
||||
|
||||
const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||
project,
|
||||
viewMode,
|
||||
|
|
@ -89,6 +131,21 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
{getProjectInitials(project.name)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* State icon in top right corner of image area */}
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
title={getStateLabel(project.state, t)}
|
||||
>
|
||||
{(() => {
|
||||
const { icon: StateIcon } = getStateIcon(
|
||||
project.state
|
||||
);
|
||||
return (
|
||||
<StateIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 opacity-70 drop-shadow-sm" />
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import TagInput from '../Tag/TagInput';
|
|||
import PriorityDropdown from '../Shared/PriorityDropdown';
|
||||
import AreaDropdown from '../Shared/AreaDropdown';
|
||||
import DatePicker from '../Shared/DatePicker';
|
||||
import ProjectStateDropdown from '../Shared/ProjectStateDropdown';
|
||||
import { PriorityType } from '../../entities/Task';
|
||||
import Switch from '../Shared/Switch';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
|
@ -19,14 +19,14 @@ import {
|
|||
CameraIcon,
|
||||
CalendarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PowerIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (project: Project) => void;
|
||||
onDelete?: (projectId: number) => Promise<void>;
|
||||
onDelete?: (projectUid: string) => Promise<void>;
|
||||
project?: Project;
|
||||
areas: Area[];
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
name: '',
|
||||
description: '',
|
||||
area_id: null,
|
||||
active: true,
|
||||
state: 'idea',
|
||||
tags: [],
|
||||
priority: 'low',
|
||||
due_date_at: null,
|
||||
|
|
@ -76,35 +76,32 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
// Collapsible sections state
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
state: false,
|
||||
tags: false,
|
||||
area: false,
|
||||
image: false,
|
||||
priority: false,
|
||||
dueDate: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Load tags when modal opens and auto-focus on the name input
|
||||
// Auto-focus on the name input when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Load tags with a delay to avoid conflicts with modal state
|
||||
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
|
||||
// Delay tag loading to avoid immediate state conflicts
|
||||
setTimeout(() => {
|
||||
if (isOpen) {
|
||||
// Only load if modal is still open
|
||||
tagsStore.loadTags();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
setTimeout(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, 200);
|
||||
}
|
||||
}, [isOpen, tagsStore]);
|
||||
}, [isOpen]);
|
||||
|
||||
// Load tags only when user actually interacts with tag input to prevent refresh
|
||||
const handleTagInputFocus = () => {
|
||||
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
|
||||
tagsStore.loadTags();
|
||||
}
|
||||
};
|
||||
|
||||
// Manage body scroll when modal is open
|
||||
useEffect(() => {
|
||||
|
|
@ -139,7 +136,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
name: '',
|
||||
description: '',
|
||||
area_id: null,
|
||||
active: true,
|
||||
state: 'idea',
|
||||
tags: [],
|
||||
priority: 'low',
|
||||
due_date_at: null,
|
||||
|
|
@ -176,13 +173,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
handleClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
if (isOpen && !modalJustOpened) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
}, [isOpen, modalJustOpened]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
|
|
@ -348,8 +345,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
tags: tags.map((name) => ({ name })),
|
||||
};
|
||||
|
||||
// Save the project
|
||||
onSave(projectData);
|
||||
// Save the project and wait for it to complete
|
||||
await onSave(projectData);
|
||||
|
||||
showSuccessToast(
|
||||
project
|
||||
|
|
@ -369,9 +366,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (project && project.id && onDelete) {
|
||||
if (project && project.uid && onDelete) {
|
||||
try {
|
||||
await onDelete(project.id);
|
||||
await onDelete(project.uid);
|
||||
showSuccessToast(t('success.projectDeleted'));
|
||||
setShowConfirmDialog(false);
|
||||
handleClose();
|
||||
|
|
@ -390,13 +387,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}, 300);
|
||||
};
|
||||
|
||||
const handleToggleActive = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
active: !prev.active,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleSection = useCallback(
|
||||
(section: keyof typeof expandedSections) => {
|
||||
setExpandedSections((prev) => {
|
||||
|
|
@ -445,7 +435,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
return createPortal(
|
||||
<>
|
||||
,
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
|
|
@ -529,34 +518,29 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Expandable Sections - Only show when expanded */}
|
||||
{/* Active Status Section - First */}
|
||||
{expandedSections.active && (
|
||||
{/* State Section - First */}
|
||||
{expandedSections.state && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'projects.active',
|
||||
'Status'
|
||||
'projects.state',
|
||||
'Project State'
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
isChecked={
|
||||
formData.active
|
||||
}
|
||||
onToggle={
|
||||
handleToggleActive
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="active"
|
||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{t(
|
||||
'projects.active',
|
||||
'Active'
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<ProjectStateDropdown
|
||||
value={
|
||||
formData.state ||
|
||||
'idea'
|
||||
}
|
||||
onChange={(state) =>
|
||||
setFormData(
|
||||
(prev) => ({
|
||||
...prev,
|
||||
state,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -576,6 +560,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
availableTags={
|
||||
availableTags
|
||||
}
|
||||
onFocus={
|
||||
handleTagInputFocus
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -741,26 +728,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
<div className="flex items-center justify-between">
|
||||
{/* Left side: Section icons */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Active Status Toggle - First */}
|
||||
{/* State Toggle - First */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleSection('active')
|
||||
toggleSection('state')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.active
|
||||
expandedSections.state
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'projects.active',
|
||||
'Status'
|
||||
'projects.state',
|
||||
'Project State'
|
||||
)}
|
||||
>
|
||||
<PowerIcon className="h-5 w-5" />
|
||||
{!formData.active && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
<PlayIcon className="h-5 w-5" />
|
||||
{formData.state &&
|
||||
formData.state !== 'idea' && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tags Toggle */}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const Projects: React.FC = () => {
|
|||
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeFilter = searchParams.get('active') || 'all';
|
||||
const stateFilter = searchParams.get('state') || 'all';
|
||||
|
||||
// Handle both 'area_id' and 'area' parameters from URL
|
||||
const getAreaIdFromParams = () => {
|
||||
|
|
@ -99,8 +99,17 @@ const Projects: React.FC = () => {
|
|||
// Filter options for dropdowns
|
||||
const statusOptions: FilterOption[] = [
|
||||
{ value: 'all', label: t('projects.filters.all') },
|
||||
{ value: 'true', label: t('projects.filters.active') },
|
||||
{ value: 'false', label: t('projects.filters.inactive') },
|
||||
{ value: 'idea', label: t('projects.states.idea', 'Idea') },
|
||||
{ value: 'planned', label: t('projects.states.planned', 'Planned') },
|
||||
{
|
||||
value: 'in_progress',
|
||||
label: t('projects.states.in_progress', 'In Progress'),
|
||||
},
|
||||
{ value: 'blocked', label: t('projects.states.blocked', 'Blocked') },
|
||||
{
|
||||
value: 'completed',
|
||||
label: t('projects.states.completed', 'Completed'),
|
||||
},
|
||||
];
|
||||
|
||||
const areaOptions: FilterOption[] = [
|
||||
|
|
@ -169,8 +178,8 @@ const Projects: React.FC = () => {
|
|||
const handleSaveProject = async (project: Project) => {
|
||||
setProjectsLoading(true);
|
||||
try {
|
||||
if (project.id) {
|
||||
await updateProject(project.id, project);
|
||||
if (project.uid) {
|
||||
await updateProject(project.uid, project);
|
||||
} else {
|
||||
await createProject(project);
|
||||
}
|
||||
|
|
@ -200,15 +209,15 @@ const Projects: React.FC = () => {
|
|||
if (!projectToDelete) return;
|
||||
|
||||
try {
|
||||
if (projectToDelete.id !== undefined) {
|
||||
if (projectToDelete.uid !== undefined) {
|
||||
setProjectsLoading(true);
|
||||
await deleteProject(projectToDelete.id);
|
||||
await deleteProject(projectToDelete.uid);
|
||||
|
||||
// Update global state
|
||||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
} else {
|
||||
console.error('Cannot delete project: ID is undefined.');
|
||||
console.error('Cannot delete project: UID is undefined.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting project:', error);
|
||||
|
|
@ -231,13 +240,13 @@ const Projects: React.FC = () => {
|
|||
return (project as any).completion_percentage || 0;
|
||||
};
|
||||
|
||||
const handleActiveFilterChange = (value: string) => {
|
||||
const handleStateFilterChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (value === 'all') {
|
||||
params.delete('active');
|
||||
params.delete('state');
|
||||
} else {
|
||||
params.set('active', value);
|
||||
params.set('state', value);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
|
@ -265,11 +274,10 @@ const Projects: React.FC = () => {
|
|||
const displayProjects = useMemo(() => {
|
||||
let filteredProjects = [...projects];
|
||||
|
||||
// Apply active filter
|
||||
if (activeFilter !== 'all') {
|
||||
const isActive = activeFilter === 'true';
|
||||
// Apply state filter
|
||||
if (stateFilter !== 'all') {
|
||||
filteredProjects = filteredProjects.filter(
|
||||
(project) => project.active === isActive
|
||||
(project) => project.state === stateFilter
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +348,7 @@ const Projects: React.FC = () => {
|
|||
});
|
||||
|
||||
return filteredProjects;
|
||||
}, [projects, activeFilter, actualAreaFilter, searchQuery, orderBy]);
|
||||
}, [projects, stateFilter, actualAreaFilter, searchQuery, orderBy]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -419,8 +427,8 @@ const Projects: React.FC = () => {
|
|||
<div className="w-full md:w-auto mb-4 md:mb-0">
|
||||
<FilterDropdown
|
||||
options={statusOptions}
|
||||
value={activeFilter}
|
||||
onChange={handleActiveFilterChange}
|
||||
value={stateFilter}
|
||||
onChange={handleStateFilterChange}
|
||||
size="desktop"
|
||||
autoWidth={true}
|
||||
/>
|
||||
|
|
@ -508,13 +516,13 @@ const Projects: React.FC = () => {
|
|||
setModalState({ isOpen: false, projectToEdit: null });
|
||||
}}
|
||||
onSave={handleSaveProject}
|
||||
onDelete={async (projectId) => {
|
||||
onDelete={async (projectUid) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
await deleteProject(projectUid);
|
||||
|
||||
// Update both local and global state
|
||||
const updatedProjects = projects.filter(
|
||||
(p: Project) => p.id !== projectId
|
||||
(p: Project) => p.uid !== projectUid
|
||||
);
|
||||
setProjects(updatedProjects);
|
||||
|
||||
|
|
|
|||
|
|
@ -123,8 +123,8 @@ const AreaDropdown: React.FC<AreaDropdownProps> = ({
|
|||
{/* Area options */}
|
||||
{areas.map((area) => (
|
||||
<button
|
||||
key={area.id}
|
||||
onClick={() => handleSelect(area.id!)}
|
||||
key={area.uid}
|
||||
onClick={() => handleSelect(null)} // Temporarily disabled
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full last:rounded-b-md"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -7,20 +7,12 @@ import {
|
|||
EllipsisVerticalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
||||
interface NoteCardProps {
|
||||
note: {
|
||||
id?: string | number;
|
||||
uid?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
tags?: { name: string; uid?: string }[];
|
||||
Tags?: { name: string; uid?: string }[];
|
||||
project?: { name: string; id?: number; uid?: string };
|
||||
Project?: { name: string; id?: number; uid?: string };
|
||||
};
|
||||
onEdit?: (note: any) => void;
|
||||
onDelete?: (note: any) => void;
|
||||
note: Note;
|
||||
onEdit?: (note: Note) => void;
|
||||
onDelete?: (note: Note) => void;
|
||||
showActions?: boolean;
|
||||
showProject?: boolean;
|
||||
}
|
||||
|
|
@ -39,6 +31,8 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
|
||||
const tags = note.tags || note.Tags || [];
|
||||
const project = note.project || note.Project;
|
||||
const noteIdentifier =
|
||||
note.uid ?? (note.id !== undefined ? String(note.id) : 'note');
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -62,14 +56,10 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
return (
|
||||
<div className="relative group">
|
||||
<Link
|
||||
to={
|
||||
note.uid
|
||||
? `/note/${note.uid}-${note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/note/${note.id}`
|
||||
}
|
||||
to={`/note/${noteIdentifier}-${note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer"
|
||||
style={{
|
||||
minHeight: '280px',
|
||||
|
|
@ -211,7 +201,7 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none transition-opacity duration-300 p-1"
|
||||
aria-label={t('notes.toggleDropdownMenu')}
|
||||
type="button"
|
||||
data-testid={`note-dropdown-${note.id}`}
|
||||
data-testid={`note-dropdown-${noteIdentifier}`}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -227,7 +217,7 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
setDropdownOpen(false);
|
||||
}}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left rounded-t-md"
|
||||
data-testid={`note-edit-${note.id}`}
|
||||
data-testid={`note-edit-${noteIdentifier}`}
|
||||
>
|
||||
{t('notes.edit', 'Edit')}
|
||||
</button>
|
||||
|
|
|
|||
223
frontend/components/Shared/ProjectStateDropdown.tsx
Normal file
223
frontend/components/Shared/ProjectStateDropdown.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
LightBulbIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
PlayIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ProjectState } from '../../entities/Project';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProjectStateDropdownProps {
|
||||
value: ProjectState;
|
||||
onChange: (value: ProjectState) => void;
|
||||
}
|
||||
|
||||
const ProjectStateDropdown: React.FC<ProjectStateDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const states = [
|
||||
{
|
||||
value: 'idea' as ProjectState,
|
||||
label: t('projects.states.idea', 'Idea'),
|
||||
description: t(
|
||||
'projects.states.idea_desc',
|
||||
'captured but not planned yet'
|
||||
),
|
||||
icon: <LightBulbIcon className="w-5 h-5 text-yellow-500" />,
|
||||
},
|
||||
{
|
||||
value: 'planned' as ProjectState,
|
||||
label: t('projects.states.planned', 'Planned'),
|
||||
description: t(
|
||||
'projects.states.planned_desc',
|
||||
'scoped and ready to start'
|
||||
),
|
||||
icon: (
|
||||
<ClipboardDocumentListIcon className="w-5 h-5 text-blue-500" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'in_progress' as ProjectState,
|
||||
label: t('projects.states.in_progress', 'In Progress'),
|
||||
description: t(
|
||||
'projects.states.in_progress_desc',
|
||||
'active work happening'
|
||||
),
|
||||
icon: <PlayIcon className="w-5 h-5 text-green-500" />,
|
||||
},
|
||||
{
|
||||
value: 'blocked' as ProjectState,
|
||||
label: t('projects.states.blocked', 'Blocked'),
|
||||
description: t(
|
||||
'projects.states.blocked_desc',
|
||||
'temporarily paused or stuck'
|
||||
),
|
||||
icon: <ExclamationTriangleIcon className="w-5 h-5 text-red-500" />,
|
||||
},
|
||||
{
|
||||
value: 'completed' as ProjectState,
|
||||
label: t('projects.states.completed', 'Completed'),
|
||||
description: t(
|
||||
'projects.states.completed_desc',
|
||||
'finished and done'
|
||||
),
|
||||
icon: <CheckCircleIcon className="w-5 h-5 text-gray-500" />,
|
||||
},
|
||||
];
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
|
||||
// Scroll dropdown into view when opening to ensure options are visible
|
||||
if (!isOpen && dropdownRef.current) {
|
||||
setTimeout(() => {
|
||||
// Find the dropdown options container
|
||||
const dropdownOptions =
|
||||
dropdownRef.current?.querySelector('.absolute.z-10');
|
||||
if (dropdownOptions) {
|
||||
dropdownOptions.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
} else {
|
||||
// Fallback to scrolling the dropdown container itself
|
||||
dropdownRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}, 150); // Increased timeout to ensure dropdown is rendered
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (state: ProjectState) => {
|
||||
onChange(state);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
// Ensure dropdown is visible after opening
|
||||
setTimeout(() => {
|
||||
const dropdownOptions =
|
||||
dropdownRef.current?.querySelector('.absolute.z-10');
|
||||
if (dropdownOptions) {
|
||||
// Try to scroll the parent modal container to show the dropdown
|
||||
const modalScrollContainer =
|
||||
document.querySelector(
|
||||
'.absolute.inset-0.overflow-y-auto'
|
||||
) ||
|
||||
document.querySelector('[style*="overflow-y"]') ||
|
||||
document.querySelector('.overflow-y-auto');
|
||||
|
||||
if (modalScrollContainer) {
|
||||
const rect = dropdownOptions.getBoundingClientRect();
|
||||
const containerRect =
|
||||
modalScrollContainer.getBoundingClientRect();
|
||||
|
||||
// Check if dropdown is below visible area
|
||||
if (rect.bottom > containerRect.bottom) {
|
||||
modalScrollContainer.scrollTo({
|
||||
top:
|
||||
modalScrollContainer.scrollTop +
|
||||
(rect.bottom - containerRect.bottom) +
|
||||
20,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback to scrollIntoView
|
||||
dropdownOptions.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedState = states.find((s) => s.value === value);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="relative inline-block text-left w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{selectedState ? (
|
||||
selectedState.icon
|
||||
) : (
|
||||
<LightBulbIcon className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<span>
|
||||
{selectedState
|
||||
? selectedState.label
|
||||
: t('projects.selectState', 'Select State')}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600">
|
||||
{states.map((state) => (
|
||||
<button
|
||||
key={state.value}
|
||||
onClick={() => handleSelect(state.value)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600 first:rounded-t-md last:rounded-b-md transition duration-150 ease-in-out"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{state.icon}
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{state.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{state.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectStateDropdown;
|
||||
|
|
@ -31,7 +31,7 @@ const SidebarProjects: React.FC<SidebarProjectsProps> = ({
|
|||
)}`}
|
||||
onClick={() =>
|
||||
handleNavClick(
|
||||
'/projects?active=true',
|
||||
'/projects',
|
||||
'Projects',
|
||||
<FolderIcon className="h-5 w-5 mr-2" />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const TagDetails: React.FC = () => {
|
|||
|
||||
// State for ProjectItem components
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
|
||||
const [hoveredNoteId, setHoveredNoteId] = useState<string | null>(null);
|
||||
const [, setProjectToDelete] = useState<Project | null>(null);
|
||||
const [, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -260,10 +260,10 @@ const TagDetails: React.FC = () => {
|
|||
<ul className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
<li
|
||||
key={note.id}
|
||||
key={note.uid}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
|
||||
onMouseEnter={() =>
|
||||
setHoveredNoteId(note.id || null)
|
||||
setHoveredNoteId(note.uid || null)
|
||||
}
|
||||
onMouseLeave={() => setHoveredNoteId(null)}
|
||||
>
|
||||
|
|
@ -282,7 +282,9 @@ const TagDetails: React.FC = () => {
|
|||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/note/${note.id}`
|
||||
: note.uid
|
||||
? `/note/${note.uid}`
|
||||
: '#'
|
||||
}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
|
|
@ -324,7 +326,7 @@ const TagDetails: React.FC = () => {
|
|||
onClick={
|
||||
() => {} // Edit functionality not implemented yet
|
||||
}
|
||||
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.uid ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Edit ${note.title}`}
|
||||
title={`Edit ${note.title}`}
|
||||
>
|
||||
|
|
@ -334,7 +336,7 @@ const TagDetails: React.FC = () => {
|
|||
onClick={
|
||||
() => {} // Delete functionality not implemented yet
|
||||
}
|
||||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.uid ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Delete ${note.title}`}
|
||||
title={`Delete ${note.title}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ interface TagInputProps {
|
|||
initialTags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
availableTags: Tag[];
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
const TagInput: React.FC<TagInputProps> = ({
|
||||
initialTags,
|
||||
onTagsChange,
|
||||
availableTags,
|
||||
onFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
|
@ -229,6 +231,7 @@ const TagInput: React.FC<TagInputProps> = ({
|
|||
placeholder={t('tags.typeToAdd')}
|
||||
className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100"
|
||||
onFocus={() => {
|
||||
onFocus?.();
|
||||
if (filteredTags.length > 0) setIsDropdownOpen(true);
|
||||
}}
|
||||
style={{ minWidth: '150px' }}
|
||||
|
|
@ -248,7 +251,7 @@ const TagInput: React.FC<TagInputProps> = ({
|
|||
>
|
||||
{filteredTags.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
key={tag.uid}
|
||||
type="button"
|
||||
onClick={() => selectTag(tag.name)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 ${
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface TagModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (tag: Tag) => void;
|
||||
onDelete?: (tagId: number) => void;
|
||||
onDelete?: (tagUid: string) => void;
|
||||
tag?: Tag | null;
|
||||
}
|
||||
|
||||
|
|
@ -127,9 +127,9 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
};
|
||||
|
||||
const handleDeleteTag = async () => {
|
||||
if (formData.id && onDelete) {
|
||||
if (formData.uid && onDelete) {
|
||||
try {
|
||||
await onDelete(formData.id);
|
||||
await onDelete(formData.uid);
|
||||
showSuccessToast(
|
||||
t('success.tagDeleted', 'Tag deleted successfully!')
|
||||
);
|
||||
|
|
@ -193,7 +193,7 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between sm:rounded-b-lg">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{tag && tag.id && onDelete && (
|
||||
{tag && tag.uid && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteTag}
|
||||
|
|
|
|||
|
|
@ -133,8 +133,8 @@ const Tags: React.FC = () => {
|
|||
const handleDeleteTag = async () => {
|
||||
if (!tagToDelete) return;
|
||||
try {
|
||||
await apiDeleteTag(tagToDelete.id!);
|
||||
setTags(tags.filter((tag) => tag.id !== tagToDelete.id));
|
||||
await apiDeleteTag(tagToDelete.uid!);
|
||||
setTags(tags.filter((tag) => tag.uid !== tagToDelete.uid));
|
||||
// Remove the deleted tag from metrics as well
|
||||
setTagMetrics((prev) => {
|
||||
const newMetrics = { ...prev };
|
||||
|
|
@ -155,10 +155,10 @@ const Tags: React.FC = () => {
|
|||
|
||||
const handleSaveTag = async (tagData: Tag) => {
|
||||
try {
|
||||
if (tagData.id) {
|
||||
await updateTag(tagData.id, tagData);
|
||||
if (tagData.uid) {
|
||||
await updateTag(tagData.uid, tagData);
|
||||
setTags(
|
||||
tags.map((tag) => (tag.id === tagData.id ? tagData : tag))
|
||||
tags.map((tag) => (tag.uid === tagData.uid ? tagData : tag))
|
||||
);
|
||||
} else {
|
||||
const newTag = await createTag(tagData);
|
||||
|
|
@ -281,7 +281,7 @@ const Tags: React.FC = () => {
|
|||
|
||||
return (
|
||||
<li
|
||||
key={tag.id}
|
||||
key={tag.uid || tag.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4"
|
||||
onMouseEnter={() =>
|
||||
setHoveredTagId(
|
||||
|
|
@ -372,7 +372,7 @@ const Tags: React.FC = () => {
|
|||
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Edit ${tag.name}`}
|
||||
title={`Edit ${tag.name}`}
|
||||
data-testid={`tag-edit-${tag.id}`}
|
||||
data-testid={`tag-edit-${tag.uid || tag.id}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -385,7 +385,7 @@ const Tags: React.FC = () => {
|
|||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Delete ${tag.name}`}
|
||||
title={`Delete ${tag.name}`}
|
||||
data-testid={`tag-delete-${tag.id}`}
|
||||
data-testid={`tag-delete-${tag.uid || tag.id}`}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -409,14 +409,16 @@ const Tags: React.FC = () => {
|
|||
setSelectedTag(null);
|
||||
}}
|
||||
onSave={handleSaveTag}
|
||||
onDelete={async (tagId) => {
|
||||
onDelete={async (tagUid) => {
|
||||
try {
|
||||
await apiDeleteTag(tagId);
|
||||
setTags(tags.filter((tag) => tag.id !== tagId));
|
||||
await apiDeleteTag(tagUid);
|
||||
setTags(
|
||||
tags.filter((tag) => tag.uid !== tagUid)
|
||||
);
|
||||
setTagMetrics((prev) => {
|
||||
const newMetrics = { ...prev };
|
||||
const deletedTag = tags.find(
|
||||
(t) => t.id === tagId
|
||||
(t) => t.uid === tagUid
|
||||
);
|
||||
if (deletedTag) {
|
||||
delete newMetrics[deletedTag.name];
|
||||
|
|
|
|||
|
|
@ -484,6 +484,7 @@ const TaskDetails: React.FC = () => {
|
|||
) => (
|
||||
<React.Fragment
|
||||
key={
|
||||
tag.uid ||
|
||||
tag.id ||
|
||||
tag.name
|
||||
}
|
||||
|
|
@ -1014,7 +1015,7 @@ const TaskDetails: React.FC = () => {
|
|||
</h4>
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
|
||||
<TaskTimeline
|
||||
taskId={task.id}
|
||||
taskUid={task.uid}
|
||||
refreshKey={timelineRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { TagIcon, XMarkIcon } from '@heroicons/react/24/solid';
|
|||
|
||||
interface TaskTagsProps {
|
||||
tags: Tag[];
|
||||
onTagRemove?: (tagId: string | number | undefined) => void;
|
||||
onTagRemove?: (tagUid: string | undefined) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ const TaskTags: React.FC<TaskTagsProps> = ({
|
|||
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={tag.id || index}
|
||||
key={tag.uid || tag.id || index}
|
||||
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium px-2 py-1.5 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
|
||||
>
|
||||
<button
|
||||
|
|
@ -54,7 +54,7 @@ const TaskTags: React.FC<TaskTagsProps> = ({
|
|||
{onTagRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagRemove(tag.id)}
|
||||
onClick={() => onTagRemove(tag.uid)}
|
||||
className="ml-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 focus:outline-none"
|
||||
aria-label={`Remove tag ${tag.name}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import {
|
|||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface TaskTimelineProps {
|
||||
taskId: number | undefined;
|
||||
taskUid: string | undefined;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
|
||||
const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskUid, refreshKey }) => {
|
||||
const { t } = useTranslation();
|
||||
const [events, setEvents] = useState<TaskEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -25,7 +25,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
|
|||
|
||||
useEffect(() => {
|
||||
const fetchTimeline = async () => {
|
||||
if (!taskId || taskId === undefined) {
|
||||
if (!taskUid || taskUid === undefined) {
|
||||
setLoading(false);
|
||||
setEvents([]);
|
||||
return;
|
||||
|
|
@ -35,7 +35,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const timeline = await getTaskTimeline(taskId);
|
||||
const timeline = await getTaskTimeline(taskUid);
|
||||
// Sort events by created_at in descending order (most recent first)
|
||||
const sortedTimeline = timeline.sort(
|
||||
(a, b) =>
|
||||
|
|
@ -53,7 +53,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
|
|||
};
|
||||
|
||||
fetchTimeline();
|
||||
}, [taskId, refreshKey]);
|
||||
}, [taskUid, refreshKey]);
|
||||
|
||||
const getTranslatedStatusLabel = (status: number | string): string => {
|
||||
// Handle both numeric and string status values
|
||||
|
|
@ -196,7 +196,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
if (!taskUid) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<SparklesIcon className="h-6 w-6 mb-2" />
|
||||
|
|
|
|||
|
|
@ -913,7 +913,13 @@ const TasksToday: React.FC = () => {
|
|||
<p className="text-sm font-semibold">
|
||||
{Array.isArray(localProjects)
|
||||
? localProjects.filter(
|
||||
(project) => project.active
|
||||
(project) =>
|
||||
project.state &&
|
||||
[
|
||||
'planned',
|
||||
'in_progress',
|
||||
'blocked',
|
||||
].includes(project.state)
|
||||
).length
|
||||
: 0}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import TaskTimeline from './TaskTimeline';
|
|||
import { ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TimelinePanelProps {
|
||||
taskId: number | undefined;
|
||||
taskUid: string | undefined;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
taskId,
|
||||
taskUid,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}) => {
|
||||
|
|
@ -52,7 +52,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="p-3 lg:p-4 flex-1 overflow-hidden">
|
||||
<TaskTimeline taskId={taskId} />
|
||||
<TaskTimeline taskUid={taskUid} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface InboxItem {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
content: string;
|
||||
status?: string; // 'added' | 'processed' | 'deleted'
|
||||
source?: string; // 'telegram'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export interface Note {
|
|||
content: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
project_id?: number; // Foreign key for project
|
||||
project_id?: number; // Foreign key for project (deprecated, use project_uid)
|
||||
project_uid?: string; // Foreign key for project by uid
|
||||
tags?: Tag[];
|
||||
Tags?: Tag[]; // Sequelize association naming (capitalized)
|
||||
project?: {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,22 @@ import { Tag } from './Tag';
|
|||
import { PriorityType, Task } from './Task';
|
||||
import { Note } from './Note';
|
||||
|
||||
export type ProjectState =
|
||||
| 'idea'
|
||||
| 'planned'
|
||||
| 'in_progress'
|
||||
| 'blocked'
|
||||
| 'completed';
|
||||
|
||||
export interface Project {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
pin_to_sidebar?: boolean;
|
||||
area?: Area;
|
||||
area_id?: number | null;
|
||||
area_uid?: string | null;
|
||||
tags?: Tag[];
|
||||
priority?: PriorityType;
|
||||
tasks?: Task[];
|
||||
|
|
@ -22,6 +29,7 @@ export interface Project {
|
|||
image_url?: string;
|
||||
task_show_completed?: boolean;
|
||||
task_sort_order?: string;
|
||||
state?: ProjectState;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface User {
|
||||
id: number;
|
||||
uid: string;
|
||||
email: string;
|
||||
language: string;
|
||||
appearance: string;
|
||||
|
|
|
|||
78
frontend/hooks/usePersistedModal.ts
Normal file
78
frontend/hooks/usePersistedModal.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface PersistedModalState {
|
||||
isOpen: boolean;
|
||||
projectId?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
const MODAL_STATE_KEY = 'project-modal-state';
|
||||
const MODAL_TIMEOUT = 5000; // 5 seconds timeout to prevent stale states
|
||||
|
||||
export const usePersistedModal = (projectId?: number) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Load persisted state on mount
|
||||
useEffect(() => {
|
||||
const savedState = sessionStorage.getItem(MODAL_STATE_KEY);
|
||||
if (savedState) {
|
||||
try {
|
||||
const state: PersistedModalState = JSON.parse(savedState);
|
||||
const now = Date.now();
|
||||
|
||||
// Check if state is recent and for the same project
|
||||
if (
|
||||
state.timestamp &&
|
||||
now - state.timestamp < MODAL_TIMEOUT &&
|
||||
state.projectId === projectId &&
|
||||
state.isOpen
|
||||
) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing modal state:', error);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Clear timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openModal = () => {
|
||||
const state: PersistedModalState = {
|
||||
isOpen: true,
|
||||
projectId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
sessionStorage.setItem(MODAL_STATE_KEY, JSON.stringify(state));
|
||||
setIsOpen(true);
|
||||
|
||||
// Clear the persisted state after timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
sessionStorage.removeItem(MODAL_STATE_KEY);
|
||||
}, MODAL_TIMEOUT);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
sessionStorage.removeItem(MODAL_STATE_KEY);
|
||||
setIsOpen(false);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
|
|
@ -95,6 +95,7 @@ interface InboxStore {
|
|||
addInboxItem: (inboxItem: InboxItem) => void;
|
||||
updateInboxItem: (inboxItem: InboxItem) => void;
|
||||
removeInboxItem: (id: number) => void;
|
||||
removeInboxItemByUid: (uid: string) => void;
|
||||
setLoading: (isLoading: boolean) => void;
|
||||
setError: (isError: boolean) => void;
|
||||
resetPagination: () => void;
|
||||
|
|
@ -629,7 +630,10 @@ export const useStore = create<StoreState>((set: any) => ({
|
|||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: state.inboxStore.inboxItems.map((item) =>
|
||||
item.id === inboxItem.id ? inboxItem : item
|
||||
(inboxItem.uid && item.uid === inboxItem.uid) ||
|
||||
(inboxItem.id && item.id === inboxItem.id)
|
||||
? inboxItem
|
||||
: item
|
||||
),
|
||||
},
|
||||
})),
|
||||
|
|
@ -649,6 +653,22 @@ export const useStore = create<StoreState>((set: any) => ({
|
|||
},
|
||||
},
|
||||
})),
|
||||
removeInboxItemByUid: (uid) =>
|
||||
set((state) => ({
|
||||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: state.inboxStore.inboxItems.filter(
|
||||
(item) => item.uid !== uid
|
||||
),
|
||||
pagination: {
|
||||
...state.inboxStore.pagination,
|
||||
total: Math.max(
|
||||
0,
|
||||
state.inboxStore.pagination.total - 1
|
||||
),
|
||||
},
|
||||
},
|
||||
})),
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, isLoading },
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
|
|||
};
|
||||
|
||||
export const updateArea = async (
|
||||
areaId: number,
|
||||
areaUid: string,
|
||||
areaData: Partial<Area>
|
||||
): Promise<Area> => {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
const response = await fetch(`/api/areas/${areaUid}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
@ -45,8 +45,8 @@ export const updateArea = async (
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteArea = async (areaId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
export const deleteArea = async (areaUid: string): Promise<void> => {
|
||||
const response = await fetch(`/api/areas/${areaUid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -70,10 +70,10 @@ export const createInboxItem = async (
|
|||
};
|
||||
|
||||
export const updateInboxItem = async (
|
||||
itemId: number,
|
||||
itemUid: string,
|
||||
content: string
|
||||
): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}`, {
|
||||
const response = await fetch(`/api/inbox/${itemUid}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
@ -87,8 +87,8 @@ export const updateInboxItem = async (
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}/process`, {
|
||||
export const processInboxItem = async (itemUid: string): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemUid}/process`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
@ -100,8 +100,8 @@ export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteInboxItem = async (itemId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}`, {
|
||||
export const deleteInboxItem = async (itemUid: string): Promise<void> => {
|
||||
const response = await fetch(`/api/inbox/${itemUid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
@ -132,15 +132,15 @@ export const loadInboxItemsToStore = async (
|
|||
|
||||
// Check for new items since last check (only for non-initial loads)
|
||||
if (!isInitialLoad) {
|
||||
const currentItemIds = new Set(
|
||||
inboxStore.inboxItems.map((item) => item.id)
|
||||
const currentItemUids = new Set(
|
||||
inboxStore.inboxItems.map((item) => item.uid).filter(Boolean)
|
||||
);
|
||||
|
||||
// New telegram items
|
||||
const newTelegramItems = items.filter(
|
||||
(item) =>
|
||||
item.id &&
|
||||
!currentItemIds.has(item.id) &&
|
||||
item.uid &&
|
||||
!currentItemUids.has(item.uid) &&
|
||||
item.source === 'telegram'
|
||||
);
|
||||
|
||||
|
|
@ -224,13 +224,13 @@ export const createInboxItemWithStore = async (
|
|||
};
|
||||
|
||||
export const updateInboxItemWithStore = async (
|
||||
itemId: number,
|
||||
itemUid: string,
|
||||
content: string
|
||||
): Promise<InboxItem> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
const updatedItem = await updateInboxItem(itemId, content);
|
||||
const updatedItem = await updateInboxItem(itemUid, content);
|
||||
inboxStore.updateInboxItem(updatedItem);
|
||||
return updatedItem;
|
||||
} catch (error) {
|
||||
|
|
@ -240,13 +240,13 @@ export const updateInboxItemWithStore = async (
|
|||
};
|
||||
|
||||
export const processInboxItemWithStore = async (
|
||||
itemId: number
|
||||
itemUid: string
|
||||
): Promise<InboxItem> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
const processedItem = await processInboxItem(itemId);
|
||||
inboxStore.removeInboxItem(itemId);
|
||||
const processedItem = await processInboxItem(itemUid);
|
||||
inboxStore.removeInboxItemByUid(itemUid);
|
||||
return processedItem;
|
||||
} catch (error) {
|
||||
console.error('Failed to process inbox item:', error);
|
||||
|
|
@ -255,13 +255,13 @@ export const processInboxItemWithStore = async (
|
|||
};
|
||||
|
||||
export const deleteInboxItemWithStore = async (
|
||||
itemId: number
|
||||
itemUid: string
|
||||
): Promise<void> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
await deleteInboxItem(itemId);
|
||||
inboxStore.removeInboxItem(itemId);
|
||||
await deleteInboxItem(itemUid);
|
||||
inboxStore.removeInboxItemByUid(itemUid);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete inbox item:', error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -4,24 +4,34 @@ import { Note } from '../entities/Note';
|
|||
|
||||
/**
|
||||
* Shared utility function to delete a note and update the global store
|
||||
* @param noteId - The ID of the note to delete
|
||||
* @param noteOrUid - The note object or UID of the note to delete
|
||||
* @param showSuccessToast - Function to show success toast
|
||||
* @param t - Translation function
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const deleteNoteWithStoreUpdate = async (
|
||||
noteId: number,
|
||||
noteOrUid: Note | string,
|
||||
showSuccessToast: (message: string) => void,
|
||||
t: (key: string) => string
|
||||
): Promise<void> => {
|
||||
await apiDeleteNote(noteId);
|
||||
let noteUid: string;
|
||||
|
||||
if (typeof noteOrUid === 'object') {
|
||||
// It's a Note object
|
||||
noteUid = noteOrUid.uid!;
|
||||
} else {
|
||||
// It's a UID string
|
||||
noteUid = noteOrUid;
|
||||
}
|
||||
|
||||
await apiDeleteNote(noteUid);
|
||||
|
||||
// Remove note from global store
|
||||
const currentNotes = useStore.getState().notesStore.notes;
|
||||
useStore
|
||||
.getState()
|
||||
.notesStore.setNotes(
|
||||
currentNotes.filter((note: Note) => note.id !== noteId)
|
||||
currentNotes.filter((note: Note) => note.uid !== noteUid)
|
||||
);
|
||||
|
||||
// Show success toast
|
||||
|
|
|
|||
|
|
@ -28,22 +28,39 @@ export const createNote = async (noteData: Note): Promise<Note> => {
|
|||
};
|
||||
|
||||
export const updateNote = async (
|
||||
noteId: number,
|
||||
noteUid: string,
|
||||
noteData: Note
|
||||
): Promise<Note> => {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
// Transform project_id to project_uid if needed
|
||||
const requestData = { ...noteData };
|
||||
if (noteData.project && noteData.project.uid) {
|
||||
requestData.project_uid = noteData.project.uid;
|
||||
} else if (noteData.project_uid) {
|
||||
// project_uid is already set, use it as-is
|
||||
} else if (noteData.project_id && !noteData.project_uid) {
|
||||
// Legacy: if only project_id is provided, we can't convert it to uid here
|
||||
// This should not happen with the new implementation, but keeping for safety
|
||||
console.warn(
|
||||
'Note update with project_id but no project_uid - this may fail'
|
||||
);
|
||||
}
|
||||
|
||||
// Use the provided noteUid
|
||||
const noteIdentifier = noteUid;
|
||||
|
||||
const response = await fetch(`/api/note/${noteIdentifier}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: getPostHeaders(),
|
||||
body: JSON.stringify(noteData),
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update note.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteNote = async (noteId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
export const deleteNote = async (noteUid: string): Promise<void> => {
|
||||
const response = await fetch(`/api/note/${noteUid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { Project } from '../entities/Project';
|
|||
import { handleAuthResponse } from './authUtils';
|
||||
|
||||
export const fetchProjects = async (
|
||||
activeFilter = 'all',
|
||||
stateFilter = 'all',
|
||||
areaFilter = ''
|
||||
): Promise<Project[]> => {
|
||||
let url = `/api/projects`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (activeFilter !== 'all') params.append('active', activeFilter);
|
||||
if (stateFilter !== 'all') params.append('state', stateFilter);
|
||||
if (areaFilter) params.append('area_id', areaFilter);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
|
|
@ -24,14 +24,14 @@ export const fetchProjects = async (
|
|||
};
|
||||
|
||||
export const fetchGroupedProjects = async (
|
||||
activeFilter = 'all',
|
||||
stateFilter = 'all',
|
||||
areaFilter = ''
|
||||
): Promise<Record<string, Project[]>> => {
|
||||
let url = `/api/projects`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append('grouped', 'true');
|
||||
if (activeFilter !== 'all') params.append('active', activeFilter);
|
||||
if (stateFilter !== 'all') params.append('state', stateFilter);
|
||||
if (areaFilter) params.append('area_id', areaFilter);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
|
|
@ -74,10 +74,10 @@ export const createProject = async (
|
|||
};
|
||||
|
||||
export const updateProject = async (
|
||||
projectId: number,
|
||||
projectUid: string,
|
||||
projectData: Partial<Project>
|
||||
): Promise<Project> => {
|
||||
const response = await fetch(`/api/project/${projectId}`, {
|
||||
const response = await fetch(`/api/project/${projectUid}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
@ -91,14 +91,14 @@ export const updateProject = async (
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId: number): Promise<void> => {
|
||||
if (!projectId || projectId === null || projectId === undefined) {
|
||||
throw new Error('Cannot delete project: Invalid project ID');
|
||||
export const deleteProject = async (projectUid: string): Promise<void> => {
|
||||
if (!projectUid || projectUid === null || projectUid === undefined) {
|
||||
throw new Error('Cannot delete project: Invalid project UID');
|
||||
}
|
||||
|
||||
console.log('Attempting to delete project with ID:', projectId);
|
||||
console.log('Attempting to delete project with UID:', projectUid);
|
||||
|
||||
const response = await fetch(`/api/project/${projectId}`, {
|
||||
const response = await fetch(`/api/project/${projectUid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ export const createTag = async (tagData: Tag): Promise<Tag> => {
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateTag = async (tagId: number, tagData: Tag): Promise<Tag> => {
|
||||
const response = await fetch(`/api/tag/${tagId}`, {
|
||||
export const updateTag = async (tagUid: string, tagData: Tag): Promise<Tag> => {
|
||||
const response = await fetch(`/api/tag/${tagUid}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
@ -50,8 +50,8 @@ export const updateTag = async (tagId: number, tagData: Tag): Promise<Tag> => {
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteTag = async (tagId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/tag/${tagId}`, {
|
||||
export const deleteTag = async (tagUid: string): Promise<void> => {
|
||||
const response = await fetch(`/api/tag/${tagUid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ const API_BASE = '/api';
|
|||
/**
|
||||
* Get task timeline (all events for a specific task)
|
||||
*/
|
||||
export const getTaskTimeline = async (taskId: number): Promise<TaskEvent[]> => {
|
||||
const response = await fetch(`${API_BASE}/task/${taskId}/timeline`, {
|
||||
export const getTaskTimeline = async (
|
||||
taskUid: string
|
||||
): Promise<TaskEvent[]> => {
|
||||
const response = await fetch(`${API_BASE}/task/${taskUid}/timeline`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
|
|
@ -29,11 +31,14 @@ export const getTaskTimeline = async (taskId: number): Promise<TaskEvent[]> => {
|
|||
* Get task completion time analytics
|
||||
*/
|
||||
export const getTaskCompletionTime = async (
|
||||
taskId: number
|
||||
taskUid: string
|
||||
): Promise<TaskCompletionTime | null> => {
|
||||
const response = await fetch(`${API_BASE}/task/${taskId}/completion-time`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE}/task/${taskUid}/completion-time`,
|
||||
{
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
|
|
@ -111,15 +116,14 @@ export const getCompletionAnalytics = async (
|
|||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
projectId?: number;
|
||||
projectUid?: string;
|
||||
} = {}
|
||||
): Promise<CompletionAnalyticsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.limit) params.append('limit', options.limit.toString());
|
||||
if (options.offset) params.append('offset', options.offset.toString());
|
||||
if (options.projectId)
|
||||
params.append('projectId', options.projectId.toString());
|
||||
if (options.projectUid) params.append('projectUid', options.projectUid);
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/tasks/completion-analytics?${params}`,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "tududi",
|
||||
"version": "v0.82",
|
||||
"version": "v0.83-rc7",
|
||||
"description": "Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration.",
|
||||
"directories": {
|
||||
"test": "test"
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"pre-push": "npm run lint:fix && npm run format:fix",
|
||||
"pre-release": "npm run lint:fix && npm run format:fix && npm run test && npm run test:ui",
|
||||
"test": "npm run backend:test",
|
||||
"test:backend": "npm run backend:test",
|
||||
"test:ui": "bash e2e/bin/run-e2e.sh && echo \"Success!\"",
|
||||
"test:ui:headed": "cross-env E2E_MODE=headed E2E_SLOWMO=500 bash e2e/bin/run-e2e.sh",
|
||||
"test:watch": "npm run frontend:test:watch",
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "غير نشط",
|
||||
"all": "الكل",
|
||||
"allAreas": "جميع المناطق"
|
||||
},
|
||||
"selectState": "اختر الحالة",
|
||||
"state": "حالة المشروع",
|
||||
"states": {
|
||||
"idea": "فكرة",
|
||||
"planned": "مخطط",
|
||||
"in_progress": "قيد التنفيذ",
|
||||
"blocked": "محجوز",
|
||||
"completed": "مكتمل",
|
||||
"idea_desc": "تم التقاطها ولكن لم يتم التخطيط لها بعد",
|
||||
"planned_desc": "تم تحديد نطاقها وجاهزة للبدء",
|
||||
"in_progress_desc": "يتم العمل النشط",
|
||||
"blocked_desc": "مؤقتًا متوقف أو عالق",
|
||||
"completed_desc": "تم الانتهاء منها"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Неактивен",
|
||||
"all": "Всички",
|
||||
"allAreas": "Всички области"
|
||||
},
|
||||
"selectState": "Изберете състояние",
|
||||
"state": "Състояние на проекта",
|
||||
"states": {
|
||||
"idea": "Идея",
|
||||
"planned": "Планирано",
|
||||
"in_progress": "В процес",
|
||||
"blocked": "Блокирано",
|
||||
"completed": "Завършено",
|
||||
"idea_desc": "Засечено, но все още не е планирано",
|
||||
"planned_desc": "Определено и готово за стартиране",
|
||||
"in_progress_desc": "Активна работа в ход",
|
||||
"blocked_desc": "Временно спряно или блокирано",
|
||||
"completed_desc": "Завършено и готово"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inaktiv",
|
||||
"all": "Alle",
|
||||
"allAreas": "Alle områder"
|
||||
},
|
||||
"selectState": "Vælg tilstand",
|
||||
"state": "Projektstatus",
|
||||
"states": {
|
||||
"idea": "Idé",
|
||||
"planned": "Planlagt",
|
||||
"in_progress": "I gang",
|
||||
"blocked": "Blokeret",
|
||||
"completed": "Færdig",
|
||||
"idea_desc": "Fanget, men ikke planlagt endnu",
|
||||
"planned_desc": "Afgrænset og klar til at starte",
|
||||
"in_progress_desc": "Aktivt arbejde i gang",
|
||||
"blocked_desc": "Midlertidigt pauseret eller fastlåst",
|
||||
"completed_desc": "Afsluttet og færdig"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -745,6 +745,20 @@
|
|||
"inactive": "Inaktiv",
|
||||
"all": "Alle",
|
||||
"allAreas": "Alle Bereiche"
|
||||
},
|
||||
"selectState": "Zustand auswählen",
|
||||
"state": "Projektzustand",
|
||||
"states": {
|
||||
"idea": "Idee",
|
||||
"planned": "Geplant",
|
||||
"in_progress": "In Arbeit",
|
||||
"blocked": "Blockiert",
|
||||
"completed": "Abgeschlossen",
|
||||
"idea_desc": "Erfasst, aber noch nicht geplant",
|
||||
"planned_desc": "Definiert und bereit zum Start",
|
||||
"in_progress_desc": "Aktive Arbeit im Gange",
|
||||
"blocked_desc": "Vorübergehend pausiert oder festgefahren",
|
||||
"completed_desc": "Fertiggestellt und abgeschlossen"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -364,6 +364,20 @@
|
|||
"inactive": "Ανενεργά",
|
||||
"all": "Όλα",
|
||||
"allAreas": "Όλες οι περιοχές"
|
||||
},
|
||||
"selectState": "Επιλέξτε Κατάσταση",
|
||||
"state": "Κατάσταση Έργου",
|
||||
"states": {
|
||||
"idea": "Ιδέα",
|
||||
"planned": "Προγραμματισμένο",
|
||||
"in_progress": "Σε Εξέλιξη",
|
||||
"blocked": "Εμποδισμένο",
|
||||
"completed": "Ολοκληρωμένο",
|
||||
"idea_desc": "Καταγεγραμμένο αλλά όχι ακόμα προγραμματισμένο",
|
||||
"planned_desc": "Καθορισμένο και έτοιμο να ξεκινήσει",
|
||||
"in_progress_desc": "Ενεργή εργασία σε εξέλιξη",
|
||||
"blocked_desc": "Προσωρινά παγωμένο ή κολλημένο",
|
||||
"completed_desc": "Ολοκληρώθηκε και τελείωσε"
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
|
|
|
|||
|
|
@ -650,11 +650,25 @@
|
|||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"metrics": "Projects",
|
||||
"selectState": "Select State",
|
||||
"state": "Project State",
|
||||
"filters": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"all": "All",
|
||||
"allAreas": "All Areas"
|
||||
},
|
||||
"states": {
|
||||
"idea": "Idea",
|
||||
"planned": "Planned",
|
||||
"in_progress": "In Progress",
|
||||
"blocked": "Blocked",
|
||||
"completed": "Completed",
|
||||
"idea_desc": "Captured but not planned yet",
|
||||
"planned_desc": "Scoped and ready to start",
|
||||
"in_progress_desc": "Active work happening",
|
||||
"blocked_desc": "Temporarily paused or stuck",
|
||||
"completed_desc": "Finished and done"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -364,6 +364,20 @@
|
|||
"inactive": "Inactivos",
|
||||
"all": "Todos",
|
||||
"allAreas": "Todas las áreas"
|
||||
},
|
||||
"selectState": "Seleccionar Estado",
|
||||
"state": "Estado del Proyecto",
|
||||
"states": {
|
||||
"idea": "Idea",
|
||||
"planned": "Planificado",
|
||||
"in_progress": "En Progreso",
|
||||
"blocked": "Bloqueado",
|
||||
"completed": "Completado",
|
||||
"idea_desc": "Capturado pero aún no planificado",
|
||||
"planned_desc": "Definido y listo para comenzar",
|
||||
"in_progress_desc": "Trabajo activo en curso",
|
||||
"blocked_desc": "Pausado temporalmente o atascado",
|
||||
"completed_desc": "Terminado y completado"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Passiivinen",
|
||||
"all": "Kaikki",
|
||||
"allAreas": "Kaikki alueet"
|
||||
},
|
||||
"selectState": "Valitse tila",
|
||||
"state": "Projektin tila",
|
||||
"states": {
|
||||
"idea": "Idea",
|
||||
"planned": "Suunniteltu",
|
||||
"in_progress": "Käynnissä",
|
||||
"blocked": "Estetty",
|
||||
"completed": "Valmis",
|
||||
"idea_desc": "Tallennettu mutta ei vielä suunniteltu",
|
||||
"planned_desc": "Määritelty ja valmis aloittamaan",
|
||||
"in_progress_desc": "Aktiivista työtä käynnissä",
|
||||
"blocked_desc": "Tilapäisesti keskeytetty tai jumissa",
|
||||
"completed_desc": "Valmistunut ja tehty"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inactif",
|
||||
"all": "Tous",
|
||||
"allAreas": "Toutes les zones"
|
||||
},
|
||||
"selectState": "Sélectionner l'état",
|
||||
"state": "État du projet",
|
||||
"states": {
|
||||
"idea": "Idée",
|
||||
"planned": "Prévu",
|
||||
"in_progress": "En cours",
|
||||
"blocked": "Bloqué",
|
||||
"completed": "Terminé",
|
||||
"idea_desc": "Capturé mais pas encore planifié",
|
||||
"planned_desc": "Défini et prêt à commencer",
|
||||
"in_progress_desc": "Travail actif en cours",
|
||||
"blocked_desc": "Temporairement mis en pause ou bloqué",
|
||||
"completed_desc": "Fini et terminé"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Tidak Aktif",
|
||||
"all": "Semua",
|
||||
"allAreas": "Semua Area"
|
||||
},
|
||||
"selectState": "Pilih Status",
|
||||
"state": "Status Proyek",
|
||||
"states": {
|
||||
"idea": "Ide",
|
||||
"planned": "Direncanakan",
|
||||
"in_progress": "Sedang Berlangsung",
|
||||
"blocked": "Terblokir",
|
||||
"completed": "Selesai",
|
||||
"idea_desc": "Tercatat tetapi belum direncanakan",
|
||||
"planned_desc": "Sudah ditentukan dan siap untuk dimulai",
|
||||
"in_progress_desc": "Pekerjaan aktif sedang berlangsung",
|
||||
"blocked_desc": "Sementara terhenti atau terjebak",
|
||||
"completed_desc": "Selesai dan selesai"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inattivo",
|
||||
"all": "Tutti",
|
||||
"allAreas": "Tutte le Aree"
|
||||
},
|
||||
"selectState": "Seleziona Stato",
|
||||
"state": "Stato del Progetto",
|
||||
"states": {
|
||||
"idea": "Idea",
|
||||
"planned": "Pianificato",
|
||||
"in_progress": "In Corso",
|
||||
"blocked": "Bloccato",
|
||||
"completed": "Completato",
|
||||
"idea_desc": "Catturato ma non ancora pianificato",
|
||||
"planned_desc": "Definito e pronto per iniziare",
|
||||
"in_progress_desc": "Lavoro attivo in corso",
|
||||
"blocked_desc": "Pausa temporanea o bloccato",
|
||||
"completed_desc": "Finito e completato"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -522,7 +522,21 @@
|
|||
},
|
||||
"active": "アクティブ",
|
||||
"inactive": "非アクティブ",
|
||||
"metrics": "プロジェクト"
|
||||
"metrics": "プロジェクト",
|
||||
"selectState": "状態を選択",
|
||||
"state": "プロジェクトの状態",
|
||||
"states": {
|
||||
"idea": "アイデア",
|
||||
"planned": "計画中",
|
||||
"in_progress": "進行中",
|
||||
"blocked": "ブロック中",
|
||||
"completed": "完了",
|
||||
"idea_desc": "キャプチャされたがまだ計画されていない",
|
||||
"planned_desc": "スコープが決まり、開始準備が整った",
|
||||
"in_progress_desc": "アクティブな作業が行われている",
|
||||
"blocked_desc": "一時的に停止または行き詰まっている",
|
||||
"completed_desc": "完了し、終了した"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "編集",
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "비활성",
|
||||
"all": "모두",
|
||||
"allAreas": "모든 영역"
|
||||
},
|
||||
"selectState": "상태 선택",
|
||||
"state": "프로젝트 상태",
|
||||
"states": {
|
||||
"idea": "아이디어",
|
||||
"planned": "계획됨",
|
||||
"in_progress": "진행 중",
|
||||
"blocked": "차단됨",
|
||||
"completed": "완료됨",
|
||||
"idea_desc": "포착되었지만 아직 계획되지 않음",
|
||||
"planned_desc": "범위가 정의되고 시작할 준비가 됨",
|
||||
"in_progress_desc": "활동적인 작업 진행 중",
|
||||
"blocked_desc": "일시적으로 중단되거나 막힘",
|
||||
"completed_desc": "완료되고 끝남"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inactief",
|
||||
"all": "Alle",
|
||||
"allAreas": "Alle gebieden"
|
||||
},
|
||||
"selectState": "Selecteer Staat",
|
||||
"state": "Projectstaat",
|
||||
"states": {
|
||||
"idea": "Idee",
|
||||
"planned": "Gepland",
|
||||
"in_progress": "In Uitvoering",
|
||||
"blocked": "Geblokkeerd",
|
||||
"completed": "Voltooid",
|
||||
"idea_desc": "Vastgelegd maar nog niet gepland",
|
||||
"planned_desc": "Afgebakend en klaar om te starten",
|
||||
"in_progress_desc": "Actief werk aan de gang",
|
||||
"blocked_desc": "Tijdelijk gepauzeerd of vastgelopen",
|
||||
"completed_desc": "Afgerond en gedaan"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inaktiv",
|
||||
"all": "Alle",
|
||||
"allAreas": "Alle områder"
|
||||
},
|
||||
"selectState": "Velg tilstand",
|
||||
"state": "Prosjektstatus",
|
||||
"states": {
|
||||
"idea": "Ide",
|
||||
"planned": "Planlagt",
|
||||
"in_progress": "Under arbeid",
|
||||
"blocked": "Blokkert",
|
||||
"completed": "Fullført",
|
||||
"idea_desc": "Fanget, men ikke planlagt ennå",
|
||||
"planned_desc": "Avgrenset og klar til å starte",
|
||||
"in_progress_desc": "Aktivt arbeid pågår",
|
||||
"blocked_desc": "Midlertidig pauset eller fastlåst",
|
||||
"completed_desc": "Ferdig og gjort"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Nieaktywne",
|
||||
"all": "Wszystkie",
|
||||
"allAreas": "Wszystkie obszary"
|
||||
},
|
||||
"selectState": "Wybierz stan",
|
||||
"state": "Stan projektu",
|
||||
"states": {
|
||||
"idea": "Pomysł",
|
||||
"planned": "Zaplanowane",
|
||||
"in_progress": "W trakcie",
|
||||
"blocked": "Zablokowane",
|
||||
"completed": "Zakończone",
|
||||
"idea_desc": "Zarejestrowane, ale jeszcze nie zaplanowane",
|
||||
"planned_desc": "Określone i gotowe do rozpoczęcia",
|
||||
"in_progress_desc": "Aktywna praca w toku",
|
||||
"blocked_desc": "Tymczasowo wstrzymane lub utknęło",
|
||||
"completed_desc": "Zakończone i gotowe"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inativo",
|
||||
"all": "Todos",
|
||||
"allAreas": "Todas as Áreas"
|
||||
},
|
||||
"selectState": "Selecionar Estado",
|
||||
"state": "Estado do Projeto",
|
||||
"states": {
|
||||
"idea": "Ideia",
|
||||
"planned": "Planejado",
|
||||
"in_progress": "Em Progresso",
|
||||
"blocked": "Bloqueado",
|
||||
"completed": "Concluído",
|
||||
"idea_desc": "Capturado, mas ainda não planejado",
|
||||
"planned_desc": "Escopado e pronto para começar",
|
||||
"in_progress_desc": "Trabalho ativo em andamento",
|
||||
"blocked_desc": "Pausado temporariamente ou preso",
|
||||
"completed_desc": "Finalizado e concluído"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Inactiv",
|
||||
"all": "Toate",
|
||||
"allAreas": "Toate zonele"
|
||||
},
|
||||
"selectState": "Selectați Starea",
|
||||
"state": "Starea Proiectului",
|
||||
"states": {
|
||||
"idea": "Idee",
|
||||
"planned": "Planificat",
|
||||
"in_progress": "În Progres",
|
||||
"blocked": "Blocat",
|
||||
"completed": "Finalizat",
|
||||
"idea_desc": "Capturat, dar încă neplanificat",
|
||||
"planned_desc": "Definit și gata de început",
|
||||
"in_progress_desc": "Lucru activ în desfășurare",
|
||||
"blocked_desc": "Pauză temporară sau blocat",
|
||||
"completed_desc": "Finalizat și terminat"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
|
|
@ -655,6 +655,20 @@
|
|||
"inactive": "Неактивные",
|
||||
"all": "Все",
|
||||
"allAreas": "Все области"
|
||||
},
|
||||
"selectState": "Выберите состояние",
|
||||
"state": "Состояние проекта",
|
||||
"states": {
|
||||
"idea": "Идея",
|
||||
"planned": "Запланировано",
|
||||
"in_progress": "В процессе",
|
||||
"blocked": "Заблокировано",
|
||||
"completed": "Завершено",
|
||||
"idea_desc": "Зафиксировано, но еще не запланировано",
|
||||
"planned_desc": "Определено и готово к началу",
|
||||
"in_progress_desc": "Активная работа идет",
|
||||
"blocked_desc": "Временно приостановлено или застряло",
|
||||
"completed_desc": "Завершено и выполнено"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue