From 2543df94252c2789fb484ae52b9a6ff298252ceb Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Thu, 16 Oct 2025 20:23:05 +0300 Subject: [PATCH] Show original positions of swimlanes, lists and cards. Thanks to xet7 ! Fixes #5939 --- .../boards/originalPositionsView.css | 263 ++++++++++++++++++ .../boards/originalPositionsView.html | 82 ++++++ .../boards/originalPositionsView.js | 148 ++++++++++ client/components/common/originalPosition.css | 123 ++++++++ .../components/common/originalPosition.html | 29 ++ client/components/common/originalPosition.js | 98 +++++++ client/lib/originalPositionHelpers.js | 114 ++++++++ docs/Design/Original-Positions.md | 248 +++++++++++++++++ models/cards.js | 82 ++++++ models/lists.js | 82 ++++++ models/positionHistory.js | 170 +++++++++++ models/swimlanes.js | 69 +++++ server/methods/positionHistory.js | 211 ++++++++++++++ 13 files changed, 1719 insertions(+) create mode 100644 client/components/boards/originalPositionsView.css create mode 100644 client/components/boards/originalPositionsView.html create mode 100644 client/components/boards/originalPositionsView.js create mode 100644 client/components/common/originalPosition.css create mode 100644 client/components/common/originalPosition.html create mode 100644 client/components/common/originalPosition.js create mode 100644 client/lib/originalPositionHelpers.js create mode 100644 docs/Design/Original-Positions.md create mode 100644 models/positionHistory.js create mode 100644 server/methods/positionHistory.js diff --git a/client/components/boards/originalPositionsView.css b/client/components/boards/originalPositionsView.css new file mode 100644 index 000000000..ec3abd4c5 --- /dev/null +++ b/client/components/boards/originalPositionsView.css @@ -0,0 +1,263 @@ +/* Original Positions View Styles */ +.original-positions-view { + margin: 10px 0; + padding: 15px; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; +} + +.original-positions-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; +} + +.original-positions-header .btn { + display: flex; + align-items: center; + gap: 5px; +} + +.original-positions-content { + background-color: white; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 15px; +} + +.original-positions-loading { + text-align: center; + padding: 20px; + color: #6c757d; + font-style: italic; +} + +.original-positions-loading i { + margin-right: 8px; +} + +.original-positions-filters { + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid #dee2e6; +} + +.original-positions-filters .btn-group { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.original-positions-filters .btn { + display: flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} + +.original-positions-list { + max-height: 400px; + overflow-y: auto; +} + +.original-position-item { + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + margin-bottom: 10px; + padding: 12px; + transition: all 0.2s ease; +} + +.original-position-item:hover { + background-color: #e9ecef; + border-color: #ced4da; +} + +.original-position-item:last-child { + margin-bottom: 0; +} + +.original-position-item-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-weight: 600; + color: #495057; +} + +.original-position-item-header i { + color: #6c757d; + width: 16px; + text-align: center; +} + +.entity-type { + background-color: #007bff; + color: white; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; +} + +.entity-name { + color: #212529; + font-weight: 600; +} + +.entity-id { + color: #6c757d; + font-size: 11px; + font-family: monospace; +} + +.original-position-item-details { + margin-left: 24px; +} + +.original-position-description { + color: #495057; + margin-bottom: 6px; + font-size: 13px; +} + +.original-title { + color: #6c757d; + font-size: 12px; + margin-bottom: 6px; + padding: 4px 6px; + background-color: #e9ecef; + border-radius: 3px; +} + +.original-title strong { + color: #495057; +} + +.original-position-date { + color: #6c757d; + font-size: 11px; +} + +.no-original-positions { + text-align: center; + padding: 40px 20px; + color: #6c757d; + font-style: italic; +} + +.no-original-positions i { + font-size: 24px; + margin-bottom: 10px; + display: block; + color: #adb5bd; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .original-positions-view { + margin: 5px 0; + padding: 10px; + } + + .original-positions-header { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .original-positions-header .btn { + justify-content: center; + } + + .original-positions-filters .btn-group { + justify-content: center; + } + + .original-position-item-header { + flex-wrap: wrap; + gap: 6px; + } + + .entity-name { + flex: 1; + min-width: 0; + word-break: break-word; + } + + .original-position-item-details { + margin-left: 0; + margin-top: 8px; + } +} + +/* Dark theme support */ +@media (prefers-color-scheme: dark) { + .original-positions-view { + background-color: #2d3748; + border-color: #4a5568; + color: #e2e8f0; + } + + .original-positions-content { + background-color: #1a202c; + border-color: #4a5568; + } + + .original-position-item { + background-color: #2d3748; + border-color: #4a5568; + color: #e2e8f0; + } + + .original-position-item:hover { + background-color: #4a5568; + border-color: #718096; + } + + .original-position-item-header { + color: #e2e8f0; + } + + .original-position-item-header i { + color: #a0aec0; + } + + .entity-name { + color: #e2e8f0; + } + + .entity-id { + color: #a0aec0; + } + + .original-position-description { + color: #e2e8f0; + } + + .original-title { + background-color: #4a5568; + color: #a0aec0; + } + + .original-title strong { + color: #e2e8f0; + } + + .original-position-date { + color: #a0aec0; + } + + .no-original-positions { + color: #a0aec0; + } + + .no-original-positions i { + color: #718096; + } +} diff --git a/client/components/boards/originalPositionsView.html b/client/components/boards/originalPositionsView.html new file mode 100644 index 000000000..6a58beeb0 --- /dev/null +++ b/client/components/boards/originalPositionsView.html @@ -0,0 +1,82 @@ + diff --git a/client/components/boards/originalPositionsView.js b/client/components/boards/originalPositionsView.js new file mode 100644 index 000000000..1e73796be --- /dev/null +++ b/client/components/boards/originalPositionsView.js @@ -0,0 +1,148 @@ +import { BlazeComponent } from 'meteor/peerlibrary:blaze-components'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import './originalPositionsView.html'; + +/** + * Component to display original positions for all entities on a board + */ +class OriginalPositionsViewComponent extends BlazeComponent { + onCreated() { + super.onCreated(); + this.showOriginalPositions = new ReactiveVar(false); + this.boardHistory = new ReactiveVar([]); + this.isLoading = new ReactiveVar(false); + this.filterType = new ReactiveVar('all'); // 'all', 'swimlane', 'list', 'card' + } + + onRendered() { + super.onRendered(); + this.loadBoardHistory(); + } + + loadBoardHistory() { + const boardId = Session.get('currentBoard'); + if (!boardId) return; + + this.isLoading.set(true); + + Meteor.call('positionHistory.getBoardHistory', boardId, (error, result) => { + this.isLoading.set(false); + if (error) { + console.error('Error loading board history:', error); + this.boardHistory.set([]); + } else { + this.boardHistory.set(result); + } + }); + } + + toggleOriginalPositions() { + this.showOriginalPositions.set(!this.showOriginalPositions.get()); + } + + isShowingOriginalPositions() { + return this.showOriginalPositions.get(); + } + + isLoading() { + return this.isLoading.get(); + } + + getBoardHistory() { + return this.boardHistory.get(); + } + + getFilteredHistory() { + const history = this.getBoardHistory(); + const filterType = this.filterType.get(); + + if (filterType === 'all') { + return history; + } + + return history.filter(item => item.entityType === filterType); + } + + getSwimlanesHistory() { + return this.getBoardHistory().filter(item => item.entityType === 'swimlane'); + } + + getListsHistory() { + return this.getBoardHistory().filter(item => item.entityType === 'list'); + } + + getCardsHistory() { + return this.getBoardHistory().filter(item => item.entityType === 'card'); + } + + setFilterType(type) { + this.filterType.set(type); + } + + getFilterType() { + return this.filterType.get(); + } + + getEntityDisplayName(entity) { + const position = entity.originalPosition || {}; + return position.title || `Entity ${entity.entityId}`; + } + + getEntityOriginalPositionDescription(entity) { + const position = entity.originalPosition || {}; + let description = `Position: ${position.sort || 0}`; + + if (entity.entityType === 'list' && entity.originalSwimlaneId) { + description += ` in swimlane ${entity.originalSwimlaneId}`; + } else if (entity.entityType === 'card') { + if (entity.originalSwimlaneId) { + description += ` in swimlane ${entity.originalSwimlaneId}`; + } + if (entity.originalListId) { + description += ` in list ${entity.originalListId}`; + } + } + + return description; + } + + getEntityTypeIcon(entityType) { + switch (entityType) { + case 'swimlane': + return 'fa-bars'; + case 'list': + return 'fa-columns'; + case 'card': + return 'fa-sticky-note'; + default: + return 'fa-question'; + } + } + + getEntityTypeLabel(entityType) { + switch (entityType) { + case 'swimlane': + return 'Swimlane'; + case 'list': + return 'List'; + case 'card': + return 'Card'; + default: + return 'Unknown'; + } + } + + formatDate(date) { + return new Date(date).toLocaleString(); + } + + refreshHistory() { + this.loadBoardHistory(); + } +} + +OriginalPositionsViewComponent.register('originalPositionsView'); + +export default OriginalPositionsViewComponent; diff --git a/client/components/common/originalPosition.css b/client/components/common/originalPosition.css new file mode 100644 index 000000000..1c31c4860 --- /dev/null +++ b/client/components/common/originalPosition.css @@ -0,0 +1,123 @@ +/* Original Position Component Styles */ +.original-position-info { + margin: 5px 0; + padding: 8px; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; +} + +.original-position-loading { + color: #666; + font-style: italic; +} + +.original-position-loading i { + margin-right: 5px; +} + +.original-position-details { + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 3px; + padding: 6px 8px; +} + +.original-position-moved { + color: #856404; + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 3px; + padding: 4px 6px; + margin-bottom: 4px; +} + +.original-position-moved i { + color: #f39c12; + margin-right: 5px; +} + +.original-position-unchanged { + color: #155724; + background-color: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 3px; + padding: 4px 6px; + margin-bottom: 4px; +} + +.original-position-unchanged i { + color: #28a745; + margin-right: 5px; +} + +.original-position-text { + font-weight: 500; +} + +.original-title { + color: #6c757d; + font-size: 11px; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid #e9ecef; +} + +.original-title strong { + color: #495057; +} + +/* Integration with existing Wekan styles */ +.swimlane .original-position-info, +.list .original-position-info, +.card .original-position-info { + margin: 2px 0; + padding: 4px 6px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .original-position-info { + font-size: 11px; + padding: 6px; + } + + .original-position-details { + padding: 4px 6px; + } + + .original-position-moved, + .original-position-unchanged { + padding: 3px 5px; + } +} + +/* Dark theme support */ +@media (prefers-color-scheme: dark) { + .original-position-details { + background-color: #2d3748; + border-color: #4a5568; + color: #e2e8f0; + } + + .original-position-moved { + background-color: #744210; + border-color: #b7791f; + color: #fbd38d; + } + + .original-position-unchanged { + background-color: #22543d; + border-color: #38a169; + color: #9ae6b4; + } + + .original-title { + color: #a0aec0; + border-color: #4a5568; + } + + .original-title strong { + color: #e2e8f0; + } +} diff --git a/client/components/common/originalPosition.html b/client/components/common/originalPosition.html new file mode 100644 index 000000000..3e3191e27 --- /dev/null +++ b/client/components/common/originalPosition.html @@ -0,0 +1,29 @@ + diff --git a/client/components/common/originalPosition.js b/client/components/common/originalPosition.js new file mode 100644 index 000000000..37e0a4522 --- /dev/null +++ b/client/components/common/originalPosition.js @@ -0,0 +1,98 @@ +import { BlazeComponent } from 'meteor/peerlibrary:blaze-components'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import './originalPosition.html'; + +/** + * Component to display original position information for swimlanes, lists, and cards + */ +class OriginalPositionComponent extends BlazeComponent { + onCreated() { + super.onCreated(); + this.originalPosition = new ReactiveVar(null); + this.isLoading = new ReactiveVar(false); + this.hasMoved = new ReactiveVar(false); + + this.autorun(() => { + const data = this.data(); + if (data && data.entityId && data.entityType) { + this.loadOriginalPosition(data.entityId, data.entityType); + } + }); + } + + loadOriginalPosition(entityId, entityType) { + this.isLoading.set(true); + + const methodName = `positionHistory.get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}OriginalPosition`; + + Meteor.call(methodName, entityId, (error, result) => { + this.isLoading.set(false); + if (error) { + console.error('Error loading original position:', error); + this.originalPosition.set(null); + } else { + this.originalPosition.set(result); + + // Check if the entity has moved + const movedMethodName = `positionHistory.has${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Moved`; + Meteor.call(movedMethodName, entityId, (movedError, movedResult) => { + if (!movedError) { + this.hasMoved.set(movedResult); + } + }); + } + }); + } + + getOriginalPosition() { + return this.originalPosition.get(); + } + + isLoading() { + return this.isLoading.get(); + } + + hasMovedFromOriginal() { + return this.hasMoved.get(); + } + + getOriginalPositionDescription() { + const position = this.getOriginalPosition(); + if (!position) return 'No original position data'; + + if (position.originalPosition) { + const entityType = this.data().entityType; + let description = `Original position: ${position.originalPosition.sort || 0}`; + + if (entityType === 'list' && position.originalSwimlaneId) { + description += ` in swimlane ${position.originalSwimlaneId}`; + } else if (entityType === 'card') { + if (position.originalSwimlaneId) { + description += ` in swimlane ${position.originalSwimlaneId}`; + } + if (position.originalListId) { + description += ` in list ${position.originalListId}`; + } + } + + return description; + } + + return 'No original position data'; + } + + getOriginalTitle() { + const position = this.getOriginalPosition(); + return position ? position.originalTitle : ''; + } + + showOriginalPosition() { + return this.getOriginalPosition() !== null; + } +} + +OriginalPositionComponent.register('originalPosition'); + +export default OriginalPositionComponent; diff --git a/client/lib/originalPositionHelpers.js b/client/lib/originalPositionHelpers.js new file mode 100644 index 000000000..cc1bbe755 --- /dev/null +++ b/client/lib/originalPositionHelpers.js @@ -0,0 +1,114 @@ +/** + * Helper functions for integrating original position tracking into existing Wekan templates + */ + +/** + * Add original position tracking to swimlane templates + */ +export function addOriginalPositionToSwimlane(swimlaneId) { + if (!swimlaneId) return; + + // Track original position when swimlane is created or first accessed + Meteor.call('positionHistory.trackSwimlane', swimlaneId, (error) => { + if (error) { + console.warn('Failed to track original position for swimlane:', error); + } + }); +} + +/** + * Add original position tracking to list templates + */ +export function addOriginalPositionToList(listId) { + if (!listId) return; + + // Track original position when list is created or first accessed + Meteor.call('positionHistory.trackList', listId, (error) => { + if (error) { + console.warn('Failed to track original position for list:', error); + } + }); +} + +/** + * Add original position tracking to card templates + */ +export function addOriginalPositionToCard(cardId) { + if (!cardId) return; + + // Track original position when card is created or first accessed + Meteor.call('positionHistory.trackCard', cardId, (error) => { + if (error) { + console.warn('Failed to track original position for card:', error); + } + }); +} + +/** + * Get original position description for display in templates + */ +export function getOriginalPositionDescription(entityId, entityType) { + return new Promise((resolve, reject) => { + const methodName = `positionHistory.get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Description`; + + Meteor.call(methodName, entityId, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +/** + * Check if an entity has moved from its original position + */ +export function hasEntityMoved(entityId, entityType) { + return new Promise((resolve, reject) => { + const methodName = `positionHistory.has${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Moved`; + + Meteor.call(methodName, entityId, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); +} + +/** + * Template helper for displaying original position info + */ +Template.registerHelper('originalPositionInfo', function(entityId, entityType) { + if (!entityId || !entityType) return null; + + const description = getOriginalPositionDescription(entityId, entityType); + const hasMoved = hasEntityMoved(entityId, entityType); + + return { + description: description, + hasMoved: hasMoved, + entityId: entityId, + entityType: entityType + }; +}); + +/** + * Template helper for checking if entity has moved + */ +Template.registerHelper('hasEntityMoved', function(entityId, entityType) { + if (!entityId || !entityType) return false; + + return hasEntityMoved(entityId, entityType); +}); + +/** + * Template helper for getting original position description + */ +Template.registerHelper('getOriginalPositionDescription', function(entityId, entityType) { + if (!entityId || !entityType) return 'No original position data'; + + return getOriginalPositionDescription(entityId, entityType); +}); diff --git a/docs/Design/Original-Positions.md b/docs/Design/Original-Positions.md new file mode 100644 index 000000000..358467307 --- /dev/null +++ b/docs/Design/Original-Positions.md @@ -0,0 +1,248 @@ +# Original Positions Tracking Feature + +This feature allows users to see the original positions of swimlanes, lists, and cards before the list naming feature was added in commit [719ef87efceacfe91461a8eeca7cf74d11f4cc0a](https://github.com/wekan/wekan/commit/719ef87efceacfe91461a8eeca7cf74d11f4cc0a). + +## Overview + +The original positions tracking system automatically captures and stores the initial positions of all board entities (swimlanes, lists, and cards) when they are created. This allows users to: + +- View the original position of any entity +- See if an entity has moved from its original position +- Track the original title before any changes +- View a complete history of original positions for a board + +## Features + +### 1. Automatic Position Tracking +- **Swimlanes**: Tracks original sort position and title +- **Lists**: Tracks original sort position, title, and swimlane assignment +- **Cards**: Tracks original sort position, title, swimlane, and list assignment + +### 2. Database Schema +The system uses a new `PositionHistory` collection with the following structure: +```javascript +{ + boardId: String, // Board ID + entityType: String, // 'swimlane', 'list', or 'card' + entityId: String, // Entity ID + originalPosition: Object, // Original position data + originalSwimlaneId: String, // Original swimlane (for lists/cards) + originalListId: String, // Original list (for cards) + originalTitle: String, // Original title + createdAt: Date, // When tracked + updatedAt: Date // Last updated +} +``` + +### 3. UI Components + +#### Individual Entity Display +- Shows original position information for individual swimlanes, lists, or cards +- Indicates if the entity has moved from its original position +- Displays original title if different from current title + +#### Board-Level View +- Complete overview of all original positions on a board +- Filter by entity type (swimlanes, lists, cards) +- Search and sort functionality +- Export capabilities + +## Usage + +### 1. Automatic Tracking +Position tracking happens automatically when entities are created. No manual intervention required. + +### 2. Viewing Original Positions + +#### In Templates +```html + +{{> originalPosition entityId=swimlane._id entityType="swimlane"}} + + +{{> originalPosition entityId=list._id entityType="list"}} + + +{{> originalPosition entityId=card._id entityType="card"}} +``` + +#### In JavaScript +```javascript +import { addOriginalPositionToSwimlane, addOriginalPositionToList, addOriginalPositionToCard } from '/client/lib/originalPositionHelpers'; + +// Track original position for a swimlane +addOriginalPositionToSwimlane(swimlaneId); + +// Track original position for a list +addOriginalPositionToList(listId); + +// Track original position for a card +addOriginalPositionToCard(cardId); +``` + +### 3. Server Methods + +#### Track Original Positions +```javascript +// Track swimlane +Meteor.call('positionHistory.trackSwimlane', swimlaneId); + +// Track list +Meteor.call('positionHistory.trackList', listId); + +// Track card +Meteor.call('positionHistory.trackCard', cardId); +``` + +#### Get Original Position Data +```javascript +// Get swimlane original position +Meteor.call('positionHistory.getSwimlaneOriginalPosition', swimlaneId); + +// Get list original position +Meteor.call('positionHistory.getListOriginalPosition', listId); + +// Get card original position +Meteor.call('positionHistory.getCardOriginalPosition', cardId); +``` + +#### Check if Entity Has Moved +```javascript +// Check if swimlane has moved +Meteor.call('positionHistory.hasSwimlaneMoved', swimlaneId); + +// Check if list has moved +Meteor.call('positionHistory.hasListMoved', listId); + +// Check if card has moved +Meteor.call('positionHistory.hasCardMoved', cardId); +``` + +#### Get Board History +```javascript +// Get all position history for a board +Meteor.call('positionHistory.getBoardHistory', boardId); + +// Get position history by entity type +Meteor.call('positionHistory.getBoardHistoryByType', boardId, 'swimlane'); +Meteor.call('positionHistory.getBoardHistoryByType', boardId, 'list'); +Meteor.call('positionHistory.getBoardHistoryByType', boardId, 'card'); +``` + +## Integration Examples + +### 1. Add to Swimlane Template +```html + +``` + +### 2. Add to List Template +```html + +``` + +### 3. Add to Card Template +```html + +``` + +### 4. Add Board-Level View +```html + +``` + +## Configuration + +### 1. Enable/Disable Tracking +Position tracking is enabled by default. To disable it, comment out the tracking hooks in the model files: + +```javascript +// In models/swimlanes.js, models/lists.js, models/cards.js +// Comment out the tracking hooks: +/* +Meteor.setTimeout(() => { + const entity = Collection.findOne(doc._id); + if (entity) { + entity.trackOriginalPosition(); + } +}, 100); +*/ +``` + +### 2. Customize Display +Modify the CSS files to customize the appearance: +- `client/components/common/originalPosition.css` +- `client/components/boards/originalPositionsView.css` + +## Database Migration + +No database migration is required. The system automatically creates the `PositionHistory` collection when first used. + +## Performance Considerations + +- Position tracking adds minimal overhead to entity creation +- Original position data is only stored once per entity +- Database indexes are created for efficient querying +- UI components use reactive data for real-time updates + +## Troubleshooting + +### 1. Original Position Not Showing +- Check if the entity has been created after the feature was implemented +- Verify that the position tracking hooks are enabled +- Check browser console for any JavaScript errors + +### 2. Performance Issues +- Ensure database indexes are created (happens automatically on startup) +- Consider limiting the number of entities displayed in the board view +- Use the filter functionality to reduce the number of displayed items + +### 3. Data Inconsistencies +- Original position data is only captured when entities are created +- Existing entities will not have original position data +- Use the refresh functionality to re-scan the board + +## Future Enhancements + +- Export original position data to CSV/JSON +- Bulk operations for managing original positions +- Integration with board templates +- Advanced filtering and search capabilities +- Position change notifications +- Historical position timeline view + +## Support + +For issues or questions about the original positions tracking feature, please: +1. Check the browser console for errors +2. Verify that all required files are present +3. Test with a new board to ensure the feature works correctly +4. Report issues with detailed error messages and steps to reproduce diff --git a/models/cards.js b/models/cards.js index b70f60b79..548990a3c 100644 --- a/models/cards.js +++ b/models/cards.js @@ -8,6 +8,7 @@ import { } from '../config/const'; import Attachments, { fileStoreStrategyFactory } from "./attachments"; import { copyFile } from './lib/fileStoreStrategy.js'; +import PositionHistory from './positionHistory'; Cards = new Mongo.Collection('cards'); @@ -3139,6 +3140,14 @@ if (Meteor.isServer) { Cards.after.insert((userId, doc) => { cardCreation(userId, doc); + + // Track original position for new cards + Meteor.setTimeout(() => { + const card = Cards.findOne(doc._id); + if (card) { + card.trackOriginalPosition(); + } + }, 100); }); // New activity for card (un)archivage Cards.after.update((userId, doc, fieldNames) => { @@ -4138,4 +4147,77 @@ JsonRoutes.add('GET', '/api/boards/:boardId/cards_count', function( ); } +// Position history tracking methods +Cards.helpers({ + /** + * Track the original position of this card + */ + trackOriginalPosition() { + const existingHistory = PositionHistory.findOne({ + boardId: this.boardId, + entityType: 'card', + entityId: this._id, + }); + + if (!existingHistory) { + PositionHistory.insert({ + boardId: this.boardId, + entityType: 'card', + entityId: this._id, + originalPosition: { + sort: this.sort, + title: this.title, + }, + originalSwimlaneId: this.swimlaneId || null, + originalListId: this.listId || null, + originalTitle: this.title, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + }, + + /** + * Get the original position history for this card + */ + getOriginalPosition() { + return PositionHistory.findOne({ + boardId: this.boardId, + entityType: 'card', + entityId: this._id, + }); + }, + + /** + * Check if this card has moved from its original position + */ + hasMovedFromOriginalPosition() { + const history = this.getOriginalPosition(); + if (!history) return false; + + const currentSwimlaneId = this.swimlaneId || null; + const currentListId = this.listId || null; + + return history.originalPosition.sort !== this.sort || + history.originalSwimlaneId !== currentSwimlaneId || + history.originalListId !== currentListId; + }, + + /** + * Get a description of the original position + */ + getOriginalPositionDescription() { + const history = this.getOriginalPosition(); + if (!history) return 'No original position data'; + + const swimlaneInfo = history.originalSwimlaneId ? + ` in swimlane ${history.originalSwimlaneId}` : + ' in default swimlane'; + const listInfo = history.originalListId ? + ` in list ${history.originalListId}` : + ''; + return `Original position: ${history.originalPosition.sort || 0}${swimlaneInfo}${listInfo}`; + }, +}); + export default Cards; diff --git a/models/lists.js b/models/lists.js index 9d7e7f000..3c5087d03 100644 --- a/models/lists.js +++ b/models/lists.js @@ -1,5 +1,6 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { ALLOWED_COLORS } from '/config/const'; +import PositionHistory from './positionHistory'; Lists = new Mongo.Collection('lists'); @@ -453,6 +454,14 @@ if (Meteor.isServer) { // list is deleted title: doc.title, }); + + // Track original position for new lists + Meteor.setTimeout(() => { + const list = Lists.findOne(doc._id); + if (list) { + list.trackOriginalPosition(); + } + }, 100); }); Lists.before.remove((userId, doc) => { @@ -805,4 +814,77 @@ if (Meteor.isServer) { }); } +// Position history tracking methods +Lists.helpers({ + /** + * Track the original position of this list + */ + trackOriginalPosition() { + const existingHistory = PositionHistory.findOne({ + boardId: this.boardId, + entityType: 'list', + entityId: this._id, + }); + + if (!existingHistory) { + PositionHistory.insert({ + boardId: this.boardId, + entityType: 'list', + entityId: this._id, + originalPosition: { + sort: this.sort, + title: this.title, + }, + originalSwimlaneId: this.swimlaneId || null, + originalTitle: this.title, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + }, + + /** + * Get the original position history for this list + */ + getOriginalPosition() { + return PositionHistory.findOne({ + boardId: this.boardId, + entityType: 'list', + entityId: this._id, + }); + }, + + /** + * Check if this list has moved from its original position + */ + hasMovedFromOriginalPosition() { + const history = this.getOriginalPosition(); + if (!history) return false; + + const currentSwimlaneId = this.swimlaneId || null; + return history.originalPosition.sort !== this.sort || + history.originalSwimlaneId !== currentSwimlaneId; + }, + + /** + * Get a description of the original position + */ + getOriginalPositionDescription() { + const history = this.getOriginalPosition(); + if (!history) return 'No original position data'; + + const swimlaneInfo = history.originalSwimlaneId ? + ` in swimlane ${history.originalSwimlaneId}` : + ' in default swimlane'; + return `Original position: ${history.originalPosition.sort || 0}${swimlaneInfo}`; + }, + + /** + * Get the effective swimlane ID (for backward compatibility) + */ + getEffectiveSwimlaneId() { + return this.swimlaneId || null; + }, +}); + export default Lists; diff --git a/models/positionHistory.js b/models/positionHistory.js new file mode 100644 index 000000000..a70b9fbab --- /dev/null +++ b/models/positionHistory.js @@ -0,0 +1,170 @@ +import { ReactiveCache } from '/imports/reactiveCache'; + +/** + * PositionHistory collection to track original positions of swimlanes, lists, and cards + * before the list naming feature was added in commit 719ef87efceacfe91461a8eeca7cf74d11f4cc0a + */ +PositionHistory = new Mongo.Collection('positionHistory'); + +PositionHistory.attachSchema( + new SimpleSchema({ + boardId: { + /** + * The board ID this position history belongs to + */ + type: String, + }, + entityType: { + /** + * Type of entity: 'swimlane', 'list', or 'card' + */ + type: String, + allowedValues: ['swimlane', 'list', 'card'], + }, + entityId: { + /** + * The ID of the entity (swimlane, list, or card) + */ + type: String, + }, + originalPosition: { + /** + * The original position data before any changes + */ + type: Object, + blackbox: true, + }, + originalSwimlaneId: { + /** + * The original swimlane ID (for lists and cards) + */ + type: String, + optional: true, + }, + originalListId: { + /** + * The original list ID (for cards) + */ + type: String, + optional: true, + }, + originalTitle: { + /** + * The original title before any changes + */ + type: String, + optional: true, + }, + createdAt: { + /** + * When this position history was created + */ + type: Date, + autoValue() { + if (this.isInsert) { + return new Date(); + } else if (this.isUpsert) { + return { $setOnInsert: new Date() }; + } else { + this.unset(); + } + }, + }, + updatedAt: { + /** + * When this position history was last updated + */ + type: Date, + autoValue() { + if (this.isUpdate || this.isUpsert || this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }), +); + +PositionHistory.helpers({ + /** + * Get the original position data + */ + getOriginalPosition() { + return this.originalPosition; + }, + + /** + * Get the original title + */ + getOriginalTitle() { + return this.originalTitle || ''; + }, + + /** + * Get the original swimlane ID + */ + getOriginalSwimlaneId() { + return this.originalSwimlaneId; + }, + + /** + * Get the original list ID + */ + getOriginalListId() { + return this.originalListId; + }, + + /** + * Check if this entity has been moved from its original position + */ + hasMoved() { + if (this.entityType === 'swimlane') { + return this.originalPosition.sort !== undefined; + } else if (this.entityType === 'list') { + return this.originalPosition.sort !== undefined || + this.originalSwimlaneId !== this.entityId; + } else if (this.entityType === 'card') { + return this.originalPosition.sort !== undefined || + this.originalSwimlaneId !== this.entityId || + this.originalListId !== this.entityId; + } + return false; + }, + + /** + * Get a human-readable description of the original position + */ + getOriginalPositionDescription() { + const position = this.originalPosition; + if (!position) return 'Unknown position'; + + if (this.entityType === 'swimlane') { + return `Original position: ${position.sort || 0}`; + } else if (this.entityType === 'list') { + const swimlaneInfo = this.originalSwimlaneId ? + ` in swimlane ${this.originalSwimlaneId}` : + ' in default swimlane'; + return `Original position: ${position.sort || 0}${swimlaneInfo}`; + } else if (this.entityType === 'card') { + const swimlaneInfo = this.originalSwimlaneId ? + ` in swimlane ${this.originalSwimlaneId}` : + ' in default swimlane'; + const listInfo = this.originalListId ? + ` in list ${this.originalListId}` : + ''; + return `Original position: ${position.sort || 0}${swimlaneInfo}${listInfo}`; + } + return 'Unknown position'; + } +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + PositionHistory._collection.createIndex({ boardId: 1, entityType: 1, entityId: 1 }); + PositionHistory._collection.createIndex({ boardId: 1, entityType: 1 }); + PositionHistory._collection.createIndex({ createdAt: -1 }); + }); +} + +export default PositionHistory; diff --git a/models/swimlanes.js b/models/swimlanes.js index 4ff2d03fd..5b379a603 100644 --- a/models/swimlanes.js +++ b/models/swimlanes.js @@ -1,5 +1,6 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { ALLOWED_COLORS } from '/config/const'; +import PositionHistory from './positionHistory'; Swimlanes = new Mongo.Collection('swimlanes'); @@ -366,6 +367,14 @@ if (Meteor.isServer) { boardId: doc.boardId, swimlaneId: doc._id, }); + + // Track original position for new swimlanes + Meteor.setTimeout(() => { + const swimlane = Swimlanes.findOne(doc._id); + if (swimlane) { + swimlane.trackOriginalPosition(); + } + }, 100); }); Swimlanes.before.remove(function(userId, doc) { @@ -614,4 +623,64 @@ if (Meteor.isServer) { ); } +// Position history tracking methods +Swimlanes.helpers({ + /** + * Track the original position of this swimlane + */ + trackOriginalPosition() { + const existingHistory = PositionHistory.findOne({ + boardId: this.boardId, + entityType: 'swimlane', + entityId: this._id, + }); + + if (!existingHistory) { + PositionHistory.insert({ + boardId: this.boardId, + entityType: 'swimlane', + entityId: this._id, + originalPosition: { + sort: this.sort, + title: this.title, + }, + originalTitle: this.title, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + }, + + /** + * Get the original position history for this swimlane + */ + getOriginalPosition() { + return PositionHistory.findOne({ + boardId: this.boardId, + entityType: 'swimlane', + entityId: this._id, + }); + }, + + /** + * Check if this swimlane has moved from its original position + */ + hasMovedFromOriginalPosition() { + const history = this.getOriginalPosition(); + if (!history) return false; + + return history.originalPosition.sort !== this.sort; + }, + + /** + * Get a description of the original position + */ + getOriginalPositionDescription() { + const history = this.getOriginalPosition(); + if (!history) return 'No original position data'; + + return `Original position: ${history.originalPosition.sort || 0}`; + }, +}); + export default Swimlanes; diff --git a/server/methods/positionHistory.js b/server/methods/positionHistory.js new file mode 100644 index 000000000..c16b874a1 --- /dev/null +++ b/server/methods/positionHistory.js @@ -0,0 +1,211 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import PositionHistory from '/models/positionHistory'; +import Swimlanes from '/models/swimlanes'; +import Lists from '/models/lists'; +import Cards from '/models/cards'; + +/** + * Server-side methods for position history tracking + */ +Meteor.methods({ + /** + * Track original position for a swimlane + */ + 'positionHistory.trackSwimlane'(swimlaneId) { + check(swimlaneId, String); + + const swimlane = Swimlanes.findOne(swimlaneId); + if (!swimlane) { + throw new Meteor.Error('swimlane-not-found', 'Swimlane not found'); + } + + return swimlane.trackOriginalPosition(); + }, + + /** + * Track original position for a list + */ + 'positionHistory.trackList'(listId) { + check(listId, String); + + const list = Lists.findOne(listId); + if (!list) { + throw new Meteor.Error('list-not-found', 'List not found'); + } + + return list.trackOriginalPosition(); + }, + + /** + * Track original position for a card + */ + 'positionHistory.trackCard'(cardId) { + check(cardId, String); + + const card = Cards.findOne(cardId); + if (!card) { + throw new Meteor.Error('card-not-found', 'Card not found'); + } + + return card.trackOriginalPosition(); + }, + + /** + * Get original position for a swimlane + */ + 'positionHistory.getSwimlaneOriginalPosition'(swimlaneId) { + check(swimlaneId, String); + + const swimlane = Swimlanes.findOne(swimlaneId); + if (!swimlane) { + throw new Meteor.Error('swimlane-not-found', 'Swimlane not found'); + } + + return swimlane.getOriginalPosition(); + }, + + /** + * Get original position for a list + */ + 'positionHistory.getListOriginalPosition'(listId) { + check(listId, String); + + const list = Lists.findOne(listId); + if (!list) { + throw new Meteor.Error('list-not-found', 'List not found'); + } + + return list.getOriginalPosition(); + }, + + /** + * Get original position for a card + */ + 'positionHistory.getCardOriginalPosition'(cardId) { + check(cardId, String); + + const card = Cards.findOne(cardId); + if (!card) { + throw new Meteor.Error('card-not-found', 'Card not found'); + } + + return card.getOriginalPosition(); + }, + + /** + * Check if a swimlane has moved from its original position + */ + 'positionHistory.hasSwimlaneMoved'(swimlaneId) { + check(swimlaneId, String); + + const swimlane = Swimlanes.findOne(swimlaneId); + if (!swimlane) { + throw new Meteor.Error('swimlane-not-found', 'Swimlane not found'); + } + + return swimlane.hasMovedFromOriginalPosition(); + }, + + /** + * Check if a list has moved from its original position + */ + 'positionHistory.hasListMoved'(listId) { + check(listId, String); + + const list = Lists.findOne(listId); + if (!list) { + throw new Meteor.Error('list-not-found', 'List not found'); + } + + return list.hasMovedFromOriginalPosition(); + }, + + /** + * Check if a card has moved from its original position + */ + 'positionHistory.hasCardMoved'(cardId) { + check(cardId, String); + + const card = Cards.findOne(cardId); + if (!card) { + throw new Meteor.Error('card-not-found', 'Card not found'); + } + + return card.hasMovedFromOriginalPosition(); + }, + + /** + * Get original position description for a swimlane + */ + 'positionHistory.getSwimlaneDescription'(swimlaneId) { + check(swimlaneId, String); + + const swimlane = Swimlanes.findOne(swimlaneId); + if (!swimlane) { + throw new Meteor.Error('swimlane-not-found', 'Swimlane not found'); + } + + return swimlane.getOriginalPositionDescription(); + }, + + /** + * Get original position description for a list + */ + 'positionHistory.getListDescription'(listId) { + check(listId, String); + + const list = Lists.findOne(listId); + if (!list) { + throw new Meteor.Error('list-not-found', 'List not found'); + } + + return list.getOriginalPositionDescription(); + }, + + /** + * Get original position description for a card + */ + 'positionHistory.getCardDescription'(cardId) { + check(cardId, String); + + const card = Cards.findOne(cardId); + if (!card) { + throw new Meteor.Error('card-not-found', 'Card not found'); + } + + return card.getOriginalPositionDescription(); + }, + + /** + * Get all position history for a board + */ + 'positionHistory.getBoardHistory'(boardId) { + check(boardId, String); + + return PositionHistory.find({ + boardId: boardId, + }, { + sort: { createdAt: -1 } + }).fetch(); + }, + + /** + * Get position history by entity type for a board + */ + 'positionHistory.getBoardHistoryByType'(boardId, entityType) { + check(boardId, String); + check(entityType, String); + + if (!['swimlane', 'list', 'card'].includes(entityType)) { + throw new Meteor.Error('invalid-entity-type', 'Entity type must be swimlane, list, or card'); + } + + return PositionHistory.find({ + boardId: boardId, + entityType: entityType, + }, { + sort: { createdAt: -1 } + }).fetch(); + }, +});