diff --git a/arts/screenshot.jpg b/arts/screenshot.jpg index ff5fb83..c0217ff 100644 Binary files a/arts/screenshot.jpg and b/arts/screenshot.jpg differ diff --git a/composer.json b/composer.json index 83aba27..110a839 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "require": { "php": "^8.3|^8.4", "spatie/laravel-package-tools": "^1.14", - "illuminate/contracts": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", "laravilt/support": "^1.0", "laravilt/query-builder": "^1.0", "laravilt/forms": "^1.0", @@ -35,12 +35,12 @@ "larastan/larastan": "^2.9||^3.0", "laravel/pint": "^1.14", "nunomaduro/collision": "^8.1.1||^7.10.0", - "orchestra/testbench": "^10.0", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-arch": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0", - "pestphp/pest-plugin-livewire": "^3.0", - "pestphp/pest-plugin-type-coverage": "^3.5", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0|^5.0", + "pestphp/pest-plugin-arch": "^3.0|^4.0|^5.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0|^5.0", + "pestphp/pest-plugin-livewire": "^3.0|^4.0|^5.0", + "pestphp/pest-plugin-type-coverage": "^3.5|^4.0|^5.0", "phpstan/extension-installer": "^1.3||^2.0", "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", "phpstan/phpstan-phpunit": "^1.3||^2.0" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..f51e71c --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,2 @@ +parameters: + ignoreErrors: [] diff --git a/phpstan.neon b/phpstan.neon index 56da168..4f48587 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,4 +8,5 @@ parameters: tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true - checkMissingIterableValueType: false + ignoreErrors: + - identifier: missingType.iterableValue diff --git a/phpunit.xml b/phpunit.xml index c2db6e4..6839f7e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,13 +9,6 @@ ./tests - - - - - - - ./src diff --git a/resources/react/app.ts b/resources/react/app.ts new file mode 100644 index 0000000..46a2dd1 --- /dev/null +++ b/resources/react/app.ts @@ -0,0 +1,15 @@ +/** + * Tables plugin for React (twin of resources/js/app.js). + * + * Registers all table-related components globally. + * + * Note: Filter components are not needed here since we use BaseFilter + * with Laravilt Form components instead. + */ +export default { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + register(options: Record = {}): void { + // No table-specific components to register yet + // Filters use BaseFilter + Form components which are already registered + }, +}; diff --git a/resources/react/components/ApiTester.css b/resources/react/components/ApiTester.css new file mode 100644 index 0000000..a87a2c9 --- /dev/null +++ b/resources/react/components/ApiTester.css @@ -0,0 +1,90 @@ +/* Sidebar tooltip styles (scoped in ApiTester.vue; `.sidebar-tooltip` is only used by this component) */ +.sidebar-tooltip { + position: absolute; + left: 100%; + top: 50%; + transform: translateY(-50%); + margin-left: 8px; + padding: 6px 10px; + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + z-index: 50; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + pointer-events: none; +} + +/* Tooltip arrow */ +.sidebar-tooltip::before { + content: ''; + position: absolute; + left: -5px; + top: 50%; + transform: translateY(-50%) rotate(45deg); + width: 8px; + height: 8px; + background: hsl(var(--popover)); + border-left: 1px solid hsl(var(--border)); + border-bottom: 1px solid hsl(var(--border)); +} + +/* Show tooltip on hover */ +.group:hover .sidebar-tooltip { + opacity: 1; + visibility: visible; +} + +/* Global scrollbar styles (not scoped) */ +/* Custom scrollbar for API Tester */ +.api-tester-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.api-tester-scrollbar::-webkit-scrollbar-track { + background: #e5e5e5; + border-radius: 4px; +} + +.api-tester-scrollbar::-webkit-scrollbar-thumb { + background: #a3a3a3; + border-radius: 4px; +} + +.api-tester-scrollbar::-webkit-scrollbar-thumb:hover { + background: #737373; +} + +/* Dark mode scrollbar */ +.dark .api-tester-scrollbar::-webkit-scrollbar-track { + background: #262626; +} + +.dark .api-tester-scrollbar::-webkit-scrollbar-thumb { + background: #525252; +} + +.dark .api-tester-scrollbar::-webkit-scrollbar-thumb:hover { + background: #737373; +} + +/* Firefox scrollbar */ +.api-tester-scrollbar { + scrollbar-width: thin; + scrollbar-color: #a3a3a3 #e5e5e5; +} + +.dark .api-tester-scrollbar { + scrollbar-color: #525252 #262626; +} + +/* Scrollbar corner */ +.api-tester-scrollbar::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/resources/react/components/ApiTester.tsx b/resources/react/components/ApiTester.tsx new file mode 100644 index 0000000..e63b804 --- /dev/null +++ b/resources/react/components/ApiTester.tsx @@ -0,0 +1,1365 @@ +import { cn } from '@/lib/utils'; +import { + Check, + ChevronDown, + ChevronRight, + ChevronsDownUp, + ChevronsUpDown, + Code, + Copy, + Database, + Download, + Edit, + Eye, + EyeOff, + FileJson, + FolderOpen, + Key, + List, + Loader2, + Plus, + PlusCircle, + Send, + Trash, + Trash2, + Zap, + type LucideIcon, +} from 'lucide-react'; +import { useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { useWatch } from '../composables/useWatch'; +import './ApiTester.css'; + +// Scoped `pre` / `select option` rules from ApiTester.vue, applied inline +const PRE_STYLE: CSSProperties = { margin: 0, background: 'transparent' }; +const OPTION_STYLE: CSSProperties = { backgroundColor: 'var(--background)', color: 'var(--foreground)' }; + +interface JsonNodeProps { + data: any; + keyName?: string; + depth?: number; + isLast?: boolean; + forceExpand?: boolean | null; +} + +// Recursive JSON Tree component for collapsible display +function JsonNode({ data, keyName = '', depth = 0, isLast = true, forceExpand = null }: JsonNodeProps) { + const [isExpanded, setIsExpanded] = useState(() => (forceExpand !== null ? forceExpand : depth < 2)); + + const isObject = data !== null && typeof data === 'object' && !Array.isArray(data); + const isArray = Array.isArray(data); + const isCollapsible = isObject || isArray; + const isEmpty = isArray ? data.length === 0 : isObject ? Object.keys(data).length === 0 : false; + + const entries: [any, any][] = isArray ? data.map((v: any, i: number) => [i, v]) : isObject ? Object.entries(data) : []; + + const valueColor = (() => { + if (data === null) return 'text-gray-500'; + if (typeof data === 'boolean') return 'text-purple-600 dark:text-purple-400'; + if (typeof data === 'number') return 'text-blue-600 dark:text-blue-400'; + if (typeof data === 'string') return 'text-green-600 dark:text-green-400'; + return 'text-foreground'; + })(); + + const formatValue = (val: any) => { + if (val === null) return 'null'; + if (typeof val === 'string') return `"${val}"`; + return String(val); + }; + + const toggle = () => { + if (isCollapsible) { + setIsExpanded(!isExpanded); + } + }; + + const indent = depth * 16; + const comma = isLast ? '' : ','; + + if (!isCollapsible) { + return ( +
+ {keyName && "{keyName}"} + {keyName && : } + {formatValue(data)} + {comma} +
+ ); + } + + if (isEmpty) { + const brackets = isArray ? '[]' : '{}'; + return ( +
+ {keyName && "{keyName}"} + {keyName && : } + {brackets + comma} +
+ ); + } + + const openBracket = isArray ? '[' : '{'; + const closeBracket = isArray ? ']' : '}'; + const itemCount = entries.length; + + if (!isExpanded) { + return ( +
+ + {keyName && "{keyName}"} + {keyName && : } + {openBracket} + {`${itemCount} ${isArray ? 'items' : 'keys'}`} + {closeBracket + comma} +
+ ); + } + + return ( +
+
+ + {keyName && "{keyName}"} + {keyName && : } + {openBracket} +
+ {entries.map(([key, value], index) => ( + + ))} +
+ {closeBracket + comma} +
+
+ ); +} + +interface ApiColumn { + name: string; + label?: string; + type?: string; + format?: string; + nullable?: boolean; + description?: string; + example?: any; + sortable?: boolean; + filterable?: boolean; + searchable?: boolean; + enum?: string[]; +} + +interface ApiAction { + name: string; + slug: string; + label: string; + description?: string; + method: string; + requiresRecord: boolean; + bulk: boolean; + requiresConfirmation: boolean; + confirmationMessage?: string; + hidden: boolean; + requestSchema?: any; + responseSchema?: any; + successMessage?: string; +} + +export interface ApiResource { + columns: ApiColumn[]; + endpoint: string; + baseUrl: string; + fullUrl: string; + paginated: boolean; + perPage: number; + allowedFilters: string[]; + allowedSorts: string[]; + allowedIncludes?: string[]; + searchableColumns: string[]; + description?: string; + version?: string; + authenticated: boolean; + headers: Record; + sampleRequest?: any; + sampleResponse?: any; + fillableFields?: string[]; + actions?: ApiAction[]; + openApiSpec?: any; +} + +export interface ApiTesterProps { + apiResource: ApiResource; + resourceSlug?: string; + records?: any[]; + apiToken?: string | null; +} + +// HTTP Methods +const httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +type HttpMethod = (typeof httpMethods)[number]; + +// Operation types +type OperationType = 'index' | 'show' | 'store' | 'update' | 'destroy' | 'bulkDestroy' | string; + +interface Operation { + key: OperationType; + label: string; + method: HttpMethod; + icon: LucideIcon; + description: string; + path: string; + needsId?: boolean; + hasBody?: boolean; + bulk?: boolean; + action?: ApiAction; +} + +interface KeyValueRow { + key: string; + value: string; + enabled: boolean; +} + +// Method colors +const methodColors: Record = { + GET: 'bg-green-500', + POST: 'bg-yellow-500', + PUT: 'bg-blue-500', + PATCH: 'bg-purple-500', + DELETE: 'bg-red-500', +}; + +const methodTextColors: Record = { + GET: 'text-green-500', + POST: 'text-yellow-500', + PUT: 'text-blue-500', + PATCH: 'text-purple-500', + DELETE: 'text-red-500', +}; + +const methodBadgeColors: Record = { + GET: 'bg-green-500/10 text-green-600 dark:text-green-400', + POST: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400', + PUT: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', + PATCH: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', + DELETE: 'bg-red-500/10 text-red-600 dark:text-red-400', +}; + +// Status colors +const getStatusColor = (status: number) => { + if (status >= 200 && status < 300) return 'text-green-500'; + if (status >= 300 && status < 400) return 'text-blue-500'; + if (status >= 400 && status < 500) return 'text-yellow-500'; + if (status >= 500) return 'text-red-500'; + return 'text-gray-500'; +}; + +const getStatusBg = (status: number) => { + if (status >= 200 && status < 300) return 'bg-green-500/10'; + if (status >= 300 && status < 400) return 'bg-blue-500/10'; + if (status >= 400 && status < 500) return 'bg-yellow-500/10'; + if (status >= 500) return 'bg-red-500/10'; + return 'bg-gray-500/10'; +}; + +// Faker-like random data generators +const faker = { + firstName: () => ['John', 'Jane', 'Michael', 'Sarah', 'David', 'Emily', 'James', 'Emma'][Math.floor(Math.random() * 8)], + lastName: () => ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis'][Math.floor(Math.random() * 8)], + fullName: () => `${faker.firstName()} ${faker.lastName()}`, + email: () => `${faker.firstName().toLowerCase()}${Math.floor(Math.random() * 999)}@example.com`, + phone: () => + `+1 ${Math.floor(Math.random() * 900) + 100} ${Math.floor(Math.random() * 900) + 100} ${Math.floor(Math.random() * 9000) + 1000}`, + company: () => ['Acme Inc.', 'Tech Corp', 'Global Solutions', 'Digital Services', 'Innovation Labs'][Math.floor(Math.random() * 5)], + website: () => `https://${faker.company().toLowerCase().replace(/[^a-z]/g, '')}.com`, + address: () => + `${Math.floor(Math.random() * 9999) + 1} ${['Main', 'Oak', 'Park', 'Cedar', 'Elm'][Math.floor(Math.random() * 5)]} ${['St', 'Ave', 'Blvd', 'Dr'][Math.floor(Math.random() * 4)]}`, + city: () => ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'San Diego'][Math.floor(Math.random() * 6)], + state: () => ['NY', 'CA', 'TX', 'FL', 'IL', 'PA', 'OH', 'GA'][Math.floor(Math.random() * 8)], + country: () => ['USA', 'Canada', 'UK', 'Australia', 'Germany'][Math.floor(Math.random() * 5)], + postalCode: () => String(Math.floor(Math.random() * 90000) + 10000), + boolean: () => Math.random() > 0.5, + number: (min = 0, max = 10000) => Math.floor(Math.random() * (max - min + 1)) + min, + decimal: (min = 0, max = 10000) => Number((Math.random() * (max - min) + min).toFixed(2)), + date: () => { + const d = new Date(); + d.setDate(d.getDate() - Math.floor(Math.random() * 365 * 30)); + return d.toISOString().split('T')[0]; + }, + datetime: () => new Date(Date.now() - Math.floor(Math.random() * 365 * 24 * 60 * 60 * 1000)).toISOString(), + tags: () => { + const allTags = ['vip', 'newsletter', 'premium', 'active', 'loyal', 'new']; + return allTags.slice(0, Math.floor(Math.random() * 3) + 1); + }, + status: () => ['active', 'inactive', 'pending'][Math.floor(Math.random() * 3)], + type: () => ['individual', 'business'][Math.floor(Math.random() * 2)], + text: () => ['Lorem ipsum dolor sit amet', 'Consectetur adipiscing elit', 'Sed do eiusmod tempor'][Math.floor(Math.random() * 3)], +}; + +// Format bytes +const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +// Quote strings that would break or be re-typed as plain YAML scalars (newlines, indicators, booleans, null, +// numbers). JSON string syntax is a valid double-quoted YAML scalar and escapes newlines and quotes. +const toYamlString = (value: string): string => + value === '' || + /[\n\r:#]/.test(value) || + /^[\s\-?,[\]{}&*!|>'"%@`]|\s$/.test(value) || + /^(?:true|false|yes|no|on|off|null|~)$/i.test(value) || + !isNaN(Number(value)) + ? JSON.stringify(value) + : value; + +const toYaml = (obj: any, indent = 0): string => { + const prefix = ' '.repeat(indent); + let yaml = ''; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) { + yaml += `${prefix}${key}: null\n`; + } else if (typeof value === 'boolean') { + yaml += `${prefix}${key}: ${value}\n`; + } else if (typeof value === 'number') { + yaml += `${prefix}${key}: ${value}\n`; + } else if (typeof value === 'string') { + yaml += `${prefix}${key}: ${toYamlString(value)}\n`; + } else if (Array.isArray(value)) { + if (value.length === 0) { + yaml += `${prefix}${key}: []\n`; + } else { + yaml += `${prefix}${key}:\n`; + for (const item of value) { + if (typeof item === 'object' && item !== null) { + const itemYaml = toYaml(item, indent + 2).trim(); + yaml += `${prefix}- ${itemYaml.split('\n').join('\n' + prefix + ' ')}\n`; + } else { + yaml += `${prefix}- ${typeof item === 'string' ? toYamlString(item) : item}\n`; + } + } + } + } else if (typeof value === 'object') { + yaml += `${prefix}${key}:\n`; + yaml += toYaml(value, indent + 1); + } + } + return yaml; +}; + +const INPUT_CLASS = + 'flex-1 px-3 py-1.5 text-sm rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary'; + +export default function ApiTester({ apiResource, resourceSlug = '', apiToken = null }: ApiTesterProps) { + // Selected operation + const [selectedOperation, setSelectedOperation] = useState('index'); + const [recordId, setRecordId] = useState(''); + + // Request state + const [method, setMethod] = useState('GET'); + const [url, setUrl] = useState(apiResource?.fullUrl || ''); + const [isLoading, setIsLoading] = useState(false); + const [response, setResponse] = useState(null); + const [responseStatus, setResponseStatus] = useState(null); + const [responseTime, setResponseTime] = useState(null); + const [responseSize, setResponseSize] = useState(null); + const [responseHeaders, setResponseHeaders] = useState>({}); + const [error, setError] = useState(null); + const [copiedResponse, setCopiedResponse] = useState(false); + + // Active tabs + const [requestTab, setRequestTab] = useState<'params' | 'headers' | 'body' | 'auth'>('params'); + const [responseTab, setResponseTab] = useState<'body' | 'headers'>('body'); + + // Response view + const [responseViewMode, setResponseViewMode] = useState<'pretty' | 'raw'>('pretty'); + const [jsonExpandKey, setJsonExpandKey] = useState(0); + const [forceExpandAll, setForceExpandAll] = useState(false); + + // API Token + const [apiTokenInput, setApiTokenInput] = useState(apiToken || ''); + const [showToken, setShowToken] = useState(false); + + // Headers + const [headers, setHeaders] = useState([ + { key: 'Accept', value: 'application/json', enabled: true }, + { key: 'Content-Type', value: 'application/json', enabled: true }, + ]); + + // Query parameters + const [queryParams, setQueryParams] = useState([]); + + // Request body + const [requestBody, setRequestBody] = useState(''); + + // Sidebar collapsed + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + // Export + const [showExportMenu, setShowExportMenu] = useState(false); + const [copiedOpenApi, setCopiedOpenApi] = useState(false); + + const fullUrl = apiResource?.fullUrl || ''; + + // Operations list + const operations = useMemo( + () => [ + { + key: 'index', + label: 'List All', + method: 'GET', + icon: List, + description: 'Get paginated list of records', + path: fullUrl, + }, + { + key: 'show', + label: 'Get One', + method: 'GET', + icon: Eye, + description: 'Get a single record by ID', + path: `${fullUrl}/{id}`, + needsId: true, + }, + { + key: 'store', + label: 'Create', + method: 'POST', + icon: PlusCircle, + description: 'Create a new record', + path: fullUrl, + hasBody: true, + }, + { + key: 'update', + label: 'Update', + method: 'PUT', + icon: Edit, + description: 'Update an existing record', + path: `${fullUrl}/{id}`, + needsId: true, + hasBody: true, + }, + { + key: 'destroy', + label: 'Delete', + method: 'DELETE', + icon: Trash, + description: 'Delete a single record', + path: `${fullUrl}/{id}`, + needsId: true, + }, + { + key: 'bulkDestroy', + label: 'Bulk Delete', + method: 'DELETE', + icon: Database, + description: 'Delete multiple records', + path: `${fullUrl}/bulk`, + hasBody: true, + }, + ], + [fullUrl], + ); + + // Custom actions from API resource + const customActions = useMemo(() => { + if (!apiResource?.actions) return []; + return apiResource.actions + .filter((a) => !a.hidden) + .map((action) => ({ + key: `action_${action.slug}`, + label: action.label, + method: action.method.toUpperCase() as HttpMethod, + icon: Zap, + description: action.description || '', + path: action.requiresRecord ? `${fullUrl}/{id}/actions/${action.slug}` : `${fullUrl}/actions/${action.slug}`, + needsId: action.requiresRecord, + hasBody: ['POST', 'PUT', 'PATCH'].includes(action.method.toUpperCase()), + bulk: action.bulk, + action, + })); + }, [apiResource?.actions, fullUrl]); + + const findOperation = (opKey: OperationType): Operation | undefined => { + const op = operations.find((o) => o.key === opKey); + if (op) return op; + return customActions.find((a) => a.key === opKey); + }; + + // Current operation + const currentOperation = findOperation(selectedOperation); + + // URL for an operation (Vue: updateUrlForOperation) + const urlForOperation = (op: Operation, id: string): string => { + let path = op.path; + if (op.needsId && id) { + path = path.replace('{id}', id); + } + return path; + }; + + // Reset params for operation + const resetParamsForOperation = (opKey: OperationType) => { + if (opKey === 'index') { + setQueryParams([ + { key: 'page', value: '1', enabled: true }, + { key: 'per_page', value: String(apiResource?.perPage || 12), enabled: true }, + ]); + } else { + setQueryParams([]); + } + }; + + // Generate sample request body + const generateSampleBody = () => { + if (!apiResource?.columns) { + setRequestBody('{}'); + return; + } + + const sampleData: Record = {}; + const fillableFields = apiResource.fillableFields || []; + + apiResource.columns.forEach((col) => { + if (fillableFields.length > 0 && !fillableFields.includes(col.name)) return; + if (['id', 'created_at', 'updated_at', 'deleted_at'].includes(col.name)) return; + + const name = col.name.toLowerCase(); + + if (name === 'name' || name === 'full_name' || name === 'fullname') { + sampleData[col.name] = faker.fullName(); + } else if (name === 'first_name' || name === 'firstname') { + sampleData[col.name] = faker.firstName(); + } else if (name === 'last_name' || name === 'lastname') { + sampleData[col.name] = faker.lastName(); + } else if (name === 'email' || name.includes('email')) { + sampleData[col.name] = faker.email(); + } else if (name === 'phone' || name.includes('phone') || name.includes('mobile')) { + sampleData[col.name] = faker.phone(); + } else if (name === 'company' || name.includes('company')) { + sampleData[col.name] = faker.company(); + } else if (name === 'website' || name.includes('url') || name.includes('site')) { + sampleData[col.name] = faker.website(); + } else if (name === 'address' || name.includes('address') || name.includes('street')) { + sampleData[col.name] = faker.address(); + } else if (name === 'city') { + sampleData[col.name] = faker.city(); + } else if (name === 'state' || name === 'province') { + sampleData[col.name] = faker.state(); + } else if (name === 'country') { + sampleData[col.name] = faker.country(); + } else if (name === 'postal_code' || name === 'zip' || name === 'zipcode') { + sampleData[col.name] = faker.postalCode(); + } else if (name === 'status') { + sampleData[col.name] = col.enum ? col.enum[Math.floor(Math.random() * col.enum.length)] : faker.status(); + } else if (name === 'type') { + sampleData[col.name] = col.enum ? col.enum[Math.floor(Math.random() * col.enum.length)] : faker.type(); + } else if (name.includes('birth') || name.includes('dob')) { + sampleData[col.name] = faker.date(); + } else if (name.includes('tags') || name.includes('labels')) { + sampleData[col.name] = faker.tags(); + } else if ( + name.includes('credit') || + name.includes('limit') || + name.includes('amount') || + name.includes('price') || + name.includes('spent') || + name.includes('total') + ) { + sampleData[col.name] = faker.decimal(100, 10000); + } else if (col.enum && col.enum.length > 0) { + sampleData[col.name] = col.enum[Math.floor(Math.random() * col.enum.length)]; + } else if (col.type === 'boolean' || name.includes('is_') || name.includes('has_')) { + sampleData[col.name] = faker.boolean(); + } else if (col.type === 'integer' || col.type === 'number') { + sampleData[col.name] = faker.number(1, 1000); + } else if (col.type === 'date') { + sampleData[col.name] = faker.date(); + } else if (col.type === 'datetime') { + sampleData[col.name] = faker.datetime(); + } else if (col.type === 'array') { + sampleData[col.name] = []; + } else { + sampleData[col.name] = col.example !== undefined && col.example !== null ? col.example : faker.text(); + } + }); + + setRequestBody(JSON.stringify(sampleData, null, 2)); + }; + + // Select an operation + const selectOperation = (opKey: OperationType) => { + setSelectedOperation(opKey); + const op = findOperation(opKey); + if (op) { + setMethod(op.method); + setUrl(urlForOperation(op, recordId)); + resetParamsForOperation(opKey); + + if (op.hasBody) { + setRequestTab('body'); + if (opKey === 'store' || opKey === 'update') { + generateSampleBody(); + } else if (opKey === 'bulkDestroy') { + setRequestBody(JSON.stringify({ ids: [] }, null, 2)); + } + } else { + setRequestTab('params'); + } + } + }; + + // Watch recordId changes (Vue watcher → handled where recordId changes) + const handleRecordIdChange = (id: string) => { + setRecordId(id); + if (currentOperation) { + setUrl(urlForOperation(currentOperation, id)); + } + }; + + // Computed URL with query params + const computedUrl = (() => { + const baseUrl = url; + const enabledParams = queryParams.filter((p) => p.enabled && p.key && p.value); + if (enabledParams.length === 0) return baseUrl; + const params = new URLSearchParams(); + enabledParams.forEach((p) => params.append(p.key, p.value)); + return `${baseUrl}?${params.toString()}`; + })(); + + // Formatted response + const formattedResponse = !response ? '' : typeof response === 'string' ? response : JSON.stringify(response, null, 2); + + // Send request + const sendRequest = async () => { + setIsLoading(true); + setError(null); + setResponse(null); + setResponseStatus(null); + setResponseTime(null); + setResponseSize(null); + setResponseHeaders({}); + + const startTime = performance.now(); + + try { + const requestHeaders: Record = {}; + headers + .filter((h) => h.enabled && h.key) + .forEach((h) => { + requestHeaders[h.key] = h.value; + }); + + if (apiTokenInput) { + requestHeaders['Authorization'] = `Bearer ${apiTokenInput}`; + } + + if (method !== 'GET') { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + if (csrfToken) { + requestHeaders['X-CSRF-TOKEN'] = csrfToken; + } + } + + const options: RequestInit = { + method, + headers: requestHeaders, + credentials: 'same-origin', + }; + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && requestBody) { + options.body = requestBody; + } + + const res = await fetch(computedUrl, options); + const endTime = performance.now(); + + setResponseStatus(res.status); + setResponseTime(Math.round(endTime - startTime)); + + const collectedHeaders: Record = {}; + res.headers.forEach((value, key) => { + collectedHeaders[key] = value; + }); + setResponseHeaders(collectedHeaders); + + const text = await res.text(); + setResponseSize(new Blob([text]).size); + + const contentType = res.headers.get('content-type'); + if (contentType?.includes('application/json')) { + try { + setResponse(JSON.parse(text)); + } catch { + setResponse(text); + } + } else { + setResponse(text); + } + } catch (err: any) { + setError(err.message || 'Request failed'); + } finally { + setIsLoading(false); + } + }; + + // Add/remove functions + const addQueryParam = () => { + setQueryParams([...queryParams, { key: '', value: '', enabled: true }]); + }; + + const removeQueryParam = (index: number) => { + setQueryParams(queryParams.filter((_, i) => i !== index)); + }; + + const updateQueryParam = (index: number, patch: Partial) => { + setQueryParams(queryParams.map((param, i) => (i === index ? { ...param, ...patch } : param))); + }; + + const addHeader = () => { + setHeaders([...headers, { key: '', value: '', enabled: true }]); + }; + + const removeHeader = (index: number) => { + setHeaders(headers.filter((_, i) => i !== index)); + }; + + const updateHeader = (index: number, patch: Partial) => { + setHeaders(headers.map((header, i) => (i === index ? { ...header, ...patch } : header))); + }; + + // Copy response + const copyResponse = async () => { + try { + const text = typeof response === 'string' ? response : JSON.stringify(response, null, 2); + await navigator.clipboard.writeText(text); + setCopiedResponse(true); + setTimeout(() => setCopiedResponse(false), 2000); + } catch (err) { + console.error('Failed to copy response:', err); + } + }; + + // Expand/Collapse all + const expandAllJson = () => { + setForceExpandAll(true); + setJsonExpandKey(jsonExpandKey + 1); + }; + + const collapseAllJson = () => { + setForceExpandAll(false); + setJsonExpandKey(jsonExpandKey + 1); + }; + + const downloadBlob = (blob: Blob, filename: string) => { + const downloadUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); + }; + + const exportOpenApiJson = () => { + if (!apiResource?.openApiSpec) return; + const blob = new Blob([JSON.stringify(apiResource.openApiSpec, null, 2)], { type: 'application/json' }); + downloadBlob(blob, `${resourceSlug || 'api'}-openapi.json`); + setShowExportMenu(false); + }; + + const exportOpenApiYaml = () => { + if (!apiResource?.openApiSpec) return; + const yamlContent = toYaml(apiResource.openApiSpec); + const blob = new Blob([yamlContent], { type: 'text/yaml' }); + downloadBlob(blob, `${resourceSlug || 'api'}-openapi.yaml`); + setShowExportMenu(false); + }; + + const copyOpenApiToClipboard = async () => { + if (!apiResource?.openApiSpec) return; + try { + await navigator.clipboard.writeText(JSON.stringify(apiResource.openApiSpec, null, 2)); + setCopiedOpenApi(true); + setTimeout(() => setCopiedOpenApi(false), 2000); + setShowExportMenu(false); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + // Watch for apiResource URL changes (immediate) + useEffect(() => { + if (fullUrl) { + const op = findOperation(selectedOperation); + if (op) { + setUrl(urlForOperation(op, recordId)); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullUrl]); + + // Watch for apiToken prop changes + useWatch(apiToken, (newToken) => { + if (newToken) { + setApiTokenInput(newToken); + } + }); + + // Initialize + useEffect(() => { + if (apiToken) { + setApiTokenInput(apiToken); + } + selectOperation('index'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sendDisabled = !!(isLoading || (currentOperation?.needsId && !recordId)); + const enabledParamsCount = queryParams.filter((p) => p.enabled).length; + + return ( +
+ {/* Sidebar - Endpoints */} +
+ {/* Sidebar Header */} +
+ {!sidebarCollapsed && ( +
+ + Endpoints +
+ )} + +
+ + {/* Endpoints List */} +
+ {/* CRUD Operations */} + {!sidebarCollapsed && ( +

CRUD Operations

+ )} + + {operations.map((op) => ( +
+ + {/* Tooltip for collapsed state */} + {sidebarCollapsed && ( +
+ {op.method} + {op.label} +
+ )} +
+ ))} + + {/* Custom Actions */} + {customActions.length > 0 && ( + <> + {!sidebarCollapsed ? ( + <> +
+

Actions

+ + ) : ( +
+ )} + + {customActions.map((action) => ( +
+ + {/* Tooltip for collapsed state */} + {sidebarCollapsed && ( +
+ {action.method} + {action.label} +
+ )} +
+ ))} + + )} +
+ + {/* API Info Footer */} + {!sidebarCollapsed && apiResource?.version && ( +
+

API {apiResource.version}

+
+ )} +
+ + {/* Main Content */} +
+ {/* Request Section */} +
+ {/* Operation Info */} + {currentOperation && ( +
+
+ {method} + {currentOperation.label} +
+ {currentOperation.description &&

{currentOperation.description}

} +
+ )} + + {/* Record ID Input */} + {currentOperation?.needsId && ( +
+
+ + handleRecordIdChange(event.target.value)} + type="text" + placeholder="Enter ID" + className="w-32 px-2 py-1 text-sm rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ )} + + {/* URL Bar */} +
+
+
+ + +
+ + setUrl(event.target.value)} + type="text" + placeholder="Enter request URL" + className="flex-1 bg-transparent px-3 py-2 text-sm font-mono focus:outline-none" + /> + + +
+
+ + {/* Request Tabs */} +
+ {(['params', 'headers', 'body', 'auth'] as const).map((tab) => ( + + ))} + +
+
+ + {showExportMenu && ( +
+
+ + + +
+
+ )} +
+
+
+ + {/* Tab Content */} +
+ {/* Params Tab */} + {requestTab === 'params' && ( +
+ {queryParams.length === 0 ? ( +
+

No query parameters

+ +
+ ) : ( + <> + {queryParams.map((param, index) => ( +
+ updateQueryParam(index, { enabled: event.target.checked })} + className="h-4 w-4 rounded border-border accent-primary" + /> + updateQueryParam(index, { key: event.target.value })} + type="text" + placeholder="Key" + className={INPUT_CLASS} + /> + updateQueryParam(index, { value: event.target.value })} + type="text" + placeholder="Value" + className={INPUT_CLASS} + /> + +
+ ))} + + + )} +
+ )} + + {/* Headers Tab */} + {requestTab === 'headers' && ( +
+ {headers.map((header, index) => ( +
+ updateHeader(index, { enabled: event.target.checked })} + className="h-4 w-4 rounded border-border accent-primary" + /> + updateHeader(index, { key: event.target.value })} + type="text" + placeholder="Key" + className={INPUT_CLASS} + /> + updateHeader(index, { value: event.target.value })} + type="text" + placeholder="Value" + className={INPUT_CLASS} + /> + +
+ ))} + +
+ )} + + {/* Body Tab */} + {requestTab === 'body' && ( +
+
+ Request Body (JSON) + +
+ +
+ )} + + {/* Auth Tab */} + {requestTab === 'auth' && ( +
+
+ + Bearer Token +
+
+ +
+ setApiTokenInput(event.target.value)} + type={showToken ? 'text' : 'password'} + placeholder="Enter your API token" + className="w-full px-3 py-2 pr-10 text-sm font-mono rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> + +
+

Token will be sent as: Authorization: Bearer <token>

+
+
+ )} +
+
+ + {/* Response Section */} +
+ {/* Response Header */} +
+
+ Response + {responseStatus ? ( + <> + + {responseStatus} + + {responseTime}ms + {responseSize ? {formatBytes(responseSize)} : null} + + ) : null} +
+ +
+
+ + +
+ + {responseTab === 'body' && response && typeof response === 'object' && ( + <> +
+ + +
+ + + + + )} + + {response && ( + + )} +
+
+ + {/* Response Body */} +
+ {isLoading ? ( +
+
+ + Sending request... +
+
+ ) : error ? ( +
+
+

Request Failed

+

{error}

+
+
+ ) : !response ? ( +
+
+ +

No Response Yet

+

Click Send to make a request

+
+
+ ) : responseTab === 'body' ? ( + responseViewMode === 'pretty' && typeof response === 'object' ? ( +
+ +
+ ) : ( +
+                                    {formattedResponse}
+                                
+ ) + ) : responseTab === 'headers' ? ( +
+ {Object.entries(responseHeaders).map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} + {Object.keys(responseHeaders).length === 0 && ( +
+

No response headers

+
+ )} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/resources/react/components/CardGrid.tsx b/resources/react/components/CardGrid.tsx new file mode 100644 index 0000000..c83c05f --- /dev/null +++ b/resources/react/components/CardGrid.tsx @@ -0,0 +1,1011 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { router } from '@inertiajs/react'; +import RecordActions from '@laravilt/actions/components/RecordActions'; +import { + AlertCircle, + AlertTriangle, + Archive, + Calendar, + CheckCircle2, + Circle, + Clock, + FileText, + Inbox, + Loader2, + Package, + PenLine, + Play, + Star, + User, + XCircle, + type LucideIcon, +} from 'lucide-react'; +import { useState, type ChangeEvent, type ComponentType, type MouseEvent } from 'react'; +import { useWatch } from '../composables/useWatch'; +import { toDisplayString } from '../lib/display'; +import { resolveColumnIcon } from '../lib/icons'; +import ColorGridColumn from './grid-columns/ColorGridColumn'; +import IconGridColumn from './grid-columns/IconGridColumn'; +import ImageGridColumn from './grid-columns/ImageGridColumn'; +import TextGridColumn from './grid-columns/TextGridColumn'; +import ToggleGridColumn from './grid-columns/ToggleGridColumn'; + +export interface CardGridProps { + grid: any; + records: any[]; + recordActions?: any[]; + loading?: boolean; + loadingMore?: boolean; + bulkActionsAvailable?: boolean; + resourceSlug: string; + modelClass?: string; + clearSelections?: number; + onUpdateSelectedRecords?: (records: (number | string)[]) => void; +} + +const columnComponents: Record> = { + text_grid_column: TextGridColumn, + image_grid_column: ImageGridColumn, + color_grid_column: ColorGridColumn, + icon_grid_column: IconGridColumn, + toggle_grid_column: ToggleGridColumn, +}; + +const getColumnComponent = (columnType: string): ComponentType => columnComponents[columnType] || TextGridColumn; + +const skeletonCount = 12; +const SKELETON_INDEXES = Array.from({ length: skeletonCount }, (_, index) => index + 1); +const LOADING_MORE_INDEXES = Array.from({ length: 12 }, (_, index) => index + 1); +const FIVE = [1, 2, 3, 4, 5]; + +// Helper to get nested value using dot notation (e.g., 'category.name') +const getNestedValue = (record: any, field: string) => { + if (!field) return null; + + // First check if the flattened key exists (from backend processing) + if (record[field] !== undefined) { + return record[field]; + } + + // Otherwise, traverse nested objects + if (field.includes('.')) { + const parts = field.split('.'); + let value = record; + for (const part of parts) { + if (value === null || value === undefined) return null; + value = value[part]; + } + return value; + } + + return record[field]; +}; + +const formatBadgeText = (text: string) => { + if (!text) return ''; + // Replace underscores with spaces and convert to title case + return String(text) + .replace(/_/g, ' ') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +}; + +// Get badge variant based on status value +const getBadgeVariant = (status: string) => { + const variantMap: Record = { + active: 'success', + draft: 'warning', + pending: 'warning', + out_of_stock: 'destructive', + archived: 'secondary', + inactive: 'secondary', + }; + return variantMap[status] || 'default'; +}; + +// Get relative time from created_at or updated_at +const getRelativeTime = (record: any) => { + const dateField = record.created_at || record.updated_at; + if (!dateField) return null; + + const date = new Date(dateField); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return 'Just now'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; + if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`; + if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago`; + return date.toLocaleDateString(); +}; + +// Get avatar color based on record id or title +const avatarColors = [ + 'bg-red-500', + 'bg-orange-500', + 'bg-amber-500', + 'bg-yellow-500', + 'bg-lime-500', + 'bg-green-500', + 'bg-emerald-500', + 'bg-teal-500', + 'bg-cyan-500', + 'bg-sky-500', + 'bg-blue-500', + 'bg-indigo-500', + 'bg-violet-500', + 'bg-purple-500', + 'bg-fuchsia-500', + 'bg-pink-500', + 'bg-rose-500', +]; + +const getAvatarColor = (record: any) => { + const index = (record.id || 0) % avatarColors.length; + return avatarColors[index]; +}; + +// Get badge color class based on status +const badgeColorClasses: Record = { + active: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + published: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + approved: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + completed: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20', + draft: 'bg-amber-500/10 text-amber-600 border-amber-500/20', + pending: 'bg-amber-500/10 text-amber-600 border-amber-500/20', + processing: 'bg-blue-500/10 text-blue-600 border-blue-500/20', + in_progress: 'bg-blue-500/10 text-blue-600 border-blue-500/20', + inactive: 'bg-slate-500/10 text-slate-600 border-slate-500/20', + archived: 'bg-slate-500/10 text-slate-600 border-slate-500/20', + cancelled: 'bg-red-500/10 text-red-600 border-red-500/20', + rejected: 'bg-red-500/10 text-red-600 border-red-500/20', + failed: 'bg-red-500/10 text-red-600 border-red-500/20', + out_of_stock: 'bg-red-500/10 text-red-600 border-red-500/20', +}; + +const getBadgeColorClass = (status: string) => badgeColorClasses[status] || 'bg-primary/10 text-primary border-primary/20'; + +// Get status icon based on status +const statusIcons: Record = { + active: CheckCircle2, + published: CheckCircle2, + approved: CheckCircle2, + completed: CheckCircle2, + draft: PenLine, + pending: Clock, + processing: Loader2, + in_progress: Play, + inactive: Circle, + archived: Archive, + cancelled: XCircle, + rejected: XCircle, + failed: AlertCircle, + out_of_stock: AlertTriangle, +}; + +const getStatusIcon = (status: string): LucideIcon => statusIcons[status] || Circle; + +const gridColsMap: Record = { + 1: 'grid-cols-1', + 2: 'grid-cols-1 sm:grid-cols-2', + 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', + 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', + 5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5', + 6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6', +}; + +function ProductSkeleton() { + return ( + <> + {/* Product Image Skeleton */} + + + {/* Product Info Skeleton */} +
+ {/* Category / SKU */} +
+ + +
+ + {/* Title */} + + + + {/* Description */} + + + + {/* Rating */} +
+
+ {FIVE.map((s) => ( + + ))} +
+ +
+ + {/* Price & Stock */} +
+
+ +
+ + +
+
+
+
+ + {/* Actions Footer Skeleton */} +
+ + + +
+ + ); +} + +function SimpleSkeleton() { + return ( + <> + {/* Main Card Content Skeleton */} +
+ {/* Header: Checkbox + ID */} +
+ + +
+ + {/* Body: Avatar + Info (centered) */} +
+ {/* Avatar Skeleton */} + + + {/* Title and Description Skeleton */} +
+
+ + +
+
+ + +
+
+
+ + {/* Meta Row Skeleton */} +
+ {/* Status Badge Skeleton */} + + {/* Timestamp Skeleton */} + +
+
+ + {/* Actions Footer Skeleton */} +
+ + + +
+ + ); +} + +export default function CardGrid({ + grid, + records, + loading = false, + loadingMore = false, + bulkActionsAvailable = false, + resourceSlug, + modelClass, + clearSelections = 0, + onUpdateSelectedRecords, +}: CardGridProps) { + const [selectedRecords, setSelectedRecords] = useState>(() => new Set()); + const [selectAll, setSelectAll] = useState(false); + + // If loading more (infinite scroll), don't show full skeleton - show records + skeleton at bottom + const isLoading = loadingMore ? false : !!loading; + + const handleSelectAll = (event: ChangeEvent) => { + const target = event.target; + setSelectAll(target.checked); + + const next = target.checked ? new Set(records.map((r) => r.id)) : new Set(); + setSelectedRecords(next); + onUpdateSelectedRecords?.(Array.from(next)); + }; + + const handleSelectRecord = (recordId: number | string) => { + const next = new Set(selectedRecords); + if (next.has(recordId)) { + next.delete(recordId); + } else { + next.add(recordId); + } + setSelectedRecords(next); + setSelectAll(next.size === records.length); + onUpdateSelectedRecords?.(Array.from(next)); + }; + + const isSelected = (recordId: number | string) => selectedRecords.has(recordId); + + // Watch for clear selections signal + useWatch(clearSelections, () => { + setSelectedRecords(new Set()); + setSelectAll(false); + }); + + const gridColumns: any[] = (() => { + // Priority 1: Use card-specific schema if defined + if (grid.card?.schema && grid.card.schema.length > 0) { + return grid.card.schema; + } + + // Priority 2: Use card-specific columns if defined + if (grid.card?.columns && grid.card.columns.length > 0) { + return grid.card.columns; + } + + // Priority 3: If using card builder fields, filter out the fields that are already displayed + if (grid.card?.imageField || grid.card?.titleField || grid.card?.priceField || grid.card?.descriptionField || grid.card?.badgeField) { + const usedFields = [ + grid.card?.imageField, + grid.card?.titleField, + grid.card?.priceField, + grid.card?.descriptionField, + grid.card?.badgeField, + ].filter(Boolean); + + return grid.columns.filter((column: any) => !usedFields.includes(column.name)); + } + + // Priority 4: Use grid columns as fallback + return grid.columns; + })(); + + // Card styling + const cardGap = (() => { + switch (grid.card?.gap) { + case 'sm': + return 'space-y-2'; + case 'md': + return 'space-y-3'; + case 'lg': + return 'space-y-4'; + default: + return 'space-y-3'; + } + })(); + + const getImageUrl = (record: any) => { + const imageField = grid.card?.imageField; + if (!imageField) return null; + return getNestedValue(record, imageField); + }; + + const getTitle = (record: any) => { + const titleField = grid.card?.titleField; + if (!titleField) return null; + return getNestedValue(record, titleField); + }; + + const getSubtitle = (record: any) => { + const subtitleField = grid.card?.subtitleField; + if (!subtitleField) return null; + return getNestedValue(record, subtitleField); + }; + + const getDescription = (record: any) => { + const descriptionField = grid.card?.descriptionField; + if (!descriptionField) return null; + return getNestedValue(record, descriptionField); + }; + + const getPrice = (record: any) => { + const priceField = grid.card?.priceField; + if (!priceField) return null; + return getNestedValue(record, priceField); + }; + + const getBadge = (record: any) => { + const badgeField = grid.card?.badgeField; + if (!badgeField) return null; + return getNestedValue(record, badgeField); + }; + + const getBadgeIcon = (record: any) => { + const badgeField = grid.card?.badgeField; + if (!badgeField) return null; + return record._icons?.[badgeField] || null; + }; + + const getBadgeIconComponent = (record: any): LucideIcon | null => { + const iconName = getBadgeIcon(record); + if (!iconName) return null; + return resolveColumnIcon(iconName); + }; + + // Dynamic grid columns based on cardsPerRow + const gridColsClass = gridColsMap[grid?.cardsPerRow || 3] || gridColsMap[3]; + + // Card style - determines which card template to use + const cardStyle = grid.card?.style || 'default'; + + // Get avatar initials from title + const getAvatarInitials = (record: any) => { + // Normalize first: the configured title field may hold a number or object + const title = toDisplayString(getTitle(record)); + if (!title) return '?'; + const words = title.split(' '); + if (words.length >= 2) { + return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase(); + } + return title.substring(0, 2).toUpperCase(); + }; + + // Handle card click to navigate to record URL + const handleCardClick = (event: MouseEvent, record: any) => { + // Don't navigate if there's no URL + if (!record._url) return; + + // Don't navigate if clicking on interactive elements + const target = event.target as HTMLElement; + const interactiveElements = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL']; + + // Check if click is on or inside an interactive element + let element: HTMLElement | null = target; + while (element) { + if (interactiveElements.includes(element.tagName)) return; + if (element.hasAttribute('data-no-card-click')) return; + if (element.classList.contains('record-actions')) return; + element = element.parentElement; + } + + // Navigate using Inertia + router.visit(record._url); + }; + + const renderRecordActions = (record: any) => ( + + ); + + // ================================ + // SIMPLE CARD STYLE + // User card design with avatar, info, status and timestamp + // ================================ + const renderSimpleCard = (record: any) => { + const badge = getBadge(record); + const imageUrl = getImageUrl(record); + const title = getTitle(record); + const description = getDescription(record); + const relativeTime = getRelativeTime(record); + const StatusIcon = getStatusIcon(badge); + + return ( +
handleCardClick(event, record)} + > + {/* Main Card Content */} +
+ {/* Top Header: Checkbox + ID */} +
+ {/* Selection Checkbox */} + {bulkActionsAvailable && ( + handleSelectRecord(record.id)} + aria-label={`Select record #${toDisplayString(record.id)}`} + className="size-5 rounded border-2 border-muted-foreground/40 bg-background cursor-pointer hover:border-primary/60" + /> + )} + {/* Record ID */} + #{toDisplayString(record.id)} +
+ + {/* Body: Avatar + Info (centered horizontally) */} +
+ {/* Avatar with Status Indicator */} +
+ + {imageUrl && } + + {getAvatarInitials(record)} + + + {/* Online/Status Indicator Dot */} + {badge && ( +
+ )} +
+ + {/* Title and Description (centered) */} +
+
+ + {title &&

{toDisplayString(title)}

} +
+ {description && ( +
+ +

{toDisplayString(description)}

+
+ )} +
+
+ + {/* Meta Row: Status Badge + Timestamp */} +
+ {/* Status Badge */} + {badge ? ( +
+ + {formatBadgeText(badge)} +
+ ) : ( +
+ )} + + {/* Timestamp with Calendar Icon */} + {relativeTime && ( +
+ + {relativeTime} +
+ )} +
+
+ + {/* Actions Footer (CENTERED) */} + {record._actions && record._actions.length > 0 && ( +
+ {renderRecordActions(record)} +
+ )} +
+ ); + }; + + // ================================ + // MEDIA CARD STYLE + // Full background image with gradient overlay + // ================================ + const renderMediaCard = (record: any) => { + const badge = getBadge(record); + const imageUrl = getImageUrl(record); + const title = getTitle(record); + const description = getDescription(record); + const BadgeIcon = getBadgeIconComponent(record); + + return ( +
handleCardClick(event, record)} + > + {/* Background Image */} +
+ {imageUrl ? ( + {title + ) : ( +
+ {toDisplayString(title).charAt(0) || '?'} +
+ )} + + {/* Gradient Overlay */} +
+ + {/* Selection Checkbox (top-left) */} + {bulkActionsAvailable && ( +
+ handleSelectRecord(record.id)} + aria-label={`Select record #${toDisplayString(record.id)}`} + className="border-white/50 data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> +
+ )} + + {/* Badge (top-right) */} + {badge && ( +
+ + {BadgeIcon && } + {formatBadgeText(badge)} + +
+ )} + + {/* Content Overlay (bottom) */} +
+ {/* Title */} + {title &&

{toDisplayString(title)}

} + + {/* Description */} + {description &&

{toDisplayString(description)}

} +
+
+ + {/* Actions Bar */} + {record._actions && record._actions.length > 0 && ( +
+ {renderRecordActions(record)} +
+ )} +
+ ); + }; + + // ================================ + // PRODUCT CARD STYLE + // E-commerce style with image, structured info, and price + // ================================ + const renderProductCard = (record: any) => { + const badge = getBadge(record); + const imageUrl = getImageUrl(record); + const title = getTitle(record); + const subtitle = getSubtitle(record); + const description = getDescription(record); + const price = getPrice(record); + const StatusIcon = getStatusIcon(badge); + + return ( +
handleCardClick(event, record)} + > + {/* Selection Checkbox (floating) */} + {bulkActionsAvailable && ( +
+ handleSelectRecord(record.id)} + aria-label={`Select record #${toDisplayString(record.id)}`} + className="size-5 rounded border-2 border-white/80 bg-white/90 backdrop-blur-sm shadow-sm cursor-pointer hover:border-primary/60" + /> +
+ )} + + {/* Badge (floating top-right) */} + {badge && ( +
+
+ + {formatBadgeText(badge)} +
+
+ )} + + {/* Product Image Container */} + {grid.card?.showImage !== false && ( +
+ {imageUrl ? ( + {title + ) : ( +
+
+ + No Image +
+
+ )} +
+ )} + + {/* Product Info */} +
+ {/* Subtitle (e.g., Category) */} + {subtitle && ( +
+ {toDisplayString(subtitle)} +
+ )} + + {/* Title */} + {title && ( +

+ {toDisplayString(title)} +

+ )} + + {/* Description */} + {description &&

{toDisplayString(description)}

} + + {/* Rating */} + {record.rating ? ( +
+
+ {FIVE.map((i) => ( + + ))} +
+ ({toDisplayString(record.review_count || 0)}) +
+ ) : null} + + {/* Price & Stock Section */} +
+
+ {/* Price */} + {price ? ( +
+ + {typeof price === 'number' ? `$${price.toFixed(2)}` : toDisplayString(price)} + +
+ ) : null} + + {/* Stock Status */} + {record.stock_quantity !== undefined && ( +
+
10 ? 'bg-emerald-500' : record.stock_quantity > 0 ? 'bg-amber-500' : 'bg-red-500', + )} + >
+ 10 ? 'text-emerald-600' : record.stock_quantity > 0 ? 'text-amber-600' : 'text-red-600', + )} + > + {record.stock_quantity > 0 ? `${record.stock_quantity} in stock` : 'Out of stock'} + +
+ )} +
+
+
+ + {/* Actions Footer */} + {record._actions && record._actions.length > 0 && grid.card?.actionsPosition === 'bottom' && ( +
+ {renderRecordActions(record)} +
+ )} +
+ ); + }; + + // ================================ + // DEFAULT CARD STYLE (Fallback) + // Used when no specific style or when style is unrecognized + // ================================ + const renderDefaultCard = (record: any) => { + const badge = getBadge(record); + const title = getTitle(record); + + return ( + handleCardClick(event, record)} + > + {/* Card Header with Checkbox */} + {(bulkActionsAvailable || title) && ( + + {bulkActionsAvailable && ( + handleSelectRecord(record.id)} + aria-label={`Select record #${toDisplayString(record.id)}`} + className="mt-1" + /> + )} +
+ {title ? ( +

{toDisplayString(title)}

+ ) : ( + Record #{toDisplayString(record.id)} + )} +
+ {badge && ( + + {formatBadgeText(badge)} + + )} +
+ )} + + + {gridColumns.map((column: any) => { + const ColumnComponent = getColumnComponent(column.component); + + return ( + // Spread the column config (limit, wrap, badge, imageWidth, editable, name, ...) first, + // then override the record-specific values + + ); + })} + + + {/* Actions Footer */} + {record._actions && record._actions.length > 0 && ( + + {renderRecordActions(record)} + + )} +
+ ); + }; + + const renderCard = + cardStyle === 'simple' + ? renderSimpleCard + : cardStyle === 'media' + ? renderMediaCard + : cardStyle === 'product' + ? renderProductCard + : renderDefaultCard; + + return ( +
+ {/* Select All (when bulk actions available) */} + {bulkActionsAvailable && records.length > 0 && ( +
+ +
+ )} + + {isLoading && !loadingMore ? ( + // Loading State (show skeleton when loading initial data) +
+ {cardStyle === 'product' + ? // Product Card Skeleton + SKELETON_INDEXES.map((i) => ( +
+ +
+ )) + : // Simple Card Skeleton (default) + SKELETON_INDEXES.map((i) => ( +
+ +
+ ))} +
+ ) : records.length > 0 ? ( + // Grid Content with records (+ loading more skeletons at bottom) +
+ {records.map((record) => renderCard(record))} + + {/* Loading more skeleton cards (shown at bottom while infinite scrolling) */} + {loadingMore && cardStyle === 'product' + ? LOADING_MORE_INDEXES.map((i) => ( +
+ +
+ )) + : loadingMore + ? LOADING_MORE_INDEXES.map((i) => ( +
+ +
+ )) + : null} +
+ ) : ( + // Empty State (only when not loading and no records) +
+
+ +
+

{grid.emptyState?.heading || 'No records found'}

+ {grid.emptyState?.description ? ( +

{grid.emptyState.description}

+ ) : ( +

There are no records to display. Try adjusting your filters or search query.

+ )} +
+ )} +
+ ); +} diff --git a/resources/react/components/DataTable.css b/resources/react/components/DataTable.css new file mode 100644 index 0000000..2ab5952 --- /dev/null +++ b/resources/react/components/DataTable.css @@ -0,0 +1,45 @@ +/* Global scrollbar styles from DataTable.vue (not scoped for proper pseudo-element support) */ +/* Custom scrollbar for DataTable - thin and elegant */ +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + margin: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 9999px; + transition: background 0.15s ease; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); +} + +/* Dark mode scrollbar */ +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.25); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.4); +} + +/* Firefox scrollbar */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; +} + +.dark .custom-scrollbar { + scrollbar-color: hsl(var(--muted-foreground) / 0.25) transparent; +} + +/* Scrollbar corner */ +.custom-scrollbar::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/resources/react/components/DataTable.tsx b/resources/react/components/DataTable.tsx new file mode 100644 index 0000000..6235ee7 --- /dev/null +++ b/resources/react/components/DataTable.tsx @@ -0,0 +1,708 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { router } from '@inertiajs/react'; +import RecordActions from '@laravilt/actions/components/RecordActions'; +import { useLatest } from '@laravilt/support/composables/hooks'; +import { useLocalization } from '@laravilt/support/composables/useLocalization'; +import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical, Inbox } from 'lucide-react'; +import { useMemo, useState, type ComponentType, type CSSProperties, type DragEvent, type MouseEvent, type ReactNode } from 'react'; +import { useWatch } from '../composables/useWatch'; +import ColorColumn from './columns/ColorColumn'; +import IconColumn from './columns/IconColumn'; +import ImageColumn from './columns/ImageColumn'; +import TextColumn from './columns/TextColumn'; +import ToggleColumn from './columns/ToggleColumn'; +import './DataTable.css'; + +export interface Column { + component: string; + name: string; + label: string; + sortable?: boolean; + toggleable?: boolean; + [key: string]: any; +} + +export interface TableRecord { + id: number | string; + _url?: string; + [key: string]: any; +} + +export interface Action { + name: string; + label?: string; + icon?: string; + color?: string; + url?: string; + requiresConfirmation?: boolean; + [key: string]: any; +} + +export interface GroupConfig { + column: string; + label: string; + collapsible: boolean; +} + +export interface RecordGroup { + value: string | number | null; + title: string; + description?: string | null; + records: TableRecord[]; +} + +export interface DataTableProps { + columns?: Column[]; + records?: TableRecord[]; + loading?: boolean; + skeletonRows?: number; + sortColumn?: string | null; + sortDirection?: 'asc' | 'desc'; + visibleColumns?: string[]; + bulkActionsAvailable?: boolean; + resourceSlug?: string; + columnExecutionRoute?: string; + modelClass?: string; + recordActions?: Action[]; + executionRoute?: string; + clearSelections?: number; + fixedActions?: boolean; + striped?: boolean; + infiniteScroll?: boolean; + useAjax?: boolean; + reorderable?: boolean; + reorderableColumn?: string; + reorderRoute?: string; + activeGroup?: string | null; + groups?: GroupConfig[]; + onSort?: (column: string, direction: 'asc' | 'desc') => void; + onUpdateSelectedRecords?: (records: (number | string)[]) => void; + onActionComplete?: (data?: any) => void; + onReorder?: (items: { id: number | string; order: number }[]) => void; + /** Vue slot `empty` */ + empty?: ReactNode; + /** Vue scoped slot `actions` */ + actions?: (scope: { record: TableRecord }) => ReactNode; +} + +const EMPTY_COLUMNS: Column[] = []; +const EMPTY_RECORDS: TableRecord[] = []; +const EMPTY_STRINGS: string[] = []; +const EMPTY_ACTIONS: Action[] = []; +const EMPTY_GROUPS: GroupConfig[] = []; + +// Scoped `.group` rule from DataTable.vue (smooth transitions for row hover states) +const ROW_TRANSITION_STYLE: CSSProperties = { + transition: 'background-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out', +}; + +const CHECKBOX_CLASS = + 'peer size-4 shrink-0 appearance-none rounded border border-input bg-background shadow-sm ring-offset-background transition-all duration-150 hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary checked:text-primary-foreground cursor-pointer'; + +function CheckIcon() { + return ( + + + + ); +} + +export default function DataTable({ + columns = EMPTY_COLUMNS, + records = EMPTY_RECORDS, + loading = false, + skeletonRows = 10, + sortColumn = null, + sortDirection = 'asc', + visibleColumns = EMPTY_STRINGS, + bulkActionsAvailable = false, + resourceSlug = '', + columnExecutionRoute, + modelClass, + executionRoute, + clearSelections = 0, + fixedActions = false, + striped = false, + reorderable = false, + reorderableColumn = 'sort_order', + reorderRoute, + activeGroup = null, + groups = EMPTY_GROUPS, + onSort, + onUpdateSelectedRecords, + onActionComplete, + onReorder, + empty, + actions, +}: DataTableProps) { + const { trans } = useLocalization(); + const recordsRef = useLatest(records); + + // Drag and drop state for reorderable + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const [localRecords, setLocalRecords] = useState(() => [...records]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [isReordering, setIsReordering] = useState(false); + + // Keep local records in sync with props (Vue: immediate deep watcher) + const [syncedRecords, setSyncedRecords] = useState(records); + if (syncedRecords !== records) { + setSyncedRecords(records); + setLocalRecords([...records]); + } + + // Drag and drop handlers + const handleDragStart = (event: DragEvent, index: number) => { + if (!reorderable) return; + setDraggedIndex(index); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(index)); + } + }; + + const handleDragOver = (event: DragEvent, index: number) => { + if (!reorderable || draggedIndex === null) return; + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + setDragOverIndex(index); + }; + + const handleDragLeave = () => { + setDragOverIndex(null); + }; + + const saveReorder = async (items: { id: number | string; order: number }[]) => { + if (!reorderRoute && !resourceSlug) return; + + setIsReordering(true); + try { + const url = reorderRoute || `/admin/${resourceSlug}/reorder`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + items, + column: reorderableColumn, + }), + }); + + // fetch only rejects on network failure; treat 4xx/5xx as a failed save too + if (!response.ok) { + throw new Error(`Reorder request failed with status ${response.status}`); + } + } catch (error) { + console.error('Failed to save reorder:', error); + // Revert to original order on error + setLocalRecords([...recordsRef.current]); + } finally { + setIsReordering(false); + } + }; + + const handleDrop = async (event: DragEvent, targetIndex: number) => { + if (!reorderable || draggedIndex === null) return; + event.preventDefault(); + + const sourceIndex = draggedIndex; + if (sourceIndex === targetIndex) { + setDraggedIndex(null); + setDragOverIndex(null); + return; + } + + // Reorder local records + const newRecords = [...localRecords]; + const [movedItem] = newRecords.splice(sourceIndex, 1); + newRecords.splice(targetIndex, 0, movedItem); + setLocalRecords(newRecords); + + // Reset drag state + setDraggedIndex(null); + setDragOverIndex(null); + + // Build new order data + const reorderData = newRecords.map((record, index) => ({ + id: record.id, + order: index + 1, + })); + + // Emit reorder event + onReorder?.(reorderData); + + // Send to server + await saveReorder(reorderData); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + // Use local records for rendering when reorderable + const displayRecords = reorderable ? localRecords : records; + + // Track collapsed groups (not used by the template — same as Vue) + const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()); + + // Check if grouping is active + const isGrouped = activeGroup !== null && activeGroup !== undefined; + + // Get active group config (not used by the template — same as Vue) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const activeGroupConfig = !activeGroup ? null : groups?.find((g) => g.column === activeGroup) || null; + + // Group records by active group column (not used by the template — same as Vue) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const groupedRecords = useMemo(() => { + if (!isGrouped || !activeGroup) { + return []; + } + + const groupMap = new Map(); + + for (const record of displayRecords) { + const groupInfo = record._group; + const groupValue = groupInfo?.value ?? null; + const groupKey = String(groupValue); + + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, { + value: groupValue, + title: groupInfo?.title || String(groupValue), + description: groupInfo?.description || null, + records: [], + }); + } + + groupMap.get(groupKey)!.records.push(record); + } + + return Array.from(groupMap.values()); + }, [isGrouped, activeGroup, displayRecords]); + + // Toggle group collapse state (not used by the template — same as Vue) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const toggleGroupCollapse = (groupValue: string | number | null) => { + const groupKey = String(groupValue); + const next = new Set(collapsedGroups); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + setCollapsedGroups(next); + }; + + // Check if a group is collapsed (not used by the template — same as Vue) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const isGroupCollapsed = (groupValue: string | number | null) => collapsedGroups.has(String(groupValue)); + + const [selectedRecords, setSelectedRecords] = useState>(() => new Set()); + + // Watch for clearSelections prop changes to clear selections + useWatch(clearSelections, (value) => { + if (value > 0) { + setSelectedRecords(new Set()); + onUpdateSelectedRecords?.([]); + } + }); + + const isColumnVisible = (column: Column): boolean => { + if (visibleColumns.length === 0) return true; + if (column.toggleable === false) return true; + return visibleColumns.includes(column.name); + }; + + const visibleColumnsFiltered = columns.filter((col) => isColumnVisible(col)); + + const allSelected = records.length === 0 ? false : records.every((record) => selectedRecords.has(record.id)); + + const someSelected = records.length === 0 ? false : records.some((record) => selectedRecords.has(record.id)) && !allSelected; + + const toggleSelectAll = () => { + const next = allSelected ? new Set() : new Set(records.map((record) => record.id)); + setSelectedRecords(next); + onUpdateSelectedRecords?.(Array.from(next)); + }; + + const toggleSelectRecord = (recordId: number | string) => { + const newSet = new Set(selectedRecords); + if (newSet.has(recordId)) { + newSet.delete(recordId); + } else { + newSet.add(recordId); + } + setSelectedRecords(newSet); + onUpdateSelectedRecords?.(Array.from(newSet)); + }; + + const isRecordSelected = (recordId: number | string) => selectedRecords.has(recordId); + + const handleSort = (column: Column) => { + if (!column.sortable) return; + + let direction: 'asc' | 'desc' = 'asc'; + + if (sortColumn === column.name) { + direction = sortDirection === 'asc' ? 'desc' : 'asc'; + } + + onSort?.(column.name, direction); + }; + + const getSortIcon = (column: Column) => { + if (!column.sortable) return null; + + if (sortColumn === column.name) { + return sortDirection === 'asc' ? ArrowUp : ArrowDown; + } + + return ArrowUpDown; + }; + + // Check if any record has actions + const hasRecordActions = records.some((record) => record._actions && record._actions.length > 0); + + // Get max actions count across all records for consistent column width + const maxActionsCount = !records.length + ? 3 // Default for skeleton + : Math.max(...records.map((record) => record._actions?.filter((a: Action) => !a.isHidden)?.length || 0), 1); + + // Calculate actions column width based on max actions (not used by the template — same as Vue) + // Each button is 32px (h-8 w-8) + 4px gap, plus 32px padding (px-4 = 16px each side) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const actionsColumnWidth = `${32 * maxActionsCount + 4 * (maxActionsCount - 1) + 32}px`; + + // Get component for column type + const getColumnComponent = (columnType: string): ComponentType => { + switch (columnType) { + case 'TextColumn': + return TextColumn; + case 'IconColumn': + return IconColumn; + case 'ImageColumn': + return ImageColumn; + case 'ColorColumn': + return ColorColumn; + case 'ToggleColumn': + return ToggleColumn; + default: + return TextColumn; + } + }; + + // Handle row click to navigate to record URL + const handleRowClick = (event: MouseEvent, record: TableRecord) => { + // Don't navigate if there's no URL + if (!record._url) return; + + // Don't navigate if clicking on interactive elements + const target = event.target as HTMLElement; + const interactiveElements = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL']; + + // Check if click is on or inside an interactive element + let element: HTMLElement | null = target; + while (element) { + if (interactiveElements.includes(element.tagName)) return; + if (element.hasAttribute('data-no-row-click')) return; + if (element.classList.contains('record-actions')) return; + element = element.parentElement; + } + + // Navigate using Inertia + router.visit(record._url); + }; + + // Get column width style based on column config + const getColumnWidthStyle = (column: Column): CSSProperties => { + const style: CSSProperties = {}; + + // Only apply explicit width if developer specified it + if (column.width) { + // Support various formats: '200px', '20%', 200 (number) + const width = typeof column.width === 'number' ? `${column.width}px` : column.width; + style.width = width; + style.minWidth = width; + style.maxWidth = width; + return style; + } + + // If column should grow, let it expand to fill remaining space + if (column.grow) { + style.flex = '1'; + return style; + } + + // Default: auto width - let content determine the width + return style; + }; + + const skeletonIndexes = Array.from({ length: skeletonRows }, (_, index) => index + 1); + const actionSkeletonIndexes = Array.from({ length: maxActionsCount }, (_, index) => index + 1); + + return ( +
+ {!loading && !records.length ? ( + // Empty State +
+
+ {empty != null ? ( + empty + ) : ( +
+
+ +
+

No records found

+

+ There are no records to display. Try adjusting your filters or search query. +

+
+ )} +
+
+ ) : ( + // Table Content (only show when there are records or loading) + + {/* Table Header */} + + + {/* Drag Handle Header (if reorderable) */} + {reorderable && ( + + )} + + {/* Checkbox Column (if bulk actions available) */} + {bulkActionsAvailable && ( + + )} + + {/* Data Column Headers */} + {visibleColumnsFiltered.map((column) => { + const SortIcon = getSortIcon(column); + + return ( + + ); + })} + + {/* Actions Header */} + {hasRecordActions && ( + + )} + + + + {/* Table Body */} + + {loading + ? // Loading State + skeletonIndexes.map((i) => ( + + {/* Drag Handle Skeleton (keeps cells aligned with the reorder header) */} + {reorderable && + )} + + {/* Column Skeletons */} + {visibleColumnsFiltered.map((column) => ( + + ))} + + {/* Actions Skeleton */} + {hasRecordActions && ( + + )} + + )) + : displayRecords.length > 0 + ? // Data Rows + displayRecords.map((record, index) => ( + handleDragStart(event, index)} + onDragOver={(event) => handleDragOver(event, index)} + onDragLeave={handleDragLeave} + onDrop={(event) => handleDrop(event, index)} + onDragEnd={handleDragEnd} + onClick={(event) => handleRowClick(event, record)} + > + {/* Drag Handle */} + {reorderable && ( + + )} + + {/* Checkbox Column */} + {bulkActionsAvailable && ( + + )} + + {/* Data Columns */} + {visibleColumnsFiltered.map((column, columnIndex) => { + const ColumnComponent = getColumnComponent(column.component); + + return ( + + ); + })} + + {/* Actions Column */} + {record._actions && record._actions.length > 0 ? ( + + ) : hasRecordActions ? ( + + ) : null} + + )) + : null} + +
+ Reorder + + + handleSort(column)} + > +
+ {column.label} + {column.sortable && SortIcon && ( + + )} +
+
+ {trans('tables::tables.columns.actions')} +
} + + {/* Checkbox Skeleton */} + {bulkActionsAvailable && ( + + + + + +
+ {actionSkeletonIndexes.map((n) => ( + + ))} +
+
+ + + + + {/* Same binding order as Vue: column config overrides the record-level values */} + + +
+ {actions ? ( + actions({ record }) + ) : ( + onActionComplete?.(data)} + /> + )} +
+
+ )} +
+ ); +} diff --git a/resources/react/components/GridToolbar.tsx b/resources/react/components/GridToolbar.tsx new file mode 100644 index 0000000..8d39395 --- /dev/null +++ b/resources/react/components/GridToolbar.tsx @@ -0,0 +1,291 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { useLocalization } from '@laravilt/support/composables'; +import { ArrowDown, ArrowUp, ArrowUpDown, Search, SlidersHorizontal, X } from 'lucide-react'; +import { useState, type KeyboardEvent, type ReactNode } from 'react'; +import { useWatch } from '../composables/useWatch'; + +interface FilterIndicator { + label: string; + removeField: string; +} + +export interface GridToolbarProps { + searchable?: boolean; + searchPlaceholder?: string; + search?: string; + filters?: any[]; + activeFilters?: Record; + filterIndicators?: FilterIndicator[]; + bulkActionsAvailable?: boolean; + selectedCount?: number; + columns?: any[]; + sortColumn?: string | null; + sortDirection?: 'asc' | 'desc'; + onUpdateSearch?: (value: string) => void; + onUpdateFilters?: (filters: Record) => void; + onRemoveFilter?: (filterName: string) => void; + onClearFilters?: () => void; + onUpdateSort?: (column: string, direction: 'asc' | 'desc') => void; + /** Vue slot `bulk-actions` */ + bulkActions?: ReactNode; + /** Vue slot `filters` (renamed: `filters` is already the filter-definitions prop) */ + filtersSlot?: ReactNode; + /** Vue slot `toolbar-actions` */ + toolbarActions?: ReactNode; + /** Vue slot `active-filters` (renamed: `activeFilters` is already the active-filter-values prop) */ + activeFiltersSlot?: ReactNode; +} + +const EMPTY: any[] = []; +const EMPTY_ACTIVE_FILTERS: Record = {}; + +export default function GridToolbar({ + searchable = true, + searchPlaceholder = 'Search...', + search = '', + filters = EMPTY, + activeFilters = EMPTY_ACTIVE_FILTERS, + bulkActionsAvailable = false, + selectedCount = 0, + columns = EMPTY, + sortColumn = null, + sortDirection = 'asc', + onUpdateSearch, + onRemoveFilter, + onClearFilters, + onUpdateSort, + bulkActions, + filtersSlot, + toolbarActions, + activeFiltersSlot, +}: GridToolbarProps) { + const { trans } = useLocalization(); + + const [localSearch, setLocalSearch] = useState(search); + + useWatch(search, (newValue) => { + setLocalSearch(newValue); + }); + + const handleSearchSubmit = () => { + onUpdateSearch?.(localSearch); + }; + + const activeFilterCount = Object.values(activeFilters).filter((value) => value !== null && value !== '' && value !== undefined).length; + + // Compute filter indicators from activeFilters instead of using the prop + const computedFilterIndicators: Array<{ label: string; removeField: string }> = []; + + Object.entries(activeFilters).forEach(([filterName, value]) => { + if (value === null || value === '' || value === undefined || value === false) { + return; + } + + // Find the filter definition + const filter = filters.find((f: any) => f.name === filterName); + if (!filter) return; + + // Get the indicator label from the filter + let label = `${filter.label || filterName}: ${value}`; + + // If filter has indicateUsing callback, use it + if (filter.indicateUsing) { + label = filter.indicateUsing; + } + + computedFilterIndicators.push({ + label, + removeField: filterName, + }); + }); + + const sortableColumns = columns.filter((col: any) => col.sortable); + + const currentSortLabel = (() => { + if (!sortColumn) return 'Sort by...'; + const column = sortableColumns.find((col: any) => col.name === sortColumn); + return column ? column.label : 'Sort by...'; + })(); + + const SortIcon = !sortColumn ? ArrowUpDown : sortDirection === 'asc' ? ArrowUp : ArrowDown; + const DirectionIcon = sortDirection === 'asc' ? ArrowUp : ArrowDown; + + const handleSortChange = (columnName: string) => { + let direction: 'asc' | 'desc' = 'asc'; + + if (sortColumn === columnName) { + // Toggle direction if same column + direction = sortDirection === 'asc' ? 'desc' : 'asc'; + } + + onUpdateSort?.(columnName, direction); + }; + + const clearSearch = () => { + onUpdateSearch?.(''); + }; + + const clearFilters = () => { + onClearFilters?.(); + }; + + const removeFilter = (filterName: string) => { + onRemoveFilter?.(filterName); + }; + + const hasActiveFilters = activeFilterCount > 0; + const hasActiveSearch = search.length > 0; + const hasComputedFilterIndicators = computedFilterIndicators.length > 0; + + return ( +
+ {/* Bulk Actions Bar */} + {bulkActionsAvailable && selectedCount > 0 && ( +
+ {selectedCount} selected +
{bulkActions}
+
+ )} + + {/* Top Row: Search and Filters */} +
+ {/* Search */} + {searchable && ( +
+ + setLocalSearch(event.target.value)} + type="search" + placeholder={searchPlaceholder || trans('tables::tables.search.placeholder')} + className="pl-9 pr-9" + onKeyUp={(event: KeyboardEvent) => { + if (event.key === 'Enter') handleSearchSubmit(); + }} + /> + {hasActiveSearch && ( + + )} +
+ )} + +
+ {/* Sort Button */} + {sortableColumns.length > 0 && ( + + + + + +
+

Sort by

+
+ {sortableColumns.map((column: any) => ( + + ))} +
+
+
+
+ )} + + {/* Filters Button */} + {filters.length > 0 && ( + + + + + +
+
+

Filters

+ {hasActiveFilters && ( + + )} +
+
{filtersSlot}
+
+
+
+ )} + + {/* Toolbar Actions Slot */} + {toolbarActions} +
+
+ + {/* Active Filters Display */} + {(hasActiveFilters || hasActiveSearch || hasComputedFilterIndicators) && ( +
+ Active filters: + + {hasActiveSearch && ( + + Search: "{search}" + + + )} + + {computedFilterIndicators.map((indicator, index) => ( + + {indicator.label} + + + ))} + + {activeFiltersSlot} + + {(hasActiveFilters || hasActiveSearch || hasComputedFilterIndicators) && ( + + )} +
+ )} +
+ ); +} diff --git a/resources/react/components/Table.tsx b/resources/react/components/Table.tsx new file mode 100644 index 0000000..5410bc3 --- /dev/null +++ b/resources/react/components/Table.tsx @@ -0,0 +1,1036 @@ +import { cn } from '@/lib/utils'; +import { router } from '@inertiajs/react'; +import ActionButton from '@laravilt/actions/components/ActionButton'; +import LaraviltComponentRenderer from '@laravilt/forms/components/LaraviltComponentRenderer'; +import { useLocalization } from '@laravilt/support/composables'; +import { useLatest } from '@laravilt/support/composables/hooks'; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type MouseEvent } from 'react'; +import { useStateRef } from '../composables/useStateRef'; +import { useWatch } from '../composables/useWatch'; +import CardGrid from './CardGrid'; +import DataTable from './DataTable'; +import TableToolbar from './TableToolbar'; + +export interface FilterIndicator { + label: string; + removeField: string; +} + +export interface RelationContext { + baseUrl: string; + relationship: string; + canEdit: boolean; + canDelete: boolean; + columnExecutionRoute?: string; +} + +export interface TableProps { + table: any; + records?: any[]; + pagination?: any; + recordActions?: any[]; + bulkActions?: any[]; + filterIndicators?: FilterIndicator[]; + resourceSlug: string; + queryRoute: string; + loading?: boolean; + currentView?: 'table' | 'grid'; + useAjax?: boolean; // Use fetch API instead of Inertia router (for relation managers) + onDataLoaded?: (data: { records: any[]; pagination: any }) => void; // Callback when data is loaded via AJAX (Vue: prop + `data-loaded` event) + relationContext?: RelationContext; // Context for relation manager to build record-specific URLs + onActionComplete?: (data?: any) => void; +} + +const DEFAULT_PAGINATION = { + total: 0, + per_page: 12, + current_page: 1, + last_page: 1, + from: 0, + to: 0, +}; + +const EMPTY: any[] = []; +const EMPTY_INDICATORS: FilterIndicator[] = []; + +// Preserve existing URL params that we don't manage (like 'view') +const PRESERVE_PARAMS = ['view']; + +export default function Table({ + table, + records = EMPTY, + pagination = DEFAULT_PAGINATION, + recordActions = EMPTY, + bulkActions = EMPTY, + filterIndicators = EMPTY_INDICATORS, + resourceSlug, + queryRoute, + loading = false, + currentView = 'table', + useAjax = false, + onDataLoaded, + relationContext, + onActionComplete, +}: TableProps) { + const { trans } = useLocalization(); + + // Latest props for async callbacks (timeouts, observer, window listener, fetch) + const live = useLatest({ table, pagination, queryRoute, useAjax, onDataLoaded, onActionComplete }); + + // Extract bulk actions from toolbarActions (handles BulkActionGroup) + const extractedBulkActions = useMemo(() => { + let actions: any[] = []; + + // First, check if bulk actions are directly provided + if (bulkActions && bulkActions.length > 0) { + actions = bulkActions; + } + // Then check table's bulkActions + else if (table.bulkActions && table.bulkActions.length > 0) { + actions = table.bulkActions; + } + // Finally, extract from toolbarActions (look for BulkActionGroup) + else if (table.toolbarActions && table.toolbarActions.length > 0) { + for (const action of table.toolbarActions) { + // Check if this is a BulkActionGroup + if (action.type === 'bulk-action-group' && action.actions) { + actions.push(...action.actions); + } + } + } + + // Filter out hidden actions (based on isHidden property from backend) + // and set preserveState: false for all bulk actions so table refreshes after action + return actions + .filter((action) => !action.isHidden) + .map((action) => ({ + ...action, + preserveState: action.preserveState ?? false, + isBulkAction: true, + deselectRecordsAfterCompletion: action.deselectRecordsAfterCompletion ?? true, + })); + }, [bulkActions, table.bulkActions, table.toolbarActions]); + + // Check if table is configured for grid-only mode + const isGridOnly = table.gridOnly === true && table.card !== null && table.card !== undefined; + + // Determine if we should show grid view (if gridOnly is enabled, always show grid) + const isGridView = isGridOnly ? true : currentView === 'grid' && table.card !== null && table.card !== undefined; + + // Check if table has card configuration (for view toggle visibility) — not used by the template (same as Vue) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hasGridOption = isGridOnly ? false : table.card !== null && table.card !== undefined; + + const [sortColumn, setSortColumn, sortColumnRef] = useStateRef(table.defaultSortColumn || null); + const [sortDirection, setSortDirection, sortDirectionRef] = useStateRef<'asc' | 'desc'>(table.defaultSortDirection || 'asc'); + const [activeGroup, setActiveGroup, activeGroupRef] = useStateRef(table.activeGroup || table.defaultGroup || null); + const [selectedRecords, setSelectedRecords] = useState<(number | string)[]>([]); + const [searchQuery, setSearchQuery, searchQueryRef] = useStateRef(''); + + // Infinite scroll is disabled when grouping is active + const isInfiniteScrollActive = !!(table.infiniteScroll && !activeGroup); + const isInfiniteScrollActiveNow = (): boolean => !!(live.current.table.infiniteScroll && !activeGroupRef.current); + + const [activeFilters, setActiveFilters, activeFiltersRef] = useStateRef>({}); + const [clearSelectionsKey, setClearSelectionsKey] = useState(0); + const [isLoadingData, setIsLoadingData, isLoadingDataRef] = useStateRef(false); + const [perPage, setPerPage, perPageRef] = useStateRef(pagination.per_page || 12); + const currentPageRef = useRef(pagination.current_page || 1); + const [isLoadingMore, setIsLoadingMore, isLoadingMoreRef] = useStateRef(false); + const isInitializedRef = useRef(false); + + // Track if we're doing a filter/search reload (should replace, not append) + const isFilterReloadRef = useRef(false); + + // Enhance records with actions for relation manager context + const enhancedRecords = useMemo(() => { + // If we have relation context, add _actions to each record with proper URLs + if (relationContext) { + return records.map((record) => { + const actions: any[] = []; + + // Add view action (no URL needed, just displays modal with data) + const viewAction = recordActions.find((a: any) => a.name === 'view'); + if (viewAction) { + actions.push({ + ...viewAction, + externalFormData: record, // Pass record data to populate form + // No URL or method - view is display only + }); + } + + // Add edit action + if (relationContext.canEdit) { + const editAction = recordActions.find((a: any) => a.name === 'edit'); + if (editAction) { + actions.push({ + ...editAction, + url: `${relationContext.baseUrl}/${record.id}`, + externalFormData: record, // Pass record data to populate form + }); + } + } + + // Add delete action + if (relationContext.canDelete) { + const deleteAction = recordActions.find((a: any) => a.name === 'delete'); + if (deleteAction) { + actions.push({ + ...deleteAction, + url: `${relationContext.baseUrl}/${record.id}`, + }); + } + } + + // Add any other actions that aren't view/edit/delete + recordActions.forEach((action: any) => { + if (action.name !== 'view' && action.name !== 'edit' && action.name !== 'delete') { + actions.push({ + ...action, + url: action.url || `${relationContext.baseUrl}/${record.id}`, + }); + } + }); + + return { + ...record, + _actions: actions, + }; + }); + } + + // For non-relation context, just use the records as-is with existing _actions + return records; + }, [relationContext, records, recordActions]); + + // Column visibility persistence + const getColumnStorageKey = () => `laravilt_columns_${resourceSlug || 'default'}`; + + const getSavedColumns = (): string[] | null => { + if (typeof window === 'undefined') return null; + try { + const saved = localStorage.getItem(getColumnStorageKey()); + if (saved) { + return JSON.parse(saved); + } + } catch (e) { + console.error('Failed to parse saved columns:', e); + } + return null; + }; + + const saveColumnPreferences = (columns: string[]) => { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(getColumnStorageKey(), JSON.stringify(columns)); + } catch (e) { + console.error('Failed to save column preferences:', e); + } + }; + + // Initialize visible columns - load from localStorage or use defaults + const [visibleColumns, setVisibleColumnsState] = useState(() => { + const saved = getSavedColumns(); + if (saved && saved.length > 0) { + return saved; + } + // Default: show all non-hidden columns + return table.columns?.filter((col: any) => !col.isToggledHiddenByDefault).map((col: any) => col.name) || []; + }); + + // Vue watches visibleColumns and saves to localStorage on every change + const setVisibleColumns = (columns: string[]) => { + setVisibleColumnsState(columns); + saveColumnPreferences(columns); + }; + + // Pagination page size options + const paginationOptions: number[] = + table.paginationPageOptions && table.paginationPageOptions.length > 0 + ? table.paginationPageOptions + : // Default options (12-based for grid layout compatibility) + [12, 24, 48, 96]; + + // Records: append or replace based on infinite scroll. + // Seed with the initial records; the watcher below handles appends. (Vue's immediate watcher appended the + // initial page to itself on deep links to page > 1, duplicating every record — not ported.) + const [allRecords, setAllRecords, allRecordsRef] = useStateRef(records); + + useWatch(records, (newRecords) => { + if (isInfiniteScrollActiveNow()) { + // If it's a filter reload OR page 1, replace all records + // Only append if loading more pages (page > 1 and NOT a filter reload) + if (isFilterReloadRef.current || live.current.pagination.current_page === 1) { + setAllRecords(newRecords); + isFilterReloadRef.current = false; // Reset the flag + } else if (live.current.pagination.current_page > 1) { + // Only append if we're actually loading more (not a filter change) + setAllRecords([...allRecordsRef.current, ...newRecords]); + } else { + setAllRecords(newRecords); + } + } else { + setAllRecords(newRecords); + } + }); + + // Watch for search, filter, sort changes to reset records (only after initialization) + useWatch(JSON.stringify([searchQuery, activeFilters, sortColumn, sortDirection]), () => { + if (!isInitializedRef.current) return; // Don't trigger on initial mount + + if (isInfiniteScrollActiveNow()) { + // Don't clear records here - let skeleton show by setting isLoadingData + // Records will be replaced when new data arrives via the other watcher + currentPageRef.current = 1; + } + }); + + // Build query params, only including non-empty values + const collectParams = (page: number, includeGroup: boolean, urlParams: URLSearchParams): Record => { + const params: Record = { + page, + per_page: perPageRef.current, + }; + + PRESERVE_PARAMS.forEach((param) => { + const value = urlParams.get(param); + if (value !== null) { + params[param] = value; + } + }); + + // Only add search if it has a value + if (searchQueryRef.current) { + params.search = searchQueryRef.current; + } + + // Only add sort if it has a value + if (sortColumnRef.current) { + params.sort = sortColumnRef.current; + params.direction = sortDirectionRef.current; + } + + // Only add filters that have values + Object.entries(activeFiltersRef.current).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '' && value !== false) { + params[key] = value; + } + }); + + // Add group if active + if (includeGroup && activeGroupRef.current) { + params.group = activeGroupRef.current; + } + + return params; + }; + + const reloadData = async (page?: number, resetPage = false) => { + // Set loading state immediately and keep it true + setIsLoadingData(true); + + const params = collectParams( + resetPage ? 1 : page || live.current.pagination.current_page, + true, + new URLSearchParams(window.location.search), + ); + + // Use AJAX (fetch) if useAjax is true - this avoids Inertia page reload + if (live.current.useAjax) { + try { + const queryString = new URLSearchParams(params).toString(); + const fetchUrl = `${live.current.queryRoute}?${queryString}`; + + const response = await fetch(fetchUrl, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (response.ok) { + const data = await response.json(); + // Emit data-loaded event for parent to update its state + live.current.onDataLoaded?.({ + records: data.data || [], + pagination: data.pagination || live.current.pagination, + }); + } + } catch (error) { + console.error('Failed to fetch data:', error); + } finally { + setIsLoadingData(false); + } + return; + } + + // Update URL manually (only for Inertia mode) + const url = new URL(window.location.href); + url.search = new URLSearchParams(params).toString(); + window.history.replaceState({}, '', url.toString()); + + router.get(live.current.queryRoute, params, { + preserveState: true, + preserveScroll: true, + onBefore: () => { + setIsLoadingData(true); + }, + onSuccess: () => { + setTimeout(() => { + setIsLoadingData(false); + }, 100); + }, + onError: () => { + setIsLoadingData(false); + }, + }); + }; + + // Handle action completion from record actions - reload data and emit to parent + const handleActionComplete = (data?: any) => { + // If using AJAX mode, reload data after action + if (live.current.useAjax) { + reloadData(); + } + + live.current.onActionComplete?.(data); + }; + + const handleSort = (column: string, direction: 'asc' | 'desc') => { + setSortColumn(column); + setSortDirection(direction); + + // For infinite scroll, mark as filter reload to replace records instead of appending + if (isInfiniteScrollActiveNow()) { + isFilterReloadRef.current = true; + currentPageRef.current = 1; + } + + reloadData(1, true); // Reset to page 1 when sorting + }; + + const handleSearch = (query: string) => { + setSearchQuery(query); + isFilterReloadRef.current = true; // Mark this as a search/filter reload + reloadData(1, true); // Reset to page 1 when searching + }; + + const handleFilterChange = (filters: Record) => { + setActiveFilters(filters); + isFilterReloadRef.current = true; + reloadData(1, true); + }; + + const handleFilterUpdate = (filterName: string, value: any) => { + // Remove filter if value is empty or false (for toggles) + if (value === null || value === undefined || value === '' || value === false) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [filterName]: _, ...rest } = activeFiltersRef.current; + setActiveFilters(rest); + } else { + setActiveFilters({ + ...activeFiltersRef.current, + [filterName]: value, + }); + } + isFilterReloadRef.current = true; // Mark this as a filter reload + reloadData(1, true); // Reset to page 1 when filtering + }; + + const clearAllFilters = () => { + setActiveFilters({}); + setSearchQuery(''); + isFilterReloadRef.current = true; // Mark this as a filter reload + reloadData(1, true); + }; + + const removeFilter = (filterName: string) => { + handleFilterUpdate(filterName, null); + }; + + const handleUpdateSelectedRecords = (ids: (number | string)[]) => { + setSelectedRecords(ids); + }; + + // Track pending load more request + const loadMorePendingRef = useRef(false); + const loadMoreTimeoutRef = useRef | null>(null); + + // Infinite scroll load more with debouncing + const loadMoreRecords = () => { + const currentPagination = live.current.pagination; + + // Guard against multiple rapid calls + if (loadMorePendingRef.current || isLoadingMoreRef.current || isLoadingDataRef.current || !currentPagination) return; + if (currentPagination.current_page >= currentPagination.last_page) return; + + // Debounce rapid scroll events + if (loadMoreTimeoutRef.current) { + clearTimeout(loadMoreTimeoutRef.current); + } + + loadMorePendingRef.current = true; + loadMoreTimeoutRef.current = setTimeout(async () => { + loadMorePendingRef.current = false; + + const latestPagination = live.current.pagination; + + // Re-check conditions after debounce + if (isLoadingMoreRef.current || isLoadingDataRef.current || !latestPagination) return; + if (latestPagination.current_page >= latestPagination.last_page) return; + + setIsLoadingMore(true); + const nextPage = latestPagination.current_page + 1; + + const params = collectParams(nextPage, false, new URLSearchParams(window.location.search)); + + // Use AJAX (fetch) if useAjax is true - this avoids Inertia page reload + if (live.current.useAjax) { + try { + const queryString = new URLSearchParams(params).toString(); + const fetchUrl = `${live.current.queryRoute}?${queryString}`; + + const response = await fetch(fetchUrl, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (response.ok) { + const data = await response.json(); + // Emit data-loaded event for parent to update its state + live.current.onDataLoaded?.({ + records: data.data || [], + pagination: data.pagination || live.current.pagination, + }); + } + } catch (error) { + console.error('Failed to fetch data:', error); + } finally { + setIsLoadingMore(false); + } + return; + } + + // Update URL (only for Inertia mode) + const url = new URL(window.location.href); + url.search = new URLSearchParams(params).toString(); + window.history.replaceState({}, '', url.toString()); + + router.get(live.current.queryRoute, params, { + preserveState: true, + preserveScroll: true, + onSuccess: () => { + setIsLoadingMore(false); + }, + onError: () => { + setIsLoadingMore(false); + }, + }); + }, 150); // 150ms debounce + }; + + // Set up intersection observer for infinite scroll + const tableEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const observerRef = useRef(null); + + const setupInfiniteScroll = () => { + if (!isInfiniteScrollActiveNow()) return; + + // Clean up existing observer + if (observerRef.current) { + observerRef.current.disconnect(); + } + + observerRef.current = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && !isLoadingMoreRef.current && !isLoadingDataRef.current) { + const currentPagination = live.current.pagination; + // Check if there are more pages before loading + if (currentPagination && currentPagination.current_page < currentPagination.last_page) { + fns.current.loadMoreRecords(); + } + } + }, + { + threshold: 0.1, + rootMargin: '50px', + }, + ); + + if (tableEndRef.current) { + observerRef.current.observe(tableEndRef.current); + } + }; + + // Handle group change + const handleGroupChange = (group: string | null) => { + setActiveGroup(group); + + // Reset infinite scroll state when toggling grouping + if (table.infiniteScroll) { + currentPageRef.current = 1; + isFilterReloadRef.current = true; + // Disconnect observer when grouping is active + if (observerRef.current && group) { + observerRef.current.disconnect(); + } + } + + // Update URL with group parameter + const urlParams = new URLSearchParams(window.location.search); + if (group) { + urlParams.set('group', group); + } else { + urlParams.delete('group'); + } + + // If using AJAX mode, reload data (reloadData() sends the new group from activeGroupRef). + // Vue calls an undefined `updateUrl()` first, which throws before reloading; AJAX mode keeps state out of the URL. + if (useAjax) { + reloadData(); + } else { + // For Inertia, do a full navigation + const currentUrl = new URL(window.location.href); + currentUrl.search = urlParams.toString(); + router.get( + currentUrl.toString(), + {}, + { + preserveState: true, + preserveScroll: true, + }, + ); + } + + // Re-setup infinite scroll observer when grouping is disabled + if (table.infiniteScroll && !group) { + setTimeout(() => { + fns.current.setupInfiniteScroll(); + }, 100); + } + }; + + // Handle reorder events from DataTable + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleReorder = async (items: { id: number | string; order: number }[]) => { + // The DataTable component handles the actual reorder request + // This handler is for any additional processing needed at Table level + // For now, we just reload data after reorder to ensure consistency + if (useAjax) { + // Wait a moment for the reorder to complete, then reload + setTimeout(() => { + fns.current.reloadData(); + }, 500); + } + }; + + const bulkActionData = useMemo( + () => ({ + ids: selectedRecords, + model: table.model, + }), + [selectedRecords, table.model], + ); + + const handlePageChange = (page: number, event?: MouseEvent) => { + if (event) { + event.preventDefault(); + } + reloadData(page); + }; + + const handlePerPageChange = (event: ChangeEvent) => { + event.preventDefault(); + setPerPage(parseInt(event.target.value)); + reloadData(undefined, true); // Reset to page 1 when changing per page + }; + + const getPageNumbers = (): (number | string)[] => { + const current = pagination.current_page; + const last = pagination.last_page; + const pages: (number | string)[] = []; + + if (last <= 7) { + // Show all pages if 7 or fewer + for (let i = 1; i <= last; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + if (current > 3) { + pages.push('...'); + } + + // Show pages around current + const start = Math.max(2, current - 1); + const end = Math.min(last - 1, current + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (current < last - 2) { + pages.push('...'); + } + + // Always show last page + pages.push(last); + } + + return pages; + }; + + // Listen for bulk action completion to clear selected records and reload table + const handleBulkActionCompleted = () => { + setSelectedRecords([]); + setClearSelectionsKey((key) => key + 1); + + // If using AJAX mode, reload data + if (live.current.useAjax) { + reloadData(); + } + + // Emit action-complete to notify parent + live.current.onActionComplete?.(); + }; + + // Latest function instances for long-lived callbacks + const fns = useLatest({ setupInfiniteScroll, loadMoreRecords, reloadData, handleBulkActionCompleted, isInfiniteScrollActiveNow }); + + // Vue: watch(tableEndRef) — re-setup the observer when the sentinel element changes + const setTableEndRef = useCallback( + (element: HTMLDivElement | null) => { + const changed = element !== tableEndRef.current; + tableEndRef.current = element; + if (changed && element && fns.current.isInfiniteScrollActiveNow()) { + fns.current.setupInfiniteScroll(); + } + }, + [fns], + ); + + // Watch for view changes - reset scroll and re-setup observer + useWatch(currentView, (newView, oldView) => { + if (newView !== oldView) { + // Reset scroll position when switching views + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + + // Re-setup observer after view change (wait for DOM update) + if (fns.current.isInfiniteScrollActiveNow()) { + setTimeout(() => { + fns.current.setupInfiniteScroll(); + }, 100); + } + } + }); + + useEffect(() => { + // Initialize filters from URL query parameters + const urlParams = new URLSearchParams(window.location.search); + const initialFilters: Record = {}; + + // Get all filter names from table configuration + const filterNames: string[] = live.current.table.filters?.map((f: any) => f.name) || []; + + // Read filter values from URL + filterNames.forEach((filterName: string) => { + const value = urlParams.get(filterName); + if (value !== null && value !== '') { + initialFilters[filterName] = value; + } + }); + + // Initialize activeFilters with URL values + setActiveFilters(initialFilters); + + // Initialize search from URL + const searchParam = urlParams.get('search'); + if (searchParam) { + setSearchQuery(searchParam); + } + + // Initialize sort from URL + const sortParam = urlParams.get('sort'); + const directionParam = urlParams.get('direction'); + if (sortParam) { + setSortColumn(sortParam); + // Validate direction - only accept 'asc' or 'desc', default to 'asc' + setSortDirection(directionParam === 'asc' || directionParam === 'desc' ? directionParam : 'asc'); + } + + // Initialize group from URL + const groupParam = urlParams.get('group'); + if (groupParam) { + setActiveGroup(groupParam); + } + + // Mark as initialized to enable watchers + isInitializedRef.current = true; + + // Update URL with current state if URL is empty or missing parameters + const currentUrl = new URL(window.location.href); + const hasPageParam = urlParams.has('page'); + const hasPerPageParam = urlParams.has('per_page'); + + if (!hasPageParam || !hasPerPageParam) { + // Build query params for current state + const params = collectParams(live.current.pagination.current_page || 1, true, urlParams); + + // Update URL without reloading + currentUrl.search = new URLSearchParams(params).toString(); + window.history.replaceState({}, '', currentUrl.toString()); + } + + const listener = () => fns.current.handleBulkActionCompleted(); + window.addEventListener('bulk-action-completed', listener); + fns.current.setupInfiniteScroll(); + + return () => { + window.removeEventListener('bulk-action-completed', listener); + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const displayedRecords = relationContext ? enhancedRecords : isInfiniteScrollActive ? allRecords : records; + + return ( +
+ {/* Table with integrated toolbar */} +
+ {/* Search, Filters, and Column Visibility (Fixed at top) */} +
+ {table && ( + 0} + selectedCount={selectedRecords.length} + showSort={isGridView} + sortColumn={sortColumn} + sortDirection={sortDirection} + groups={table.groups} + activeGroup={activeGroup} + visibleColumns={visibleColumns} + onUpdateVisibleColumns={setVisibleColumns} + onUpdateSearch={handleSearch} + onUpdateFilters={handleFilterChange} + onRemoveFilter={removeFilter} + onClearFilters={clearAllFilters} + onUpdateSort={handleSort} + onUpdateActiveGroup={handleGroupChange} + toolbarActions={(table.headerActions || []).map((action: any) => ( + + ))} + filtersSlot={(table.filters || []).map((filter: any) => ( +
+ {filter.formField ? ( + // Render custom form field if available + handleFilterUpdate(filter.name, value), + }} + /> + ) : ( + // Fallback to default filter component based on type + handleFilterUpdate(filter.name, value), + }} + /> + )} +
+ ))} + bulkActions={extractedBulkActions.map((action: any) => ( + + ))} + /> + )} +
+ + {/* Scrollable Records Area */} +
+ {!isGridView ? ( + // Render DataTable for table view + 0} + resourceSlug={resourceSlug} + columnExecutionRoute={relationContext?.columnExecutionRoute || table.columnExecutionRoute} + modelClass={table.model} + clearSelections={clearSelectionsKey} + fixedActions={table.fixedActions} + striped={table.striped} + infiniteScroll={isInfiniteScrollActive} + useAjax={useAjax} + reorderable={table.reorderable} + reorderableColumn={table.reorderableColumn} + reorderRoute={table.reorderRoute} + groups={table.groups} + activeGroup={activeGroup} + onSort={handleSort} + onUpdateSelectedRecords={handleUpdateSelectedRecords} + onActionComplete={handleActionComplete} + onReorder={handleReorder} + /> + ) : ( + // Render CardGrid for grid view + 0} + resourceSlug={resourceSlug} + modelClass={table.model} + clearSelections={clearSelectionsKey} + onUpdateSelectedRecords={handleUpdateSelectedRecords} + /> + )} + + {/* Infinite Scroll Loading Indicator & Observer (inside scrollable area) */} + {isInfiniteScrollActive && ( +
+ {isLoadingMore ? ( +
+
+
+ {trans('tables::tables.infinite_scroll.loading_more')} +
+
+ ) : pagination && pagination.current_page < pagination.last_page ? ( +
+
+ {trans('tables::tables.infinite_scroll.scroll_for_more')} +
+
+ ) : ( +
+ {trans('tables::tables.infinite_scroll.no_more_records')} +
+ )} +
+ )} +
+ {/* End Scrollable Records Area */} + + {/* Pagination (Fixed at bottom) */} + {!isInfiniteScrollActive && pagination && pagination.total > 0 && ( +
+
+ {/* Pagination Info (left side) */} +
+ {trans('tables::tables.pagination.showing')} {pagination.from} {trans('tables::tables.pagination.to')} {pagination.to}{' '} + {trans('tables::tables.pagination.of')} {pagination.total} +
+ + {/* Pagination Controls (centered) */} +
+ {/* Previous Button */} + + + {/* Page Numbers */} +
+ {getPageNumbers().map((page, index) => + page === '...' ? ( + + ... + + ) : ( + + ), + )} +
+ + {/* Next Button */} + +
+ + {/* Per Page Selector (right side) */} +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/resources/react/components/TableToolbar.tsx b/resources/react/components/TableToolbar.tsx new file mode 100644 index 0000000..8ababd9 --- /dev/null +++ b/resources/react/components/TableToolbar.tsx @@ -0,0 +1,467 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { useLocalization } from '@laravilt/support/composables/useLocalization'; +import { ArrowDown, ArrowUp, ArrowUpDown, Check, Columns3, Eye, EyeOff, LayoutList, Search, SlidersHorizontal, X } from 'lucide-react'; +import { useState, type KeyboardEvent, type ReactNode } from 'react'; +import { useWatch } from '../composables/useWatch'; + +interface Column { + name: string; + label: string; + toggleable?: boolean; + [key: string]: any; +} + +interface Filter { + name: string; + label: string; + component: string; + [key: string]: any; +} + +interface FilterIndicator { + label: string; + removeField: string; +} + +interface GroupConfig { + column: string; + label: string; + collapsible: boolean; +} + +export interface TableToolbarProps { + searchable?: boolean; + searchPlaceholder?: string; + search?: string; + filters?: Filter[]; + activeFilters?: Record; + filterIndicators?: FilterIndicator[]; + columns?: Column[]; + visibleColumns?: string[]; + bulkActionsAvailable?: boolean; + selectedCount?: number; + showSort?: boolean; + sortColumn?: string | null; + sortDirection?: 'asc' | 'desc'; + groups?: GroupConfig[]; + activeGroup?: string | null; + onUpdateSearch?: (value: string) => void; + onUpdateFilters?: (filters: Record) => void; + onUpdateVisibleColumns?: (columns: string[]) => void; + onRemoveFilter?: (filterName: string) => void; + onClearFilters?: () => void; + onUpdateSort?: (column: string, direction: 'asc' | 'desc') => void; + onUpdateActiveGroup?: (group: string | null) => void; + /** Vue slot `bulk-actions` */ + bulkActions?: ReactNode; + /** Vue slot `filters` (renamed: `filters` is already the filter-definitions prop) */ + filtersSlot?: ReactNode; + /** Vue slot `toolbar-actions` */ + toolbarActions?: ReactNode; + /** Vue slot `active-filters` (renamed: `activeFilters` is already the active-filter-values prop) */ + activeFiltersSlot?: ReactNode; +} + +const EMPTY_FILTERS: Filter[] = []; +const EMPTY_ACTIVE_FILTERS: Record = {}; +const EMPTY_INDICATORS: FilterIndicator[] = []; +const EMPTY_COLUMNS: Column[] = []; +const EMPTY_VISIBLE: string[] = []; +const EMPTY_GROUPS: GroupConfig[] = []; + +export default function TableToolbar({ + searchable = true, + searchPlaceholder = 'Search...', + search = '', + filters = EMPTY_FILTERS, + activeFilters = EMPTY_ACTIVE_FILTERS, + columns = EMPTY_COLUMNS, + visibleColumns = EMPTY_VISIBLE, + bulkActionsAvailable = false, + selectedCount = 0, + showSort = false, + sortColumn = null, + sortDirection = 'asc', + groups = EMPTY_GROUPS, + activeGroup = null, + onUpdateSearch, + onUpdateVisibleColumns, + onRemoveFilter, + onClearFilters, + onUpdateSort, + onUpdateActiveGroup, + bulkActions, + filtersSlot, + toolbarActions, + activeFiltersSlot, +}: TableToolbarProps) { + const { trans } = useLocalization(); + + const [localSearch, setLocalSearch] = useState(search); + + // Update local search when prop changes (e.g., from clear button) + useWatch(search, (newValue) => { + setLocalSearch(newValue); + }); + + const handleSearchSubmit = () => { + onUpdateSearch?.(localSearch); + }; + + const toggleableColumns = columns.filter((col) => col.toggleable !== false); + + const activeFilterCount = Object.values(activeFilters).filter((value) => value !== null && value !== '' && value !== undefined).length; + + // Compute filter indicators from activeFilters instead of using the prop + const computedFilterIndicators: Array<{ label: string; removeField: string }> = []; + + Object.entries(activeFilters).forEach(([filterName, value]) => { + if (value === null || value === '' || value === undefined || value === false) { + return; + } + + // Find the filter definition + const filter = filters.find((f: any) => f.name === filterName); + if (!filter) return; + + // Get the indicator label from the filter + let label = `${filter.label || filterName}: ${value}`; + + // If filter has indicateUsing callback, use it + if (filter.indicateUsing) { + label = filter.indicateUsing; + } + + computedFilterIndicators.push({ + label, + removeField: filterName, + }); + }); + + const isColumnVisible = (columnName: string) => { + if (visibleColumns.length === 0) return true; + return visibleColumns.includes(columnName); + }; + + const toggleColumn = (columnName: string) => { + let newVisibleColumns: string[]; + + // If starting from "show all" state (empty array), initialize with all columns + if (visibleColumns.length === 0) { + // User is hiding a column, so start with all columns except this one + newVisibleColumns = columns.map((col) => col.name).filter((name) => name !== columnName); + } else { + // Toggle column in existing array + newVisibleColumns = isColumnVisible(columnName) + ? visibleColumns.filter((name) => name !== columnName) + : [...visibleColumns, columnName]; + } + + onUpdateVisibleColumns?.(newVisibleColumns); + }; + + const clearSearch = () => { + onUpdateSearch?.(''); + }; + + const clearFilters = () => { + onClearFilters?.(); + }; + + const removeFilter = (filterName: string) => { + onRemoveFilter?.(filterName); + }; + + const hasActiveFilters = activeFilterCount > 0; + const hasActiveSearch = search.length > 0; + const hasComputedFilterIndicators = computedFilterIndicators.length > 0; + + // Sorting functionality for grid view + const sortableColumns = columns.filter((col) => col.sortable); + + const currentSortLabel = (() => { + if (!sortColumn) return trans('tables::tables.toolbar.sort_by') + '...'; + const column = sortableColumns.find((col) => col.name === sortColumn); + return column ? column.label : trans('tables::tables.toolbar.sort_by') + '...'; + })(); + + const SortIcon = !sortColumn ? ArrowUpDown : sortDirection === 'asc' ? ArrowUp : ArrowDown; + + const handleSortChange = (columnName: string) => { + let direction: 'asc' | 'desc' = 'asc'; + + if (sortColumn === columnName) { + // Toggle direction if same column + direction = sortDirection === 'asc' ? 'desc' : 'asc'; + } + + onUpdateSort?.(columnName, direction); + }; + + // Grouping functionality + const hasGroups = groups && groups.length > 0; + + const activeGroupLabel = (() => { + if (!activeGroup) return trans('tables::tables.toolbar.group_by') || 'Group by'; + const group = groups?.find((g) => g.column === activeGroup); + return group ? group.label : trans('tables::tables.toolbar.group_by') || 'Group by'; + })(); + + const handleGroupChange = (groupColumn: string | null) => { + onUpdateActiveGroup?.(groupColumn); + }; + + const DirectionIcon = sortDirection === 'asc' ? ArrowUp : ArrowDown; + + return ( +
+ {/* Bulk Actions Bar (when items are selected) */} + {bulkActionsAvailable && selectedCount > 0 && ( +
+ + {selectedCount} {trans('tables::tables.bulk.selected').replace(':count ', '')} + +
{bulkActions}
+
+ )} + + {/* Top Row: Search, Filters, Column Toggle */} +
+ {/* Search */} + {searchable && ( +
+ + setLocalSearch(event.target.value)} + type="search" + placeholder={searchPlaceholder || trans('tables::tables.search.placeholder')} + className="ps-9 pe-9" + onKeyUp={(event: KeyboardEvent) => { + if (event.key === 'Enter') handleSearchSubmit(); + }} + /> + {hasActiveSearch && ( + + )} +
+ )} + +
+ {/* Sort Button (for grid view) */} + {showSort && sortableColumns.length > 0 && ( + + + + + +
+

{trans('tables::tables.toolbar.sort_by')}

+
+ {sortableColumns.map((column) => ( + + ))} +
+
+
+
+ )} + + {/* Filters Button */} + {filters.length > 0 && ( + + + + + +
+
+

{trans('tables::tables.toolbar.filters')}

+ {hasActiveFilters && ( + + )} +
+
{filtersSlot}
+
+
+
+ )} + + {/* Group By Selector */} + {hasGroups && ( + + + + + +
+

{trans('tables::tables.toolbar.group_by') || 'Group by'}

+
+ {/* No Grouping Option */} + + {/* Group Options */} + {groups.map((group) => ( + + ))} +
+
+
+
+ )} + + {/* Column Toggle */} + {toggleableColumns.length > 0 && ( + + + + + + {trans('tables::tables.toolbar.toggle_columns')} + + {toggleableColumns.map((column) => { + const VisibilityIcon = isColumnVisible(column.name) ? Eye : EyeOff; + + return ( + { + event.preventDefault(); + toggleColumn(column.name); + }} + className="gap-2" + > + + {column.label} + + ); + })} + + + )} + + {/* Toolbar Actions Slot */} + {toolbarActions} +
+
+ + {/* Active Filters Display */} + {(hasActiveFilters || hasActiveSearch || hasComputedFilterIndicators) && ( +
+ {trans('tables::tables.toolbar.active_filters')}: + + {hasActiveSearch && ( + + {trans('tables::tables.toolbar.search')}: "{search}" + + + )} + + {/* Computed filter indicators with individual removal */} + {computedFilterIndicators.map((indicator, index) => ( + + {indicator.label} + + + ))} + + {activeFiltersSlot} + + {(hasActiveFilters || hasActiveSearch || hasComputedFilterIndicators) && ( + + )} +
+ )} +
+ ); +} diff --git a/resources/react/components/columns/ColorColumn.tsx b/resources/react/components/columns/ColorColumn.tsx new file mode 100644 index 0000000..1cc4f6f --- /dev/null +++ b/resources/react/components/columns/ColorColumn.tsx @@ -0,0 +1,127 @@ +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { useNotification } from '@laravilt/notifications/composables/useNotification'; + +export interface ColorColumnProps { + value: any; + copyable?: boolean; + copyMessage?: string | null; + copyMessageDuration?: number | null; + wrap?: boolean; + description?: string | null; + descriptionPosition?: 'above' | 'below'; + maxVisible?: number; +} + +export default function ColorColumn({ + value, + copyable = false, + copyMessage = null, + copyMessageDuration = null, + description = null, + descriptionPosition = 'below', + maxVisible = 4, +}: ColorColumnProps) { + const { notify } = useNotification(); + + const colors: any[] = !value ? [] : Array.isArray(value) ? value : [value]; + const visibleColors = colors.slice(0, maxVisible); + const hiddenColors = colors.slice(maxVisible); + const hasMoreColors = colors.length > maxVisible; + + const handleCopy = (color: string) => { + if (copyable && color) { + navigator.clipboard.writeText(color); + notify(copyMessage || 'Copied!', `Color ${color} copied to clipboard`, 'success', { + duration: copyMessageDuration || 1500, + }); + } + }; + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {colors.length > 1 ? ( + // Main content - stacked circles style for multiple colors +
+
+ {visibleColors.map((color: any, index: number) => ( +
+ + + + )} +
+
+ ) : colors.length === 1 ? ( + // Single color - simple square style +
+
+ ) : null} + + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/columns/IconColumn.tsx b/resources/react/components/columns/IconColumn.tsx new file mode 100644 index 0000000..8dc22ee --- /dev/null +++ b/resources/react/components/columns/IconColumn.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/lib/utils'; +import { resolveColumnIcon } from '../../lib/icons'; + +export interface IconColumnProps { + value: any; + boolean?: boolean; + wrap?: boolean; + icon?: string | null; + color?: string | null; + size?: string | null; +} + +// Map color to Tailwind classes +const colorMap: Record = { + primary: 'text-primary', + success: 'text-green-500', + danger: 'text-destructive', + warning: 'text-yellow-500', + info: 'text-blue-500', + gray: 'text-muted-foreground', + secondary: 'text-muted-foreground', +}; + +// Map size to icon classes +const sizeMap: Record = { + xs: 'h-3 w-3', + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-6 w-6', + xl: 'h-8 w-8', + '2xl': 'h-10 w-10', + 'extra-small': 'h-3 w-3', + small: 'h-4 w-4', + medium: 'h-5 w-5', + large: 'h-6 w-6', + 'extra-large': 'h-8 w-8', + 'two-extra-large': 'h-10 w-10', +}; + +export default function IconColumn({ value, wrap = false, icon = null, color = null, size = null }: IconColumnProps) { + // Use evaluated icon from backend, or fallback to value (column data) + const LucideIconComponent = resolveColumnIcon(icon || value); + + const colorClass = !color ? 'text-muted-foreground' : colorMap[color] || 'text-muted-foreground'; + const sizeClass = sizeMap[size || 'large'] || 'h-6 w-6'; + + return ( +
+ {LucideIconComponent && } +
+ ); +} diff --git a/resources/react/components/columns/ImageColumn.tsx b/resources/react/components/columns/ImageColumn.tsx new file mode 100644 index 0000000..9b7ff1d --- /dev/null +++ b/resources/react/components/columns/ImageColumn.tsx @@ -0,0 +1,211 @@ +import { cn } from '@/lib/utils'; +import type { CSSProperties, SyntheticEvent } from 'react'; + +export interface ImageColumnProps { + value: any; + imageWidth?: string | number | null; + imageHeight?: string | number | null; + square?: boolean; + circular?: boolean; + stacked?: boolean; + ring?: number; + overlap?: number; + limit?: number | null; + limitedRemainingText?: boolean; + limitedRemainingTextSize?: string; + wrap?: boolean; + disk?: string | null; + visibility?: string | null; + defaultImageUrl?: string | null; + checkFileExistence?: boolean; + extraImgAttributes?: Record; + description?: string | null; + descriptionPosition?: 'above' | 'below'; +} + +const EMPTY_ATTRIBUTES: Record = {}; + +const ringMap: Record = { + 0: 'ring-0', + 1: 'ring-1', + 2: 'ring-2', + 3: 'ring', + 4: 'ring-4', + 5: 'ring-[5px]', + 6: 'ring-[6px]', + 7: 'ring-[7px]', + 8: 'ring-8', +}; + +// Negative margin for overlap +const overlapMap: Record = { + 0: '', + 1: '-ml-1', + 2: '-ml-2', + 3: '-ml-3', + 4: '-ml-4', + 5: '-ml-5', + 6: '-ml-6', + 7: '-ml-7', + 8: '-ml-8', +}; + +const remainingSizeMap: Record = { + xs: 'text-xs', + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + xl: 'text-xl', +}; + +/** + * Split `v-bind="extraImgAttributes"` into DOM props, merging `class`/`style` like Vue does. + */ +function splitExtraAttributes(extra: Record): { rest: Record; className?: string; style?: CSSProperties } { + const { class: extraClass, style: extraStyle, ...rest } = extra || {}; + + return { + rest, + className: typeof extraClass === 'string' ? extraClass : undefined, + style: extraStyle && typeof extraStyle === 'object' ? (extraStyle as CSSProperties) : undefined, + }; +} + +export default function ImageColumn({ + value, + imageWidth = null, + imageHeight = null, + square = false, + circular = false, + stacked = false, + ring = 3, + overlap = 4, + limit = null, + limitedRemainingText = false, + limitedRemainingTextSize = 'sm', + wrap = false, + disk = null, + visibility = null, + defaultImageUrl = null, + extraImgAttributes = EMPTY_ATTRIBUTES, + description = null, + descriptionPosition = 'below', +}: ImageColumnProps) { + const images: any[] = !value + ? // Show default image if no value but defaultImageUrl is set + defaultImageUrl + ? [defaultImageUrl] + : [] + : Array.isArray(value) + ? value + : [value]; + + // Track if we're showing the default image (to prevent error handler loop) + const isDefaultImage = (image: string) => image === defaultImageUrl; + + const displayImages = !limit ? images : images.slice(0, limit); + + const remainingCount = !limit || images.length <= limit ? 0 : images.length - limit; + + // Default size for table images (36x36 - compact but visible) + const defaultSize = '36px'; + const sizeStyle: CSSProperties = { + width: imageWidth ? (typeof imageWidth === 'number' ? `${imageWidth}px` : imageWidth) : defaultSize, + height: imageHeight ? (typeof imageHeight === 'number' ? `${imageHeight}px` : imageHeight) : defaultSize, + }; + + const shapeClass = circular ? 'rounded-full' : square ? 'aspect-square' : 'rounded-md'; + const ringClass = ringMap[ring] || 'ring'; + const overlapClass = overlapMap[overlap] || '-ml-4'; + const remainingSizeClass = remainingSizeMap[limitedRemainingTextSize] || 'text-sm'; + + const getImageUrl = (image: string): string => { + // If it's already an absolute URL, return it + if (image.startsWith('http://') || image.startsWith('https://') || image.startsWith('data:')) { + return image; + } + + // Handle disk and visibility for Laravel storage + // If visibility is 'public' or disk is 'public', use /storage/ path + // Otherwise, this should be a temporary URL generated by the backend + if (visibility === 'public' || disk === 'public') { + return `/storage/${image}`; + } + + // For non-public storage, assume the backend will provide temporary URLs + // If it's a relative path, prepend /storage/ as default + if (!image.startsWith('/')) { + return `/storage/${image}`; + } + + return image; + }; + + const handleImageError = (event: SyntheticEvent, image: string) => { + const imgElement = event.currentTarget; + // Only set default image if this isn't already the default + if (defaultImageUrl && !isDefaultImage(image)) { + imgElement.src = defaultImageUrl; + } + }; + + const extra = splitExtraAttributes(extraImgAttributes); + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ {stacked ? ( + // Stacked images +
+ {displayImages.map((image: any, index: number) => ( +
0 && overlapClass)}> + {`Image handleImageError(event, image)} + /> +
+ ))} + + {limitedRemainingText && remainingCount > 0 && ( + +{remainingCount} + )} +
+ ) : ( + // Regular images + <> + {displayImages.map((image: any, index: number) => ( + {`Image handleImageError(event, image)} + /> + ))} + + {limitedRemainingText && remainingCount > 0 && ( + +{remainingCount} + )} + + )} +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/columns/TextColumn.tsx b/resources/react/components/columns/TextColumn.tsx new file mode 100644 index 0000000..ec3f17e --- /dev/null +++ b/resources/react/components/columns/TextColumn.tsx @@ -0,0 +1,310 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { useNotification } from '@laravilt/notifications/composables/useNotification'; +import { Copy } from 'lucide-react'; +import type { MouseEvent } from 'react'; +import { toDisplayString } from '../../lib/display'; +import { resolveColumnIcon } from '../../lib/icons'; + +export interface TextColumnProps { + value: any; + limit?: number; + wrap?: boolean; + copyable?: string | null; + badge?: boolean; + dateTimeFormat?: string | null; + dateFormat?: string | null; + icon?: string | null; + weight?: string | null; + moneyFormat?: { currency: string; divideBy: number } | null; + color?: string | null; + description?: string | null; + descriptionPosition?: 'above' | 'below'; + html?: boolean; + placeholder?: string | null; + // New FilamentPHP v4 compatible props + alignment?: 'start' | 'center' | 'end' | 'justify'; + tooltip?: string | null; + url?: string | null; + openUrlInNewTab?: boolean; + prefix?: string | null; + suffix?: string | null; + grow?: boolean; + size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | null; +} + +// Map color to badge variant +const badgeColorMap: Record = { + primary: 'primary', + success: 'success', + danger: 'danger', + warning: 'warning', + info: 'info', + gray: 'gray', + secondary: 'secondary', + // Legacy mappings + destructive: 'danger', + default: 'primary', +}; + +const weightClasses: Record = { + thin: 'font-thin', + extralight: 'font-extralight', + light: 'font-light', + normal: 'font-normal', + medium: 'font-medium', + semibold: 'font-semibold', + bold: 'font-bold', + extrabold: 'font-extrabold', + black: 'font-black', +}; + +const alignmentClasses: Record = { + start: 'text-start', + center: 'text-center', + end: 'text-end', + justify: 'text-justify', +}; + +const sizeClasses: Record = { + xs: 'text-xs', + sm: 'text-sm', + base: 'text-base', + lg: 'text-lg', + xl: 'text-xl', +}; + +export default function TextColumn({ + value, + limit, + wrap = false, + copyable = null, + badge = false, + dateTimeFormat = null, + dateFormat = null, + icon = null, + weight = null, + moneyFormat = null, + color = null, + description = null, + descriptionPosition = 'below', + html = false, + placeholder = null, + alignment = 'start', + tooltip = null, + url = null, + openUrlInNewTab = false, + prefix = null, + suffix = null, + grow = false, + size = null, +}: TextColumnProps) { + const { notify } = useNotification(); + + // Check if value is an array (for badge rendering of many-to-many relationships) + const isArray = Array.isArray(value); + + // Check if value is empty (null, undefined, empty string, or empty array) + const isEmpty = value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0); + + // Check if value is an object or array (for JSON formatting) + const isObjectOrArray = typeof value === 'object' && value !== null; + + const badgeVariant = !color ? 'secondary' : badgeColorMap[color] || 'secondary'; + + const formattedValue = ((): string => { + if (value === null || value === undefined || value === '') { + return ''; + } + + // If it's an array for badges, don't format it here - we'll render badges individually + if (isArray && badge) { + return ''; + } + + // If it's an object or array (not for badges), format as JSON + if (isObjectOrArray && !badge) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + let result = String(value); + + // Format as date/datetime + if (dateTimeFormat) { + try { + const date = new Date(value); + // Check if date is valid + if (!isNaN(date.getTime())) { + result = date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + } catch { + result = String(value); + } + } else if (dateFormat) { + try { + const date = new Date(value); + // Check if date is valid + if (!isNaN(date.getTime())) { + result = date.toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } + } catch { + result = String(value); + } + } + + // Format as money + if (moneyFormat) { + const numValue = Number(value) / moneyFormat.divideBy; + result = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: moneyFormat.currency, + }).format(numValue); + } + + // Apply character limit (not for HTML or JSON) + if (limit && result.length > limit && !html && !isObjectOrArray) { + result = result.substring(0, limit) + '...'; + } + + return result; + })(); + + const LucideIconComponent = icon ? resolveColumnIcon(icon) : null; + + const handleCopy = () => { + if (copyable && formattedValue) { + navigator.clipboard.writeText(formattedValue); + notify(copyable, 'Copied to clipboard', 'success', { + duration: 2000, + }); + } + }; + + const weightClass = (weight && weightClasses[weight]) || ''; + const alignmentClass = alignmentClasses[alignment] || 'text-start'; + const sizeClass = (size && sizeClasses[size]) || ''; + + const containerClass = ['flex flex-col gap-1 max-w-full overflow-hidden', grow ? 'flex-1' : '', alignmentClass] + .filter(Boolean) + .join(' '); + + // Combine prefix and suffix with value + const displayValue = ((): string => { + const current = formattedValue; + if (!current) return current; + + let result = current; + if (prefix) result = prefix + result; + if (suffix) result = result + suffix; + return result; + })(); + + const textClass = cn(weightClass, sizeClass, wrap ? 'whitespace-normal break-words' : limit ? 'truncate' : 'break-words'); + + // Vue renders `` + const Tag = (url ? 'a' : 'div') as 'a'; + + const content = ( + + {/* Icon outside badge when not using badge */} + {LucideIconComponent && !badge && } + + {isEmpty && placeholder ? ( + // Show placeholder when empty + {placeholder} + ) : badge && isArray && !isEmpty ? ( + // Multiple badges for array values (many-to-many relationships) +
+ {(value as any[]).map((item: any, index: number) => ( + + {LucideIconComponent && } + {toDisplayString(item)} + + ))} +
+ ) : badge && !isEmpty ? ( + // Single badge + + {LucideIconComponent && } + {displayValue} + + ) : html ? ( + // HTML content +
+ ) : isObjectOrArray && !badge ? ( + // JSON/Object/Array formatting +
+                    {displayValue}
+                
+ ) : ( + // Regular text + {displayValue} + )} + + {copyable && ( + + )} + + ); + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Wrap with tooltip if provided */} + {tooltip ? ( + + + {content} + +

{tooltip}

+
+
+
+ ) : ( + content + )} + + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/columns/ToggleColumn.tsx b/resources/react/components/columns/ToggleColumn.tsx new file mode 100644 index 0000000..84a76b2 --- /dev/null +++ b/resources/react/components/columns/ToggleColumn.tsx @@ -0,0 +1,129 @@ +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import { router } from '@inertiajs/react'; +import { useNotification } from '@laravilt/notifications/composables/useNotification'; +import { useLocalization } from '@laravilt/support/composables/useLocalization'; +import { useEffect, useState } from 'react'; +import { useStateRef } from '../../composables/useStateRef'; + +export interface ToggleColumnProps { + value: any; + name: string; + recordId: number | string; + resourceSlug?: string; + columnExecutionRoute?: string; + editable?: boolean; + disabled?: boolean; + description?: string | null; + descriptionPosition?: 'above' | 'below'; + // Text labels for i18n support + successNotificationTitle?: string; + successNotificationMessage?: string; + errorNotificationTitle?: string; + errorNotificationMessage?: string; +} + +export default function ToggleColumn({ + value, + name, + recordId, + columnExecutionRoute, + editable = true, + disabled = false, + description = null, + descriptionPosition = 'below', + successNotificationTitle = 'Updated', + successNotificationMessage = 'Value updated successfully', + errorNotificationTitle = 'Error', + errorNotificationMessage = 'Failed to update value', +}: ToggleColumnProps) { + const { trans } = useLocalization(); + const { notify } = useNotification(); + + // Compute the execution URL - replace __ID__ placeholder with actual record ID + const executionUrl = columnExecutionRoute ? columnExecutionRoute.replace('__ID__', String(recordId)) : null; + + // Translated labels - use tables::tables namespace + const translatedSuccessTitle = + successNotificationTitle !== 'Updated' ? successNotificationTitle : trans('tables::tables.toggle_column.success_notification_title'); + const translatedSuccessMessage = + successNotificationMessage !== 'Value updated successfully' + ? successNotificationMessage + : trans('tables::tables.toggle_column.success_notification_message'); + const translatedErrorTitle = + errorNotificationTitle !== 'Error' ? errorNotificationTitle : trans('tables::tables.toggle_column.error_notification_title'); + const translatedErrorMessage = + errorNotificationMessage !== 'Failed to update value' + ? errorNotificationMessage + : trans('tables::tables.toggle_column.error_notification_message'); + + // Use the same boolean conversion as the Edit page - simple Boolean() cast + const [localValue, setLocalValue] = useState(() => Boolean(value)); + const [isUpdating, setIsUpdating, isUpdatingRef] = useStateRef(false); + + // Watch for prop changes to update local value (but not during updates to avoid conflicts) + useEffect(() => { + if (!isUpdatingRef.current) { + setLocalValue(Boolean(value)); + } + }, [value, isUpdatingRef]); + + const setChecked = (newValue: boolean) => { + if (disabled || !editable || isUpdatingRef.current || !executionUrl) return; + + setIsUpdating(true); + + // Send update to backend (convert to 1/0 for database) + router.patch( + executionUrl, + { + column: name, + value: newValue ? 1 : 0, + }, + { + preserveScroll: true, + preserveState: true, + only: ['records'], + onSuccess: () => { + setLocalValue(newValue); + setIsUpdating(false); + notify(translatedSuccessTitle, translatedSuccessMessage, 'success', { + duration: 2000, + }); + }, + onError: (errors: Record) => { + setIsUpdating(false); + + const errorMessage = errors[name] || translatedErrorMessage; + notify(translatedErrorTitle, errorMessage, 'error', { + duration: 3000, + }); + }, + }, + ); + }; + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ setChecked(checked)} + /> +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/filters/TextFilter.tsx b/resources/react/components/filters/TextFilter.tsx new file mode 100644 index 0000000..17c6ff1 --- /dev/null +++ b/resources/react/components/filters/TextFilter.tsx @@ -0,0 +1,52 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useState, type KeyboardEvent } from 'react'; +import { useWatch } from '../../composables/useWatch'; + +export interface TextFilterProps { + name: string; + label: string; + modelValue?: string | null; + placeholder?: string; + type?: 'text' | 'email' | 'url' | 'number'; + onUpdateModelValue?: (value: string | null) => void; +} + +export default function TextFilter({ + name, + label, + modelValue = null, + placeholder = 'Enter text...', + type = 'text', + onUpdateModelValue, +}: TextFilterProps) { + const [localValue, setLocalValue] = useState(modelValue || ''); + + useWatch(modelValue, (newValue) => { + setLocalValue(newValue || ''); + }); + + const handleInput = () => { + const value = localValue.trim(); + onUpdateModelValue?.(value || null); + }; + + return ( +
+ + setLocalValue(event.target.value)} + onBlur={handleInput} + onKeyUp={(event: KeyboardEvent) => { + if (event.key === 'Enter') handleInput(); + }} + /> +
+ ); +} diff --git a/resources/react/components/filters/ToggleFilter.tsx b/resources/react/components/filters/ToggleFilter.tsx new file mode 100644 index 0000000..17a34bd --- /dev/null +++ b/resources/react/components/filters/ToggleFilter.tsx @@ -0,0 +1,26 @@ +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; + +export interface ToggleFilterProps { + name: string; + label: string; + modelValue?: boolean | null; + description?: string; + onUpdateModelValue?: (value: boolean | null) => void; +} + +export default function ToggleFilter({ name, label, modelValue = null, description, onUpdateModelValue }: ToggleFilterProps) { + const checked = modelValue === true; + + return ( +
+
+ + {description &&

{description}

} +
+ onUpdateModelValue?.(value)} /> +
+ ); +} diff --git a/resources/react/components/grid-columns/ColorGridColumn.tsx b/resources/react/components/grid-columns/ColorGridColumn.tsx new file mode 100644 index 0000000..d03420d --- /dev/null +++ b/resources/react/components/grid-columns/ColorGridColumn.tsx @@ -0,0 +1,68 @@ +import { cn } from '@/lib/utils'; +import { useNotification } from '@laravilt/notifications/composables/useNotification'; + +export interface ColorGridColumnProps { + value: any; + copyable?: boolean; + copyMessage?: string | null; + copyMessageDuration?: number | null; + wrap?: boolean; + description?: string | null; + descriptionPosition?: 'above' | 'below'; +} + +export default function ColorGridColumn({ + value, + copyable = false, + copyMessage = null, + copyMessageDuration = null, + wrap = false, + description = null, + descriptionPosition = 'below', +}: ColorGridColumnProps) { + const { notify } = useNotification(); + + const colors: any[] = !value ? [] : Array.isArray(value) ? value : [value]; + + const handleCopy = (color: string) => { + if (copyable && color) { + navigator.clipboard.writeText(color); + notify(copyMessage || 'Copied!', `Color ${color} copied to clipboard`, 'success', { + duration: copyMessageDuration || 1500, + }); + } + }; + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ {colors.map((color: any, index: number) => ( +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/grid-columns/IconGridColumn.tsx b/resources/react/components/grid-columns/IconGridColumn.tsx new file mode 100644 index 0000000..bd9bc47 --- /dev/null +++ b/resources/react/components/grid-columns/IconGridColumn.tsx @@ -0,0 +1,75 @@ +import { cn } from '@/lib/utils'; +import { resolveColumnIcon } from '../../lib/icons'; + +export interface IconGridColumnProps { + value: any; + boolean?: boolean; + wrap?: boolean; + icon?: string | null; + color?: string | null; + size?: string | null; + description?: string | null; + descriptionPosition?: 'above' | 'below'; +} + +// Map color to Tailwind classes +const colorMap: Record = { + primary: 'text-primary', + success: 'text-green-500', + danger: 'text-destructive', + warning: 'text-yellow-500', + info: 'text-blue-500', + gray: 'text-muted-foreground', + secondary: 'text-muted-foreground', +}; + +// Map size to icon classes +const sizeMap: Record = { + xs: 'h-3 w-3', + sm: 'h-4 w-4', + md: 'h-5 w-5', + lg: 'h-6 w-6', + xl: 'h-8 w-8', + '2xl': 'h-10 w-10', + 'extra-small': 'h-3 w-3', + small: 'h-4 w-4', + medium: 'h-5 w-5', + large: 'h-6 w-6', + 'extra-large': 'h-8 w-8', + 'two-extra-large': 'h-10 w-10', +}; + +export default function IconGridColumn({ + value, + wrap = false, + icon = null, + color = null, + size = null, + description = null, + descriptionPosition = 'below', +}: IconGridColumnProps) { + // Use evaluated icon from backend, or fallback to value (column data) + const LucideIconComponent = resolveColumnIcon(icon || value); + + const colorClass = !color ? 'text-muted-foreground' : colorMap[color] || 'text-muted-foreground'; + const sizeClass = sizeMap[size || 'large'] || 'h-6 w-6'; + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ {LucideIconComponent && } +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/grid-columns/ImageGridColumn.tsx b/resources/react/components/grid-columns/ImageGridColumn.tsx new file mode 100644 index 0000000..452d1b5 --- /dev/null +++ b/resources/react/components/grid-columns/ImageGridColumn.tsx @@ -0,0 +1,211 @@ +import { cn } from '@/lib/utils'; +import type { CSSProperties, SyntheticEvent } from 'react'; + +export interface ImageGridColumnProps { + value: any; + imageWidth?: string | number | null; + imageHeight?: string | number | null; + square?: boolean; + circular?: boolean; + stacked?: boolean; + ring?: number; + overlap?: number; + limit?: number | null; + limitedRemainingText?: boolean; + limitedRemainingTextSize?: string; + wrap?: boolean; + disk?: string | null; + visibility?: string | null; + defaultImageUrl?: string | null; + checkFileExistence?: boolean; + extraImgAttributes?: Record; + description?: string | null; + descriptionPosition?: 'above' | 'below'; +} + +const EMPTY_ATTRIBUTES: Record = {}; + +const ringMap: Record = { + 0: 'ring-0', + 1: 'ring-1', + 2: 'ring-2', + 3: 'ring', + 4: 'ring-4', + 5: 'ring-[5px]', + 6: 'ring-[6px]', + 7: 'ring-[7px]', + 8: 'ring-8', +}; + +// Negative margin for overlap +const overlapMap: Record = { + 0: '', + 1: '-ml-1', + 2: '-ml-2', + 3: '-ml-3', + 4: '-ml-4', + 5: '-ml-5', + 6: '-ml-6', + 7: '-ml-7', + 8: '-ml-8', +}; + +const remainingSizeMap: Record = { + xs: 'text-xs', + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + xl: 'text-xl', +}; + +/** + * Split `v-bind="extraImgAttributes"` into DOM props, merging `class`/`style` like Vue does. + */ +function splitExtraAttributes(extra: Record): { rest: Record; className?: string; style?: CSSProperties } { + const { class: extraClass, style: extraStyle, ...rest } = extra || {}; + + return { + rest, + className: typeof extraClass === 'string' ? extraClass : undefined, + style: extraStyle && typeof extraStyle === 'object' ? (extraStyle as CSSProperties) : undefined, + }; +} + +export default function ImageGridColumn({ + value, + imageWidth = null, + imageHeight = null, + square = false, + circular = false, + stacked = false, + ring = 3, + overlap = 4, + limit = null, + limitedRemainingText = false, + limitedRemainingTextSize = 'sm', + wrap = false, + disk = null, + visibility = null, + defaultImageUrl = null, + extraImgAttributes = EMPTY_ATTRIBUTES, + description = null, + descriptionPosition = 'below', +}: ImageGridColumnProps) { + const images: any[] = !value + ? // Show default image if no value but defaultImageUrl is set + defaultImageUrl + ? [defaultImageUrl] + : [] + : Array.isArray(value) + ? value + : [value]; + + const displayImages = !limit ? images : images.slice(0, limit); + + const remainingCount = !limit || images.length <= limit ? 0 : images.length - limit; + + const sizeStyle: CSSProperties = {}; + + if (imageWidth) { + sizeStyle.width = typeof imageWidth === 'number' ? `${imageWidth}px` : imageWidth; + } + + if (imageHeight) { + sizeStyle.height = typeof imageHeight === 'number' ? `${imageHeight}px` : imageHeight; + } + + const shapeClass = circular ? 'rounded-full' : square ? 'aspect-square' : 'rounded-md'; + const ringClass = ringMap[ring] || 'ring'; + const overlapClass = overlapMap[overlap] || '-ml-4'; + const remainingSizeClass = remainingSizeMap[limitedRemainingTextSize] || 'text-sm'; + + const getImageUrl = (image: string): string => { + // If it's already an absolute URL, return it + if (image.startsWith('http://') || image.startsWith('https://') || image.startsWith('data:')) { + return image; + } + + // Handle disk and visibility for Laravel storage + // If visibility is 'public' or disk is 'public', use /storage/ path + // Otherwise, this should be a temporary URL generated by the backend + if (visibility === 'public' || disk === 'public') { + return `/storage/${image}`; + } + + // For non-public storage, assume the backend will provide temporary URLs + // If it's a relative path, prepend /storage/ as default + if (!image.startsWith('/')) { + return `/storage/${image}`; + } + + return image; + }; + + const handleImageError = (event: SyntheticEvent) => { + const imgElement = event.currentTarget; + // Only fall back once: if the default image itself fails, stop (prevents an error/reload loop) + if (defaultImageUrl && imgElement.getAttribute('src') !== defaultImageUrl) { + imgElement.src = defaultImageUrl; + } + }; + + const extra = splitExtraAttributes(extraImgAttributes); + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ {stacked ? ( + // Stacked images +
+ {displayImages.map((image: any, index: number) => ( +
0 && overlapClass)}> + {`Image +
+ ))} + + {limitedRemainingText && remainingCount > 0 && ( + +{remainingCount} + )} +
+ ) : ( + // Regular images + <> + {displayImages.map((image: any, index: number) => ( + {`Image + ))} + + {limitedRemainingText && remainingCount > 0 && ( + +{remainingCount} + )} + + )} +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/grid-columns/TextGridColumn.tsx b/resources/react/components/grid-columns/TextGridColumn.tsx new file mode 100644 index 0000000..7d65f2c --- /dev/null +++ b/resources/react/components/grid-columns/TextGridColumn.tsx @@ -0,0 +1,201 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useNotification } from '@laravilt/notifications/composables/useNotification'; +import { Copy } from 'lucide-react'; +import { toDisplayString } from '../../lib/display'; +import { resolveColumnIcon } from '../../lib/icons'; + +export interface TextGridColumnProps { + value: any; + limit?: number; + wrap?: boolean; + copyable?: string | null; + badge?: boolean; + dateTimeFormat?: string | null; + dateFormat?: string | null; + icon?: string | null; + weight?: string | null; + moneyFormat?: { currency: string; divideBy: number } | null; + color?: string | null; + description?: string | null; + descriptionPosition?: 'above' | 'below'; +} + +// Map color to badge variant +const badgeColorMap: Record = { + primary: 'default', + success: 'success', + danger: 'destructive', + warning: 'warning', + info: 'secondary', + gray: 'secondary', + secondary: 'secondary', +}; + +const weightClasses: Record = { + thin: 'font-thin', + extralight: 'font-extralight', + light: 'font-light', + normal: 'font-normal', + medium: 'font-medium', + semibold: 'font-semibold', + bold: 'font-bold', + extrabold: 'font-extrabold', + black: 'font-black', +}; + +export default function TextGridColumn({ + value, + limit, + wrap = false, + copyable = null, + badge = false, + dateTimeFormat = null, + dateFormat = null, + icon = null, + weight = null, + moneyFormat = null, + color = null, + description = null, + descriptionPosition = 'below', +}: TextGridColumnProps) { + const { notify } = useNotification(); + + // Check if value is an array (for badge rendering of many-to-many relationships) + const isArray = Array.isArray(value); + + const badgeVariant = !color ? 'secondary' : badgeColorMap[color] || 'secondary'; + + const formattedValue = ((): string => { + if (value === null || value === undefined) { + return ''; + } + + // If it's an array for badges, don't format it here - we'll render badges individually + if (isArray && badge) { + return ''; + } + + // If it's an object or array (not for badges), format as JSON (same as TextColumn) + if (typeof value === 'object' && !badge) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + let result = String(value); + + // Format as date/datetime + if (dateTimeFormat && value) { + try { + const date = new Date(value); + // Check if date is valid + if (!isNaN(date.getTime())) { + result = date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + } catch { + result = String(value); + } + } else if (dateFormat && value) { + try { + const date = new Date(value); + // Check if date is valid + if (!isNaN(date.getTime())) { + result = date.toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } + } catch { + result = String(value); + } + } + + // Format as money + if (moneyFormat) { + const numValue = Number(value) / moneyFormat.divideBy; + result = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: moneyFormat.currency, + }).format(numValue); + } + + // Apply character limit + if (limit && result.length > limit) { + result = result.substring(0, limit) + '...'; + } + + return result; + })(); + + const LucideIconComponent = icon ? resolveColumnIcon(icon) : null; + + const handleCopy = () => { + if (copyable && formattedValue) { + navigator.clipboard.writeText(formattedValue); + notify(copyable, 'Copied to clipboard', 'success', { + duration: 1500, + }); + } + }; + + const weightClass = (weight && weightClasses[weight]) || ''; + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ {/* Icon outside badge when not using badge */} + {LucideIconComponent && !badge && } + + {badge && isArray ? ( + // Multiple badges for array values (many-to-many relationships) +
+ {(value as any[]).map((item: any, index: number) => ( + + {LucideIconComponent && } + {toDisplayString(item)} + + ))} +
+ ) : badge ? ( + // Single badge + + {LucideIconComponent && } + {formattedValue} + + ) : ( + // Regular text + {formattedValue} + )} + + {copyable && ( + + )} +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/components/grid-columns/ToggleGridColumn.tsx b/resources/react/components/grid-columns/ToggleGridColumn.tsx new file mode 100644 index 0000000..075f3ea --- /dev/null +++ b/resources/react/components/grid-columns/ToggleGridColumn.tsx @@ -0,0 +1,107 @@ +import { Switch } from '@/components/ui/switch'; +import { router, usePage } from '@inertiajs/react'; +import { useNotification } from '@laravilt/notifications/composables/useNotification'; +import { useLocalization } from '@laravilt/support/composables/useLocalization'; +import { useEffect, useState } from 'react'; +import { useStateRef } from '../../composables/useStateRef'; + +export interface ToggleGridColumnProps { + value: any; + name: string; + recordId: number | string; + resourceSlug: string; + editable?: boolean; + disabled?: boolean; + description?: string | null; + descriptionPosition?: 'above' | 'below'; +} + +export default function ToggleGridColumn({ + value, + name, + recordId, + resourceSlug, + editable = true, + disabled = false, + description = null, + descriptionPosition = 'below', +}: ToggleGridColumnProps) { + const { trans } = useLocalization(); + const { notify } = useNotification(); + + // Get the panel path from Inertia shared data + const page = usePage(); + const panelPath = (page.props as any).panel?.path || 'dashboard'; + + // Use the same boolean conversion as the Edit page - simple Boolean() cast + const [localValue, setLocalValue] = useState(() => Boolean(value)); + const [isUpdating, setIsUpdating, isUpdatingRef] = useStateRef(false); + + // Watch for prop changes to update local value (but not during updates to avoid conflicts) + useEffect(() => { + if (!isUpdatingRef.current) { + setLocalValue(Boolean(value)); + } + }, [value, isUpdatingRef]); + + const setChecked = (newValue: boolean) => { + if (disabled || !editable || isUpdatingRef.current) return; + + setIsUpdating(true); + + // Send update to backend (convert to 1/0 for database) + router.patch( + `/${panelPath}/${resourceSlug}/${recordId}/column`, + { + column: name, + value: newValue ? 1 : 0, + }, + { + preserveScroll: true, + preserveState: true, + only: ['records'], + onSuccess: () => { + setLocalValue(newValue); + setIsUpdating(false); + notify( + trans('tables::tables.toggle_column.success_notification_title'), + trans('tables::tables.toggle_column.success_notification_message'), + 'success', + { duration: 2000 }, + ); + }, + onError: (errors: Record) => { + setIsUpdating(false); + + const errorMessage = errors[name] || trans('tables::tables.toggle_column.error_notification_message'); + notify(trans('tables::tables.toggle_column.error_notification_title'), errorMessage, 'error', { + duration: 3000, + }); + }, + }, + ); + }; + + return ( +
+ {/* Description above */} + {description && descriptionPosition === 'above' && ( +
{description}
+ )} + + {/* Main content */} +
+ setChecked(checked)} + /> +
+ + {/* Description below */} + {description && descriptionPosition === 'below' && ( +
{description}
+ )} +
+ ); +} diff --git a/resources/react/composables/useStateRef.ts b/resources/react/composables/useStateRef.ts new file mode 100644 index 0000000..3561ef2 --- /dev/null +++ b/resources/react/composables/useStateRef.ts @@ -0,0 +1,18 @@ +import { useCallback, useRef, useState, type RefObject } from 'react'; + +/** + * State that is also readable synchronously through a ref — the React equivalent of a Vue `ref()` that is + * written in a handler and read right away (or later from a timeout/observer) in the same tick. + * The setter updates the ref immediately and schedules the re-render. + */ +export function useStateRef(initial: T | (() => T)): [T, (value: T) => void, RefObject] { + const [state, setState] = useState(initial); + const ref = useRef(state); + + const set = useCallback((value: T) => { + ref.current = value; + setState(value); + }, []); + + return [state, set, ref]; +} diff --git a/resources/react/composables/useWatch.ts b/resources/react/composables/useWatch.ts new file mode 100644 index 0000000..50dbc35 --- /dev/null +++ b/resources/react/composables/useWatch.ts @@ -0,0 +1,21 @@ +import { useLatest } from '@laravilt/support/composables/hooks'; +import { useEffect, useRef } from 'react'; + +/** + * React twin of a non-immediate Vue `watch(source, cb)`: runs `callback(value, previous)` after a render in + * which `value` changed (Object.is), never on mount. Safe under StrictMode's double effect invocation. + */ +export function useWatch(value: T, callback: (value: T, previous: T) => void): void { + const previous = useRef(value); + const latest = useLatest(callback); + + useEffect(() => { + if (Object.is(previous.current, value)) { + return; + } + + const old = previous.current; + previous.current = value; + latest.current(value, old); + }, [value, latest]); +} diff --git a/resources/react/lib/display.ts b/resources/react/lib/display.ts new file mode 100644 index 0000000..522d8c0 --- /dev/null +++ b/resources/react/lib/display.ts @@ -0,0 +1,24 @@ +/** + * Equivalent of Vue's `{{ value }}` interpolation (`toDisplayString`): + * null/undefined → '', arrays and plain objects → pretty JSON, everything else → String(). + * React cannot render objects or booleans as children, Vue can. + */ +export function toDisplayString(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (value === null || value === undefined) { + return ''; + } + + if ( + Array.isArray(value) || + (typeof value === 'object' && + ((value as object).toString === Object.prototype.toString || typeof (value as any).toString !== 'function')) + ) { + return JSON.stringify(value, null, 2); + } + + return String(value); +} diff --git a/resources/react/lib/icons.ts b/resources/react/lib/icons.ts new file mode 100644 index 0000000..bcb5948 --- /dev/null +++ b/resources/react/lib/icons.ts @@ -0,0 +1,43 @@ +import { resolveIcon } from '@laravilt/support/lib/icons'; +import * as LucideIcons from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +/** + * Heroicon → Lucide mapping used by every Vue column component. + */ +const heroiconMap: Record = { + 'heroicon-o-check-circle': 'CheckCircle', + 'heroicon-o-x-circle': 'XCircle', + 'heroicon-o-exclamation-circle': 'AlertCircle', + 'heroicon-o-information-circle': 'Info', +}; + +/** + * Resolve a column icon name exactly like the Vue columns did + * (`iconMap[name] || name` → PascalCase on `-` → lookup in every lucide export, aliases included), + * falling back to the shared `resolveIcon()` for the formats it understands. + * + * The fallback is needed because `resolveIcon()` only searches lucide's `icons` map, + * which does not contain aliases such as `CheckCircle`, `XCircle` or `AlertCircle`. + */ +export function resolveColumnIcon(name: unknown): LucideIcon | null { + if (!name) { + return null; + } + + const iconName = String(name); + const mappedIconName = heroiconMap[iconName] || iconName; + + const pascalCaseName = mappedIconName + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + + const direct = (LucideIcons as Record)[pascalCaseName]; + + if (direct && (typeof direct === 'object' || typeof direct === 'function')) { + return direct as LucideIcon; + } + + return resolveIcon(iconName); +} diff --git a/src/Mcp/Tools/GenerateTableTool.php b/src/Mcp/Tools/GenerateTableTool.php index 1322ec8..f2f3e88 100644 --- a/src/Mcp/Tools/GenerateTableTool.php +++ b/src/Mcp/Tools/GenerateTableTool.php @@ -2,7 +2,7 @@ namespace Laravilt\Tables\Mcp\Tools; -use Illuminate\JsonSchema\JsonSchema; +use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; diff --git a/src/Mcp/Tools/SearchDocsTool.php b/src/Mcp/Tools/SearchDocsTool.php index 03167c9..f646038 100644 --- a/src/Mcp/Tools/SearchDocsTool.php +++ b/src/Mcp/Tools/SearchDocsTool.php @@ -2,7 +2,7 @@ namespace Laravilt\Tables\Mcp\Tools; -use Illuminate\JsonSchema\JsonSchema; +use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\File; use Laravel\Mcp\Request; use Laravel\Mcp\Response;