Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16532d10bd | ||
|
|
d94817bac5 |
@@ -1,5 +1,19 @@
|
||||
# Changelog — FlowDeck
|
||||
|
||||
## v1.4.0 (2026-07-10) — Propriétés Avancées
|
||||
|
||||
### Added
|
||||
- **Table `collection_properties`**: remplace `project_properties`, liée aux collections
|
||||
- Types: title, text, number, select, multi_select, status, date, person, checkbox, url, email, phone, files, unique_id
|
||||
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- Relations/Rollups/Formulas: schéma prêt (colonnes dispo, implémentation v1.5)
|
||||
- **Service `property_types.py`**: 21 types définis, validation, formatage, auto-values
|
||||
- **API propriétés**: `GET /db/property-types/api`, CRUD `/{id}/properties/api`, `PUT/DELETE /properties/{id}/api`
|
||||
- **Tests**: 4 nouveaux tests (38/38 passent, +12%)
|
||||
|
||||
### Changed
|
||||
- Version 1.3.0 → 1.4.0
|
||||
|
||||
## v1.3.0 (2026-07-10) — Database Concept
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# FlowDeck
|
||||
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
|
||||
Clone de l'interface **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Table, multi-vues.
|
||||
|
||||
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
|
||||
> **v1.3.0** — Database Concept (collections, pages, vues, GiteaBoardCompat)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -13,28 +13,33 @@ docker compose up -d
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
## Features (v0.4.0)
|
||||
## Features
|
||||
|
||||
### UI Notion-Style
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
|
||||
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
|
||||
### Database Concept (v1.3.0)
|
||||
- **Collections**: databases indépendantes de Gitea, schéma JSON, icon, description
|
||||
- **Pages**: propriétés JSON, position, parent_id (sub-items ready), lien Gitea optionnel
|
||||
- **Vues**: configurables (type, filters, sorts, visible_properties)
|
||||
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
|
||||
- **API REST**: CRUD complet collections + pages (12 endpoints)
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- **Sidebar gauche** avec sections hiérarchiques (Recents, Private, Library, Trash)
|
||||
- **Topbar** avec breadcrumbs (workspace > projet > page)
|
||||
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
|
||||
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
|
||||
- **Hover states** subtils avec icônes d'action qui apparaissent
|
||||
- **Typographie Inter**, coins arrondis 4-6px
|
||||
- **Éditeur de blocs** Notion-style: slash menu (/), navigation clavier, placeholders
|
||||
- **Drag & drop** pages dans la sidebar (SortableJS)
|
||||
|
||||
### Vues Multiples
|
||||
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
|
||||
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
|
||||
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
|
||||
- **Team Load**: Barres empilées par membre d'équipe
|
||||
- **Detailed board**: cartes avec toutes les propriétés visibles
|
||||
### Vues Multiples (v0.5–v0.8)
|
||||
- **Kanban board**: colonnes avec sous-groupes, drag & drop cartes
|
||||
- **Table view**: colonnes triables, groupes rétractables
|
||||
- **Status overview**: Donut chart SVG avec segments, compteurs
|
||||
- **Team Load**: Barres empilées par membre
|
||||
- **Detailed board**: cartes avec propriétés visibles
|
||||
|
||||
### Filtres & Tri
|
||||
- **Filtres cumulables** (logique AND) avec pastilles cliquables
|
||||
### Filtres & Tri (v0.7)
|
||||
- **Filtres cumulables** (logique AND) avec pastilles
|
||||
- **Tri multi-critères** hiérarchique
|
||||
- **Filtre par statut**: checkboxes multiples avec points colorés
|
||||
- **Filtre AI keywords**: recherche + sélection multiple
|
||||
- **Filtre par statut**: checkboxes multiples
|
||||
|
||||
### Intégration Gitea
|
||||
- **Issues Gitea → cartes** Notion-style
|
||||
@@ -46,10 +51,10 @@ docker compose up -d
|
||||
|
||||
| Couche | Techno |
|
||||
|--------|--------|
|
||||
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
|
||||
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB |
|
||||
| Backend | Python 3.12 + FastAPI + httpx |
|
||||
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
||||
| Déploiement | Docker (python:3.12-slim), docker-compose |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -62,7 +67,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
|
||||
## Roadmap
|
||||
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
|
||||
Voir [ROADMAP.md](ROADMAP.md) — v1.4.0 Propriétés Avancées → v2.0.0 Multi-Users.
|
||||
|
||||
## Développement
|
||||
|
||||
@@ -73,6 +78,12 @@ pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8080
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 34/34 passent
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
||||
+41
-37
@@ -54,46 +54,50 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
- [x] Heading 4 support
|
||||
- [x] Breadcrumbs contextuels (workspace > projet > page)
|
||||
|
||||
### v1.3.0 ✅ — Database Concept (Bloc 1 du gap analysis)
|
||||
### (2026-07-10, commit c574d2c, tag v1.3.0)
|
||||
|
||||
- [x] **Table `collections`** — base de données abstraite, indépendante de Gitea
|
||||
- `id`, `name`, `description`, `icon`, `schema_json`
|
||||
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
|
||||
- [x] **Table `collection_pages`** — remplace `cards`, pages génériques
|
||||
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
|
||||
- `gitea_issue_id` optionnel, auto-propriétés (`created_at`, `updated_at`)
|
||||
- [x] **Table `collection_views`** — vues configurables par collection
|
||||
- `name`, `view_type`, `config_json`, `position`
|
||||
- [x] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
|
||||
- Les boards Gitea existants deviennent des collections
|
||||
- Migration transparente `cards` → `collection_pages`
|
||||
- Routes `/db/boards/api`, `/db/board/{o}/{r}/api`, `/db/board/{o}/{r}/sync`
|
||||
- [x] **Router `/db`** — API CRUD collections + pages (12 endpoints)
|
||||
- [x] **34/34 tests** passent (+6 nouveaux tests v1.3)
|
||||
- [x] **Database lock** — mode lecture seule (`collections.is_locked`)
|
||||
- [x] **Database description** — champ `description` sur les collections
|
||||
|
||||
---
|
||||
|
||||
## Upcoming
|
||||
|
||||
### v1.3.0 — Database Concept (Bloc 1 du gap analysis)
|
||||
### v1.4.0 ✅ — Propriétés Avancées (Bloc 2)
|
||||
### (2026-07-10)
|
||||
|
||||
- [ ] **Table `collections`** — base de données abstraite, indépendante de Gitea
|
||||
- `id`, `name`, `description`, `icon`, `schema_json`
|
||||
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
|
||||
- Supporte les templates de database
|
||||
- [ ] **Table `collection_pages`** — remplace `cards`, pages génériques
|
||||
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
|
||||
- `gitea_issue_id` optionnel pour les pages liées à Gitea
|
||||
- Auto-propriétés : `created_at`, `updated_at`, `created_by`, `updated_by`
|
||||
- [ ] **Table `collection_views`** — vues configurables par collection
|
||||
- `name`, `view_type` (table/board/calendar/timeline/gallery/list)
|
||||
- `config_json` (filtres, tris, group_by, visible_properties, card_size...)
|
||||
- `position` pour l'ordre des onglets
|
||||
- [ ] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
|
||||
- Les boards Gitea existants deviennent des collections automatiquement
|
||||
- Migration transparente des `cards` → `collection_pages`
|
||||
- [ ] **Database lock** — mode lecture seule (`collections.is_locked`)
|
||||
- [ ] **Database description** — texte riche au-dessus de la database
|
||||
|
||||
### v1.4.0 — Propriétés Avancées (Bloc 2)
|
||||
|
||||
- [ ] **Table `collection_properties`** — remplace `project_properties`
|
||||
- [x] **Table `collection_properties`** — remplace `project_properties`
|
||||
- Liée à `collections` (pas à `project_owner/name`)
|
||||
- Supporte tous les types Notion
|
||||
- [ ] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
||||
- [ ] **Type `checkbox`** — booléen natif
|
||||
- [ ] **Type `url`** — lien cliquable
|
||||
- [ ] **Type `email`** — email cliquable
|
||||
- [ ] **Type `phone`** — téléphone cliquable
|
||||
- [ ] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
||||
- [ ] **Type `files`** — uploads/images attachés aux pages
|
||||
- [ ] **Type `unique_id`** — ID auto-incrémenté par collection
|
||||
- [ ] **Type `created_time` / `created_by`** — auto-propriétés
|
||||
- [ ] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
||||
- [ ] **Migration** `project_properties` → `collection_properties`
|
||||
- Supporte tous les types Notion (21 types)
|
||||
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
|
||||
- [x] **Type `checkbox`** — booléen natif
|
||||
- [x] **Type `url`** — lien cliquable
|
||||
- [x] **Type `email`** — email cliquable
|
||||
- [x] **Type `phone`** — téléphone cliquable
|
||||
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
|
||||
- [x] **Type `files`** — uploads/images attachés aux pages
|
||||
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
|
||||
- [x] **Type `created_time` / `created_by`** — auto-propriétés
|
||||
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
|
||||
- [x] **Service `property_types.py`** — validation, formatage, auto-values
|
||||
- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api`
|
||||
- [x] **38/38 tests** passent (+4 nouveaux tests v1.4)
|
||||
- [ ] **Migration** `project_properties` → `collection_properties` (v1.5)
|
||||
|
||||
### v1.5.0 — Relations & Rollups (Bloc 2 suite)
|
||||
|
||||
@@ -202,13 +206,13 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
||||
## Résumé des phases
|
||||
|
||||
```
|
||||
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ⬜ v1.4.0 ⬜ v1.5.0 ⬜ v1.6.0 ⬜ v1.7.0 ⬜
|
||||
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ⬜ v1.6.0 ⬜ v1.7.0 ⬜
|
||||
Production Pages Editor DB Concept Properties Relations Views View+
|
||||
──────────────────────────────────────────────
|
||||
52 fonctionnalités identifiées dans le gap analysis
|
||||
│
|
||||
├─ Bloc 1: Database Concept (v1.3) — 6 items
|
||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items
|
||||
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
|
||||
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items (11/14 ✅)
|
||||
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items
|
||||
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
|
||||
├─ Bloc 5: My Tasks (v1.9) — 7 items
|
||||
|
||||
+53
-159
@@ -1,177 +1,71 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
|
||||
> **Début**: 2026-07-08 | **Version actuelle**: v1.3.0 | **Prochaine**: v1.4.0
|
||||
|
||||
## Avancement Global
|
||||
|
||||
| Phase | Description | Statut | Progression |
|
||||
|-------|-------------|--------|-------------|
|
||||
| Phase 1 | Documents & Planification | ✅ | 100% |
|
||||
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ✅ | 100% |
|
||||
| Phase 3 | Sidebar (sections, hover, context menu) | ✅ | 100% |
|
||||
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | 🚧 | 40% |
|
||||
| Phase 5 | Filtres & Tri | 🚧 | 25% |
|
||||
| Phase 6 | Cartes enrichies | 🚧 | 30% |
|
||||
| Phase 7 | Intégration Gitea complète | 🚧 | 15% |
|
||||
| Phase 8 | Polish & Tests | ⬜ | 0% |
|
||||
| Phase | Description | Statut | Tests |
|
||||
|-------|-------------|--------|-------|
|
||||
| v0.2 – v0.9 | Base → Backend | ✅ | — |
|
||||
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
|
||||
| v1.1 | Pages & Sidebar Notion | ✅ | — |
|
||||
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
|
||||
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
|
||||
| v1.4 | Propriétés Avancées (14 types) | ⬜ | — |
|
||||
| v1.5 | Relations & Rollups | ⬜ | — |
|
||||
| v1.6 | Vues Calendrier/Timeline/Gallery/List | ⬜ | — |
|
||||
| v1.7 | Vues Améliorées (group_by, card_size) | ⬜ | — |
|
||||
| v1.8 | Sub-items & Dépendances | ⬜ | — |
|
||||
| v1.9 | My Tasks & Dashboard Unifié | ⬜ | — |
|
||||
| v2.0 | Éditeur Complet & Multi-Users | ⬜ | — |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Documents & Planification
|
||||
## v1.3.0 — Database Concept (complété 2026-07-10)
|
||||
|
||||
### 1.1 Analyse des images Notion
|
||||
- [x] Pull des 19 images de référence
|
||||
- [x] Analyse du layout complet Notion (~1400px, dark mode)
|
||||
- [x] Analyse Kanban board 1 (colonnes + cartes + groupes Design/Engineering)
|
||||
- [x] Analyse Detailed board (cartes avec propriétés visibles)
|
||||
- [x] Analyse Table view (colonnes Name, Status, Assign, Deadline, Team, AI keywords)
|
||||
- [x] Analyse Search & Filter (barre de recherche, panneau filtres)
|
||||
- [x] Analyse Panel Filter (liste des propriétés filtrables)
|
||||
- [x] Analyse Status Panel Filter (checkboxes multiples + Clear selection)
|
||||
- [x] Analyse Sort Panel 1 (simple: propriété + ascendant/descendant)
|
||||
- [x] Analyse Sort Panel 2 (multi-critères: 3 sorts hiérarchiques)
|
||||
- [x] Analyse Filters Cumulables (AND, pastilles, + Filter, Reset)
|
||||
- [x] Analyse AI Keywords Filter (recherche + tags colorés)
|
||||
- [x] Analyse Status Overview (donut chart 18 total, segments, légende)
|
||||
- [x] Analyse Team Load (barres empilées par avatar)
|
||||
- [x] Analyse Sidebar Layout (sections: Home, Recents, Favorites, Library)
|
||||
- [x] Analyse Sidebar Context Menu (right-click: Copy link, Duplicate, Rename, Move to Trash...)
|
||||
- [x] Analyse Sidebar Mouse Over Elements (hover: fond #333, boutons ... et +)
|
||||
- [x] Analyse Sidebar Mouse Over Section (tooltip "Open in Library", icônes)
|
||||
- [x] Analyse Kanban Board 2 (structure groupes + colonnes)
|
||||
### Tables
|
||||
- [x] `collections` — base de données abstraite, schéma JSON, lien Gitea optionnel
|
||||
- [x] `collection_pages` — pages avec `parent_id`, `property_values_json`, `position`
|
||||
- [x] `collection_views` — vues configurables (`view_type`, `config_json`)
|
||||
- [x] Index: `idx_cp_collection`, `idx_cp_parent`, `idx_cp_gitea`
|
||||
|
||||
### 1.2 Documents
|
||||
- [x] ROADMAP.md mis à jour avec vision v0.4 → v1.0
|
||||
- [x] WORKLOAD.md créé
|
||||
- [ ] README.md mis à jour
|
||||
- [ ] ARCHITECTURE.md mis à jour
|
||||
- [ ] CHANGELOG.md (après premier commit de code)
|
||||
### API
|
||||
- [x] `GET/POST /db/api` — lister/créer collections
|
||||
- [x] `GET/PUT/DELETE /db/api/{id}` — CRUD collection
|
||||
- [x] `GET/POST /db/{id}/pages/api` — pages dans une collection
|
||||
- [x] `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
|
||||
- [x] `GET /db/{id}` — vue HTML collection
|
||||
- [x] `GET /db/boards/api` — boards Gitea comme collections
|
||||
- [x] `GET /db/board/{o}/{r}/api` — board spécifique
|
||||
- [x] `POST /db/board/{o}/{r}/sync` — sync board → collection
|
||||
|
||||
### Adapter
|
||||
- [x] `GiteaBoardCompat` — `from_board()`, `from_card()`, `list_boards_as_collections()`, `sync_to_collection()`
|
||||
|
||||
### Infra
|
||||
- [x] CSRF exempté pour `/db/`
|
||||
- [x] Version 1.0.0 → 1.3.0
|
||||
- [x] 34/34 tests passent
|
||||
- [x] Tag git v1.3.0
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Layout Notion
|
||||
## Prochaines étapes (v1.4.0 — Propriétés Avancées)
|
||||
|
||||
### 2.1 Structure HTML
|
||||
- [x] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
|
||||
- [x] Fond global: `#191919`
|
||||
- [x] Police Inter importée via Google Fonts
|
||||
### Table `collection_properties`
|
||||
- [ ] Remplacer `project_properties` (liée à `project_owner/name`) par `collection_properties` (liée à `collections`)
|
||||
- [ ] Types: number, checkbox, url, email, phone, status (9 couleurs), files, unique_id
|
||||
- [ ] Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
|
||||
- [ ] Format number: nombre, %, €, $, £, ¥
|
||||
|
||||
### 2.2 Sidebar (gauche, ~240px, fond #222)
|
||||
- [x] Workspace info (avatar B + nom Bruno)
|
||||
- [x] Boutons Home, Search, Updates, Settings
|
||||
- [x] Section Recents (avec indentation sous-pages)
|
||||
- [x] Section Favorites (Agents, Shared, Private)
|
||||
- [x] Section Notion apps (Calendar, Desktop)
|
||||
- [x] Section Library (My Tasks, Marketplace, Help, Trash)
|
||||
- [x] Footer: Invite members, New chat Ctrl+O, bouton édition
|
||||
|
||||
### 2.3 Topbar (haut, fond #191919)
|
||||
- [x] Logo workspace + nom à gauche
|
||||
- [x] Titre page + statut (Private/Shared) au centre
|
||||
- [x] Icônes: notifications, historique, recherche
|
||||
- [x] Boutons: Share, favori, menu (...)
|
||||
|
||||
### 2.4 Content Area (fond #191919)
|
||||
- [x] Image de couverture optionnelle
|
||||
- [x] Titre de page (28-32px bold)
|
||||
- [x] Barre de navigation des vues (onglets)
|
||||
- [x] Barre d'outils (filtres, tri, recherche, New)
|
||||
- [x] Zone de contenu principale (Kanban / Table / Graphiques)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CSS — Design System
|
||||
|
||||
### 3.1 Variables CSS
|
||||
```css
|
||||
:root {
|
||||
--bg-primary: #191919;
|
||||
--bg-secondary: #222222;
|
||||
--bg-tertiary: #333333;
|
||||
--bg-hover: #2F2E2E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #A0A0A0;
|
||||
--color-blue: #3366CC;
|
||||
--color-green: #00CC66;
|
||||
--color-yellow: #FFD700;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Composants
|
||||
- [x] `.sidebar` — 240px fixe, fond #222
|
||||
- [x] `.sidebar-section` — titre 12px + liste
|
||||
- [x] `.sidebar-item` — 14px, padding 8px 12px, hover #333
|
||||
- [x] `.sidebar-item.active` — fond #333
|
||||
- [x] `.topbar` — hauteur 44px, flex
|
||||
- [x] `.view-tabs` — onglets horizontaux
|
||||
- [x] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
|
||||
- [x] `.kanban-board` — flex horizontal, gap 12px
|
||||
- [x] `.kanban-column` — min-width 260px, arrondi 6px
|
||||
- [x] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
|
||||
- [x] `.btn-primary` — bleu #3366CC
|
||||
- [x] `.btn-secondary` — gris #333333
|
||||
- [x] `.filter-pill` — pastille avec texte + croix de suppression
|
||||
- [x] `.sort-panel` — menu déroulant avec options
|
||||
- [x] `.dropdown-menu` — fond #333, bordure subtile, ombre
|
||||
- [x] `.tooltip` — fond #3A3A3A, texte blanc
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: JavaScript (Alpine.js)
|
||||
|
||||
### 4.1 Sidebar
|
||||
- [x] Toggle collapse/expand sidebar
|
||||
- [x] Hover reveal buttons (... et +)
|
||||
- [x] Context menu au clic droit
|
||||
- [ ] Drag & drop pour réorganiser les favoris
|
||||
|
||||
### 4.2 Vues
|
||||
- [x] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
|
||||
- [x] Kanban: drag & drop cartes (SortableJS)
|
||||
- [ ] Table: tri par colonne, redimensionnement
|
||||
- [ ] Status Overview: Donut chart (SVG ou Canvas)
|
||||
- [ ] Team Load: Barres empilées
|
||||
|
||||
### 4.3 Filtres & Tri
|
||||
- [x] Ajout/suppression filtres (UI)
|
||||
- [ ] Logique AND entre filtres
|
||||
- [ ] Tri multi-niveaux
|
||||
- [ ] Persistance dans l'URL ou localStorage
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Backend (FastAPI)
|
||||
|
||||
### 5.1 Nouvelles routes API
|
||||
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
|
||||
- [ ] `POST /api/filters` — appliquer filtres
|
||||
- [ ] `POST /api/sorts` — appliquer tris
|
||||
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
|
||||
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
|
||||
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
|
||||
|
||||
### 5.2 Base de données (nouvelles tables)
|
||||
- [ ] `view_config` — configuration par vue
|
||||
- [ ] `filters` — filtres sauvegardés par utilisateur
|
||||
- [ ] `sorts` — tris sauvegardés
|
||||
- [ ] `card_properties` — propriétés custom par carte
|
||||
|
||||
### 5.3 Gitea Sync
|
||||
- [x] Labels → tags couleur sur cartes
|
||||
- [ ] Milestones → groupes/sprints
|
||||
- [x] Assignees → avatars + noms
|
||||
- [x] Due dates → échéances (via Gitea API)
|
||||
- [ ] Comments → affichage dans cartes
|
||||
|
||||
---
|
||||
### Intégration UI
|
||||
- [ ] Property editor inline par type (select dropdown, date picker, checkbox...)
|
||||
- [ ] Affichage des propriétés dans la modale de détail page
|
||||
- [ ] Migration project_properties → collection_properties
|
||||
|
||||
## Notes
|
||||
|
||||
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
|
||||
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
|
||||
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
|
||||
- **Espacements**: padding 8-12px, gap 8-12px
|
||||
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
|
||||
- **Stack**: FastAPI + Jinja2 + HTMX + Alpine.js + SortableJS + SQLite (WAL)
|
||||
- **Docker**: python:3.12-slim, port 8080, volume /data
|
||||
- **Tests**: pytest, 34 tests, client TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
@@ -184,6 +184,27 @@ def init_db():
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
|
||||
CREATE TABLE IF NOT EXISTS collection_properties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
prop_type TEXT NOT NULL DEFAULT 'text',
|
||||
options_json TEXT DEFAULT '[]',
|
||||
number_format TEXT DEFAULT 'number',
|
||||
related_collection_id INTEGER REFERENCES collections(id),
|
||||
reverse_name TEXT,
|
||||
relation_property_id INTEGER REFERENCES collection_properties(id),
|
||||
target_property_id INTEGER REFERENCES collection_properties(id),
|
||||
rollup_function TEXT,
|
||||
formula_expression TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
required BOOLEAN NOT NULL DEFAULT 0,
|
||||
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, name)
|
||||
);
|
||||
""")
|
||||
# Migration: add parent_id if missing (v1.0.0+)
|
||||
try:
|
||||
|
||||
+2
-2
@@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI):
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("FlowDeck v1.3.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v1.4.0 started on port %d", settings.app_port)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="1.3.0",
|
||||
version="1.4.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ async def health():
|
||||
|
||||
return {
|
||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"db": db_ok,
|
||||
"gitea": gitea_ok,
|
||||
}
|
||||
|
||||
@@ -247,6 +247,120 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
return {"collection_id": coll_id, "status": "synced"}
|
||||
|
||||
|
||||
# ── Collection Properties (v1.4.0) ──
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
async def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties for a collection."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"properties": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
async def create_property_api(request: Request, collection_id: int):
|
||||
"""API: create a new property on a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
|
||||
prop_type = body.get("prop_type", "text")
|
||||
options_json = json.dumps(body.get("options", []))
|
||||
number_format = body.get("number_format", "number")
|
||||
required = int(body.get("required", False))
|
||||
visible = int(body.get("visible_in_views", True))
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
position, required, visible_in_views)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, prop_type, options_json, number_format, max_pos, required, visible),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists")
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
async def update_property_api(request: Request, prop_id: int):
|
||||
"""API: update a property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
|
||||
name = body.get("name", existing["name"])
|
||||
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
|
||||
number_format = body.get("number_format", existing["number_format"])
|
||||
required = int(body.get("required", existing["required"]))
|
||||
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_properties
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?
|
||||
WHERE id=?""",
|
||||
(name, options_json, number_format, required, visible, prop_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
async def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Property not found")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── {collection_id} wildcards (LAST — catches everything else) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""FlowDeck — Property types & validation (v1.4.0)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── Property type definitions ──
|
||||
|
||||
PROPERTY_TYPES: dict[str, dict] = {
|
||||
"title": {
|
||||
"storage": "string",
|
||||
"validation": "max 2000 chars",
|
||||
"default": "",
|
||||
},
|
||||
"text": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"number": {
|
||||
"storage": "float",
|
||||
"validation": "numeric",
|
||||
"format_options": ["number", "percent", "dollar", "euro", "pound", "yen"],
|
||||
"default": None,
|
||||
},
|
||||
"select": {
|
||||
"storage": "string (from options_json)",
|
||||
"default": None,
|
||||
},
|
||||
"multi_select": {
|
||||
"storage": "json array of strings",
|
||||
"default": [],
|
||||
},
|
||||
"status": {
|
||||
"storage": "string (from options_json) + color",
|
||||
"default": None,
|
||||
"colors": ["gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"],
|
||||
},
|
||||
"date": {
|
||||
"storage": "ISO 8601 string",
|
||||
"default": None,
|
||||
},
|
||||
"person": {
|
||||
"storage": "json array of {id, login, avatar_url}",
|
||||
"default": [],
|
||||
},
|
||||
"checkbox": {
|
||||
"storage": "boolean",
|
||||
"default": False,
|
||||
},
|
||||
"url": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"email": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"phone": {
|
||||
"storage": "string",
|
||||
"default": "",
|
||||
},
|
||||
"files": {
|
||||
"storage": "json array of {url, name}",
|
||||
"default": [],
|
||||
},
|
||||
"unique_id": {
|
||||
"storage": "integer (auto-increment per collection)",
|
||||
"default": None,
|
||||
},
|
||||
"relation": {
|
||||
"storage": "json array of page IDs",
|
||||
"default": [],
|
||||
},
|
||||
"rollup": {
|
||||
"storage": "computed — not stored",
|
||||
"default": None,
|
||||
},
|
||||
"formula": {
|
||||
"storage": "computed — not stored",
|
||||
"default": None,
|
||||
},
|
||||
"created_time": {
|
||||
"storage": "auto — ISO 8601",
|
||||
"default": None,
|
||||
},
|
||||
"created_by": {
|
||||
"storage": "auto — {id, login}",
|
||||
"default": None,
|
||||
},
|
||||
"last_edited_time": {
|
||||
"storage": "auto — ISO 8601",
|
||||
"default": None,
|
||||
},
|
||||
"last_edited_by": {
|
||||
"storage": "auto — {id, login}",
|
||||
"default": None,
|
||||
},
|
||||
}
|
||||
|
||||
# CSV-friendly subset (no relation/rollup/formula)
|
||||
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
|
||||
"date", "person", "checkbox", "url", "email", "phone", "files"]
|
||||
|
||||
# Auto-property types (computed server-side)
|
||||
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
|
||||
|
||||
|
||||
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
|
||||
"""Validate a property value against its type. Returns (ok, error_message)."""
|
||||
if value is None:
|
||||
return True, ""
|
||||
|
||||
if prop_type == "number":
|
||||
try:
|
||||
float(value)
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid number"
|
||||
elif prop_type == "checkbox":
|
||||
if not isinstance(value, bool):
|
||||
return False, "Checkbox must be true or false"
|
||||
elif prop_type in ("select", "status"):
|
||||
if options and value not in [o.get("name", o) if isinstance(o, dict) else o for o in options]:
|
||||
return False, f"'{value}' is not a valid option"
|
||||
elif prop_type == "multi_select":
|
||||
if not isinstance(value, list):
|
||||
return False, "Multi-select must be a list"
|
||||
if options:
|
||||
valid_names = {o.get("name", o) if isinstance(o, dict) else o for o in options}
|
||||
for v in value:
|
||||
if v not in valid_names:
|
||||
return False, f"'{v}' is not a valid option"
|
||||
elif prop_type == "date":
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
elif prop_type == "url":
|
||||
if not isinstance(value, str):
|
||||
return False, "URL must be a string"
|
||||
elif prop_type == "email":
|
||||
if not isinstance(value, str) or ("@" not in value if value else False):
|
||||
return False, "Invalid email format"
|
||||
elif prop_type == "phone":
|
||||
if not isinstance(value, str):
|
||||
return False, "Phone must be a string"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
|
||||
"""Compute the value of an auto-property."""
|
||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
||||
if user:
|
||||
return {"id": user.get("id"), "login": user.get("login")}
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_next_unique_id(collection_id: int, conn) -> int:
|
||||
"""Get the next unique_id for a collection (max + 1)."""
|
||||
row = conn.execute(
|
||||
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
|
||||
FROM collection_pages WHERE collection_id=?""",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else 1
|
||||
|
||||
|
||||
def format_number(value: float, fmt: str = "number") -> str:
|
||||
"""Format a number value for display."""
|
||||
if value is None:
|
||||
return ""
|
||||
if fmt == "percent":
|
||||
return f"{value}%"
|
||||
elif fmt == "dollar":
|
||||
return f"${value:,.2f}"
|
||||
elif fmt == "euro":
|
||||
return f"€{value:,.2f}"
|
||||
elif fmt == "pound":
|
||||
return f"£{value:,.2f}"
|
||||
elif fmt == "yen":
|
||||
return f"¥{value:,.0f}"
|
||||
return str(value)
|
||||
+114
-3
@@ -34,7 +34,7 @@ def test_health(client):
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert data["db"] is True
|
||||
assert data["version"] == "1.3.0"
|
||||
assert data["version"] == "1.4.0"
|
||||
|
||||
|
||||
def test_dashboard(client):
|
||||
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
|
||||
|
||||
def test_version_in_health(client):
|
||||
resp = client.get("/api/health")
|
||||
assert resp.json()["version"] == "1.3.0"
|
||||
assert resp.json()["version"] == "1.4.0"
|
||||
|
||||
|
||||
def test_db_tables_exist(client):
|
||||
@@ -204,7 +204,8 @@ def test_db_tables_exist(client):
|
||||
required = {"boards", "cards", "notes", "checklists", "checklist_items",
|
||||
"col_mapping", "users", "user_tokens",
|
||||
"project_properties", "property_values", "ai_keywords",
|
||||
"collections", "collection_pages", "collection_views"}
|
||||
"collections", "collection_pages", "collection_views",
|
||||
"collection_properties"}
|
||||
assert required <= names
|
||||
|
||||
|
||||
@@ -356,3 +357,113 @@ def test_boards_as_collections(client):
|
||||
data = resp.json()
|
||||
assert "boards" in data
|
||||
assert isinstance(data["boards"], list)
|
||||
|
||||
|
||||
# ── v1.4.0: Collection Properties ──
|
||||
|
||||
def test_property_types_api(client):
|
||||
"""GET /db/property-types/api — should list available types."""
|
||||
resp = client.get("/db/property-types/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "types" in data
|
||||
assert "text" in data["types"]
|
||||
assert "number" in data["types"]
|
||||
assert "checkbox" in data["types"]
|
||||
assert "status" in data["types"]
|
||||
|
||||
|
||||
def test_collection_properties_crud(client):
|
||||
"""Full CRUD on collection properties."""
|
||||
# Create collection
|
||||
resp = client.post("/db/api", json={"name": "Props Test DB"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
# List properties (empty)
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["properties"] == []
|
||||
|
||||
# Create a text property
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "Description",
|
||||
"prop_type": "text",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "created"
|
||||
prop_id = resp.json()["id"]
|
||||
|
||||
# Create a number property
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "Estimation",
|
||||
"prop_type": "number",
|
||||
"number_format": "number",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Create a status property with options
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "State",
|
||||
"prop_type": "status",
|
||||
"options": [
|
||||
{"name": "Todo", "color": "gray"},
|
||||
{"name": "Done", "color": "green"},
|
||||
],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Create a checkbox property
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={
|
||||
"name": "Verified",
|
||||
"prop_type": "checkbox",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# List — should have 4
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
assert len(resp.json()["properties"]) == 4
|
||||
|
||||
# Update a property
|
||||
resp = client.put(f"/db/properties/{prop_id}/api", json={
|
||||
"name": "Description Longue",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify update
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
names = [p["name"] for p in resp.json()["properties"]]
|
||||
assert "Description Longue" in names
|
||||
|
||||
# Delete a property
|
||||
resp = client.delete(f"/db/properties/{prop_id}/api")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify deletion
|
||||
resp = client.get(f"/db/{coll_id}/properties/api")
|
||||
assert len(resp.json()["properties"]) == 3
|
||||
|
||||
# Cleanup
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
def test_collection_properties_duplicate(client):
|
||||
"""Creating duplicate property name should return 409."""
|
||||
resp = client.post("/db/api", json={"name": "Dup Test"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
|
||||
def test_collection_properties_validation(client):
|
||||
"""Validation: missing name should return 400."""
|
||||
resp = client.post("/db/api", json={"name": "Val Test"})
|
||||
coll_id = resp.json()["id"]
|
||||
|
||||
resp = client.post(f"/db/{coll_id}/properties/api", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
client.delete(f"/db/api/{coll_id}")
|
||||
|
||||
Reference in New Issue
Block a user