implemented Kanban features

This commit is contained in:
Marius 2025-06-20 16:11:11 +02:00
parent b3c5d8d52f
commit 3d7ff2c2f0
5 changed files with 1191 additions and 5 deletions

168
kanban-test-notes.md Normal file
View file

@ -0,0 +1,168 @@
# Kanban Test Notes
Copy these notes into your Obsidian vault to test the kanban functionality:
## Task - Setup Database.md
```markdown
---
title: Setup Database
status: todo
priority: high
assignee: John
due: 2024-01-15
project: Website Redesign
tags:
- test-kanban
- backend
---
Set up the initial database schema and connections.
```
## Task - Design Homepage.md
```markdown
---
title: Design Homepage
status: in-progress
priority: high
assignee: Sarah
due: 2024-01-20
project: Website Redesign
tags:
- test-kanban
- design
---
Create mockups and wireframes for the new homepage.
```
## Task - Write API Documentation.md
```markdown
---
title: Write API Documentation
status: review
priority: medium
assignee: Mike
due: 2024-01-25
project: Website Redesign
tags:
- test-kanban
- documentation
---
Document all API endpoints and usage examples.
```
## Task - Deploy to Production.md
```markdown
---
title: Deploy to Production
status: done
priority: low
assignee: DevOps
due: 2024-01-10
project: Website Redesign
tags:
- test-kanban
- deployment
---
Deploy the completed features to production environment.
```
## Task - Fix Login Bug.md
```markdown
---
title: Fix Login Bug
status: todo
priority: medium
assignee: John
due: 2024-01-18
project: Website Redesign
tags:
- test-kanban
- bugfix
---
Resolve the authentication issue preventing user login.
```
## Task - Update User Interface.md
```markdown
---
title: Update User Interface
status: in-progress
priority: low
assignee: Sarah
due: 2024-01-30
project: Website Redesign
tags:
- test-kanban
- ui
---
Modernize the user interface with new design system.
```
# Test DataCards Blocks
Add these DataCards blocks to a note to test the kanban functionality:
## Basic Kanban Test
```datacards
TABLE file.link as "Task", priority, assignee, due
FROM #test-kanban
GROUP BY status
SORT status ASC
// Settings
preset: kanban
```
## Filtered Kanban Test (High Priority Only)
```datacards
TABLE file.link as "Task", priority, assignee, due
FROM #test-kanban
WHERE priority = "high"
GROUP BY status
SORT priority DESC
// Settings
preset: kanban
kanbanColumnWidth: 250px
kanbanShowColumnCounts: true
kanbanCompactCards: true
```
## Group by Priority (Alternative Kanban)
```datacards
TABLE file.link as "Task", status, assignee, due
FROM #test-kanban
GROUP BY priority
SORT priority ASC
// Settings
preset: kanban
```
## Group by Assignee (Team View)
```datacards
TABLE file.link as "Task", status, priority, due
FROM #test-kanban
GROUP BY assignee
SORT assignee ASC
// Settings
preset: kanban
```
## Non-Kanban Grouped Results (For Comparison)
```datacards
TABLE file.link as "Task", priority, assignee, due
FROM #test-kanban
GROUP BY status
SORT status ASC
// Settings
preset: grid
```

View file

