Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion src/components/ui/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
ArrowUpDown,
Braces,
ClipboardPaste,
ClipboardType,
ClipboardList,
Copy,
CopyPlus,
Clock,
Expand Down Expand Up @@ -93,6 +95,8 @@ import {
} from "../../utils/foreignKeys";
import {
getCellValueFilterOperators,
parseClipboardInList,
MAX_CLIPBOARD_FILTER_VALUES,
type CellValueFilterOperator,
} from "../../utils/cellValueFilter";
import { isLinkString } from "../../utils/jsonValueDecorators";
Expand Down Expand Up @@ -148,7 +152,7 @@ interface DataGridProps {
onFilterByValue?: (
column: string,
operator: CellValueFilterOperator,
value: unknown,
value: unknown | unknown[],
columnType?: string,
) => void;
onForeignKeyShowPanel?: (fk: ForeignKey, value: unknown) => void;
Expand Down Expand Up @@ -242,6 +246,7 @@ const CELL_VALUE_FILTER_LABEL_KEYS: Record<CellValueFilterOperator, string> = {
"<>": "dataGrid.filterNotEquals",
"IS NULL": "dataGrid.filterIsNull",
"IS NOT NULL": "dataGrid.filterIsNotNull",
IN: "dataGrid.filterInClipboard",
};

export const DataGrid = React.memo(
Expand Down Expand Up @@ -3113,6 +3118,83 @@ export const DataGrid = React.memo(
},
});
}

// "Column = clipboard": the whole clipboard text is one
// value. Read the clipboard when the item is clicked (not
// when the menu opens) and trim before comparing; an empty
// clipboard shows a short message instead of a filter.
menuItems.push({
label: t("dataGrid.filterEqualsClipboard", {
column: colName,
}),
icon: ClipboardType,
action: async () => {
setContextMenu(null);
try {
const clipboardText = (await readText()).trim();
if (!clipboardText) {
showToast(t("dataGrid.filterClipboardEmpty"), {
kind: "error",
});
return;
}
onFilterByValue(
colName,
"=",
clipboardText,
colDataType || undefined,
);
} catch (e) {
console.error("Failed to read clipboard:", e);
showToast(t("dataGrid.filterClipboardReadFailed"), {
kind: "error",
});
}
},
});

