diff --git a/src/composables/useTableFields.ts b/src/composables/useTableFields.ts index 1afde1d..30cec71 100644 --- a/src/composables/useTableFields.ts +++ b/src/composables/useTableFields.ts @@ -2,6 +2,7 @@ import { ref, Ref } from 'vue'; import type { Field } from '@directus/types'; import { useExistingLanguageDetection } from './useExistingLanguageDetection'; import type { Language } from '../types/table.types'; +import { getTranslationFieldMetadata } from '../utils/resolveTranslationsCollection'; export function useTableFields( fields: Ref, @@ -56,34 +57,6 @@ export function useTableFields( return field?.name || fieldKey; } - // Helper function for translation field metadata - function getTranslationFieldMetadata(fieldKey: string) { - if (fieldKey.startsWith('translations.')) { - const subFieldName = fieldKey.split('.')[1]; - - // Find the translations relation - const relationsForField = relationsStore.getRelationsForField( - collection.value, - 'translations' - ); - - if (relationsForField && relationsForField.length > 0) { - const relation = relationsForField[0]; - // For O2M translations, the related collection contains the field definitions - const translationsCollection = relation.related_collection || relation.collection; - - if (translationsCollection) { - // Get field metadata from the translations collection - const translationField = fieldsStore.getField(translationsCollection, subFieldName); - if (translationField) { - return translationField; - } - } - } - } - return null; - } - // Rename field functions function renameField(fieldKey: string) { renameFieldKey.value = fieldKey; @@ -98,7 +71,12 @@ export function useTableFields( // Special handling for translation fields if (actualFieldKey.startsWith('translations.') && !field) { - const translationField = getTranslationFieldMetadata(actualFieldKey); + const translationField = getTranslationFieldMetadata( + collection.value, + actualFieldKey, + fieldsStore, + relationsStore + ); if (translationField) { field = translationField; } diff --git a/src/super-table.vue b/src/super-table.vue index 3491284..6b13210 100644 --- a/src/super-table.vue +++ b/src/super-table.vue @@ -289,6 +289,8 @@ import { useTableEdits } from './composables/useTableEdits'; import { useTablePagination } from './composables/useTablePagination'; import { useTableFields } from './composables/useTableFields'; import { useFilterPresets } from './composables/useFilterPresets'; +import { getTranslationFieldMetadata } from './utils/resolveTranslationsCollection'; +import { buildSearchFilter } from './utils/buildSearchFilter'; import { useTranslationConfig, getTranslationLanguageFieldPath, @@ -520,41 +522,6 @@ const fieldsWithRelational = computed(() => { }); // Table headers with relational field support -// Helper function to get translation field metadata -function getTranslationFieldMetadata(fieldKey: string) { - if (fieldKey.startsWith('translations.')) { - const subFieldName = fieldKey.split('.')[1]; - - // Find the translations relation - const relationsForField = relationsStore.getRelationsForField(collection.value, 'translations'); - - if (relationsForField && relationsForField.length > 0) { - const relation = relationsForField[0]; - // For O2M translations, the related collection contains the field definitions - const translationsCollection = relation.related_collection || relation.collection; - - if (translationsCollection) { - // Get field metadata from the translations collection - const translationField = fieldsStore.getField(translationsCollection, subFieldName); - if (translationField) { - return translationField; - } - } - } - - // Fallback: Common translation field types - const commonTranslationFields: Record = { - description: { type: 'text', meta: { interface: 'input-rich-text-html' } }, - content: { type: 'text', meta: { interface: 'input-rich-text-html' } }, - title: { type: 'string', meta: { interface: 'input' } }, - name: { type: 'string', meta: { interface: 'input' } }, - subtitle: { type: 'string', meta: { interface: 'input' } }, - }; - - return commonTranslationFields[subFieldName] || null; - } - return null; -} const tableHeaders = computed(() => { const activeFields = fields.value @@ -571,7 +538,12 @@ const tableHeaders = computed(() => { // Special handling for translation fields if (actualFieldKey.startsWith('translations.') && !fieldData) { - const translationField = getTranslationFieldMetadata(actualFieldKey); + const translationField = getTranslationFieldMetadata( + collection.value, + actualFieldKey, + fieldsStore, + relationsStore + ); if (translationField) { fieldData = { ...translationField, @@ -714,134 +686,17 @@ const onSearchInput = debounce((val: string) => { emit('update:search', val); }, 300); -// Build search filter for all fields including translations -function buildSearchFilter(query: string) { - if (!query || query.trim() === '') return null; - - const searchValue = query.trim(); - const conditions: any[] = []; - const processedFields = new Set(); // Track processed fields to avoid duplicates - - // Helper function to check if a string is a valid UUID - const isValidUUID = (str: string) => { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return uuidRegex.test(str); - }; - - // Helper function to check if a string is a valid integer - const isValidInteger = (str: string) => { - return /^\d+$/.test(str); - }; - - // Check if the search value is a valid UUID or integer - const searchIsUUID = isValidUUID(searchValue); - const searchIsInteger = isValidInteger(searchValue); - const searchAsInteger = searchIsInteger ? parseInt(searchValue, 10) : null; - - // Process each visible field - fields.value.forEach((fieldKey: string) => { - // Remove language suffix if present (e.g., "translations.description:de-DE" -> "translations.description") - const actualFieldKey = fieldKey.includes(':') ? fieldKey.split(':')[0] : fieldKey; - - // Skip if we've already processed this field (prevents duplicates from multi-language fields) - if (processedFields.has(actualFieldKey)) { - return; - } - processedFields.add(actualFieldKey); - - if (actualFieldKey.includes('.')) { - // Handle relational fields - const parts = actualFieldKey.split('.'); - const rootField = parts[0]; - const nestedField = parts.slice(1).join('.'); - - if (rootField === 'translations') { - // For translations, always search in ALL languages - // This ensures users can find content regardless of the displayed language - conditions.push({ - translations: { - _some: { - [nestedField]: { - _icontains: searchValue, - }, - }, - }, - }); - } else { - // Other relational fields - conditions.push({ - [actualFieldKey]: { - _icontains: searchValue, - }, - }); - } - } else { - // Direct fields - check if it's a searchable type - const field = fieldsStore.getField(collection.value, actualFieldKey); - const searchableTypes = ['string', 'text']; - - if (field && searchableTypes.includes(field.type)) { - conditions.push({ - [actualFieldKey]: { - _icontains: searchValue, - }, - }); - } else if (field && field.type === 'uuid' && searchIsUUID) { - // For UUID fields, use _eq if the search value is a complete valid UUID - conditions.push({ - [actualFieldKey]: { - _eq: searchValue, - }, - }); - } else if (field && field.type === 'integer' && searchIsInteger) { - // For integer fields (including ID), use _eq if the search value is a valid integer - conditions.push({ - [actualFieldKey]: { - _eq: searchAsInteger, - }, - }); - } - // Note: UUID fields only support _eq, _neq, _in, _nin comparisons - // Integer fields support exact match with _eq when searching by number - } - }); - - // If no searchable fields, fallback to all string/text/uuid/integer fields - if (conditions.length === 0) { - fieldsInCollection.value.forEach((field: Field) => { - const searchableTypes = ['string', 'text']; - - if (searchableTypes.includes(field.type) && !field.meta?.hidden) { - conditions.push({ - [field.field]: { - _icontains: searchValue, - }, - }); - } else if (field.type === 'uuid' && !field.meta?.hidden && searchIsUUID) { - // Add UUID fields to search if we have a valid UUID - conditions.push({ - [field.field]: { - _eq: searchValue, - }, - }); - } else if (field.type === 'integer' && !field.meta?.hidden && searchIsInteger) { - // Add integer fields (including ID) to search if we have a valid integer - conditions.push({ - [field.field]: { - _eq: searchAsInteger, - }, - }); - } - }); - } - - return conditions.length > 0 ? { _or: conditions } : null; -} - // Computed search filter -const searchFilter = computed(() => { - return buildSearchFilter(searchQuery.value); -}); +const searchFilter = computed(() => + buildSearchFilter({ + query: searchQuery.value, + visibleFields: fields.value, + fieldsInCollection: fieldsInCollection.value, + collection: collection.value, + fieldsStore, + relationsStore, + }) +); // Build deep parameter for relational fields const deep = computed(() => { diff --git a/src/utils/buildSearchFilter.ts b/src/utils/buildSearchFilter.ts new file mode 100644 index 0000000..a3d4e22 --- /dev/null +++ b/src/utils/buildSearchFilter.ts @@ -0,0 +1,264 @@ +import type { Field, Filter } from '@directus/types'; +import { resolveTranslationsCollection } from './resolveTranslationsCollection'; + +/** + * Minimal subset of the Directus fields store that this helper depends on. + * Mirrors the pattern used in `src/utils/displayHeuristics.ts` so consumers + * can be unit-tested without pulling in the full Pinia store. + */ +interface FieldsStoreLike { + getField: (collection: string | null, field: string) => Field | null | undefined; + /** Optional — used to enumerate searchable columns of a translations collection. */ + getFieldsForCollection?: (collection: string) => Field[] | null | undefined; +} + +interface RelationsStoreLike { + getRelationsForField: ( + collection: string, + field: string + ) => + | Array<{ + collection?: string; + field?: string; + related_collection?: string | null; + meta?: { + one_collection?: string | null; + many_collection?: string | null; + one_field?: string | null; + } | null; + }> + | null + | undefined; +} + +export interface BuildSearchFilterArgs { + /** The user's raw search input. Empty/whitespace returns null. */ + query: string; + /** Currently configured (visible) field keys, may include language suffixes (`field:de-DE`) and dot-notation (`translations.name`). */ + visibleFields: string[]; + /** All fields of the collection, used as a fallback search scope when no visible field produces a clause. */ + fieldsInCollection: Field[]; + /** Parent collection name. */ + collection: string; + fieldsStore: FieldsStoreLike; + /** Optional — required for top-level translations alias search to work. */ + relationsStore?: RelationsStoreLike; +} + +const SEARCHABLE_TEXT_TYPES = ['string', 'text'] as const; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isValidUUID(value: string): boolean { + return UUID_REGEX.test(value); +} + +function isValidInteger(value: string): boolean { + return /^\d+$/.test(value); +} + +/** + * Build a Directus filter for the user's search input. + * + * Replaces Directus' native `?search=` parameter with an explicit `_or` filter + * because the native parameter cannot reach across translations and other + * relational targets that this layout commonly exposes. + * + * Behavior overview: + * + * 1. Visible-field pass (per layout configuration) + * - `translations.` → `{ translations: { _some: { : { _icontains } } } }` + * - other dot-notation → `{ : { _icontains } }` + * - top-level translations alias (issue #24 Sub-Bug B fix): + * One outer-_or clause per searchable column: + * `{ : { _some: { : { _icontains } } } }`, + * `{ : { _some: { : { _icontains } } } }`, … + * (We deliberately do NOT wrap them in a single `_some._or` because + * Directus' query parser does not evaluate `_or` inside `_some` + * correctly — it returns all rows.) + * Detected via `field.meta.special.includes('translations')`, so it + * works for any alias name (e.g. `translations`, `i18n`, `localizations`). + * - direct string/text → `{ : { _icontains } }` + * - direct uuid + valid UUID input → `{ : { _eq } }` + * - direct integer + numeric input → `{ : { _eq } }` + * + * 2. Hidden-field pass — cumulative (issue #24 Sub-Bug A fix) + * Runs ALWAYS in addition to the visible pass, not only when the visible + * pass yielded nothing. Fields already covered by the visible pass are + * skipped via `processedFields` so duplicates do not occur. This makes + * the layout's search match the broad coverage of Directus' native + * `?search=` parameter, which scans all string/text columns regardless + * of which are visible in the layout. + * - All string/text fields with `meta.hidden !== true` + * - UUID fields when input is a valid UUID + * - Integer fields when input is numeric + * + * @returns a filter `{ _or: [...] }` or `null` if no clauses were produced + */ +export function buildSearchFilter(args: BuildSearchFilterArgs): Filter | null { + const { query, visibleFields, fieldsInCollection, collection, fieldsStore, relationsStore } = + args; + + if (!query || query.trim() === '') return null; + + const searchValue = query.trim(); + const conditions: Filter[] = []; + const processedFields = new Set(); + + const searchIsUUID = isValidUUID(searchValue); + const searchIsInteger = isValidInteger(searchValue); + const searchAsInteger = searchIsInteger ? parseInt(searchValue, 10) : null; + + // Visible-field pass + visibleFields.forEach((fieldKey: string) => { + // Strip language suffix (e.g. `translations.description:de-DE` → `translations.description`) + const actualFieldKey = fieldKey.includes(':') ? fieldKey.split(':')[0]! : fieldKey; + + // Avoid duplicate clauses across multi-language configurations + if (processedFields.has(actualFieldKey)) return; + processedFields.add(actualFieldKey); + + if (actualFieldKey.includes('.')) { + const parts = actualFieldKey.split('.'); + const rootField = parts[0]!; + const nestedField = parts.slice(1).join('.'); + + if (rootField === 'translations') { + // Search across ALL languages so users can find content regardless of the + // currently displayed language column. + conditions.push({ + translations: { + _some: { + [nestedField]: { _icontains: searchValue }, + }, + }, + }); + } else { + conditions.push({ + [actualFieldKey]: { _icontains: searchValue }, + }); + } + return; + } + + const field = fieldsStore.getField(collection, actualFieldKey); + if (!field) return; + + // Top-level translations alias — issue #24 Sub-Bug B fix. + // Detect via `meta.special` so renamed aliases (e.g. `i18n`, `localizations`) + // work too, not only the literal field name `translations`. + if (isTranslationsAlias(field) && relationsStore) { + const translationsClauses = buildTranslationsClauses( + collection, + actualFieldKey, + searchValue, + fieldsStore, + relationsStore + ); + conditions.push(...translationsClauses); + return; + } + + if (SEARCHABLE_TEXT_TYPES.includes(field.type as (typeof SEARCHABLE_TEXT_TYPES)[number])) { + conditions.push({ [actualFieldKey]: { _icontains: searchValue } }); + } else if (field.type === 'uuid' && searchIsUUID) { + conditions.push({ [actualFieldKey]: { _eq: searchValue } }); + } else if (field.type === 'integer' && searchIsInteger) { + conditions.push({ [actualFieldKey]: { _eq: searchAsInteger } }); + } + // UUID/integer fields with mismatched input formats are skipped because + // they only support _eq comparisons in Directus, not _icontains. + }); + + // Cumulative hidden-field pass — also covers columns the user has not added + // to the layout. Fields already handled in the visible pass are skipped via + // `processedFields`, so we never emit duplicate clauses. + fieldsInCollection.forEach((field: Field) => { + if (processedFields.has(field.field)) return; + if (field.meta?.hidden === true) return; + + if (SEARCHABLE_TEXT_TYPES.includes(field.type as (typeof SEARCHABLE_TEXT_TYPES)[number])) { + conditions.push({ [field.field]: { _icontains: searchValue } }); + processedFields.add(field.field); + } else if (field.type === 'uuid' && searchIsUUID) { + conditions.push({ [field.field]: { _eq: searchValue } }); + processedFields.add(field.field); + } else if (field.type === 'integer' && searchIsInteger) { + conditions.push({ [field.field]: { _eq: searchAsInteger } }); + processedFields.add(field.field); + } + }); + + return conditions.length > 0 ? ({ _or: conditions } as Filter) : null; +} + +function isTranslationsAlias(field: Field): boolean { + const special = field.meta?.special; + return Array.isArray(special) && special.includes('translations'); +} + +/** + * A field of the translations collection is searchable when it carries text + * content the user actually authors. We exclude: + * - non-text types (integer/uuid PKs, dates, booleans, etc.) + * - hidden fields (per `meta.hidden`) + * - M2O relation fields (e.g. `_id`, `languages_code`) + * - schema-level foreign keys, in case `meta.special` is missing + */ +function isSearchableTranslationField(field: Field): boolean { + if (!SEARCHABLE_TEXT_TYPES.includes(field.type as (typeof SEARCHABLE_TEXT_TYPES)[number])) { + return false; + } + if (field.meta?.hidden === true) return false; + + const special = field.meta?.special; + if (Array.isArray(special) && special.includes('m2o')) return false; + + // Defensive: even when `special` is missing, the schema can tell us a column is a FK. + if (field.schema && (field.schema as { foreign_key_table?: string | null }).foreign_key_table) { + return false; + } + + return true; +} + +/** + * Build one outer-`_or` clause per searchable text field of the translations + * collection — each clause is a self-contained `{ : { _some: { : ... } } }`. + * + * IMPORTANT: We deliberately do NOT consolidate into a single + * `{ : { _some: { _or: [...] } } }` because Directus' query parser + * does not evaluate `_or` correctly inside `_some` — it ends up matching + * every row. The top-level-OR construction is verified to work both + * for "find any" semantics and as a SQL EXISTS join. + * + * Returns an empty array when: + * - getFieldsForCollection is not available (incomplete store mock) + * - the translations collection cannot be resolved (schema not loaded) + * - the translations collection has no searchable text columns + */ +function buildTranslationsClauses( + parentCollection: string, + aliasFieldKey: string, + searchValue: string, + fieldsStore: FieldsStoreLike, + relationsStore: RelationsStoreLike +): Filter[] { + if (!fieldsStore.getFieldsForCollection) return []; + + const translationsCollection = resolveTranslationsCollection( + parentCollection, + aliasFieldKey, + relationsStore + ); + if (!translationsCollection) return []; + + const translationFields = fieldsStore.getFieldsForCollection(translationsCollection) ?? []; + const clauses: Filter[] = []; + for (const field of translationFields) { + if (!isSearchableTranslationField(field)) continue; + clauses.push({ + [aliasFieldKey]: { _some: { [field.field]: { _icontains: searchValue } } }, + } as Filter); + } + return clauses; +} diff --git a/src/utils/resolveTranslationsCollection.ts b/src/utils/resolveTranslationsCollection.ts new file mode 100644 index 0000000..eacf3d3 --- /dev/null +++ b/src/utils/resolveTranslationsCollection.ts @@ -0,0 +1,115 @@ +import type { Field } from '@directus/types'; + +/** + * Minimal subset of the Directus relations store needed by this helper. + * Mirrors the pattern used in `src/utils/displayHeuristics.ts` so consumers + * can be unit-tested without pulling in the full Pinia store. + */ +interface RelationsStoreLike { + getRelationsForField: ( + collection: string, + field: string + ) => + | Array<{ + collection?: string; + field?: string; + related_collection?: string | null; + meta?: { + one_collection?: string | null; + many_collection?: string | null; + one_field?: string | null; + } | null; + }> + | null + | undefined; +} + +interface FieldsStoreLike { + getField: (collection: string | null, field: string) => Field | null | undefined; +} + +/** + * Resolve the translations collection name for a parent collection's + * translations alias field. + * + * Why this is non-trivial: + * + * Directus stores the M2O relation from the *child* (translations) collection + * to the *parent*. When we call + * + * relationsStore.getRelationsForField(parent, 'translations') + * + * the returned relation object has: + * + * relation.collection = translations collection (the "many" side) + * relation.related_collection = parent collection (the "one" side) + * relation.meta.many_collection = translations collection (canonical) + * relation.meta.one_collection = parent collection + * + * We treat `meta.many_collection` as the canonical source: it is always the + * side that *holds* the translation rows, independent of query direction. + * `relation.collection` is a defensive fallback when meta is incomplete. + * + * Earlier versions of this codebase used `relation.related_collection || + * relation.collection`, which returned the parent collection. That bug was + * masked by a hardcoded `commonTranslationFields` fallback in the call sites. + * Both the buggy resolver and the hardcoded fallback are removed in favor of + * this schema-authoritative implementation. + * + * @returns the translations collection name, or `null` if it cannot be resolved + */ +export function resolveTranslationsCollection( + parentCollection: string, + translationsFieldKey: string, + relationsStore: RelationsStoreLike +): string | null { + let relations: ReturnType; + try { + relations = relationsStore.getRelationsForField(parentCollection, translationsFieldKey); + } catch { + // The store can throw during unloaded states (e.g. early app boot). + // Treat that as "not resolvable" rather than crashing the layout. + return null; + } + + if (!relations || relations.length === 0) return null; + + const relation = relations[0]; + return relation?.meta?.many_collection || relation?.collection || null; +} + +/** + * Resolve a translation sub-field's metadata from the actual schema. + * + * Replaces the previous inline implementations in `super-table.vue` and + * `useTableFields.ts` that used a hardcoded `commonTranslationFields` + * fallback (`name`, `title`, `description`, `content`, `subtitle`). The + * fallback was a workaround for the wrong property-path described above — + * with the corrected resolver, the schema can serve as the single source + * of truth and edge cases like custom translation field names (`body`, + * `slug`, `summary`, etc.) work correctly. + * + * @param fieldKey e.g. `'translations.name'`, or `'i18n.title'` for renamed aliases + * @returns the schema Field, or `null` if not resolvable / not a translation key + */ +export function getTranslationFieldMetadata( + parentCollection: string, + fieldKey: string, + fieldsStore: FieldsStoreLike, + relationsStore: RelationsStoreLike +): Field | null { + if (!fieldKey.includes('.')) return null; + + const [translationsFieldKey, ...rest] = fieldKey.split('.'); + const subFieldName = rest.join('.'); + if (!translationsFieldKey || !subFieldName) return null; + + const translationsCollection = resolveTranslationsCollection( + parentCollection, + translationsFieldKey, + relationsStore + ); + if (!translationsCollection) return null; + + return fieldsStore.getField(translationsCollection, subFieldName) ?? null; +} diff --git a/tests/unit/utils/buildSearchFilter.test.ts b/tests/unit/utils/buildSearchFilter.test.ts new file mode 100644 index 0000000..0e38ede --- /dev/null +++ b/tests/unit/utils/buildSearchFilter.test.ts @@ -0,0 +1,628 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildSearchFilter } from '@/utils/buildSearchFilter'; +import type { Field } from '@directus/types'; + +/** + * These tests pin the *current* behavior of buildSearchFilter — the verbatim + * port from super-table.vue:689-810. They serve as a safety net for + * subsequent steps (cumulative hidden-field pass, top-level translations + * search) and document the parts of the behavior that are intentional vs. + * the parts that #24 will change. + */ + +function field(partial: Partial & Pick): Field { + return { collection: 'posts', meta: null, schema: null, ...partial } as Field; +} + +function fieldsStore(byCollection: Record>) { + return { + getField: vi.fn((collection: string | null, fieldKey: string) => { + if (!collection) return null; + return byCollection[collection]?.[fieldKey] ?? null; + }), + }; +} + +describe('buildSearchFilter — characterization (current behavior)', () => { + it('returns null for empty / whitespace-only queries', () => { + const args = { + visibleFields: ['name'], + fieldsInCollection: [field({ field: 'name', type: 'string' })], + collection: 'posts', + fieldsStore: fieldsStore({ posts: { name: field({ field: 'name', type: 'string' }) } }), + }; + expect(buildSearchFilter({ query: '', ...args })).toBeNull(); + expect(buildSearchFilter({ query: ' ', ...args })).toBeNull(); + }); + + it('builds _icontains clause for visible string fields', () => { + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['name'], + fieldsInCollection: [field({ field: 'name', type: 'string' })], + collection: 'posts', + fieldsStore: fieldsStore({ posts: { name: field({ field: 'name', type: 'string' }) } }), + }); + + expect(result).toEqual({ _or: [{ name: { _icontains: 'hello' } }] }); + }); + + it('uses _eq with raw value for UUID fields when query is a valid UUID', () => { + const validUuid = '11111111-2222-3333-4444-555555555555'; + const result = buildSearchFilter({ + query: validUuid, + visibleFields: ['author'], + fieldsInCollection: [field({ field: 'author', type: 'uuid' })], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { author: field({ field: 'author', type: 'uuid' }) }, + }), + }); + + expect(result).toEqual({ _or: [{ author: { _eq: validUuid } }] }); + }); + + it('uses _eq with parsed integer for integer fields when query is numeric', () => { + const result = buildSearchFilter({ + query: '42', + visibleFields: ['id'], + fieldsInCollection: [field({ field: 'id', type: 'integer' })], + collection: 'posts', + fieldsStore: fieldsStore({ posts: { id: field({ field: 'id', type: 'integer' }) } }), + }); + + expect(result).toEqual({ _or: [{ id: { _eq: 42 } }] }); + }); + + it('skips visible UUID/integer fields when input format does not match', () => { + // 'hello' is neither a UUID nor an integer; visible UUID/integer fields + // skip in that case (they only support _eq in Directus). + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['author', 'id', 'name'], + fieldsInCollection: [ + field({ field: 'author', type: 'uuid' }), + field({ field: 'id', type: 'integer' }), + field({ field: 'name', type: 'string' }), + ], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { + author: field({ field: 'author', type: 'uuid' }), + id: field({ field: 'id', type: 'integer' }), + name: field({ field: 'name', type: 'string' }), + }, + }), + }); + + expect(result).toEqual({ _or: [{ name: { _icontains: 'hello' } }] }); + }); + + it('builds _some clause for dot-notation translation fields', () => { + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations.name'], + fieldsInCollection: [], // not consulted for dot-notation + collection: 'posts', + fieldsStore: fieldsStore({}), + }); + + expect(result).toEqual({ + _or: [{ translations: { _some: { name: { _icontains: 'Berg' } } } }], + }); + }); + + it('handles dot-notation for non-translation relations as direct _icontains', () => { + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['author.email'], + fieldsInCollection: [], + collection: 'posts', + fieldsStore: fieldsStore({}), + }); + + expect(result).toEqual({ _or: [{ 'author.email': { _icontains: 'Berg' } }] }); + }); + + it('strips language suffix and deduplicates fields across multi-language columns', () => { + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations.name:de-DE', 'translations.name:en-US'], + fieldsInCollection: [], + collection: 'posts', + fieldsStore: fieldsStore({}), + }); + + // Both columns share the actualFieldKey 'translations.name' — only one clause emitted. + expect(result).toEqual({ + _or: [{ translations: { _some: { name: { _icontains: 'Berg' } } } }], + }); + }); + + it('searches all collection text fields when no visible field is searchable', () => { + // Visible field is unsearchable (boolean), so only the cumulative pass + // contributes — exactly like before, but now via the cumulative path. + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['archived'], + fieldsInCollection: [ + field({ field: 'archived', type: 'boolean' }), + field({ field: 'name', type: 'string' }), + field({ field: 'description', type: 'text' }), + ], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { archived: field({ field: 'archived', type: 'boolean' }) }, + }), + }); + + expect(result).toEqual({ + _or: [ + { name: { _icontains: 'hello' } }, + { description: { _icontains: 'hello' } }, + ], + }); + }); + + it('cumulative pass: includes hidden/non-visible fields IN ADDITION to visible ones (#24 Sub-Bug A fix)', () => { + // The visible 'name' clause exists, AND the non-visible 'description' is + // also searched because the cumulative pass now runs unconditionally. + // Native `?search=` would do the same — this aligns the layout's behavior + // with the broad coverage Directus users expect. + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['name'], + fieldsInCollection: [ + field({ field: 'name', type: 'string' }), + field({ field: 'description', type: 'text' }), + ], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { name: field({ field: 'name', type: 'string' }) }, + }), + }); + + expect(result).toEqual({ + _or: [ + { name: { _icontains: 'hello' } }, + { description: { _icontains: 'hello' } }, + ], + }); + }); + + it('cumulative pass: skips fields that the visible pass already covered (no duplicates)', () => { + // Both 'name' and 'description' are visible AND in fieldsInCollection. + // The cumulative pass must skip them via processedFields. + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['name', 'description'], + fieldsInCollection: [ + field({ field: 'name', type: 'string' }), + field({ field: 'description', type: 'text' }), + ], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { + name: field({ field: 'name', type: 'string' }), + description: field({ field: 'description', type: 'text' }), + }, + }), + }); + + expect(result).toEqual({ + _or: [ + { name: { _icontains: 'hello' } }, + { description: { _icontains: 'hello' } }, + ], + }); + }); + + it('hidden-field fallback excludes fields with meta.hidden === true', () => { + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['archived'], + fieldsInCollection: [ + field({ field: 'archived', type: 'boolean' }), + field({ field: 'name', type: 'string' }), + field({ field: 'internal', type: 'string', meta: { hidden: true } as any }), + ], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { archived: field({ field: 'archived', type: 'boolean' }) }, + }), + }); + + expect(result).toEqual({ _or: [{ name: { _icontains: 'hello' } }] }); + }); + + it('returns null when nothing produces a clause', () => { + const result = buildSearchFilter({ + query: 'hello', + visibleFields: ['archived'], + fieldsInCollection: [field({ field: 'archived', type: 'boolean' })], + collection: 'posts', + fieldsStore: fieldsStore({ + posts: { archived: field({ field: 'archived', type: 'boolean' }) }, + }), + }); + + expect(result).toBeNull(); + }); +}); + +describe('buildSearchFilter — top-level translations alias (#24 Sub-Bug B fix)', () => { + function makeStores(opts: { + parentCollection: string; + aliasFieldName: string; + aliasFieldSpecial?: string[]; + translationsCollection: string; + translationsFields: Field[]; + }) { + const aliasField = field({ + field: opts.aliasFieldName, + type: 'alias', + meta: { special: opts.aliasFieldSpecial ?? ['translations'] } as any, + }); + + return { + aliasField, + fieldsStore: { + getField: vi.fn((collection: string | null, fieldKey: string) => { + if (collection === opts.parentCollection && fieldKey === opts.aliasFieldName) { + return aliasField; + } + return null; + }), + getFieldsForCollection: vi.fn((collection: string) => + collection === opts.translationsCollection ? opts.translationsFields : [] + ), + }, + relationsStore: { + getRelationsForField: vi.fn((collection: string, fieldKey: string) => { + if (collection === opts.parentCollection && fieldKey === opts.aliasFieldName) { + return [ + { + collection: opts.translationsCollection, + related_collection: opts.parentCollection, + meta: { + many_collection: opts.translationsCollection, + one_collection: opts.parentCollection, + one_field: opts.aliasFieldName, + }, + }, + ]; + } + return []; + }), + }, + }; + } + + it('emits one outer-_or clause per searchable text field (avoids _or-inside-_some Directus bug)', () => { + const { aliasField, fieldsStore, relationsStore } = makeStores({ + parentCollection: 'posts', + aliasFieldName: 'translations', + translationsCollection: 'posts_translations', + translationsFields: [ + field({ field: 'name', type: 'string' }), + field({ field: 'description', type: 'text' }), + ], + }); + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore, + relationsStore, + }); + + expect(result).toEqual({ + _or: [ + { translations: { _some: { name: { _icontains: 'Berg' } } } }, + { translations: { _some: { description: { _icontains: 'Berg' } } } }, + ], + }); + }); + + it('emits a single clause when only one translation column is searchable', () => { + const { aliasField, fieldsStore, relationsStore } = makeStores({ + parentCollection: 'posts', + aliasFieldName: 'translations', + translationsCollection: 'posts_translations', + translationsFields: [field({ field: 'name', type: 'string' })], + }); + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore, + relationsStore, + }); + + expect(result).toEqual({ + _or: [{ translations: { _some: { name: { _icontains: 'Berg' } } } }], + }); + }); + + it('works with renamed translations alias (e.g. i18n)', () => { + const { aliasField, fieldsStore, relationsStore } = makeStores({ + parentCollection: 'articles', + aliasFieldName: 'i18n', + translationsCollection: 'articles_i18n', + translationsFields: [field({ field: 'title', type: 'string' })], + }); + + const result = buildSearchFilter({ + query: 'foo', + visibleFields: ['i18n'], + fieldsInCollection: [aliasField], + collection: 'articles', + fieldsStore, + relationsStore, + }); + + expect(result).toEqual({ + _or: [{ i18n: { _some: { title: { _icontains: 'foo' } } } }], + }); + }); + + it('excludes M2O fields from the translations search (e.g. _id, languages_code)', () => { + const { aliasField, fieldsStore, relationsStore } = makeStores({ + parentCollection: 'posts', + aliasFieldName: 'translations', + translationsCollection: 'posts_translations', + translationsFields: [ + field({ field: 'id', type: 'integer' }), + field({ + field: 'posts_id', + type: 'integer', + meta: { special: ['m2o'] } as any, + }), + field({ + field: 'languages_code', + type: 'string', + meta: { special: ['m2o'] } as any, // string-typed M2O — must still be excluded + }), + field({ field: 'name', type: 'string' }), + ], + }); + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore, + relationsStore, + }); + + expect(result).toEqual({ + _or: [{ translations: { _some: { name: { _icontains: 'Berg' } } } }], + }); + }); + + it('excludes hidden translation fields', () => { + const { aliasField, fieldsStore, relationsStore } = makeStores({ + parentCollection: 'posts', + aliasFieldName: 'translations', + translationsCollection: 'posts_translations', + translationsFields: [ + field({ field: 'name', type: 'string' }), + field({ + field: 'internal_notes', + type: 'string', + meta: { hidden: true } as any, + }), + ], + }); + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore, + relationsStore, + }); + + expect(result).toEqual({ + _or: [{ translations: { _some: { name: { _icontains: 'Berg' } } } }], + }); + }); + + it('excludes fields with schema.foreign_key_table even when meta.special is missing', () => { + const { aliasField, fieldsStore, relationsStore } = makeStores({ + parentCollection: 'posts', + aliasFieldName: 'translations', + translationsCollection: 'posts_translations', + translationsFields: [ + field({ + field: 'languages_code', + type: 'string', + meta: null, + schema: { foreign_key_table: 'languages' } as any, + }), + field({ field: 'name', type: 'string' }), + ], + }); + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore, + relationsStore, + }); + + expect(result).toEqual({ + _or: [{ translations: { _some: { name: { _icontains: 'Berg' } } } }], + }); + }); + + it('skips clause when translations collection cannot be resolved', () => { + const aliasField = field({ + field: 'translations', + type: 'alias', + meta: { special: ['translations'] } as any, + }); + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore: { + getField: vi.fn().mockReturnValue(aliasField), + getFieldsForCollection: vi.fn(), + }, + relationsStore: { getRelationsForField: vi.fn().mockReturnValue([]) }, + }); + + // No translations clause is added — but field is still marked processed, + // so the cumulative pass does not synthesize a string clause for it. + expect(result).toBeNull(); + }); + + it('skips clause when getFieldsForCollection is not available on the store', () => { + const aliasField = field({ + field: 'translations', + type: 'alias', + meta: { special: ['translations'] } as any, + }); + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore: { getField: vi.fn().mockReturnValue(aliasField) }, // no getFieldsForCollection + relationsStore: { getRelationsForField: vi.fn().mockReturnValue([]) }, + }); + + expect(result).toBeNull(); + }); + + it('skips translations alias when relationsStore is omitted from args', () => { + const aliasField = field({ + field: 'translations', + type: 'alias', + meta: { special: ['translations'] } as any, + }); + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations'], + fieldsInCollection: [aliasField], + collection: 'posts', + fieldsStore: { getField: vi.fn().mockReturnValue(aliasField) }, + // relationsStore omitted — translations search opts out, no crash + }); + + expect(result).toBeNull(); + }); + + it('handles multiple translations relations (e.g. translations + seo_translations)', () => { + const translationsAlias = field({ + field: 'translations', + type: 'alias', + meta: { special: ['translations'] } as any, + }); + const seoAlias = field({ + field: 'seo_translations', + type: 'alias', + meta: { special: ['translations'] } as any, + }); + + const fieldsByCollection: Record = { + posts_translations: [field({ field: 'name', type: 'string' })], + posts_seo: [field({ field: 'meta_title', type: 'string' })], + }; + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['translations', 'seo_translations'], + fieldsInCollection: [translationsAlias, seoAlias], + collection: 'posts', + fieldsStore: { + getField: vi.fn((coll: string | null, key: string) => { + if (coll === 'posts' && key === 'translations') return translationsAlias; + if (coll === 'posts' && key === 'seo_translations') return seoAlias; + return null; + }), + getFieldsForCollection: vi.fn((coll: string) => fieldsByCollection[coll] ?? []), + }, + relationsStore: { + getRelationsForField: vi.fn((coll: string, key: string) => { + if (coll === 'posts' && key === 'translations') { + return [ + { + collection: 'posts_translations', + meta: { many_collection: 'posts_translations' }, + }, + ]; + } + if (coll === 'posts' && key === 'seo_translations') { + return [ + { + collection: 'posts_seo', + meta: { many_collection: 'posts_seo' }, + }, + ]; + } + return []; + }), + }, + }); + + expect(result).toEqual({ + _or: [ + { translations: { _some: { name: { _icontains: 'Berg' } } } }, + { seo_translations: { _some: { meta_title: { _icontains: 'Berg' } } } }, + ], + }); + }); + + it('combines translations clauses with visible string clauses and cumulative hidden ones', () => { + const translationsAlias = field({ + field: 'translations', + type: 'alias', + meta: { special: ['translations'] } as any, + }); + const codeField = field({ field: 'code', type: 'string' }); + const internalField = field({ field: 'internal', type: 'string' }); + + const result = buildSearchFilter({ + query: 'Berg', + visibleFields: ['code', 'translations'], + fieldsInCollection: [codeField, translationsAlias, internalField], + collection: 'posts', + fieldsStore: { + getField: vi.fn((coll: string | null, key: string) => { + if (coll === 'posts' && key === 'code') return codeField; + if (coll === 'posts' && key === 'translations') return translationsAlias; + return null; + }), + getFieldsForCollection: vi.fn(() => [ + field({ field: 'name', type: 'string' }), + field({ field: 'body', type: 'text' }), + ]), + }, + relationsStore: { + getRelationsForField: vi.fn(() => [ + { collection: 'posts_translations', meta: { many_collection: 'posts_translations' } }, + ]), + }, + }); + + expect(result).toEqual({ + _or: [ + { code: { _icontains: 'Berg' } }, + { translations: { _some: { name: { _icontains: 'Berg' } } } }, + { translations: { _some: { body: { _icontains: 'Berg' } } } }, + { internal: { _icontains: 'Berg' } }, + ], + }); + }); +}); diff --git a/tests/unit/utils/resolveTranslationsCollection.test.ts b/tests/unit/utils/resolveTranslationsCollection.test.ts new file mode 100644 index 0000000..824de2d --- /dev/null +++ b/tests/unit/utils/resolveTranslationsCollection.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + resolveTranslationsCollection, + getTranslationFieldMetadata, +} from '@/utils/resolveTranslationsCollection'; + +describe('resolveTranslationsCollection', () => { + it('returns the translations collection from meta.many_collection (canonical path)', () => { + const relationsStore = { + getRelationsForField: vi.fn().mockReturnValue([ + { + collection: 'posts_translations', + related_collection: 'posts', + meta: { + many_collection: 'posts_translations', + one_collection: 'posts', + one_field: 'translations', + }, + }, + ]), + }; + + expect(resolveTranslationsCollection('posts', 'translations', relationsStore)).toBe( + 'posts_translations' + ); + }); + + it('falls back to relation.collection when meta.many_collection is missing', () => { + const relationsStore = { + getRelationsForField: vi.fn().mockReturnValue([ + { collection: 'posts_translations', related_collection: 'posts', meta: null }, + ]), + }; + + expect(resolveTranslationsCollection('posts', 'translations', relationsStore)).toBe( + 'posts_translations' + ); + }); + + it('returns null when no relations exist for the field', () => { + const relationsStore = { getRelationsForField: vi.fn().mockReturnValue([]) }; + expect(resolveTranslationsCollection('posts', 'translations', relationsStore)).toBeNull(); + }); + + it('returns null when getRelationsForField returns null/undefined', () => { + const nullStore = { getRelationsForField: vi.fn().mockReturnValue(null) }; + const undefinedStore = { getRelationsForField: vi.fn().mockReturnValue(undefined) }; + + expect(resolveTranslationsCollection('posts', 'translations', nullStore)).toBeNull(); + expect(resolveTranslationsCollection('posts', 'translations', undefinedStore)).toBeNull(); + }); + + it('returns null gracefully when getRelationsForField throws', () => { + const relationsStore = { + getRelationsForField: vi.fn().mockImplementation(() => { + throw new Error('store not loaded'); + }), + }; + + expect(resolveTranslationsCollection('posts', 'translations', relationsStore)).toBeNull(); + }); + + it('works with custom alias names (not literal "translations")', () => { + const relationsStore = { + getRelationsForField: vi.fn().mockImplementation((collection: string, field: string) => { + if (collection === 'articles' && field === 'i18n') { + return [ + { + collection: 'articles_i18n', + related_collection: 'articles', + meta: { many_collection: 'articles_i18n', one_field: 'i18n' }, + }, + ]; + } + return []; + }), + }; + + expect(resolveTranslationsCollection('articles', 'i18n', relationsStore)).toBe('articles_i18n'); + expect(relationsStore.getRelationsForField).toHaveBeenCalledWith('articles', 'i18n'); + }); +}); + +describe('getTranslationFieldMetadata', () => { + const nameField = { + field: 'name', + type: 'string', + meta: { interface: 'input' }, + } as any; + + function makeStores(translationsCollection: string, fields: Record) { + return { + fieldsStore: { + getField: vi.fn().mockImplementation((collection: string, field: string) => { + if (collection === translationsCollection) return fields[field] ?? null; + return null; + }), + }, + relationsStore: { + getRelationsForField: vi.fn().mockReturnValue([ + { + collection: translationsCollection, + related_collection: 'posts', + meta: { many_collection: translationsCollection, one_field: 'translations' }, + }, + ]), + }, + }; + } + + it('returns the schema field for an existing translation subfield', () => { + const { fieldsStore, relationsStore } = makeStores('posts_translations', { name: nameField }); + + expect( + getTranslationFieldMetadata('posts', 'translations.name', fieldsStore, relationsStore) + ).toEqual(nameField); + expect(fieldsStore.getField).toHaveBeenCalledWith('posts_translations', 'name'); + }); + + it('returns null for a non-existing subfield (no hardcoded fallback)', () => { + // 'title' used to be silently mapped to a hardcoded definition by the old + // commonTranslationFields fallback. After the fix, schema is the only + // source — non-existing fields return null, and callers must decide how + // to handle them (typically: skip rendering as a translation column). + const { fieldsStore, relationsStore } = makeStores('posts_translations', {}); + + expect( + getTranslationFieldMetadata('posts', 'translations.title', fieldsStore, relationsStore) + ).toBeNull(); + }); + + it('returns null for keys without dot-notation', () => { + const fieldsStore = { getField: vi.fn() }; + const relationsStore = { getRelationsForField: vi.fn() }; + + expect( + getTranslationFieldMetadata('posts', 'translations', fieldsStore, relationsStore) + ).toBeNull(); + expect( + getTranslationFieldMetadata('posts', '', fieldsStore, relationsStore) + ).toBeNull(); + expect(fieldsStore.getField).not.toHaveBeenCalled(); + expect(relationsStore.getRelationsForField).not.toHaveBeenCalled(); + }); + + it('returns null for malformed keys (trailing dot, leading dot)', () => { + const fieldsStore = { getField: vi.fn() }; + const relationsStore = { getRelationsForField: vi.fn() }; + + expect( + getTranslationFieldMetadata('posts', 'translations.', fieldsStore, relationsStore) + ).toBeNull(); + expect( + getTranslationFieldMetadata('posts', '.name', fieldsStore, relationsStore) + ).toBeNull(); + }); + + it('returns null when the translations collection cannot be resolved', () => { + const fieldsStore = { getField: vi.fn() }; + const relationsStore = { getRelationsForField: vi.fn().mockReturnValue([]) }; + + expect( + getTranslationFieldMetadata('posts', 'translations.name', fieldsStore, relationsStore) + ).toBeNull(); + expect(fieldsStore.getField).not.toHaveBeenCalled(); + }); + + it('handles deeply nested subfield keys (e.g. translations.seo.title)', () => { + // Edge case: if a user configures a key like `translations.seo.title`, we + // join everything after the first segment as the subfield name. The schema + // lookup will then either find a literal field with that name or return null. + const { fieldsStore, relationsStore } = makeStores('posts_translations', { + 'seo.title': { field: 'seo.title', type: 'string' } as any, + }); + + expect( + getTranslationFieldMetadata('posts', 'translations.seo.title', fieldsStore, relationsStore) + ).toEqual({ field: 'seo.title', type: 'string' }); + }); + + it('works with renamed translations alias (e.g. i18n.title)', () => { + const titleField = { field: 'title', type: 'string' } as any; + const fieldsStore = { + getField: vi.fn().mockImplementation((collection: string, field: string) => { + if (collection === 'articles_i18n' && field === 'title') return titleField; + return null; + }), + }; + const relationsStore = { + getRelationsForField: vi.fn().mockReturnValue([ + { + collection: 'articles_i18n', + related_collection: 'articles', + meta: { many_collection: 'articles_i18n', one_field: 'i18n' }, + }, + ]), + }; + + expect( + getTranslationFieldMetadata('articles', 'i18n.title', fieldsStore, relationsStore) + ).toEqual(titleField); + }); +});