diff --git a/kanban-test-notes.md b/kanban-test-notes.md new file mode 100644 index 0000000..41d6ec3 --- /dev/null +++ b/kanban-test-notes.md @@ -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 +``` diff --git a/src/models/settings.ts b/src/models/settings.ts index 217e9ee..2dfd1f9 100644 --- a/src/models/settings.ts +++ b/src/models/settings.ts @@ -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: {}, diff --git a/src/services/renderer.ts b/src/services/renderer.ts index 5322558..307cec0 100644 --- a/src/services/renderer.ts +++ b/src/services/renderer.ts @@ -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 { + 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(); + + 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(); + + rows.forEach(row => { + if (row && typeof row === 'object') { + Object.keys(row).forEach(key => headerSet.add(key)); + } + }); + + return Array.from(headerSet); + } } diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index 8d38794..39689c3 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -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(); diff --git a/styles.css b/styles.css index 5fcd2a5..7410988 100644 --- a/styles.css +++ b/styles.css @@ -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;