diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css index 6665fa7c2..6f01a799b 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css @@ -189,7 +189,21 @@ .ai-message ::ng-deep ol, .ai-message ::ng-deep ul { - padding-left: 28px; + padding-left: 20px; + margin: 8px 0; +} + +.ai-message ::ng-deep li { + margin: 4px 0; + line-height: 1.5; +} + +.ai-message ::ng-deep ol { + list-style-position: outside; +} + +.ai-message ::ng-deep ul { + list-style-position: outside; } .ai-message ::ng-deep pre { diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts index 46921fd95..1138124d0 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { ImageEditComponent } from '../../../ui-components/record-edit-fields/image/image.component'; import { Location } from '@angular/common'; import { LongTextEditComponent } from '../../../ui-components/record-edit-fields/long-text/long-text.component'; +import { MarkdownEditComponent } from '../../../ui-components/record-edit-fields/markdown/markdown.component'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog'; @@ -53,6 +54,7 @@ import { normalizeTableName } from 'src/app/lib/normalize'; PasswordEditComponent, ImageEditComponent, CodeEditComponent, + MarkdownEditComponent, WidgetComponent, TextEditComponent, LongTextEditComponent, @@ -160,6 +162,7 @@ export class DbTableWidgetsComponent implements OnInit { } `, JSON: `// No settings required`, + Markdown: `// No settings required`, Money: `// Configure money widget settings // example: { diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css new file mode 100644 index 000000000..d5062f6f0 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css @@ -0,0 +1,6 @@ +.code-editor-box { + min-height: 200px; + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html new file mode 100644 index 000000000..f410deb90 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html @@ -0,0 +1,11 @@ +{{ normalizedLabel }} {{ required ? '*' : '' }} + +
+ + +
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts new file mode 100644 index 000000000..ec4fcf891 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts @@ -0,0 +1,30 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MarkdownEditComponent } from './markdown.component'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideHttpClient } from '@angular/common/http'; + +describe('MarkdownEditComponent', () => { + let component: MarkdownEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MarkdownEditComponent, BrowserAnimationsModule], + providers: [provideHttpClient()] + }).compileComponents(); + + fixture = TestBed.createComponent(MarkdownEditComponent); + component = fixture.componentInstance; + + component.widgetStructure = { + widget_params: {} + } as any; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts new file mode 100644 index 000000000..89b5eb96a --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts @@ -0,0 +1,42 @@ +import { Component, Input } from '@angular/core'; + +import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; +import { CodeEditorModule } from '@ngstack/code-editor'; +import { CommonModule } from '@angular/common'; +import { UiSettingsService } from 'src/app/services/ui-settings.service'; + +@Component({ + selector: 'app-edit-markdown', + templateUrl: './markdown.component.html', + styleUrl: './markdown.component.css', + imports: [CommonModule, CodeEditorModule], +}) +export class MarkdownEditComponent extends BaseEditFieldComponent { + @Input() value; + + public mutableCodeModel: Object; + public codeEditorOptions = { + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + wordWrap: 'on', + }; + public codeEditorTheme = 'vs-dark'; + + constructor( + private _uiSettings: UiSettingsService, + ) { + super(); + } + + ngOnInit(): void { + super.ngOnInit(); + this.mutableCodeModel = { + language: 'markdown', + uri: `${this.label}.md`, + value: this.value + } + + this.codeEditorTheme = this._uiSettings.editorTheme; + } +} diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css new file mode 100644 index 000000000..27736edaf --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css @@ -0,0 +1,48 @@ +.markdown-container { + padding: 16px; + background: #f9f9f9; + border-radius: 4px; + border: 1px solid #e0e0e0; + min-height: 100px; +} + +.markdown-container ::ng-deep h1, +.markdown-container ::ng-deep h2, +.markdown-container ::ng-deep h3, +.markdown-container ::ng-deep h4, +.markdown-container ::ng-deep h5, +.markdown-container ::ng-deep h6 { + margin-top: 16px; + margin-bottom: 8px; +} + +.markdown-container ::ng-deep p { + margin-bottom: 8px; +} + +.markdown-container ::ng-deep ul, +.markdown-container ::ng-deep ol { + margin-left: 20px; + margin-bottom: 8px; +} + +.markdown-container ::ng-deep code { + background: #f5f5f5; + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; +} + +.markdown-container ::ng-deep pre { + background: #f5f5f5; + padding: 12px; + border-radius: 4px; + overflow-x: auto; +} + +.markdown-container ::ng-deep blockquote { + border-left: 4px solid #ddd; + padding-left: 16px; + margin-left: 0; + color: #666; +} diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html new file mode 100644 index 000000000..4270d38fd --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts new file mode 100644 index 000000000..a4262aaca --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts @@ -0,0 +1,30 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MarkdownRecordViewComponent } from './markdown.component'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideMarkdown } from 'ngx-markdown'; +import { provideHttpClient } from '@angular/common/http'; + +describe('MarkdownRecordViewComponent', () => { + let component: MarkdownRecordViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MarkdownRecordViewComponent, BrowserAnimationsModule], + providers: [ + provideHttpClient(), + provideMarkdown() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(MarkdownRecordViewComponent); + component = fixture.componentInstance; + component.value = '# Test Markdown'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts new file mode 100644 index 000000000..9972dae88 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts @@ -0,0 +1,19 @@ +import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; +import { Component, Injectable, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MarkdownModule } from 'ngx-markdown'; + +@Injectable() +@Component({ + selector: 'app-markdown-record-view', + templateUrl: './markdown.component.html', + styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './markdown.component.css'], + imports: [CommonModule, MarkdownModule] +}) +export class MarkdownRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { + public renderedMarkdown: string = ''; + + ngOnInit(): void { + this.renderedMarkdown = this.value || ''; + } +} diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css new file mode 100644 index 000000000..6c6f41777 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css @@ -0,0 +1,40 @@ +.markdown-preview { + display: inline-block; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.markdown-preview ::ng-deep p { + margin: 0; + display: inline; +} + +.markdown-preview ::ng-deep h1, +.markdown-preview ::ng-deep h2, +.markdown-preview ::ng-deep h3, +.markdown-preview ::ng-deep h4, +.markdown-preview ::ng-deep h5, +.markdown-preview ::ng-deep h6 { + margin: 0; + display: inline; + font-size: inherit; + font-weight: bold; +} + +.markdown-preview ::ng-deep ul, +.markdown-preview ::ng-deep ol { + display: inline; + list-style: none; + margin: 0; + padding: 0; +} + +.markdown-preview ::ng-deep code { + background: #f5f5f5; + padding: 1px 3px; + border-radius: 2px; + font-family: monospace; + font-size: 0.9em; +} diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html new file mode 100644 index 000000000..aaa8bbe84 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html @@ -0,0 +1,13 @@ +
+ + {{ displayTitle }} + + +
diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts new file mode 100644 index 000000000..e2a18966d --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MarkdownDisplayComponent } from './markdown.component'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideMarkdown } from 'ngx-markdown'; +import { provideHttpClient } from '@angular/common/http'; + +describe('MarkdownDisplayComponent', () => { + let component: MarkdownDisplayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MarkdownDisplayComponent, BrowserAnimationsModule], + providers: [ + provideHttpClient(), + provideMarkdown() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(MarkdownDisplayComponent); + component = fixture.componentInstance; + component.value = '# Test Markdown'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Title extraction', () => { + it('should extract H1 heading as title', () => { + component.value = '# Main Title\n\nSome paragraph text here.'; + component.ngOnInit(); + expect(component.displayTitle).toBe('Main Title'); + }); + + it('should extract H2 heading as title', () => { + component.value = '## Secondary Title\n\nContent follows.'; + component.ngOnInit(); + expect(component.displayTitle).toBe('Secondary Title'); + }); + + it('should prioritize heading over paragraph', () => { + component.value = 'Some intro text\n\n# Actual Title\n\nParagraph content.'; + component.ngOnInit(); + expect(component.displayTitle).toBe('Actual Title'); + }); + + it('should use first paragraph if no heading exists', () => { + component.value = 'This is the first paragraph.\n\nThis is the second paragraph.'; + component.ngOnInit(); + expect(component.displayTitle).toBe('This is the first paragraph.'); + }); + + it('should truncate long titles at 100 characters', () => { + const longTitle = 'a'.repeat(150); + component.value = `# ${longTitle}`; + component.ngOnInit(); + expect(component.displayTitle).toBe('a'.repeat(100) + '...'); + }); + + it('should handle markdown with bold and italic formatting', () => { + component.value = '# **Bold** and *italic* title'; + component.ngOnInit(); + expect(component.displayTitle).toBe('**Bold** and *italic* title'); + }); + + it('should handle empty markdown', () => { + component.value = ''; + component.ngOnInit(); + expect(component.displayTitle).toBe('—'); + }); + + it('should handle null/undefined markdown', () => { + component.value = null as any; + component.ngOnInit(); + expect(component.displayTitle).toBe('—'); + }); + + it('should handle markdown with only whitespace', () => { + component.value = ' \n\n '; + component.ngOnInit(); + expect(component.displayTitle).toBe('—'); + }); + + it('should extract title from markdown with code blocks', () => { + component.value = '# API Documentation\n\n```javascript\nconst x = 5;\n```'; + component.ngOnInit(); + expect(component.displayTitle).toBe('API Documentation'); + }); + + it('should extract title from markdown with lists', () => { + component.value = '# Shopping List\n\n- Item 1\n- Item 2'; + component.ngOnInit(); + expect(component.displayTitle).toBe('Shopping List'); + }); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts new file mode 100644 index 000000000..0998b1786 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts @@ -0,0 +1,80 @@ +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MarkdownModule } from 'ngx-markdown'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { marked } from 'marked'; + +@Component({ + selector: 'app-markdown-display', + templateUrl: './markdown.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './markdown.component.css'], + imports: [CommonModule, MarkdownModule, ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] +}) +export class MarkdownDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { + public renderedMarkdown: string = ''; + public displayTitle: string = ''; + + ngOnInit(): void { + this.renderedMarkdown = this.value || ''; + this.displayTitle = this._extractTitle(this.value); + } + + /** + * Extracts a title from markdown content using marked lexer + * Prioritizes headings, then falls back to first paragraph + * @param markdown The markdown content + * @returns Extracted and cleaned title string + */ + private _extractTitle(markdown: string): string { + if (!markdown || markdown.trim() === '') { + return '—'; + } + + try { + const tokens = marked.lexer(markdown); + + // First pass: look for headings (h1-h6) + for (const token of tokens) { + if (token.type === 'heading') { + return this._truncateText(token.text, 100); + } + } + + // Second pass: look for first paragraph or text + for (const token of tokens) { + if (token.type === 'paragraph') { + return this._truncateText(token.text, 100); + } + if (token.type === 'text' && token.text.trim().length > 0) { + return this._truncateText(token.text, 100); + } + } + + // If no heading or paragraph found, return first non-empty line + const firstLine = markdown.trim().split('\n')[0]; + return this._truncateText(firstLine, 100); + } catch (error) { + // Fallback if parsing fails + const firstLine = markdown.trim().split('\n')[0]; + return this._truncateText(firstLine, 100); + } + } + + /** + * Truncates text to specified length and adds ellipsis if needed + * @param text Text to truncate + * @param maxLength Maximum length before truncation + * @returns Truncated text with ellipsis if applicable + */ + private _truncateText(text: string, maxLength: number): string { + const cleaned = text.trim(); + if (cleaned.length > maxLength) { + return cleaned.substring(0, maxLength) + '...'; + } + return cleaned || '—'; + } +} diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts index 7f01a9548..ba6fabe41 100644 --- a/frontend/src/app/consts/record-edit-types.ts +++ b/frontend/src/app/consts/record-edit-types.ts @@ -11,6 +11,7 @@ import { IdEditComponent } from '../components/ui-components/record-edit-fields/ import { ImageEditComponent } from '../components/ui-components/record-edit-fields/image/image.component'; import { JsonEditorEditComponent } from '../components/ui-components/record-edit-fields/json-editor/json-editor.component'; import { LongTextEditComponent } from 'src/app/components/ui-components/record-edit-fields/long-text/long-text.component' +import { MarkdownEditComponent } from '../components/ui-components/record-edit-fields/markdown/markdown.component'; import { MoneyEditComponent } from '../components/ui-components/record-edit-fields/money/money.component'; import { NumberEditComponent } from 'src/app/components/ui-components/record-edit-fields/number/number.component'; import { PasswordEditComponent } from '../components/ui-components/record-edit-fields/password/password.component'; @@ -44,6 +45,7 @@ export const UIwidgets = { Foreign_key: ForeignKeyEditComponent, Image: ImageEditComponent, JSON: JsonEditorEditComponent, + Markdown: MarkdownEditComponent, Money: MoneyEditComponent, Number: NumberEditComponent, Password: PasswordEditComponent, diff --git a/frontend/src/app/consts/record-view-types.ts b/frontend/src/app/consts/record-view-types.ts index eb5d416a8..13553a5b9 100644 --- a/frontend/src/app/consts/record-view-types.ts +++ b/frontend/src/app/consts/record-view-types.ts @@ -10,6 +10,7 @@ import { IdRecordViewComponent } from '../components/ui-components/record-view-f import { ImageRecordViewComponent } from '../components/ui-components/record-view-fields/image/image.component'; import { JsonEditorRecordViewComponent } from '../components/ui-components/record-view-fields/json-editor/json-editor.component'; import { LongTextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/long-text/long-text.component'; +import { MarkdownRecordViewComponent } from '../components/ui-components/record-view-fields/markdown/markdown.component'; import { MoneyRecordViewComponent } from '../components/ui-components/record-view-fields/money/money.component'; import { NumberRecordViewComponent } from '../components/ui-components/record-view-fields/number/number.component'; import { PasswordRecordViewComponent } from '../components/ui-components/record-view-fields/password/password.component'; @@ -32,6 +33,7 @@ export const UIwidgets = { Time: TimeRecordViewComponent, DateTime: DateTimeRecordViewComponent, JSON: JsonEditorRecordViewComponent, + Markdown: MarkdownRecordViewComponent, Textarea: LongTextRecordViewComponent, String: TextRecordViewComponent, Readonly: StaticTextRecordViewComponent, diff --git a/frontend/src/app/consts/table-display-types.ts b/frontend/src/app/consts/table-display-types.ts index d0eda963a..e640d660a 100644 --- a/frontend/src/app/consts/table-display-types.ts +++ b/frontend/src/app/consts/table-display-types.ts @@ -10,6 +10,7 @@ import { IdDisplayComponent } from '../components/ui-components/table-display-fi import { ImageDisplayComponent } from '../components/ui-components/table-display-fields/image/image.component'; import { JsonEditorDisplayComponent } from '../components/ui-components/table-display-fields/json-editor/json-editor.component'; import { LongTextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/long-text/long-text.component'; +import { MarkdownDisplayComponent } from '../components/ui-components/table-display-fields/markdown/markdown.component'; import { MoneyDisplayComponent } from '../components/ui-components/table-display-fields/money/money.component'; import { NumberDisplayComponent } from '../components/ui-components/table-display-fields/number/number.component'; import { PasswordDisplayComponent } from '../components/ui-components/table-display-fields/password/password.component'; @@ -37,6 +38,7 @@ export const UIwidgets = { Foreign_key: ForeignKeyDisplayComponent, Image: ImageDisplayComponent, JSON: JsonEditorDisplayComponent, + Markdown: MarkdownDisplayComponent, Money: MoneyDisplayComponent, Number: NumberDisplayComponent, Password: PasswordDisplayComponent,