// "Column IN (clipboard)": split clipboard text into lines
// (blank lines and duplicates dropped, values capped) and
// build an IN list typed for the clicked column.
menuItems.push({
label: t("dataGrid.filterInClipboard", {
column: colName,
}),
icon: ClipboardList,
action: async () => {
setContextMenu(null);
try {
const clipboardText = await readText();
const { values, truncated } =
parseClipboardInList(clipboardText);
if (values.length === 0) {
showToast(t("dataGrid.filterClipboardEmpty"), {
kind: "error",
});
return;
}
if (truncated) {
showToast(
t("dataGrid.filterClipboardTruncated", {
max: MAX_CLIPBOARD_FILTER_VALUES,
}),
{ kind: "warning" },
);
}
onFilterByValue(
colName,
"IN",
values,
colDataType || undefined,
);
} catch (e) {
console.error("Failed to read clipboard:", e);
showToast(t("dataGrid.filterClipboardReadFailed"), {
kind: "error",
});
}
},
});
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,11 @@
"filterNotEquals": "Filtern: {{column}} <> dieser Wert",
"filterIsNull": "Filtern: {{column}} IS NULL",
"filterIsNotNull": "Filtern: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Filtern: {{column}} = Wert aus der Zwischenablage",
"filterInClipboard": "Filtern: {{column}} IN (Zwischenablage)",
"filterClipboardEmpty": "Die Zwischenablage ist leer – kein Filter angewendet",
"filterClipboardReadFailed": "Die Zwischenablage konnte nicht gelesen werden",
"filterClipboardTruncated": "Liste aus der Zwischenablage auf {{max}} Werte gekürzt",
"copyCells": "{{count}} Zelle(n) kopieren",
"copyRows": "{{count}} Zeile(n) kopieren",
"copied": "In Zwischenablage kopiert",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,11 @@
"filterNotEquals": "Filter: {{column}} <> this value",
"filterIsNull": "Filter: {{column}} IS NULL",
"filterIsNotNull": "Filter: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Filter: {{column}} = clipboard value",
"filterInClipboard": "Filter: {{column}} IN (clipboard)",
"filterClipboardEmpty": "Clipboard is empty — no filter applied",
"filterClipboardReadFailed": "Could not read the clipboard",
"filterClipboardTruncated": "Clipboard list truncated to {{max}} values",
"copyCells": "Copy {{count}} Cells",
"copyRows": "Copy {{count}} Rows",
"copied": "Copied to clipboard",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1339,6 +1339,11 @@
"filterNotEquals": "Filtrar: {{column}} <> este valor",
"filterIsNull": "Filtrar: {{column}} IS NULL",
"filterIsNotNull": "Filtrar: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Filtrar: {{column}} = valor del portapapeles",
"filterInClipboard": "Filtrar: {{column}} IN (portapapeles)",
"filterClipboardEmpty": "El portapapeles está vacío; no se aplicó ningún filtro",
"filterClipboardReadFailed": "No se pudo leer el portapapeles",
"filterClipboardTruncated": "Lista del portapapeles truncada a {{max}} valores",
"copyCells": "Copiar {{count}} Celdas",
"copyRows": "Copiar {{count}} Filas",
"copied": "Copiado al portapapeles",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,11 @@
"filterNotEquals": "Filtrer : {{column}} <> cette valeur",
"filterIsNull": "Filtrer : {{column}} IS NULL",
"filterIsNotNull": "Filtrer : {{column}} IS NOT NULL",
"filterEqualsClipboard": "Filtrer : {{column}} = valeur du presse-papiers",
"filterInClipboard": "Filtrer : {{column}} IN (presse-papiers)",
"filterClipboardEmpty": "Le presse-papiers est vide — aucun filtre appliqué",
"filterClipboardReadFailed": "Impossible de lire le presse-papiers",
"filterClipboardTruncated": "Liste du presse-papiers tronquée à {{max}} valeurs",
"copyCells": "Copier {{count}} cellule(s)",
"copyRows": "Copier {{count}} ligne(s)",
"copied": "Copié dans le presse-papiers",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,11 @@
"filterNotEquals": "Filtra: {{column}} <> questo valore",
"filterIsNull": "Filtra: {{column}} IS NULL",
"filterIsNotNull": "Filtra: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Filtra: {{column}} = valore degli appunti",
"filterInClipboard": "Filtra: {{column}} IN (appunti)",
"filterClipboardEmpty": "Gli appunti sono vuoti, nessun filtro applicato",
"filterClipboardReadFailed": "Impossibile leggere gli appunti",
"filterClipboardTruncated": "Elenco degli appunti troncato a {{max}} valori",
"copyCells": "Copia {{count}} celle",
"copyRows": "Copia {{count}} righe",
"copied": "Copiato negli appunti",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,11 @@
"filterNotEquals": "フィルター:{{column}} <> この値",
"filterIsNull": "フィルター:{{column}} IS NULL",
"filterIsNotNull": "フィルター:{{column}} IS NOT NULL",
"filterEqualsClipboard": "フィルター:{{column}} = クリップボードの値",
"filterInClipboard": "フィルター:{{column}} IN (クリップボード)",
"filterClipboardEmpty": "クリップボードが空です。フィルターは適用されませんでした",
"filterClipboardReadFailed": "クリップボードを読み取れませんでした",
"filterClipboardTruncated": "クリップボードのリストは{{max}}件に切り詰められました",
"copyCells": "{{count}} 個のセルをコピー",
"copyRows": "{{count}} 行をコピー",
"copied": "クリップボードにコピーしました",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,11 @@
"filterNotEquals": "필터: {{column}} <> 이 값",
"filterIsNull": "필터: {{column}} IS NULL",
"filterIsNotNull": "필터: {{column}} IS NOT NULL",
"filterEqualsClipboard": "필터: {{column}} = 클립보드 값",
"filterInClipboard": "필터: {{column}} IN (클립보드)",
"filterClipboardEmpty": "클립보드가 비어 있어 필터가 적용되지 않았습니다",
"filterClipboardReadFailed": "클립보드를 읽을 수 없습니다",
"filterClipboardTruncated": "클립보드 목록이 {{max}}개 값으로 잘렸습니다",
"copyCells": "{{count}}개 셀 복사",
"copyRows": "{{count}}개 행 복사",
"copied": "클립보드에 복사되었습니다",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,11 @@
"filterNotEquals": "Filtrar: {{column}} <> este valor",
"filterIsNull": "Filtrar: {{column}} IS NULL",
"filterIsNotNull": "Filtrar: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Filtrar: {{column}} = valor da área de transferência",
"filterInClipboard": "Filtrar: {{column}} IN (área de transferência)",
"filterClipboardEmpty": "A área de transferência está vazia — nenhum filtro aplicado",
"filterClipboardReadFailed": "Não foi possível ler a área de transferência",
"filterClipboardTruncated": "Lista da área de transferência truncada para {{max}} valores",
"copyCells": "Copiar {{count}} Células",
"copyRows": "Copiar {{count}} Linhas",
"copied": "Copiado para a área de transferência",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,11 @@
"filterNotEquals": "Фильтр: {{column}} <> это значение",
"filterIsNull": "Фильтр: {{column}} IS NULL",
"filterIsNotNull": "Фильтр: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Фильтр: {{column}} = значение из буфера обмена",
"filterInClipboard": "Фильтр: {{column}} IN (буфер обмена)",
"filterClipboardEmpty": "Буфер обмена пуст — фильтр не применён",
"filterClipboardReadFailed": "Не удалось прочитать буфер обмена",
"filterClipboardTruncated": "Список из буфера обмена сокращён до {{max}} значений",
"copyCells": "Копировать {{count}} ячеек",
"copyRows": "Копировать {{count}} строк",
"copied": "Скопировано в буфер обмена",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/tl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,11 @@
"filterNotEquals": "Filter: {{column}} <> value na ito",
"filterIsNull": "Filter: {{column}} IS NULL",
"filterIsNotNull": "Filter: {{column}} IS NOT NULL",
"filterEqualsClipboard": "Salain: {{column}} = value ng clipboard",
"filterInClipboard": "Salain: {{column}} IN (clipboard)",
"filterClipboardEmpty": "Walang laman ang clipboard — walang inilapat na salain",
"filterClipboardReadFailed": "Hindi nabasa ang clipboard",
"filterClipboardTruncated": "Ang listahan ng clipboard ay pinutol sa {{max}} na value",
"copyCells": "Kopyahin ang {{count}} Cell",
"copyRows": "Kopyahin ang {{count}} Row",
"copied": "Nakopya sa clipboard",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,11 @@
"filterNotEquals": "筛选:{{column}} <> 此值",
"filterIsNull": "筛选:{{column}} IS NULL",
"filterIsNotNull": "筛选:{{column}} IS NOT NULL",
"filterEqualsClipboard": "筛选:{{column}} = 剪贴板值",
"filterInClipboard": "筛选:{{column}} IN (剪贴板)",
"filterClipboardEmpty": "剪贴板为空,未应用筛选",
"filterClipboardReadFailed": "无法读取剪贴板",
"filterClipboardTruncated": "剪贴板列表已截断为 {{max}} 个值",
"copyCells": "复制 {{count}} 个单元格",
"copyRows": "复制 {{count}} 行",
"copied": "已复制到剪贴板",
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2976,7 +2976,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
(
column: string,
operator: CellValueFilterOperator,
value: unknown,
value: unknown | unknown[],
columnType?: string,
) => {
const currentTab = tabsRef.current.find(
Expand Down
44 changes: 41 additions & 3 deletions src/utils/cellValueFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,43 @@ import type { DriverCapabilities, PluginManifest } from "../types/plugins";
import { quoteIdentifier } from "./identifiers";
import { formatSqlValueForColumnComparison } from "./foreignKeys";

export type CellValueFilterOperator = "=" | "<>" | "IS NULL" | "IS NOT NULL";
export type CellValueFilterOperator =
| "="
| "<>"
| "IS NULL"
| "IS NOT NULL"
| "IN";

/** Cap on the number of clipboard values turned into an IN list. */
export const MAX_CLIPBOARD_FILTER_VALUES = 1000;

/**
* Split clipboard text into a deduplicated list of trimmed values for an IN
* filter. Lines are split on line breaks; blank lines (including a trailing
* newline) are ignored and duplicates collapse to their first occurrence.
*
* More than {@link MAX_CLIPBOARD_FILTER_VALUES} distinct values are dropped and
* reported via `truncated`, so the caller can toast about the cut-off.
*/
export function parseClipboardInList(text: string): {
values: string[];
truncated: boolean;
} {
const seen = new Set<string>();
const values: string[] = [];
for (const line of text.split(/\r\n|\r|\n/)) {
const value = line.trim();
if (value === "" || seen.has(value)) continue;
seen.add(value);
if (values.length < MAX_CLIPBOARD_FILTER_VALUES) values.push(value);
}
return { values, truncated: seen.size > MAX_CLIPBOARD_FILTER_VALUES };
}

/**
* Build a WHERE-clause fragment that filters the current table by a cell value.
* Output: `"col" = 42`, `"col" <> 'escaped''value'`, `"col" IS NULL`, or
* `"col" IS NOT NULL`.
* Output: `"col" = 42`, `"col" <> 'escaped''value'`, `"col" IS NULL`,
* `"col" IS NOT NULL`, or `"col" IN (1, 2, 3)` (the value must be an array).
*
* `columnType` is the data type of the column the user clicked on; it decides
* whether a numeric-looking string is emitted unquoted (numeric column) or
Expand All @@ -23,6 +54,13 @@ export function buildCellValueFilterClause(
const col = quoteIdentifier(column, driver);
if (operator === "IS NULL") return `${col} IS NULL`;
if (operator === "IS NOT NULL") return `${col} IS NOT NULL`;
if (operator === "IN") {
const list = Array.isArray(value) ? value : [value];
const literals = list.map((item) =>
formatSqlValueForColumnComparison(item, driver, columnType),
);
return `${col} IN (${literals.join(", ")})`;
}
return `${col} ${operator} ${formatSqlValueForColumnComparison(value, driver, columnType)}`;
}

Expand Down
Loading