473 lines
18 KiB
TypeScript
473 lines
18 KiB
TypeScript
|
|
/**
|
||
|
|
* View Designer — Criteria grid (SSMS Query Designer'in orta bolumu).
|
||
|
|
*
|
||
|
|
* Sutunlar: Column · Alias · Table · Output · Group By · Sort Type · Sort Order
|
||
|
|
* · Filter · Or... Filtre hucreleri serbest yuklemdir ("> 100", "LIKE '%a%'");
|
||
|
|
* yaninda kucuk bir yardimci ile de doldurulabilir.
|
||
|
|
*/
|
||
|
|
import { useState } from 'react'
|
||
|
|
import { FaCode, FaGripVertical, FaMagic, FaPlus, FaTrash } from 'react-icons/fa'
|
||
|
|
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
||
|
|
import {
|
||
|
|
GROUP_MODES,
|
||
|
|
GROUP_MODE_LABEL,
|
||
|
|
isAggregateMode,
|
||
|
|
orColumnCount,
|
||
|
|
quoteLiteral,
|
||
|
|
type CriteriaRow,
|
||
|
|
type GroupMode,
|
||
|
|
type ViewSource,
|
||
|
|
} from './viewModel'
|
||
|
|
|
||
|
|
interface CriteriaGridProps {
|
||
|
|
rows: CriteriaRow[]
|
||
|
|
sources: ViewSource[]
|
||
|
|
columnsOf: (sourceId: string) => DatabaseColumnDto[]
|
||
|
|
grouped: boolean
|
||
|
|
onUpdateRow: (id: string, patch: Partial<CriteriaRow>) => void
|
||
|
|
onUpdateFilter: (id: string, index: number, value: string) => void
|
||
|
|
onRemoveRow: (id: string) => void
|
||
|
|
onMoveRow: (id: string, delta: number) => void
|
||
|
|
onAddRow: () => void
|
||
|
|
onAddExpression: () => void
|
||
|
|
labels: {
|
||
|
|
column: string
|
||
|
|
alias: string
|
||
|
|
table: string
|
||
|
|
output: string
|
||
|
|
groupBy: string
|
||
|
|
sortType: string
|
||
|
|
sortOrder: string
|
||
|
|
filter: string
|
||
|
|
or: string
|
||
|
|
addRow: string
|
||
|
|
addExpression: string
|
||
|
|
expressionPlaceholder: string
|
||
|
|
empty: string
|
||
|
|
builder: string
|
||
|
|
builderValue: string
|
||
|
|
builderLiteral: string
|
||
|
|
builderExpression: string
|
||
|
|
apply: string
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const OPERATORS = [
|
||
|
|
'=',
|
||
|
|
'<>',
|
||
|
|
'>',
|
||
|
|
'>=',
|
||
|
|
'<',
|
||
|
|
'<=',
|
||
|
|
'LIKE',
|
||
|
|
'NOT LIKE',
|
||
|
|
'IN',
|
||
|
|
'NOT IN',
|
||
|
|
'IS NULL',
|
||
|
|
'IS NOT NULL',
|
||
|
|
'BETWEEN',
|
||
|
|
]
|
||
|
|
|
||
|
|
export function CriteriaGrid({
|
||
|
|
rows,
|
||
|
|
sources,
|
||
|
|
columnsOf,
|
||
|
|
grouped,
|
||
|
|
onUpdateRow,
|
||
|
|
onUpdateFilter,
|
||
|
|
onRemoveRow,
|
||
|
|
onMoveRow,
|
||
|
|
onAddRow,
|
||
|
|
onAddExpression,
|
||
|
|
labels,
|
||
|
|
}: CriteriaGridProps) {
|
||
|
|
const [builder, setBuilder] = useState<{ rowId: string; index: number } | null>(null)
|
||
|
|
|
||
|
|
const orCount = orColumnCount(rows)
|
||
|
|
|
||
|
|
const cellCls =
|
||
|
|
'w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-gray-300 focus:border-blue-500 focus:bg-white dark:text-gray-100 dark:hover:border-gray-600 dark:focus:bg-gray-700'
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||
|
|
<div className="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-2 py-1 dark:border-gray-700 dark:bg-gray-800">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30"
|
||
|
|
onClick={onAddRow}
|
||
|
|
disabled={sources.length === 0}
|
||
|
|
>
|
||
|
|
<FaPlus className="text-[9px]" /> {labels.addRow}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||
|
|
onClick={onAddExpression}
|
||
|
|
>
|
||
|
|
<FaCode className="text-[9px]" /> {labels.addExpression}
|
||
|
|
</button>
|
||
|
|
<span className="text-[10px] text-gray-400">{rows.length}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-auto">
|
||
|
|
<table className="w-full border-collapse text-xs">
|
||
|
|
<thead className="sticky top-0 z-10 bg-gray-100 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-300">
|
||
|
|
<tr>
|
||
|
|
<th className="w-6 border border-gray-200 px-1 py-1 dark:border-gray-700" />
|
||
|
|
<th className="min-w-[150px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||
|
|
{labels.column}
|
||
|
|
</th>
|
||
|
|
<th className="min-w-[110px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||
|
|
{labels.alias}
|
||
|
|
</th>
|
||
|
|
<th className="min-w-[90px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||
|
|
{labels.table}
|
||
|
|
</th>
|
||
|
|
<th className="w-14 border border-gray-200 px-1 py-1 dark:border-gray-700">
|
||
|
|
{labels.output}
|
||
|
|
</th>
|
||
|
|
{grouped && (
|
||
|
|
<th className="min-w-[110px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||
|
|
{labels.groupBy}
|
||
|
|
</th>
|
||
|
|
)}
|
||
|
|
<th className="w-[86px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||
|
|
{labels.sortType}
|
||
|
|
</th>
|
||
|
|
<th className="w-[70px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700">
|
||
|
|
{labels.sortOrder}
|
||
|
|
</th>
|
||
|
|
{Array.from({ length: orCount }).map((_, i) => (
|
||
|
|
<th
|
||
|
|
key={i}
|
||
|
|
className="min-w-[130px] border border-gray-200 px-1 py-1 text-left dark:border-gray-700"
|
||
|
|
>
|
||
|
|
{i === 0 ? labels.filter : labels.or}
|
||
|
|
</th>
|
||
|
|
))}
|
||
|
|
<th className="w-8 border border-gray-200 px-1 py-1 dark:border-gray-700" />
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{rows.length === 0 && (
|
||
|
|
<tr>
|
||
|
|
<td
|
||
|
|
className="border border-gray-200 px-2 py-6 text-center text-xs text-gray-400 dark:border-gray-700"
|
||
|
|
colSpan={(grouped ? 9 : 8) + orCount}
|
||
|
|
>
|
||
|
|
{labels.empty}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)}
|
||
|
|
{rows.map((row) => {
|
||
|
|
const isAgg = grouped && isAggregateMode(row.groupMode)
|
||
|
|
const isExpression = row.expression !== undefined
|
||
|
|
return (
|
||
|
|
<tr
|
||
|
|
key={row.id}
|
||
|
|
className={
|
||
|
|
row.output ? 'bg-white dark:bg-gray-900' : 'bg-gray-50/70 dark:bg-gray-800/40'
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<td className="border border-gray-200 px-0.5 text-center dark:border-gray-700">
|
||
|
|
<div className="flex flex-col items-center leading-none">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-[8px] text-gray-300 hover:text-blue-500"
|
||
|
|
onClick={() => onMoveRow(row.id, -1)}
|
||
|
|
>
|
||
|
|
▲
|
||
|
|
</button>
|
||
|
|
<FaGripVertical className="text-[8px] text-gray-300" />
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-[8px] text-gray-300 hover:text-blue-500"
|
||
|
|
onClick={() => onMoveRow(row.id, 1)}
|
||
|
|
>
|
||
|
|
▼
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||
|
|
{isExpression ? (
|
||
|
|
<input
|
||
|
|
className={`${cellCls} font-mono text-[11px] text-indigo-700 dark:text-indigo-300`}
|
||
|
|
placeholder={labels.expressionPlaceholder}
|
||
|
|
value={row.expression ?? ''}
|
||
|
|
title={row.expression}
|
||
|
|
onChange={(e) => onUpdateRow(row.id, { expression: e.target.value })}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<select
|
||
|
|
className={cellCls}
|
||
|
|
value={row.columnName}
|
||
|
|
onChange={(e) => onUpdateRow(row.id, { columnName: e.target.value })}
|
||
|
|
>
|
||
|
|
<option value="">—</option>
|
||
|
|
{columnsOf(row.sourceId).map((c) => (
|
||
|
|
<option key={c.columnName} value={c.columnName}>
|
||
|
|
{c.columnName}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||
|
|
<input
|
||
|
|
className={cellCls}
|
||
|
|
placeholder={row.columnName}
|
||
|
|
value={row.alias}
|
||
|
|
onChange={(e) => onUpdateRow(row.id, { alias: e.target.value })}
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||
|
|
{isExpression ? (
|
||
|
|
<span className="px-1 text-[10px] italic text-gray-400">SQL</span>
|
||
|
|
) : (
|
||
|
|
<select
|
||
|
|
className={cellCls}
|
||
|
|
value={row.sourceId}
|
||
|
|
onChange={(e) =>
|
||
|
|
onUpdateRow(row.id, { sourceId: e.target.value, columnName: '' })
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{sources.map((s) => (
|
||
|
|
<option key={s.id} value={s.id}>
|
||
|
|
{s.alias || s.objectName}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td className="border border-gray-200 text-center dark:border-gray-700">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
className="h-3.5 w-3.5"
|
||
|
|
checked={row.output}
|
||
|
|
disabled={row.groupMode === 'Where'}
|
||
|
|
onChange={(e) => onUpdateRow(row.id, { output: e.target.checked })}
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
{grouped && (
|
||
|
|
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||
|
|
<select
|
||
|
|
className={cellCls}
|
||
|
|
value={row.groupMode}
|
||
|
|
disabled={isExpression}
|
||
|
|
onChange={(e) => {
|
||
|
|
const mode = e.target.value as GroupMode
|
||
|
|
onUpdateRow(row.id, {
|
||
|
|
groupMode: mode,
|
||
|
|
...(mode === 'Where' ? { output: false } : {}),
|
||
|
|
})
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{GROUP_MODES.map((m) => (
|
||
|
|
<option key={m} value={m}>
|
||
|
|
{GROUP_MODE_LABEL[m]}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</td>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||
|
|
<select
|
||
|
|
className={cellCls}
|
||
|
|
value={row.sortType}
|
||
|
|
onChange={(e) => {
|
||
|
|
const sortType = e.target.value as '' | 'ASC' | 'DESC'
|
||
|
|
onUpdateRow(row.id, {
|
||
|
|
sortType,
|
||
|
|
sortOrder: sortType
|
||
|
|
? row.sortOrder || rows.filter((r) => r.sortType).length + 1
|
||
|
|
: 0,
|
||
|
|
})
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="">—</option>
|
||
|
|
<option value="ASC">Ascending</option>
|
||
|
|
<option value="DESC">Descending</option>
|
||
|
|
</select>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td className="border border-gray-200 px-0.5 dark:border-gray-700">
|
||
|
|
<input
|
||
|
|
type="number"
|
||
|
|
min={0}
|
||
|
|
className={cellCls}
|
||
|
|
value={row.sortOrder || ''}
|
||
|
|
disabled={!row.sortType}
|
||
|
|
onChange={(e) =>
|
||
|
|
onUpdateRow(row.id, { sortOrder: Number(e.target.value) || 0 })
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
{Array.from({ length: orCount }).map((_, i) => (
|
||
|
|
<td
|
||
|
|
key={i}
|
||
|
|
className="relative border border-gray-200 px-0.5 dark:border-gray-700"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-0.5">
|
||
|
|
<input
|
||
|
|
className={cellCls}
|
||
|
|
placeholder={i === 0 ? "> 100 · = 'A' · IS NULL" : ''}
|
||
|
|
value={row.filters[i] ?? ''}
|
||
|
|
onChange={(e) => onUpdateFilter(row.id, i, e.target.value)}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="shrink-0 text-[9px] text-gray-300 hover:text-blue-500"
|
||
|
|
title={labels.builder}
|
||
|
|
onClick={() =>
|
||
|
|
setBuilder(
|
||
|
|
builder?.rowId === row.id && builder.index === i
|
||
|
|
? null
|
||
|
|
: { rowId: row.id, index: i },
|
||
|
|
)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<FaMagic />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
{builder?.rowId === row.id && builder.index === i && (
|
||
|
|
<FilterBuilder
|
||
|
|
labels={labels}
|
||
|
|
onApply={(text) => {
|
||
|
|
onUpdateFilter(row.id, i, text)
|
||
|
|
setBuilder(null)
|
||
|
|
}}
|
||
|
|
onClose={() => setBuilder(null)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{isAgg && (row.filters[i] ?? '').trim() && (
|
||
|
|
<span className="pointer-events-none absolute -top-1 right-6 rounded bg-indigo-100 px-1 text-[8px] font-semibold text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
||
|
|
HAVING
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
))}
|
||
|
|
|
||
|
|
<td className="border border-gray-200 text-center dark:border-gray-700">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-[10px] text-gray-400 hover:text-red-500"
|
||
|
|
onClick={() => onRemoveRow(row.id)}
|
||
|
|
>
|
||
|
|
<FaTrash />
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Filtre yardimcisi ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function FilterBuilder({
|
||
|
|
labels,
|
||
|
|
onApply,
|
||
|
|
onClose,
|
||
|
|
}: {
|
||
|
|
labels: CriteriaGridProps['labels']
|
||
|
|
onApply: (text: string) => void
|
||
|
|
onClose: () => void
|
||
|
|
}) {
|
||
|
|
const [operator, setOperator] = useState('=')
|
||
|
|
const [value, setValue] = useState('')
|
||
|
|
const [value2, setValue2] = useState('')
|
||
|
|
const [mode, setMode] = useState<'literal' | 'expression'>('literal')
|
||
|
|
|
||
|
|
const needsValue = operator !== 'IS NULL' && operator !== 'IS NOT NULL'
|
||
|
|
|
||
|
|
const build = () => {
|
||
|
|
if (!needsValue) return operator
|
||
|
|
const render = (v: string) => (mode === 'literal' ? quoteLiteral(v.trim()) : v.trim())
|
||
|
|
if (operator === 'BETWEEN') return `BETWEEN ${render(value)} AND ${render(value2)}`
|
||
|
|
if (operator === 'IN' || operator === 'NOT IN') {
|
||
|
|
if (mode === 'expression') return `${operator} (${value.trim()})`
|
||
|
|
const items = value
|
||
|
|
.split(',')
|
||
|
|
.map((v) => v.trim())
|
||
|
|
.filter(Boolean)
|
||
|
|
.map(quoteLiteral)
|
||
|
|
return `${operator} (${items.join(', ')})`
|
||
|
|
}
|
||
|
|
return `${operator} ${render(value)}`
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="absolute left-0 top-full z-50 mt-1 w-[230px] rounded-lg border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<select
|
||
|
|
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
|
|
value={operator}
|
||
|
|
onChange={(e) => setOperator(e.target.value)}
|
||
|
|
>
|
||
|
|
{OPERATORS.map((op) => (
|
||
|
|
<option key={op} value={op}>
|
||
|
|
{op}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
{needsValue && (
|
||
|
|
<>
|
||
|
|
<input
|
||
|
|
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
|
|
placeholder={labels.builderValue}
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => setValue(e.target.value)}
|
||
|
|
/>
|
||
|
|
{operator === 'BETWEEN' && (
|
||
|
|
<input
|
||
|
|
className="mb-1 w-full rounded border px-1 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
|
|
placeholder={labels.builderValue}
|
||
|
|
value={value2}
|
||
|
|
onChange={(e) => setValue2(e.target.value)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
<select
|
||
|
|
className="mb-1 w-full rounded border px-1 py-1 text-[11px] dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
|
|
value={mode}
|
||
|
|
onChange={(e) => setMode(e.target.value as 'literal' | 'expression')}
|
||
|
|
>
|
||
|
|
<option value="literal">{labels.builderLiteral}</option>
|
||
|
|
<option value="expression">{labels.builderExpression}</option>
|
||
|
|
</select>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex-1 rounded bg-blue-600 px-1 py-1 text-[11px] font-semibold text-white hover:bg-blue-700"
|
||
|
|
onClick={() => onApply(build())}
|
||
|
|
>
|
||
|
|
{labels.apply}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
|
|
onClick={onClose}
|
||
|
|
>
|
||
|
|
✕
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default CriteriaGrid
|