Compare commits
5 commits
a41611a86f
...
844f5f7ce4
| Author | SHA1 | Date | |
|---|---|---|---|
| 844f5f7ce4 | |||
| 8b32739e51 | |||
| d8094c4812 | |||
| 4895f7c4b7 | |||
| dfcf56aa4d |
19 changed files with 1129 additions and 19 deletions
|
|
@ -51,7 +51,10 @@ jobs:
|
||||||
cargo test --locked -- --skip api
|
cargo test --locked -- --skip api
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
- name: "Integration Tests"
|
- name: "Integration Tests"
|
||||||
run: cargo test --locked --test api
|
run: |
|
||||||
|
mkdir backend/reports/
|
||||||
|
mkdir backend/reports/store
|
||||||
|
cargo test --locked --test api
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
check-frontend:
|
check-frontend:
|
||||||
|
|
|
||||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
||||||
target
|
target
|
||||||
coverage
|
coverage
|
||||||
|
reports
|
||||||
|
|
||||||
|
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
|
||||||
12
backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json
generated
Normal file
12
backend/.sqlx/query-3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE projects \n SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7 \n WHERE id=$8",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 8
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d"
|
||||||
|
}
|
||||||
20
backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json
generated
Normal file
20
backend/.sqlx/query-93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0.json
generated
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT MAX(sort_order) as max_order FROM projects;",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "max_order",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0"
|
||||||
|
}
|
||||||
12
backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json
generated
Normal file
12
backend/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM projects WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314"
|
||||||
|
}
|
||||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
|
|
@ -3500,9 +3500,11 @@ dependencies = [
|
||||||
"bitflags 2.9.3",
|
"bitflags 2.9.3",
|
||||||
"bytes",
|
"bytes",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
|
"http-body 1.0.1",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
serde = "1.0.219"
|
serde = "1.0.219"
|
||||||
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] }
|
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] }
|
||||||
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
|
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
|
||||||
tower-http = { version = "0.6.0", features = ["cors"] }
|
tower-http = { version = "0.6.0", features = ["cors", "trace"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
uuid = { version = "1.18.0", features = ["serde", "v4"] }
|
uuid = { version = "1.18.0", features = ["serde", "v4"] }
|
||||||
|
|
|
||||||
17
backend/migrations/20251023043152_create_projects.down.sql
Normal file
17
backend/migrations/20251023043152_create_projects.down.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
ALTER TABLE tasks RENAME TO tasks_old;
|
||||||
|
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
|
title VARCHAR NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tasks SELECT id, title, description, status, created_at, updated_at, completed_at FROM tasks_old;
|
||||||
|
|
||||||
|
DROP TABLE tasks_old;
|
||||||
|
DROP TABLE projects;
|
||||||
|
DROP TABLE folders;
|
||||||
39
backend/migrations/20251023043152_create_projects.up.sql
Normal file
39
backend/migrations/20251023043152_create_projects.up.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
CREATE TABLE folders (
|
||||||
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
|
title VARCHAR NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
|
title VARCHAR NOT NULL,
|
||||||
|
color VARCHAR(7) NOT NULL, -- hex code
|
||||||
|
folder_id UUID,
|
||||||
|
sort_order INTEGER NOT NULL UNIQUE,
|
||||||
|
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
FOREIGN KEY(folder_id) REFERENCES folders(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE tasks RENAME TO tasks_old;
|
||||||
|
|
||||||
|
-- Add up migration script here
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
|
title VARCHAR NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT CHECK(status IN ('todo', 'done', 'backlog')) DEFAULT 'todo',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
project_id UUID,
|
||||||
|
FOREIGN KEY(project_id) REFERENCES projects(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tasks SELECT *, NULL as project_id FROM tasks_old;
|
||||||
|
|
||||||
|
DROP TABLE tasks_old;
|
||||||
|
|
@ -32,6 +32,7 @@ async fn main() {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.nest("/api/tasks", services::create_task_router())
|
.nest("/api/tasks", services::create_task_router())
|
||||||
|
.nest("/api/projects", services::create_project_router())
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
mod project;
|
||||||
mod task;
|
mod task;
|
||||||
|
|
||||||
|
pub use project::*;
|
||||||
pub use task::*;
|
pub use task::*;
|
||||||
|
|
|
||||||
122
backend/src/models/project.rs
Normal file
122
backend/src/models/project.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Pool, Row, Sqlite};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::services::AppError;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[sqlx(rename_all = "lowercase")]
|
||||||
|
pub enum ProjectStatus {
|
||||||
|
Active,
|
||||||
|
Done,
|
||||||
|
Backlog,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Serialize)]
|
||||||
|
pub struct ProjectModel {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub color: String,
|
||||||
|
pub folder_id: Option<Uuid>,
|
||||||
|
pub sort_order: i64,
|
||||||
|
pub status: ProjectStatus,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectModel {
|
||||||
|
async fn get_new_sort_order(pool: &Pool<Sqlite>) -> anyhow::Result<i64> {
|
||||||
|
let result = sqlx::query!("SELECT MAX(sort_order) as max_order FROM projects;")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result.max_order {
|
||||||
|
Some(i) => Ok(i + 1000),
|
||||||
|
None => Ok(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn insert(
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
title: String,
|
||||||
|
color: String,
|
||||||
|
) -> anyhow::Result<ProjectModel, AppError> {
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err(AppError::Unprocessable(
|
||||||
|
"Title must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if color.len() != 7 || color.chars().next() != Some('#') {
|
||||||
|
return Err(AppError::Unprocessable(
|
||||||
|
"Color is not a valid hex".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
let sort_order = ProjectModel::get_new_sort_order(pool).await?;
|
||||||
|
|
||||||
|
let result = sqlx::query_as(
|
||||||
|
"INSERT INTO projects(id, title, color, sort_order) VALUES($1, $2, $3, $4) RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(title)
|
||||||
|
.bind(color)
|
||||||
|
.bind(sort_order)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_id(
|
||||||
|
pool: &Pool<Sqlite>,
|
||||||
|
id: Uuid,
|
||||||
|
) -> anyhow::Result<ProjectModel, AppError> {
|
||||||
|
sqlx::query_as("SELECT * FROM projects WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| match e {
|
||||||
|
sqlx::Error::RowNotFound => AppError::NotFound,
|
||||||
|
e => e.into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &Pool<Sqlite>) -> anyhow::Result<Vec<ProjectModel>> {
|
||||||
|
sqlx::query_as("SELECT * FROM projects;")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(self, pool: &Pool<Sqlite>) -> anyhow::Result<ProjectModel, AppError> {
|
||||||
|
let now: DateTime<Utc> = Utc::now();
|
||||||
|
|
||||||
|
let _ = sqlx::query!(
|
||||||
|
"UPDATE projects
|
||||||
|
SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7
|
||||||
|
WHERE id=$8",
|
||||||
|
self.title,
|
||||||
|
self.color,
|
||||||
|
self.folder_id,
|
||||||
|
self.status,
|
||||||
|
self.sort_order,
|
||||||
|
now,
|
||||||
|
self.completed_at,
|
||||||
|
self.id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ProjectModel::get_by_id(pool, self.id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(pool: &Pool<Sqlite>, id: Uuid) -> anyhow::Result<()> {
|
||||||
|
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod projects;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -26,11 +27,14 @@ pub struct ErrorJson {
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> axum::response::Response {
|
||||||
match self {
|
match self {
|
||||||
Self::InternalError(anyhow_error) => (
|
Self::InternalError(anyhow_error) => {
|
||||||
|
error!(err = ?anyhow_error, "Internal Server Error");
|
||||||
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("Something went wrong {}", anyhow_error),
|
format!("Something went wrong {}", anyhow_error),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response()
|
||||||
|
}
|
||||||
Self::JsonExtractError(rej) => (
|
Self::JsonExtractError(rej) => (
|
||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
Json(ErrorJson {
|
Json(ErrorJson {
|
||||||
|
|
@ -85,5 +89,7 @@ impl From<sqlx::Error> for AppError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub use projects::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
pub use tasks::*;
|
pub use tasks::*;
|
||||||
|
use tracing::error;
|
||||||
|
|
|
||||||
106
backend/src/services/projects.rs
Normal file
106
backend/src/services/projects.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::WithRejection;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::{Pool, Sqlite};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::{ProjectModel, ProjectStatus};
|
||||||
|
|
||||||
|
use super::AppError;
|
||||||
|
|
||||||
|
pub fn create_project_router() -> Router<Pool<Sqlite>> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", post(create_project).get(list_projects))
|
||||||
|
.route(
|
||||||
|
"/{project_id}",
|
||||||
|
get(get_project).put(update_project).delete(delete_project),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateProjectRequest {
|
||||||
|
title: String,
|
||||||
|
color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_project(
|
||||||
|
State(pool): State<Pool<Sqlite>>,
|
||||||
|
WithRejection(Json(input), _): WithRejection<Json<CreateProjectRequest>, AppError>,
|
||||||
|
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||||
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(ProjectModel::insert(&pool, input.title, input.color).await?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_projects(
|
||||||
|
State(pool): State<Pool<Sqlite>>,
|
||||||
|
) -> Result<(StatusCode, Json<Vec<ProjectModel>>), AppError> {
|
||||||
|
Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_project(
|
||||||
|
State(pool): State<Pool<Sqlite>>,
|
||||||
|
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||||
|
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(ProjectModel::get_by_id(&pool, project_id).await?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct UpdateProjectRequest {
|
||||||
|
title: Option<String>,
|
||||||
|
color: Option<String>,
|
||||||
|
status: Option<ProjectStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_project(
|
||||||
|
State(pool): State<Pool<Sqlite>>,
|
||||||
|
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||||
|
WithRejection(Json(input), _): WithRejection<Json<UpdateProjectRequest>, AppError>,
|
||||||
|
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
|
||||||
|
let mut project = ProjectModel::get_by_id(&pool, project_id).await?;
|
||||||
|
|
||||||
|
if let Some(new_title) = input.title {
|
||||||
|
if new_title.len() == 0 {
|
||||||
|
return Err(AppError::Unprocessable(
|
||||||
|
"Title must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
project.title = new_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(color) = input.color {
|
||||||
|
if color.len() != 7 || color.chars().next() != Some('#') {
|
||||||
|
return Err(AppError::Unprocessable(
|
||||||
|
"Color is not a valid hex".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
project.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = input.status {
|
||||||
|
project.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, Json(project.update(&pool).await?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_project(
|
||||||
|
State(pool): State<Pool<Sqlite>>,
|
||||||
|
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
ProjectModel::get_by_id(&pool, project_id).await?;
|
||||||
|
ProjectModel::delete(&pool, project_id).await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
use axum_test::{TestServer, TestServerConfig, Transport};
|
use axum_test::{TestServer, TestServerConfig, Transport};
|
||||||
|
use hurl::report::html::{Testcase, write_report};
|
||||||
use hurl::runner::{self, RunnerOptions, Value, VariableSet};
|
use hurl::runner::{self, RunnerOptions, Value, VariableSet};
|
||||||
use hurl::util::logger::LoggerOptionsBuilder;
|
use hurl::util::logger::LoggerOptionsBuilder;
|
||||||
|
use hurl_core::input::Input;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
async fn create_app() -> Router {
|
async fn create_app() -> Router {
|
||||||
|
tracing_subscriber::fmt::try_init();
|
||||||
use backend::database::{DatabaseConfig, create_pool};
|
use backend::database::{DatabaseConfig, create_pool};
|
||||||
use backend::services;
|
use backend::services;
|
||||||
|
|
||||||
|
|
@ -24,6 +30,8 @@ async fn create_app() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.nest("/api/tasks", services::create_task_router())
|
.nest("/api/tasks", services::create_task_router())
|
||||||
|
.nest("/api/projects", services::create_project_router())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,11 +57,14 @@ async fn run_hurl_test(hurl_file_path: &str) {
|
||||||
let logger_opts = LoggerOptionsBuilder::new().build();
|
let logger_opts = LoggerOptionsBuilder::new().build();
|
||||||
let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap();
|
let result = runner::run(&content, None, &runner_opts, &variables, &logger_opts).unwrap();
|
||||||
|
|
||||||
assert!(
|
let input = Input::new(hurl_file_path);
|
||||||
result.success,
|
let test_case = Testcase::from(&result, &input);
|
||||||
"Hurl test failed for {}: {:?}",
|
test_case
|
||||||
hurl_file_path, result
|
.write_html(&content, &result.entries, Path::new("reports/store"), &[])
|
||||||
);
|
.expect("Failed to write html files");
|
||||||
|
write_report(Path::new("reports/"), &vec![test_case]).expect("Failed to write report");
|
||||||
|
|
||||||
|
assert!(result.success, "Hurl test failed for {}", hurl_file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
|
@ -75,3 +86,8 @@ async fn test_update_tasks_api() {
|
||||||
async fn test_delete_tasks_api() {
|
async fn test_delete_tasks_api() {
|
||||||
run_hurl_test("./tests/api/delete_tasks.hurl").await;
|
run_hurl_test("./tests/api/delete_tasks.hurl").await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_projects_api() {
|
||||||
|
run_hurl_test("./tests/api/projects.hurl").await;
|
||||||
|
}
|
||||||
|
|
|
||||||
387
backend/tests/api/projects.hurl
Normal file
387
backend/tests/api/projects.hurl
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
# Project API Tests
|
||||||
|
|
||||||
|
# Test: Create a new project with all fields (POST /api/projects)
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Test Project Alpha",
|
||||||
|
"color": "#1976d2"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
project_id: jsonpath "$.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.title" == "Test Project Alpha"
|
||||||
|
jsonpath "$.color" == "#1976d2"
|
||||||
|
jsonpath "$.status" == "active"
|
||||||
|
jsonpath "$.folder_id" == null
|
||||||
|
jsonpath "$.sort_order" == 0
|
||||||
|
jsonpath "$.id" exists
|
||||||
|
jsonpath "$.created_at" exists
|
||||||
|
jsonpath "$.updated_at" exists
|
||||||
|
|
||||||
|
# Test: Create another new project
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Test Project Beta",
|
||||||
|
"color": "#ffffff"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
project_id_beta: jsonpath "$.id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.title" == "Test Project Beta"
|
||||||
|
jsonpath "$.color" == "#ffffff"
|
||||||
|
jsonpath "$.status" == "active"
|
||||||
|
jsonpath "$.folder_id" == null
|
||||||
|
jsonpath "$.sort_order" == 1000
|
||||||
|
jsonpath "$.id" exists
|
||||||
|
jsonpath "$.created_at" exists
|
||||||
|
jsonpath "$.updated_at" exists
|
||||||
|
|
||||||
|
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Missing Color"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 422
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
|
||||||
|
# Test: Create project with invalid data (missing title)
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"color": "#ffffff"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 422
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Create project with invalid data (invalid color)
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Invalid Color",
|
||||||
|
"color": "#ffffffasdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 422
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
|
||||||
|
# Test: Get a specific project by ID (GET /api/projects/{id})
|
||||||
|
GET {{host}}/api/projects/{{project_id}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id}}"
|
||||||
|
jsonpath "$.title" == "Test Project Alpha"
|
||||||
|
jsonpath "$.color" == "#1976d2"
|
||||||
|
jsonpath "$.status" == "active"
|
||||||
|
jsonpath "$.folder_id" == null
|
||||||
|
jsonpath "$.created_at" exists
|
||||||
|
jsonpath "$.updated_at" exists
|
||||||
|
|
||||||
|
# Test: Get a minimal project by ID
|
||||||
|
GET {{host}}/api/projects/{{project_id_beta}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id_beta}}"
|
||||||
|
jsonpath "$.title" == "Test Project Beta"
|
||||||
|
jsonpath "$.color" == "#ffffff"
|
||||||
|
jsonpath "$.status" == "active"
|
||||||
|
|
||||||
|
# Test: Get non-existent project
|
||||||
|
GET {{host}}/api/projects/00000000-0000-0000-0000-000000000000
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Get project with invalid UUID format
|
||||||
|
GET {{host}}/api/projects/invalid-uuid
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: List all projects (GET /api/projects)
|
||||||
|
GET {{host}}/api/projects
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
initial_count: jsonpath "$" count
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" isCollection
|
||||||
|
jsonpath "$[*].id" contains "{{project_id}}"
|
||||||
|
jsonpath "$[*].id" contains "{{project_id_beta}}"
|
||||||
|
jsonpath "$[*].title" contains "Test Project Alpha"
|
||||||
|
jsonpath "$[*].title" contains "Test Project Beta"
|
||||||
|
|
||||||
|
# Test: Verify all projects have required fields
|
||||||
|
GET {{host}}/api/projects
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{project_id}}')].title" exists
|
||||||
|
jsonpath "$[?(@.id=='{{project_id}}')].status" exists
|
||||||
|
jsonpath "$[?(@.id=='{{project_id}}')].created_at" exists
|
||||||
|
jsonpath "$[?(@.id=='{{project_id}}')].updated_at" exists
|
||||||
|
jsonpath "$[?(@.id=='{{project_id}}')].sort_order" exists
|
||||||
|
|
||||||
|
# Test: Update project title only (PUT /api/projects/{id})
|
||||||
|
PUT {{host}}/api/projects/{{project_id}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Updated Project Alpha"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
first_updated_at: jsonpath "$.updated_at"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id}}"
|
||||||
|
jsonpath "$.title" == "Updated Project Alpha"
|
||||||
|
jsonpath "$.color" == "#1976d2"
|
||||||
|
jsonpath "$.status" == "active"
|
||||||
|
|
||||||
|
|
||||||
|
# Test: Update status to done
|
||||||
|
PUT {{host}}/api/projects/{{project_id}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"status": "done"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id}}"
|
||||||
|
jsonpath "$.status" == "done"
|
||||||
|
|
||||||
|
# Test: Update status to backlog
|
||||||
|
PUT {{host}}/api/projects/{{project_id}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"status": "backlog"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id}}"
|
||||||
|
jsonpath "$.status" == "backlog"
|
||||||
|
|
||||||
|
# Test: Update color
|
||||||
|
PUT {{host}}/api/projects/{{project_id}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"color": "#388e3c"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id}}"
|
||||||
|
jsonpath "$.color" == "#388e3c"
|
||||||
|
|
||||||
|
|
||||||
|
# Test: Update multiple fields together
|
||||||
|
PUT {{host}}/api/projects/{{project_id}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Completely Updated Project",
|
||||||
|
"color": "#d32f2f",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.id" == "{{project_id}}"
|
||||||
|
jsonpath "$.title" == "Completely Updated Project"
|
||||||
|
jsonpath "$.color" == "#d32f2f"
|
||||||
|
jsonpath "$.status" == "active"
|
||||||
|
|
||||||
|
|
||||||
|
# Test: Update non-existent project
|
||||||
|
PUT {{host}}/api/projects/00000000-0000-0000-0000-000000000000
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "This should fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Update with invalid UUID format
|
||||||
|
PUT {{host}}/api/projects/invalid-uuid-format
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "This should also fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Update with empty title
|
||||||
|
PUT {{host}}/api/projects/{{project_id_beta}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 422
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Update with invalid status
|
||||||
|
PUT {{host}}/api/projects/{{project_id_beta}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"status": "invalid_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 422
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Update with invalid color format
|
||||||
|
PUT {{host}}/api/projects/{{project_id_beta}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"color": "invalid-color"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 422
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Setup: Create a project to delete
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Project to Delete",
|
||||||
|
"color": "#222222"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
delete_project_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
# Test: Delete project successfully (DELETE /api/projects/{id})
|
||||||
|
DELETE {{host}}/api/projects/{{delete_project_id}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
# Test: Verify project is deleted (should return 404)
|
||||||
|
GET {{host}}/api/projects/{{delete_project_id}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Setup: Create another project for additional delete tests
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Another Project to Delete",
|
||||||
|
"color": "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
another_delete_project_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
# Test: Verify project exists before deletion
|
||||||
|
GET {{host}}/api/projects/{{another_delete_project_id}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.title" == "Another Project to Delete"
|
||||||
|
|
||||||
|
# Test: Delete the project
|
||||||
|
DELETE {{host}}/api/projects/{{another_delete_project_id}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
# Test: Confirm project no longer exists
|
||||||
|
GET {{host}}/api/projects/{{another_delete_project_id}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Delete non-existent project
|
||||||
|
DELETE {{host}}/api/projects/00000000-0000-0000-0000-000000000000
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Delete with invalid UUID format
|
||||||
|
DELETE {{host}}/api/projects/invalid-uuid-format
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: Multiple deletions of same project (idempotency test)
|
||||||
|
POST {{host}}/api/projects
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"title": "Project for Idempotency Test",
|
||||||
|
"color": "#234567"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
idempotent_project_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
# First deletion should succeed
|
||||||
|
DELETE {{host}}/api/projects/{{idempotent_project_id}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
# Second deletion should return 404 (project already gone)
|
||||||
|
DELETE {{host}}/api/projects/{{idempotent_project_id}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error" exists
|
||||||
|
|
||||||
|
# Test: List shows remaining projects after deletions
|
||||||
|
GET {{host}}/api/projects
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[*].id" contains "{{project_id}}"
|
||||||
|
jsonpath "$[*].id" contains "{{project_id_beta}}"
|
||||||
|
jsonpath "$[*].id" not contains "{{delete_project_id}}"
|
||||||
|
jsonpath "$[*].id" not contains "{{another_delete_project_id}}"
|
||||||
|
jsonpath "$[*].id" not contains "{{idempotent_project_id}}"
|
||||||
|
|
||||||
|
# Cleanup: Delete remaining test projects
|
||||||
|
DELETE {{host}}/api/projects/{{project_id}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{host}}/api/projects/{{project_id_beta}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
# Test: Verify all test projects are cleaned up
|
||||||
|
GET {{host}}/api/projects
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[*].id" not contains "{{project_id}}"
|
||||||
|
jsonpath "$[*].id" not contains "{{project_id_beta}}"
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Chip,
|
|
||||||
Stack,
|
Stack,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -46,7 +45,7 @@ interface TaskListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskList({ className, initialTasks }: TaskListProps) {
|
export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
const { tasks, loading, error, deleteTask } = useTasks({
|
const { tasks, loading, error, updateTask, deleteTask } = useTasks({
|
||||||
autoFetch: !initialTasks,
|
autoFetch: !initialTasks,
|
||||||
initialData: initialTasks,
|
initialData: initialTasks,
|
||||||
})
|
})
|
||||||
|
|
@ -217,6 +216,13 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStatusChange = async (taskId: string, newStatus: TaskStatus) => {
|
||||||
|
const result = await updateTask(taskId, { status: newStatus })
|
||||||
|
if (!result) {
|
||||||
|
console.error('Failed to update task status')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isAllSelected =
|
const isAllSelected =
|
||||||
filteredAndSortedTasks.length > 0 &&
|
filteredAndSortedTasks.length > 0 &&
|
||||||
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
|
filteredAndSortedTasks.every(task => selectedTaskIds.has(task.id))
|
||||||
|
|
@ -447,12 +453,139 @@ export function TaskList({ className, initialTasks }: TaskListProps) {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<Select
|
||||||
label={task.status}
|
value={task.status}
|
||||||
color={getStatusColor(task.status)}
|
onChange={e =>
|
||||||
|
handleStatusChange(
|
||||||
|
task.id,
|
||||||
|
e.target.value as TaskStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
MenuProps={{
|
||||||
|
PaperProps: {
|
||||||
|
sx: {
|
||||||
|
padding: '4px',
|
||||||
|
minWidth: '100px',
|
||||||
|
'& .MuiList-root': {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
minWidth: 90,
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
backgroundColor:
|
||||||
|
getStatusColor(task.status) === 'primary'
|
||||||
|
? 'primary.main'
|
||||||
|
: getStatusColor(task.status) === 'success'
|
||||||
|
? 'success.main'
|
||||||
|
: 'action.disabled',
|
||||||
|
color:
|
||||||
|
getStatusColor(task.status) === 'default'
|
||||||
|
? 'text.primary'
|
||||||
|
: 'white',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
padding: '2px 12px 2px 12px',
|
||||||
|
paddingRight: '24px !important',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '20px',
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
|
'& .MuiSelect-icon': {
|
||||||
|
right: '4px',
|
||||||
|
color:
|
||||||
|
getStatusColor(task.status) === 'default'
|
||||||
|
? 'text.primary'
|
||||||
|
: 'white',
|
||||||
|
fontSize: '1rem',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
value={TaskStatus.Todo}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
margin: '2px 4px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
minHeight: '24px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.dark',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Todo
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
value={TaskStatus.Done}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'success.main',
|
||||||
|
color: 'white',
|
||||||
|
margin: '2px 4px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
minHeight: '24px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'success.dark',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: 'success.main',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'success.dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
value={TaskStatus.Backlog}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: 'action.disabled',
|
||||||
|
color: 'text.primary',
|
||||||
|
margin: '2px 4px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
minHeight: '24px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: 'action.disabled',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Backlog
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import type { Task, CreateTaskRequest, ApiError } from '~/types/task'
|
import type {
|
||||||
|
Task,
|
||||||
|
CreateTaskRequest,
|
||||||
|
UpdateTaskRequest,
|
||||||
|
ApiError,
|
||||||
|
} from '~/types/task'
|
||||||
import { TaskStatus } from '~/types/task'
|
import { TaskStatus } from '~/types/task'
|
||||||
import { apiClient } from '~/services/api'
|
import { apiClient } from '~/services/api'
|
||||||
|
|
||||||
|
|
@ -13,6 +18,7 @@ interface UseTasksState {
|
||||||
interface UseTasksActions {
|
interface UseTasksActions {
|
||||||
fetchTasks: () => Promise<void>
|
fetchTasks: () => Promise<void>
|
||||||
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
createTask: (data: CreateTaskRequest) => Promise<Task | null>
|
||||||
|
updateTask: (id: string, data: UpdateTaskRequest) => Promise<Task | null>
|
||||||
deleteTask: (id: string) => Promise<boolean>
|
deleteTask: (id: string) => Promise<boolean>
|
||||||
refreshTasks: () => Promise<void>
|
refreshTasks: () => Promise<void>
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
|
|
@ -91,6 +97,30 @@ export function useTasks(
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const updateTask = useCallback(
|
||||||
|
async (id: string, data: UpdateTaskRequest): Promise<Task | null> => {
|
||||||
|
try {
|
||||||
|
const updatedTask = await apiClient.updateTask(id, data)
|
||||||
|
|
||||||
|
// Update the task in the local state immediately
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
tasks: prev.tasks.map(task => (task.id === id ? updatedTask : task)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return updatedTask
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as ApiError
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: apiError.message,
|
||||||
|
}))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const deleteTask = useCallback(async (id: string): Promise<boolean> => {
|
const deleteTask = useCallback(async (id: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await apiClient.deleteTask(id)
|
await apiClient.deleteTask(id)
|
||||||
|
|
@ -176,6 +206,7 @@ export function useTasks(
|
||||||
...state,
|
...state,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
createTask,
|
createTask,
|
||||||
|
updateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
refreshTasks,
|
refreshTasks,
|
||||||
clearError,
|
clearError,
|
||||||
|
|
|
||||||
199
plan/02_PROJECTS/plan.md
Normal file
199
plan/02_PROJECTS/plan.md
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
# Project-Based Organization Implementation Plan
|
||||||
|
|
||||||
|
## Phase Overview
|
||||||
|
This plan implements project-based organization with a clean folder/project hierarchy:
|
||||||
|
- **Folders**: Organizational containers for projects (can be reordered)
|
||||||
|
- **Projects**: Work containers that hold tasks (can belong to folders or exist at root)
|
||||||
|
- **Tasks**: Belong to zero or one project (never directly to folders)
|
||||||
|
|
||||||
|
## Backend Implementation
|
||||||
|
|
||||||
|
### 1. Database Schema Changes
|
||||||
|
- **New Tables:**
|
||||||
|
- `folders` table: id, name, color, sort_order, created_at, updated_at
|
||||||
|
- `projects` table: id, name, description, color, due_date, status, folder_id (nullable), sort_order, created_at, updated_at
|
||||||
|
- **Schema Updates:**
|
||||||
|
- Add `project_id` field to existing `tasks` table (nullable foreign key)
|
||||||
|
- Database indexes for efficient querying
|
||||||
|
|
||||||
|
### 2. Backend Models & Services
|
||||||
|
- **FolderModel** (backend/src/models/folder.rs):
|
||||||
|
- CRUD operations for folders
|
||||||
|
- Reordering methods for sort_order management
|
||||||
|
- **ProjectModel** (backend/src/models/project.rs):
|
||||||
|
- CRUD operations with folder relationship
|
||||||
|
- Methods for reordering within folders and at root level
|
||||||
|
- Query methods for folder contents
|
||||||
|
- **Updated TaskModel** (backend/src/models/task.rs):
|
||||||
|
- Add project_id field and foreign key constraint
|
||||||
|
- Project relationship methods
|
||||||
|
- **API Services:**
|
||||||
|
- folders.rs - Folder CRUD and reordering endpoints
|
||||||
|
- projects.rs - Project CRUD, folder assignment, reordering
|
||||||
|
- Updated tasks.rs - Project filtering and assignment
|
||||||
|
|
||||||
|
### 3. Database Migrations
|
||||||
|
- Create folders table
|
||||||
|
- Create projects table with folder foreign key
|
||||||
|
- Add project_id to tasks table
|
||||||
|
- Set up proper indexes and constraints
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
### 1. Data Models & Types
|
||||||
|
- **Folder Types** (frontend/app/types/folder.ts):
|
||||||
|
- Folder interface, CreateFolderRequest, UpdateFolderRequest
|
||||||
|
- **Project Types** (frontend/app/types/project.ts):
|
||||||
|
- Project interface with folder_id field
|
||||||
|
- ProjectStatus enum, CRUD request types
|
||||||
|
- **Updated Task Types** (frontend/app/types/task.ts):
|
||||||
|
- Add project_id field to Task interface
|
||||||
|
|
||||||
|
### 2. Sidebar & Navigation
|
||||||
|
- **ProjectSidebar** (frontend/app/components/ProjectSidebar.tsx):
|
||||||
|
- Hierarchical display: Folders → Projects → (Root Projects)
|
||||||
|
- Drag-and-drop for both folder and project reordering
|
||||||
|
- Inline creation forms for folders and projects
|
||||||
|
- Collapsible folder sections
|
||||||
|
- **FolderSection** (frontend/app/components/FolderSection.tsx):
|
||||||
|
- Individual folder with contained projects
|
||||||
|
- Folder editing and project management
|
||||||
|
|
||||||
|
### 3. Project Management
|
||||||
|
- **ProjectForm** (frontend/app/components/ProjectForm.tsx):
|
||||||
|
- Create/edit projects with folder assignment
|
||||||
|
- 16-color palette, due dates, status selection
|
||||||
|
- **FolderForm** (frontend/app/components/FolderForm.tsx):
|
||||||
|
- Simple folder creation/editing (name, color)
|
||||||
|
|
||||||
|
### 4. Updated Task Components
|
||||||
|
- **Enhanced TaskList** with Project column
|
||||||
|
- **Updated TaskForm** with project selection dropdown
|
||||||
|
- Project filtering throughout the UI
|
||||||
|
|
||||||
|
### 5. Routes & State Management
|
||||||
|
- **New Routes:**
|
||||||
|
- `/projects` - Projects overview
|
||||||
|
- `/projects/{id}` - Project detail page
|
||||||
|
- `/folders/{id}` - Folder contents view
|
||||||
|
- **State Hooks:**
|
||||||
|
- `useFolders.ts`, `useProjects.ts`, updated `useTasks.ts`
|
||||||
|
- **API Integration:**
|
||||||
|
- Folder and project API clients
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: Backend Foundation
|
||||||
|
1. Create folders and projects tables (migrations)
|
||||||
|
2. Implement FolderModel and ProjectModel
|
||||||
|
3. Add project_id to TaskModel
|
||||||
|
4. Create API endpoints for folders and projects
|
||||||
|
5. Unit tests for new functionality
|
||||||
|
|
||||||
|
### Phase 2: Frontend Core
|
||||||
|
1. Implement folder and project types
|
||||||
|
2. Create basic ProjectSidebar (no drag-drop yet)
|
||||||
|
3. Add project column to TaskList
|
||||||
|
4. Update TaskForm with project selection
|
||||||
|
5. Basic project/folder CRUD forms
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features
|
||||||
|
1. Drag-and-drop reordering in sidebar
|
||||||
|
2. Project and folder detail pages
|
||||||
|
3. Hierarchical project creation flows
|
||||||
|
4. Project filtering and search
|
||||||
|
5. Mobile responsive sidebar
|
||||||
|
|
||||||
|
### Phase 4: Polish & Integration
|
||||||
|
1. Project color theming throughout UI
|
||||||
|
2. Performance optimization
|
||||||
|
3. Enhanced UX for project management
|
||||||
|
4. Integration testing
|
||||||
|
5. Migration tools for existing data
|
||||||
|
|
||||||
|
## Data Relationships
|
||||||
|
```
|
||||||
|
folders (1) → (many) projects
|
||||||
|
projects (1) → (many) tasks
|
||||||
|
folders (no direct relationship) tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
This clean separation ensures tasks only belong to projects, while folders serve purely as organizational containers for projects.
|
||||||
|
|
||||||
|
## Technical Implementation Details
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
#### Folders Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE folders (
|
||||||
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Projects Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
|
name VARCHAR NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(7), -- hex color code
|
||||||
|
due_date DATE,
|
||||||
|
status TEXT CHECK(status IN ('active', 'done', 'backlog')) DEFAULT 'active',
|
||||||
|
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tasks Table Update
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tasks ADD COLUMN project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
16-color palette for folders and projects:
|
||||||
|
- Primary Blues: #1976d2, #0288d1, #0097a7
|
||||||
|
- Greens: #388e3c, #689f38, #7cb342
|
||||||
|
- Oranges/Reds: #f57c00, #ff5722, #d32f2f
|
||||||
|
- Purples: #7b1fa2, #512da8, #303f9f
|
||||||
|
- Neutrals: #455a64, #616161, #757575, #424242
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
#### Folders
|
||||||
|
- `GET /api/folders` - List all folders with projects
|
||||||
|
- `POST /api/folders` - Create folder
|
||||||
|
- `PUT /api/folders/{id}` - Update folder
|
||||||
|
- `DELETE /api/folders/{id}` - Delete folder
|
||||||
|
- `PUT /api/folders/reorder` - Reorder folders
|
||||||
|
|
||||||
|
#### Projects
|
||||||
|
- `GET /api/projects` - List all projects
|
||||||
|
- `GET /api/projects?folder_id={id}` - List projects in folder
|
||||||
|
- `POST /api/projects` - Create project
|
||||||
|
- `PUT /api/projects/{id}` - Update project
|
||||||
|
- `DELETE /api/projects/{id}` - Delete project
|
||||||
|
- `PUT /api/projects/reorder` - Reorder projects within folder/root
|
||||||
|
|
||||||
|
#### Tasks (Updated)
|
||||||
|
- `GET /api/tasks?project_id={id}` - List tasks in project
|
||||||
|
- Task CRUD operations include project_id field
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
1. Existing tasks remain unassigned (project_id = NULL)
|
||||||
|
2. Users can create folders and projects
|
||||||
|
3. Users can assign existing tasks to projects
|
||||||
|
4. No data loss during migration
|
||||||
|
5. Backwards compatibility maintained
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
- Unit tests for all model methods
|
||||||
|
- API integration tests with Hurl
|
||||||
|
- Frontend component tests
|
||||||
|
- End-to-end project workflow tests
|
||||||
Loading…
Add table
Add a link
Reference in a new issue