Переглянути джерело

feat: add markdown support for textarea fields

- Add markdown option to field schema (enabled when widget is textarea)
- Install @uiw/react-md-editor, react-markdown, and remark-gfm packages
- Create MarkdownEditor component with WYSIWYG editing
- Create MarkdownRenderer component for display (full and compact modes)
- Update PageRenderer and DocumentListRenderer to use markdown components
- Show markdown toggle in view editor when textarea widget is selected
- Render markdown in table cells (compact), cards (full), and kanban (compact)
fszontagh 6 місяців тому
батько
коміт
35524d2592

Різницю між файлами не показано, бо вона завелика
+ 740 - 56
webui/package-lock.json


+ 4 - 1
webui/package.json

@@ -15,9 +15,12 @@
   },
   "dependencies": {
     "@headlessui/react": "^2.2.0",
+    "@uiw/react-md-editor": "^4.0.11",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
-    "react-router-dom": "^7.1.0"
+    "react-markdown": "^10.1.0",
+    "react-router-dom": "^7.1.0",
+    "remark-gfm": "^4.0.1"
   },
   "devDependencies": {
     "@eslint/js": "^9.18.0",

+ 40 - 0
webui/src/components/MarkdownEditor.tsx

@@ -0,0 +1,40 @@
+// Markdown editor component using @uiw/react-md-editor
+import MDEditor from '@uiw/react-md-editor'
+
+interface MarkdownEditorProps {
+  value: string
+  onChange: (value: string) => void
+  label?: string
+  required?: boolean
+  placeholder?: string
+  height?: number
+}
+
+export default function MarkdownEditor({
+  value,
+  onChange,
+  label,
+  required,
+  placeholder,
+  height = 200,
+}: MarkdownEditorProps) {
+  return (
+    <div data-color-mode="light">
+      {label && (
+        <label className="mb-1.5 block text-sm font-medium text-gray-700">
+          {label}
+          {required && <span className="ml-1 text-red-500">*</span>}
+        </label>
+      )}
+      <MDEditor
+        value={value}
+        onChange={(val) => onChange(val || '')}
+        preview="edit"
+        height={height}
+        textareaProps={{
+          placeholder: placeholder || 'Write markdown here...',
+        }}
+      />
+    </div>
+  )
+}

+ 38 - 0
webui/src/components/MarkdownRenderer.tsx

@@ -0,0 +1,38 @@
+// Markdown renderer component using react-markdown
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+
+interface MarkdownRendererProps {
+  content: string
+  className?: string
+  /** If true, renders in a compact single-line style for table cells */
+  compact?: boolean
+}
+
+export default function MarkdownRenderer({
+  content,
+  className = '',
+  compact = false,
+}: MarkdownRendererProps) {
+  if (!content) return null
+
+  if (compact) {
+    // For table cells, render a truncated plain text version
+    // Strip markdown syntax for preview
+    const plainText = content
+      .replace(/[#*_~`>\[\]()!]/g, '')
+      .replace(/\n+/g, ' ')
+      .trim()
+    return (
+      <span className={`truncate ${className}`} title={plainText}>
+        {plainText.length > 100 ? plainText.slice(0, 100) + '...' : plainText}
+      </span>
+    )
+  }
+
+  return (
+    <div className={`prose prose-sm max-w-none ${className}`}>
+      <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
+    </div>
+  )
+}

+ 56 - 5
webui/src/components/PageRenderer/DocumentListRenderer.tsx

@@ -8,6 +8,8 @@ import { useWorkspace } from '@/contexts/WorkspaceContext'
 import { usePermissions } from '@/hooks/usePermissions'
 import Button from '@/components/Button'
 import ConfirmModal from '@/components/ConfirmModal'
+import MarkdownEditor from '@/components/MarkdownEditor'
+import MarkdownRenderer from '@/components/MarkdownRenderer'
 import type { DocumentListConfig, Document } from '@/types'
 
 interface ViewField {
@@ -17,6 +19,7 @@ interface ViewField {
   required?: boolean
   options?: Array<{ value: string; label: string }> | string[]
   widget?: string  // Widget type override (e.g., 'textarea' for text fields)
+  markdown?: boolean  // Enable markdown editing/rendering for textarea fields
 }
 
 interface View {
@@ -400,6 +403,20 @@ export function DocumentListRenderer({
 
     // Check for textarea - either by type or by widget override
     if (field.type === 'textarea' || field.type === 'text-long' || field.widget === 'textarea') {
+      // Use markdown editor if markdown is enabled (not in compact/inline mode)
+      if (field.markdown && !compact) {
+        return (
+          <div key={field.name}>
+            <MarkdownEditor
+              label={field.label}
+              value={String(value)}
+              onChange={(val) => setFormData(prev => ({ ...prev, [field.name]: val }))}
+              required={field.required}
+              height={150}
+            />
+          </div>
+        )
+      }
       return (
         <div key={field.name} className={compact ? "flex-1 min-w-[200px]" : ""}>
           {!compact && (
@@ -608,11 +625,23 @@ export function DocumentListRenderer({
                       renderInlineEditRow(doc)
                     ) : (
                       <tr key={doc.id} className="hover:bg-gray-50">
-                        {visibleColumns.map((colName) => (
-                          <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
-                            {formatCellValue(getColumnValue(doc, colName), fields.find(f => f.name === colName)?.type)}
-                          </td>
-                        ))}
+                        {visibleColumns.map((colName) => {
+                          const field = fields.find(f => f.name === colName)
+                          const value = getColumnValue(doc, colName)
+                          // For markdown fields, render as compact markdown
+                          if (field?.markdown && value) {
+                            return (
+                              <td key={colName} className="max-w-xs px-4 py-3 text-sm text-gray-900">
+                                <MarkdownRenderer content={String(value)} compact />
+                              </td>
+                            )
+                          }
+                          return (
+                            <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
+                              {formatCellValue(value, field?.type)}
+                            </td>
+                          )
+                        })}
                         <td className="whitespace-nowrap px-4 py-3 text-right text-sm">
                           <div className="flex justify-end gap-2">
                             {showEditButton && (
@@ -724,6 +753,17 @@ export function DocumentListRenderer({
                           const bodyField = fields.find(f => f.name === colName)
                           const val = getColumnValue(doc, colName)
                           if (val === null || val === undefined) return null
+                          // Render markdown fields with full rendering
+                          if (bodyField?.markdown) {
+                            return (
+                              <div key={colName} className="text-sm">
+                                <span className="text-gray-500">{bodyField?.label || colName}:</span>
+                                <div className="mt-1">
+                                  <MarkdownRenderer content={String(val)} className="text-gray-900" />
+                                </div>
+                              </div>
+                            )
+                          }
                           return (
                             <div key={colName} className="flex items-center text-sm">
                               <span className="text-gray-500 mr-2">{bodyField?.label || colName}:</span>
@@ -838,6 +878,17 @@ export function DocumentListRenderer({
                               const bodyField = fields.find(f => f.name === colName)
                               const val = getColumnValue(doc, colName)
                               if (val === null || val === undefined) return null
+                              // Render markdown fields with compact rendering in kanban
+                              if (bodyField?.markdown) {
+                                return (
+                                  <div key={colName} className="text-xs text-gray-500">
+                                    <span>{bodyField?.label || colName}: </span>
+                                    <span className="text-gray-700">
+                                      <MarkdownRenderer content={String(val)} compact />
+                                    </span>
+                                  </div>
+                                )
+                              }
                               return (
                                 <div key={colName} className="text-xs text-gray-500">
                                   <span>{bodyField?.label || colName}: </span>

+ 28 - 0
webui/src/components/PageRenderer/index.tsx

@@ -12,6 +12,7 @@ import DocumentListRenderer from './DocumentListRenderer'
 import StatCardRenderer from './StatCardRenderer'
 import Button from '@/components/Button'
 import Input from '@/components/Input'
+import MarkdownEditor from '@/components/MarkdownEditor'
 import type {
   Page,
   LayoutComponent,
@@ -30,6 +31,7 @@ interface ViewField {
   required?: boolean
   options?: Array<{ value: string; label: string }> | string[]
   widget?: string  // Widget type override (e.g., 'textarea' for text fields)
+  markdown?: boolean  // Enable markdown editing/rendering for textarea fields
 }
 
 // Normalize options to {value, label} format
@@ -419,6 +421,19 @@ export function PageRenderer({ page, isEditing = false, onComponentClick, onReor
 
     // Check for textarea - either by type or by widget override
     if (field.type === 'textarea' || field.type === 'text-long' || field.widget === 'textarea') {
+      // Use markdown editor if markdown is enabled
+      if (field.markdown) {
+        return (
+          <div key={field.name}>
+            <MarkdownEditor
+              label={field.label}
+              value={String(value)}
+              onChange={(val) => setCreateFormData(prev => ({ ...prev, [field.name]: val }))}
+              required={field.required}
+            />
+          </div>
+        )
+      }
       return (
         <div key={field.name}>
           <label className="mb-1.5 block text-sm font-medium text-gray-700">
@@ -574,6 +589,19 @@ export function PageRenderer({ page, isEditing = false, onComponentClick, onReor
 
     // Check for textarea - either by type or by widget override
     if (field.type === 'textarea' || field.type === 'text-long' || field.widget === 'textarea') {
+      // Use markdown editor if markdown is enabled
+      if (field.markdown) {
+        return (
+          <div key={field.name}>
+            <MarkdownEditor
+              label={field.label}
+              value={String(value)}
+              onChange={(val) => setEditFormData(prev => ({ ...prev, [field.name]: val }))}
+              required={field.required}
+            />
+          </div>
+        )
+      }
       return (
         <div key={field.name}>
           <label className="mb-1.5 block text-sm font-medium text-gray-700">

+ 22 - 1
webui/src/pages/Views.tsx

@@ -89,6 +89,7 @@ interface SchemaField {
   default_value?: unknown
   options?: string[]
   reference_collection?: string
+  markdown?: boolean // Enable markdown editing/rendering for textarea fields
 }
 
 interface ViewSchema {
@@ -270,7 +271,7 @@ function FieldEditor({
             <label className="mb-1.5 block text-sm font-medium text-gray-700">Widget</label>
             <select
               value={field.widget}
-              onChange={(e) => onUpdate({ ...field, widget: e.target.value })}
+              onChange={(e) => onUpdate({ ...field, widget: e.target.value, markdown: e.target.value !== 'textarea' ? false : field.markdown })}
               className="w-full rounded-lg border border-gray-300 px-3 py-2"
             >
               {getWidgetsForType(field.type).map((w) => (
@@ -281,6 +282,22 @@ function FieldEditor({
             </select>
           </div>
 
+          {/* Markdown option - only visible for textarea widget */}
+          {field.widget === 'textarea' && (
+            <div className="flex items-center">
+              <label className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  checked={field.markdown || false}
+                  onChange={(e) => onUpdate({ ...field, markdown: e.target.checked })}
+                  className="h-4 w-4 rounded border-gray-300"
+                />
+                <span className="text-sm text-gray-700">Enable Markdown</span>
+              </label>
+              <span className="ml-2 text-xs text-gray-500">(WYSIWYG editor)</span>
+            </div>
+          )}
+
           <Input
             label="Field Group"
             value={field.group}
@@ -378,6 +395,7 @@ function ViewModal({
           default_value: f.default_value,
           options: f.options,
           reference_collection: f.reference_collection,
+          markdown: f.markdown,
         }))
     }
     return []
@@ -548,6 +566,9 @@ function ViewModal({
         if (f.reference_collection) {
           field.reference_collection = f.reference_collection
         }
+        if (f.markdown) {
+          field.markdown = f.markdown
+        }
         return field
       })
 

Деякі файли не було показано, через те що забагато файлів було змінено