@ -5,7 +5,7 @@
/**
* Preset options for card display
*/
export type CardPreset = 'grid' | 'portrait' | 'square' | 'compact' | 'dense';
export type CardPreset = 'grid' | 'portrait' | 'square' | 'compact' | 'dense' | 'kanban';
/**
* Font size options
@ -71,6 +71,15 @@ export interface DataCardsSettings {
// Card interaction settings
enableClickableCards: boolean; // Make the entire card clickable to open the note
// Kanban settings
kanbanColumnWidth: string; // Width of kanban columns (e.g., "300px")
kanbanColumnSpacing: number; // Gap between kanban columns
kanbanStatusOrder: string[]; // Order of status values for kanban columns
kanbanHideEmptyColumns: boolean; // Hide columns with no cards
kanbanShowColumnCounts: boolean; // Show card count in column headers
kanbanCompactCards: boolean; // Use smaller cards for more density
kanbanMobileStackColumns: boolean; // Stack columns vertically on mobile
// Formatting settings
defaultDateFormat: string;
propertyFormatters: {
@ -143,6 +152,15 @@ export const DEFAULT_SETTINGS: DataCardsSettings = {
// Card interaction settings
enableClickableCards: false, // Disabled by default
// Kanban settings
kanbanColumnWidth: '300px', // Default column width
kanbanColumnSpacing: 16, // Default spacing between columns
kanbanStatusOrder: ['todo', 'in-progress', 'review', 'done'], // Default status order
kanbanHideEmptyColumns: false, // Show empty columns by default
kanbanShowColumnCounts: true, // Show card counts by default
kanbanCompactCards: false, // Use normal card size by default
kanbanMobileStackColumns: false, // Use horizontal scroll on mobile by default
// Formatting settings
defaultDateFormat: 'YYYY-MM-DD',
propertyFormatters: {},

View file

@ -47,6 +47,14 @@ export class RendererService {
// SPECIAL CHECK: Log the exact structure of the results
Logger.debug('SPECIAL CHECK - renderCards called with results:', results);
// IMMEDIATE DEBUG - Check if this is GROUP BY
console.log('=== IMMEDIATE DEBUG START ===');
console.log('Results type:', typeof results);
console.log('Results is array:', Array.isArray(results));
console.log('Results keys:', Object.keys(results || {}));
console.log('Results:', results);
console.log('=== IMMEDIATE DEBUG END ===');
// Check if the results are empty
let isEmpty = false;
@ -316,10 +324,53 @@ export class RendererService {
cardsContainer.addClass(`datacards-image-fit-${imageFit}`);
// Handle different types of Dataview results
if (results && results.values && Array.isArray(results.values)) {
// First check for GROUP BY results (which have a specific structure)
console.log('STRUCTURE DEBUG - Results type:', typeof results);
console.log('STRUCTURE DEBUG - Results keys:', Object.keys(results || {}));
console.log('STRUCTURE DEBUG - Results.values type:', typeof results?.values);
console.log('STRUCTURE DEBUG - Results.values is array:', Array.isArray(results?.values));
console.log('STRUCTURE DEBUG - First few results.values items:', results?.values?.slice(0, 2));
console.log('STRUCTURE DEBUG - Full results object:', JSON.stringify(results, null, 2));
if (this.isGroupByResults(results)) {
Logger.debug('Detected GROUP BY results');
// Check if kanban preset is enabled
if (settings.preset === 'kanban') {
Logger.debug('Rendering GROUP BY results as kanban');
// Handle different GROUP BY result types
if (results.idMeaning) {
// TABLE + GROUP BY - aggregated data, limited support
this.renderDataviewTableGroupByKanban(cardsContainer, results, settings, component);
} else if (results.primaryMeaning) {
// LIST + GROUP BY - need to re-query for individual notes
this.renderDataviewListGroupByKanban(cardsContainer, results, settings, component);
} else {
// Legacy GROUP BY structure - fallback to table rendering
const groupByData = Array.isArray(results) ? results : results.values;
this.renderGroupedResults(cardsContainer, groupByData, settings, component);
}
} else {
Logger.debug('Rendering GROUP BY results as grouped cards');
// Extract the actual GROUP BY data (might be nested in values)
const groupByData = Array.isArray(results) ? results : results.values;
this.renderGroupedResults(cardsContainer, groupByData, settings, component);
}
} else if (results && results.values && Array.isArray(results.values)) {
Logger.debug('Detected table-like results with values array');
// Handle table-like results (most common)
this.renderTableResults(cardsContainer, results, settings, component); // Pass component
// Check if kanban preset is enabled for regular queries (not GROUP BY)
if (settings.preset === 'kanban' && results.headers && Array.isArray(results.headers)) {
Logger.debug('Rendering regular query results as kanban (programmatic grouping)');
this.renderProgrammaticKanban(cardsContainer, results, settings, component);
} else if (results.headers && Array.isArray(results.headers)) {
// Handle table-like results (most common)
this.renderTableResults(cardsContainer, results, settings, component); // Pass component
} else {
Logger.debug('Table results missing headers, treating as array results');
this.renderArrayResults(cardsContainer, results.values, settings, component);
}
} else if (results && Array.isArray(results)) {
Logger.debug('Detected array results');
// Handle array results
@ -1227,7 +1278,8 @@ export class RendererService {
propertyName: string,
propertyValue: any,
settings: DataCardsSettings,
component: Component // Added component parameter
component: Component, // Added component parameter
cardData?: any // Optional card data for inline editing
): void {
Logger.debug(`Adding property to card: ${propertyName} = ${propertyValue}`);
Logger.debug(`Property type: ${typeof propertyValue}`);
@ -1269,6 +1321,9 @@ export class RendererService {
// Check if this is the file property (special handling for file links)
if (propertyName.toLowerCase() === 'file') {
this.formatFileProperty(propertyEl, propertyValue);
} else if (settings.preset === 'kanban' && propertyName === 'status' && cardData) {
// Special handling for status property in kanban mode - make it editable
this.formatEditableStatusProperty(propertyEl, propertyValue, cardData, component);
} else {
// Check if there's a custom formatter for this property
const formatter = settings.propertyFormatters[propertyName];
@ -1283,6 +1338,176 @@ export class RendererService {
}
}
/**
* Format an editable status property for kanban mode
* Creates a dropdown that allows changing the status directly
*
* @param propertyEl The property element
* @param currentValue The current status value
* @param cardData The card data containing file information
* @param component The parent component for lifecycle management
*/
private formatEditableStatusProperty(
propertyEl: HTMLElement,
currentValue: any,
cardData: any,
component: Component
): void {
const valueEl = propertyEl.createEl('div', {
cls: 'datacards-property-value datacards-editable-status',
});
// Common status options for kanban
const statusOptions = ['todo', 'in-progress', 'review', 'done'];
// Create a select dropdown
const selectEl = valueEl.createEl('select', {
cls: 'datacards-status-select'
});
// Add options to the select
statusOptions.forEach(status => {
const optionEl = selectEl.createEl('option', {
value: status,
text: status
});
if (status === String(currentValue)) {
optionEl.selected = true;
}
});
// Add change event listener
selectEl.addEventListener('change', async (event) => {
const newStatus = (event.target as HTMLSelectElement).value;
await this.updateNoteStatus(cardData, newStatus);
});
}
/**
* Update the status property of a note
*
* @param cardData The card data containing file information
* @param newStatus The new status value
*/
private async updateNoteStatus(cardData: any, newStatus: string): Promise<void> {
try {
console.log('=== UPDATE NOTE STATUS DEBUG ===');
console.log('Card data:', cardData);
console.log('New status:', newStatus);
console.log('Card data type:', typeof cardData);
console.log('Card data keys:', Object.keys(cardData || {}));
// Extract file information from cardData
let file: TFile | null = null;
// Try different ways to get the file
if (cardData && cardData.file) {
console.log('Found cardData.file:', cardData.file);
if (cardData.file instanceof TFile) {
file = cardData.file;
} else if (cardData.file.path) {
console.log('Getting file by path:', cardData.file.path);
const abstractFile = this.app.vault.getAbstractFileByPath(cardData.file.path);
if (abstractFile instanceof TFile) {
file = abstractFile;
}
}
}
// Try other properties
if (!file && cardData && cardData.path) {
console.log('Trying cardData.path:', cardData.path);
const abstractFile = this.app.vault.getAbstractFileByPath(cardData.path);
if (abstractFile instanceof TFile) {
file = abstractFile;
}
}
// Try filePath property
if (!file && cardData && cardData.filePath) {
console.log('Trying cardData.filePath:', cardData.filePath);
const abstractFile = this.app.vault.getAbstractFileByPath(cardData.filePath);
if (abstractFile instanceof TFile) {
file = abstractFile;
}
}
// If cardData itself is a file path string
if (!file && typeof cardData === 'string') {
console.log('Trying cardData as string path:', cardData);
const abstractFile = this.app.vault.getAbstractFileByPath(cardData);
if (abstractFile instanceof TFile) {
file = abstractFile;
}
}
if (!file) {
console.error('Could not determine file from card data. Available properties:', Object.keys(cardData || {}));
console.error('Full card data:', JSON.stringify(cardData, null, 2));
return;
}
console.log('Found file:', file.path);
// Read the file content
const content = await this.app.vault.read(file);
console.log('Current file content length:', content.length);
console.log('First 200 chars:', content.substring(0, 200));
// Update the frontmatter
const updatedContent = this.updateFrontmatterStatus(content, newStatus);
console.log('Updated content length:', updatedContent.length);
console.log('Updated first 200 chars:', updatedContent.substring(0, 200));
// Write back to file
await this.app.vault.modify(file, updatedContent);
console.log('Successfully updated status to:', newStatus);
console.log('=== UPDATE COMPLETE ===');
// Trigger a refresh of the DataCards
setTimeout(() => {
this.triggerRefresh();
}, 100);
} catch (error) {
console.error('Error updating note status:', error);
console.error('Error stack:', error.stack);
}
}
/**
* Update the status property in frontmatter
*
* @param content The file content
* @param newStatus The new status value
* @returns Updated content
*/
private updateFrontmatterStatus(content: string, newStatus: string): string {
// Simple frontmatter parsing and updating
const frontmatterRegex = /^---\n([\s\S]*?)\n---/;
const match = content.match(frontmatterRegex);
if (!match) {
// No frontmatter, add it
return `---\nstatus: ${newStatus}\n---\n\n${content}`;
}
const frontmatter = match[1];
const statusRegex = /^status:\s*(.*)$/m;
if (frontmatter.match(statusRegex)) {
// Update existing status
const updatedFrontmatter = frontmatter.replace(statusRegex, `status: ${newStatus}`);
return content.replace(frontmatterRegex, `---\n${updatedFrontmatter}\n---`);
} else {
// Add status to existing frontmatter
const updatedFrontmatter = `${frontmatter}\nstatus: ${newStatus}`;
return content.replace(frontmatterRegex, `---\n${updatedFrontmatter}\n---`);
}
}
/**
* Format a property value using a custom formatter
*
@ -2321,4 +2546,564 @@ export class RendererService {
));
}
}
/**
* Check if the results are from a GROUP BY query
* GROUP BY results have a specific structure with grouped data
*
* @param results The Dataview query results
* @returns True if the results are from a GROUP BY query
*/
private isGroupByResults(results: any): boolean {
console.log('GROUP BY CHECK - Input results:', results);
console.log('GROUP BY CHECK - Is array:', Array.isArray(results));
// Check for Dataview's GROUP BY structure using idMeaning (TABLE + GROUP BY)
if (results && typeof results === 'object' && results.idMeaning) {
const isGroupBy = results.idMeaning.type === 'group';
console.log('GROUP BY CHECK - Found idMeaning.type:', results.idMeaning.type);
console.log('GROUP BY CHECK - Is GROUP BY (TABLE):', isGroupBy);
return isGroupBy;
}
// Check for Dataview's GROUP BY structure using primaryMeaning (LIST + GROUP BY)
if (results && typeof results === 'object' && results.primaryMeaning) {
const isGroupBy = results.primaryMeaning.type === 'group';
console.log('GROUP BY CHECK - Found primaryMeaning.type:', results.primaryMeaning.type);
console.log('GROUP BY CHECK - Is GROUP BY (LIST):', isGroupBy);
return isGroupBy;
}
// Legacy check: GROUP BY results are arrays where each item has a 'key' and 'rows' property
if (!Array.isArray(results)) {
// Maybe GROUP BY results are nested in a values property?
if (results && results.values && Array.isArray(results.values)) {
console.log('GROUP BY CHECK - Checking results.values:', results.values);
console.log('GROUP BY CHECK - First item in values:', results.values[0]);
const hasGroupByStructure = results.values.length > 0 && results.values.every((item: any) =>
item &&
typeof item === 'object' &&
'key' in item &&
'rows' in item &&
Array.isArray(item.rows)
);
console.log('GROUP BY CHECK - Values has GROUP BY structure:', hasGroupByStructure);
return hasGroupByStructure;
}
console.log('GROUP BY CHECK - Not an array and no values property');
return false;
}
// Check if all items have the GROUP BY structure
const hasGroupByStructure = results.length > 0 && results.every((item: any) =>
item &&
typeof item === 'object' &&
'key' in item &&
'rows' in item &&
Array.isArray(item.rows)
);
console.log('GROUP BY CHECK - Direct array has GROUP BY structure:', hasGroupByStructure);
return hasGroupByStructure;
}
/**
* Render Dataview's TABLE + GROUP BY results as kanban columns
* This handles the special case where Dataview returns aggregated table data
*
* @param container The container element
* @param results The Dataview GROUP BY table results
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private renderDataviewTableGroupByKanban(container: HTMLElement, results: any, settings: DataCardsSettings, component: Component): void {
console.log('=== DATAVIEW GROUP BY KANBAN ===');
console.log('Results:', results);
console.log('Group column:', results.idMeaning?.name);
// Apply kanban-specific styling to container
container.addClass('datacards-kanban-container');
// Set kanban column width and spacing
container.style.setProperty('--kanban-column-width', settings.kanbanColumnWidth);
container.style.setProperty('--kanban-column-spacing', `${settings.kanbanColumnSpacing}px`);
// Extract unique group values from the results
const groupColumn = results.idMeaning?.name || 'status';
const groupColumnIndex = results.headers.indexOf(groupColumn);
if (groupColumnIndex === -1) {
console.error('Group column not found in headers:', groupColumn);
return;
}
// Get unique group values
const groupValues = [...new Set(results.values.map((row: any[]) => row[groupColumnIndex]))];
console.log('Group values:', groupValues);
// For each group value, we need to query the individual notes
// Since we can't re-query here, we'll show a message for now
groupValues.forEach((groupValue: string) => {
if (!groupValue) return;
// Create column container
const column = container.createEl('div', {
cls: 'datacards-kanban-column'
});
// Create column header
const header = column.createEl('div', {
cls: 'datacards-kanban-header'
});
// Add group title
header.createEl('span', {
cls: 'datacards-kanban-title',
text: groupValue
});
// Create cards container within the column
const cardsContainer = column.createEl('div', {
cls: 'datacards-kanban-cards'
});
// Add a message explaining the limitation
cardsContainer.createEl('div', {
cls: 'datacards-kanban-message',
text: 'Kanban with TABLE + GROUP BY is not fully supported. Use LIST + GROUP BY instead.'
});
});
}
/**
* Render GROUP BY results as grouped cards (non-kanban)
*
* @param container The container element
* @param results The GROUP BY results
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private renderGroupedResults(container: HTMLElement, results: any[], settings: DataCardsSettings, component: Component): void {
Logger.debug('Rendering grouped results with', results.length, 'groups');
// For non-kanban grouped results, render each group as a section
results.forEach((group: any) => {
// Create group header
container.createEl('div', {
cls: 'datacards-group-header',
text: `${group.key} (${group.rows.length})`
});
// Create group container
const groupContainer = container.createEl('div', {
cls: 'datacards-group-container'
});
// Render the rows in this group using array rendering logic
if (group.rows && group.rows.length > 0) {
// GROUP BY results have object-based rows, use array renderer
this.renderArrayResults(groupContainer, group.rows, settings, component);
}
});
}
/**
* Render Dataview's LIST + GROUP BY results as kanban columns
* This handles LIST queries with GROUP BY which return group names only
*
* @param container The container element
* @param results The Dataview LIST + GROUP BY results
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private renderDataviewListGroupByKanban(container: HTMLElement, results: any, settings: DataCardsSettings, component: Component): void {
console.log('=== DATAVIEW LIST GROUP BY KANBAN ===');
console.log('Results:', results);
console.log('Group column:', results.primaryMeaning?.name);
// Apply kanban-specific styling to container
container.addClass('datacards-kanban-container');
// Set kanban column width and spacing
container.style.setProperty('--kanban-column-width', settings.kanbanColumnWidth);
container.style.setProperty('--kanban-column-spacing', `${settings.kanbanColumnSpacing}px`);
// Get group values from the LIST results
const groupValues = results.values || [];
console.log('Group values:', groupValues);
// For each group value, create a column
groupValues.forEach((groupValue: string) => {
if (!groupValue) return;
// Create column container
const column = container.createEl('div', {
cls: 'datacards-kanban-column'
});
// Create column header
const header = column.createEl('div', {
cls: 'datacards-kanban-header'
});
// Add group title
header.createEl('span', {
cls: 'datacards-kanban-title',
text: groupValue
});
// Create cards container within the column
const cardsContainer = column.createEl('div', {
cls: 'datacards-kanban-cards'
});
// For now, show a message. In the future, we could re-query individual notes for this group
cardsContainer.createEl('div', {
cls: 'datacards-kanban-message',
text: `Group: ${groupValue} - Use regular queries without GROUP BY for full kanban functionality.`
});
});
}
/**
* Render regular query results as kanban by grouping them programmatically
* This is the main kanban implementation that works with regular TABLE queries
*
* @param container The container element
* @param results The regular Dataview query results
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private renderProgrammaticKanban(container: HTMLElement, results: any, settings: DataCardsSettings, component: Component): void {
console.log('=== PROGRAMMATIC KANBAN ===');
console.log('Results:', results);
console.log('Headers:', results.headers);
// Apply kanban-specific styling to container
container.addClass('datacards-kanban-container');
// Set kanban column width and spacing
container.style.setProperty('--kanban-column-width', settings.kanbanColumnWidth);
container.style.setProperty('--kanban-column-spacing', `${settings.kanbanColumnSpacing}px`);
// Determine the grouping column (hardcoded to 'status' for now)
const groupColumn = 'status';
const groupColumnIndex = results.headers.indexOf(groupColumn);
if (groupColumnIndex === -1) {
console.error(`Kanban group column '${groupColumn}' not found in headers:`, results.headers);
container.createEl('div', {
cls: 'datacards-error',
text: `Kanban group column '${groupColumn}' not found. Available columns: ${results.headers.join(', ')}`
});
return;
}
// Group the rows by the grouping column
const groupedRows = new Map<string, any[]>();
results.values.forEach((row: any[], index: number) => {
const groupValue = row[groupColumnIndex];
const groupKey = groupValue ? String(groupValue) : 'undefined';
if (!groupedRows.has(groupKey)) {
groupedRows.set(groupKey, []);
}
groupedRows.get(groupKey)!.push(row);
});
console.log('Grouped rows:', groupedRows);
// Create columns for each group
const sortedGroups = Array.from(groupedRows.entries()).sort(([a], [b]) => a.localeCompare(b));
sortedGroups.forEach(([groupKey, rows]) => {
this.createProgrammaticKanbanColumn(container, groupKey, rows, results.headers, settings, component);
});
}
/**
* Create a kanban column for programmatically grouped data
*
* @param container The parent container
* @param groupKey The group key (status value)
* @param rows The rows for this group
* @param headers The table headers
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private createProgrammaticKanbanColumn(
container: HTMLElement,
groupKey: string,
rows: any[][],
headers: string[],
settings: DataCardsSettings,
component: Component
): void {
// Create column container
const column = container.createEl('div', {
cls: 'datacards-kanban-column'
});
// Create column header
const header = column.createEl('div', {
cls: 'datacards-kanban-header'
});
// Add group title
header.createEl('span', {
cls: 'datacards-kanban-title',
text: groupKey
});
// Add card count
header.createEl('span', {
cls: 'datacards-kanban-count',
text: `(${rows.length})`
});
// Create cards container within the column
const cardsContainer = column.createEl('div', {
cls: 'datacards-kanban-cards'
});
// Render each row as a card using the existing table rendering logic
// But we need to modify it to pass card data for inline editing
this.renderKanbanCards(cardsContainer, rows, headers, settings, component);
}
/**
* Render kanban cards with inline editing support
*
* @param container The container element
* @param rows The table rows
* @param headers The table headers
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private renderKanbanCards(
container: HTMLElement,
rows: any[][],
headers: string[],
settings: DataCardsSettings,
component: Component
): void {
// Create a card for each row
rows.forEach((row: any[] & { file?: any; path?: any; source?: any; originalFile?: any }, rowIndex: number) => {
Logger.debug(`Processing kanban row ${rowIndex}`);
const card = this.createCardElement(container);
// Add image if available
if (settings.imageProperty && row[headers.indexOf(settings.imageProperty)] !== undefined) {
this.addImageToCard(card, row[headers.indexOf(settings.imageProperty)]);
}
// Add content container
const contentEl = card.createEl('div', { cls: 'datacards-content' });
// First, add the file property separately if it exists
const fileIndex = headers.findIndex((h: string) => h.toLowerCase() === 'file');
if (fileIndex !== -1 && row[fileIndex] !== undefined) {
const filePropertyEl = contentEl.createEl('div', {
cls: 'datacards-property datacards-file-property-container',
});
this.formatFileProperty(filePropertyEl, row[fileIndex]);
// If clickable cards are enabled, make the card clickable
if (settings.enableClickableCards) {
this.makeCardClickable(card, row[fileIndex]);
}
}
// Create a properties container
const propertiesContainer = contentEl.createEl('div', {
cls: 'datacards-properties-container',
});
// Determine if properties should be scrollable
const shouldScroll = this.shouldUseScrollableProperties(settings);
// Apply scrollable class and height if needed
if (shouldScroll) {
propertiesContainer.addClass('datacards-scrollable-properties');
// Set content height via data attribute
const contentHeight = this.getContentHeight(settings);
propertiesContainer.setAttribute('data-content-height', contentHeight);
propertiesContainer.addClass(`datacards-content-height-${contentHeight.replace('px', '')}`);
}
// Determine which properties to show
const propertiesToShow = settings.properties === 'all'
? headers
: Array.isArray(settings.properties) ? settings.properties : [];
// Filter out excluded properties and the file property
const filteredProperties = propertiesToShow.filter((prop: string) =>
!settings.exclude.includes(prop) &&
prop !== settings.imageProperty &&
prop.toLowerCase() !== 'file'
);
Logger.debug(`Using all headers as properties:`, headers);
Logger.debug(`Filtered properties (after excluding file):`, filteredProperties);
// Add each property to the properties container
filteredProperties.forEach((property: string) => {
const propIndex = headers.indexOf(property);
if (propIndex !== -1) {
const propValue = row[propIndex];
Logger.debug(`Checking property '${property}' in headers: true`);
Logger.debug(`Property '${property}' value:`, propValue);
Logger.debug(`Property '${property}' type:`, typeof propValue);
Logger.debug(`Adding property to card: ${property} = ${propValue}`);
// Create card data object for inline editing
const fileData = row[fileIndex];
console.log('File data for card:', fileData);
console.log('File data type:', typeof fileData);
console.log('File data keys:', Object.keys(fileData || {}));
const cardData = {
file: fileData,
path: fileData?.path,
source: fileData,
originalFile: fileData,
// Also try to extract the path directly if it's a string
filePath: typeof fileData === 'string' ? fileData : fileData?.path
};
// Use the standard property formatting, passing the component and card data
this.addPropertyToCard(propertiesContainer, property, propValue, settings, component, cardData);
} else {
Logger.debug(`Property '${property}' not found in headers`);
}
});
// If no properties were added, add a debug message
if (filteredProperties.length === 0 && !headers.some((h: string) => h.toLowerCase() === 'file')) {
Logger.debug('No properties were added to the card');
contentEl.createEl('div', {
cls: 'datacards-property',
text: 'No properties to display'
});
}
});
}
/**
* Sort groups by the specified status order
*
* @param groups The groups to sort
* @param settings The settings containing status order
* @returns Sorted groups
*/
private sortGroupsByStatusOrder(groups: any[], settings: DataCardsSettings): any[] {
if (!settings.kanbanStatusOrder || settings.kanbanStatusOrder.length === 0) {
return groups;
}
return [...groups].sort((a, b) => {
const aIndex = settings.kanbanStatusOrder.indexOf(a.key);
const bIndex = settings.kanbanStatusOrder.indexOf(b.key);
// If both are in the order array, sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// If only one is in the order array, prioritize it
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
// If neither is in the order array, maintain original order
return 0;
});
}
/**
* Create a kanban column for a group
*
* @param container The container element
* @param group The group data
* @param settings The merged settings
* @param component The parent component for lifecycle management
*/
private createKanbanColumn(container: HTMLElement, group: any, settings: DataCardsSettings, component: Component): void {
// Create column container
const column = container.createEl('div', {
cls: 'datacards-kanban-column'
});
// Create column header
const header = column.createEl('div', {
cls: 'datacards-kanban-header'
});
// Add group title
const title = header.createEl('span', {
cls: 'datacards-kanban-title',
text: group.key
});
// Add card count if enabled
if (settings.kanbanShowColumnCounts) {
header.createEl('span', {
cls: 'datacards-kanban-count',
text: `(${group.rows.length})`
});
}
// Create cards container within the column
const cardsContainer = column.createEl('div', {
cls: 'datacards-kanban-cards'
});
// Apply compact cards class if enabled
if (settings.kanbanCompactCards) {
cardsContainer.addClass('datacards-kanban-compact');
}
// Render the cards in this column
if (group.rows && group.rows.length > 0) {
console.log(`=== KANBAN COLUMN DEBUG: ${group.key} ===`);
console.log(`Number of rows: ${group.rows.length}`);
console.log(`First row:`, group.rows[0]);
console.log(`First row keys:`, Object.keys(group.rows[0] || {}));
console.log(`All rows:`, group.rows);
console.log(`=== END COLUMN DEBUG ===`);
// GROUP BY results have object-based rows, not array-based
// We need to render them as array results instead of table results
this.renderArrayResults(cardsContainer, group.rows, settings, component);
}
}
/**
* Extract headers from rows for GROUP BY results
*
* @param rows The rows to extract headers from
* @returns Array of header names
*/
private extractHeadersFromRows(rows: any[]): string[] {
if (!rows || rows.length === 0) {
return [];
}
// Get all unique keys from all rows
const headerSet = new Set<string>();
rows.forEach(row => {
if (row && typeof row === 'object') {
Object.keys(row).forEach(key => headerSet.add(key));
}
});
return Array.from(headerSet);
}
}

View file

@ -46,6 +46,7 @@ export class DataCardsSettingTab extends PluginSettingTab {
.addOption('square', 'Square (1:1 cards with minimal text, 4 columns)')
.addOption('compact', 'Compact (side-by-side layout, 4 columns)')
.addOption('dense', 'Dense (maximum density, 6 columns)')
.addOption('kanban', 'Kanban (horizontal columns for grouped data)')
.setValue(this.plugin.settings.preset)
.onChange(async (value: string) => {
this.plugin.settings.preset = value as any;
@ -307,6 +308,7 @@ export class DataCardsSettingTab extends PluginSettingTab {
.addOption('square', 'Square (1:1 cards with minimal text)')
.addOption('compact', 'Compact (side-by-side layout)')
.addOption('dense', 'Dense (maximum density)')
.addOption('kanban', 'Kanban (horizontal columns)')
.setValue(this.plugin.settings.mobilePreset)
.onChange(async (value: string) => {
this.plugin.settings.mobilePreset = value as any;
@ -356,6 +358,72 @@ export class DataCardsSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
// Kanban settings
new Setting(containerEl).setName('Kanban').setHeading();
new Setting(containerEl)
.setName('Column width')
.setDesc('Width of kanban columns (e.g., 300px, 250px)')
.addText(text => text
.setPlaceholder('300px')
.setValue(this.plugin.settings.kanbanColumnWidth)
.onChange(async (value) => {
this.plugin.settings.kanbanColumnWidth = value || '300px';
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Column spacing')
.setDesc('Space between kanban columns (in pixels)')
.addSlider(slider => slider
.setLimits(8, 32, 4)
.setValue(this.plugin.settings.kanbanColumnSpacing)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.kanbanColumnSpacing = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Show column counts')
.setDesc('Display the number of cards in each kanban column header')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.kanbanShowColumnCounts)
.onChange(async (value) => {
this.plugin.settings.kanbanShowColumnCounts = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Hide empty columns')
.setDesc('Hide kanban columns that contain no cards')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.kanbanHideEmptyColumns)
.onChange(async (value) => {
this.plugin.settings.kanbanHideEmptyColumns = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Compact cards')
.setDesc('Use smaller cards in kanban mode for higher density')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.kanbanCompactCards)
.onChange(async (value) => {
this.plugin.settings.kanbanCompactCards = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Mobile: Stack columns')
.setDesc('Stack kanban columns vertically on mobile devices instead of horizontal scrolling')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.kanbanMobileStackColumns)
.onChange(async (value) => {
this.plugin.settings.kanbanMobileStackColumns = value;
await this.plugin.saveSettings();
}));
// Performance settings
new Setting(containerEl).setName('Performance').setHeading();

View file

@ -834,6 +834,137 @@ a.datacards-tag:hover {
margin-bottom: 6px;
}
/* Kanban preset - Horizontal columns for grouped data */
.datacards-preset-kanban {
--kanban-column-width: 300px;
--kanban-column-spacing: 16px;
}
.datacards-preset-kanban.datacards-kanban-container {
display: flex;
gap: var(--kanban-column-spacing);
overflow-x: auto;
padding-bottom: 8px;
align-items: flex-start;
}
/* Kanban column */
.datacards-kanban-column {
flex: 0 0 var(--kanban-column-width);
background: var(--background-secondary);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--background-modifier-border);
min-height: 200px;
}
/* Kanban column header */
.datacards-kanban-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--background-modifier-border);
border-radius: 4px;
font-weight: 600;
font-size: 0.9em;
}
.datacards-kanban-title {
color: var(--text-normal);
}
.datacards-kanban-count {
color: var(--text-muted);
font-size: 0.8em;
font-weight: normal;
}
/* Kanban cards container */
.datacards-kanban-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Kanban cards styling */
.datacards-kanban-cards .datacards-card {
margin-bottom: 0; /* Remove default margin since we use gap */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.datacards-kanban-cards .datacards-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
/* Compact kanban cards */
.datacards-kanban-compact .datacards-card {
font-size: 0.9em;
}
.datacards-kanban-compact .datacards-content {
padding: 8px;
}
.datacards-kanban-compact .datacards-property {
margin-bottom: 4px;
}
.datacards-kanban-compact .datacards-image-container {
height: 120px;
}
/* Editable status dropdown styles for kanban */
.datacards-editable-status {
position: relative;
}
.datacards-status-select {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
color: var(--text-normal);
cursor: pointer;
width: 100%;
transition: border-color 0.2s ease;
}
.datacards-status-select:hover {
border-color: var(--interactive-accent);
}
.datacards-status-select:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
/* Style the status options */
.datacards-status-select option {
background: var(--background-primary);
color: var(--text-normal);
padding: 4px;
}
/* Grouped results (non-kanban) */
.datacards-group-header {
font-weight: 600;
font-size: 1.1em;
margin: 16px 0 8px 0;
padding: 8px 0;
border-bottom: 1px solid var(--background-modifier-border);
color: var(--text-normal);
}
.datacards-group-container {
margin-bottom: 24px;
}
/* Responsive adjustments - these are now handled by the mobile settings in the plugin */
/* These media queries serve as fallbacks for blocks without explicit settings */
@media (max-width: 1200px) {
@ -899,6 +1030,22 @@ a.datacards-tag:hover {
--card-columns: 1;
}
/* Kanban mobile adjustments */
.datacards-preset-kanban.datacards-kanban-container {
--kanban-column-width: 280px; /* Narrower columns on mobile */
}
/* Stack kanban columns vertically if setting is enabled */
.datacards-preset-kanban.datacards-mobile-stack {
flex-direction: column;
overflow-x: visible;
}
.datacards-preset-kanban.datacards-mobile-stack .datacards-kanban-column {
flex: 1 1 auto;
margin-bottom: 16px;
}
/* Reduce card spacing on mobile */
.datacards-container {
gap: 12px;