From 5de9386dd8b87cdbff5c884d6c3b9b4c6d5108d5 Mon Sep 17 00:00:00 2001 From: daniel-slaugh Date: Fri, 25 Sep 2026 08:34:18 -0600 Subject: [PATCH 1/2] Remove login toasts; fix table searching for names with spaces --- .../Datastream/DatastreamSelectorCard.vue | 4 +- .../components/Datastream/DatastreamTable.vue | 78 ++++++++++--------- .../VisualizeData/DataVisDatasetsTable.vue | 4 +- .../__tests__/DataVisDatasetsTable.spec.ts | 28 +++++++ .../base/__tests__/HsQuerySearchInput.spec.ts | 68 ++++++++++++++++ .../utils/__tests__/datastreamSearch.spec.ts | 38 +++++++++ .../src/utils/datastreamSearch.ts | 34 +++++--- django/core/iam/auth/adapters.py | 12 +++ django/interfaces/account/views.py | 3 - django/static/css/auth.css | 12 +-- django/templates/allauth/layouts/base.html | 9 --- .../tests/core/iam/test_account_navigation.py | 36 ++++++++- .../design-system/vue/HsQuerySearchInput.vue | 67 +++++++++------- packages/design-system/vue/index.ts | 2 + packages/design-system/vue/querySearch.ts | 58 ++++++++++++++ 15 files changed, 350 insertions(+), 103 deletions(-) create mode 100644 packages/design-system/vue/querySearch.ts diff --git a/apps/data-management/src/components/Datastream/DatastreamSelectorCard.vue b/apps/data-management/src/components/Datastream/DatastreamSelectorCard.vue index 5f4ed043b..40b4448bc 100644 --- a/apps/data-management/src/components/Datastream/DatastreamSelectorCard.vue +++ b/apps/data-management/src/components/Datastream/DatastreamSelectorCard.vue @@ -400,7 +400,9 @@ const availableMonitoringSites = computed( const monitoringSiteById = computed( () => new Map(availableMonitoringSites.value.map((site) => [site.id, site])) ) -const parsedSearch = computed(() => parseDatastreamQuery(search.value)), +const parsedSearch = computed(() => + parseDatastreamQuery(search.value, searchQualifiers.value) + ), plainSearch = computed(() => parsedSearch.value.text.toLocaleLowerCase()) const scopedDatastreams = computed(() => availableDatastreams.value.filter( diff --git a/apps/data-management/src/components/Datastream/DatastreamTable.vue b/apps/data-management/src/components/Datastream/DatastreamTable.vue index 1c6e6d876..7c500016d 100644 --- a/apps/data-management/src/components/Datastream/DatastreamTable.vue +++ b/apps/data-management/src/components/Datastream/DatastreamTable.vue @@ -989,7 +989,9 @@ const visibleDatastreams = computed(() => { }) }) -const parsedSearch = computed(() => parseDatastreamQuery(search.value)) +const parsedSearch = computed(() => + parseDatastreamQuery(search.value, searchQualifiers.value) +) const normalizedSearch = computed(() => parsedSearch.value.text.toLocaleLowerCase() ) @@ -1004,42 +1006,46 @@ const uniqueSorted = (values: Array) => (a, b) => a.localeCompare(b) ) const option = (value: string): FilterOption => ({ value, label: value }) -const filterDefinitions = computed(() => [ - { - key: 'observed-property', - label: 'Observed property', - options: uniqueSorted( - visibleDatastreams.value.map((item) => item.observedPropertyName) - ).map(option), - selectedCount: parsedSearch.value.filters['observed-property'].length, - }, - { - key: 'unit', - label: 'Unit', - options: uniqueSorted( - visibleDatastreams.value.map((item) => item.unitName) - ).map(option), - selectedCount: parsedSearch.value.filters.unit.length, - }, - { - key: 'method', - label: 'Method', - options: uniqueSorted( - visibleDatastreams.value.map((item) => item.methodName) - ).map(option), - selectedCount: parsedSearch.value.filters.method.length, - }, - { - key: 'processing-level', - label: 'Processing level', - options: uniqueSorted( - visibleDatastreams.value.map((item) => item.processingLevelName) - ).map(option), - selectedCount: parsedSearch.value.filters['processing-level'].length, - }, -]) +const filterOptions = computed[]>( + () => [ + { + key: 'observed-property', + label: 'Observed property', + options: uniqueSorted( + visibleDatastreams.value.map((item) => item.observedPropertyName) + ).map(option), + }, + { + key: 'unit', + label: 'Unit', + options: uniqueSorted( + visibleDatastreams.value.map((item) => item.unitName) + ).map(option), + }, + { + key: 'method', + label: 'Method', + options: uniqueSorted( + visibleDatastreams.value.map((item) => item.methodName) + ).map(option), + }, + { + key: 'processing-level', + label: 'Processing level', + options: uniqueSorted( + visibleDatastreams.value.map((item) => item.processingLevelName) + ).map(option), + }, + ] +) +const filterDefinitions = computed(() => + filterOptions.value.map((filter) => ({ + ...filter, + selectedCount: parsedSearch.value.filters[filter.key].length, + })) +) const searchQualifiers = computed(() => [ - ...filterDefinitions.value.map((filter) => ({ + ...filterOptions.value.map((filter) => ({ key: filter.key, label: filter.label, values: filter.options.map((item) => item.value), diff --git a/apps/data-management/src/components/VisualizeData/DataVisDatasetsTable.vue b/apps/data-management/src/components/VisualizeData/DataVisDatasetsTable.vue index b96bfacc4..c0678e630 100644 --- a/apps/data-management/src/components/VisualizeData/DataVisDatasetsTable.vue +++ b/apps/data-management/src/components/VisualizeData/DataVisDatasetsTable.vue @@ -404,7 +404,9 @@ const searchQualifiers = computed(() => [ ], }, ]) -const parsedSearch = computed(() => parseDatastreamQuery(search.value)) +const parsedSearch = computed(() => + parseDatastreamQuery(search.value, searchQualifiers.value) +) const plainSearch = computed(() => parsedSearch.value.text) const canonicalValues = (candidates: string[], requested: string[]) => { diff --git a/apps/data-management/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts b/apps/data-management/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts index adb6e699c..3265e4689 100644 --- a/apps/data-management/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts +++ b/apps/data-management/src/components/VisualizeData/__tests__/DataVisDatasetsTable.spec.ts @@ -17,6 +17,34 @@ describe('DataVisDatasetsTable query hydration', () => { setActivePinia(createPinia()) }) + it('filters by a typed multi-word site name without quotes', async () => { + const store = useDataVisStore() + const site = { + id: 'site-1', + name: 'Logan River', + workspaceId: 'workspace-1', + } + store.monitoringSites = [ + site, + { ...site, id: 'site-2', name: 'Bear River' }, + ] as any + store.datastreams = [ + { id: 'stream-1', name: 'Discharge', monitoringSiteId: 'site-1' }, + { id: 'stream-2', name: 'Discharge', monitoringSiteId: 'site-2' }, + ] as any + const wrapper = shallowMount(DataVisDatasetsTable) + await wrapper + .getComponent({ name: 'HsQuerySearchInput' }) + .vm.$emit('update:modelValue', 'site:Logan River') + expect(store.selectedMonitoringSites.map((item) => item.id)).toEqual([ + 'site-1', + ]) + expect(store.filteredDatastreams.map((item) => item.id)).toEqual([ + 'stream-1', + ]) + wrapper.unmount() + }) + it('hydrates the site qualifier while a deep-linked datastream is plotted', () => { const store = useDataVisStore() const site = { diff --git a/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts b/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts index dfdeed7f6..e7a33aa91 100644 --- a/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts +++ b/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts @@ -4,8 +4,76 @@ import { mount } from '@vue/test-utils' import { afterEach, describe, expect, it } from 'vitest' import { HsQuerySearchInput } from '@hydroserver/design-system/vue' +import { parseDatastreamQuery } from '@/utils/datastreamSearch' describe('HsQuerySearchInput', () => { + const qualifiers = [ + { key: 'site', label: 'Sites', values: ['Logan River', 'Bear River'] }, + { key: 'unit', label: 'Units', values: ['Feet'] }, + ] + + function mountSearch(modelValue = '') { + const wrapper = mount(HsQuerySearchInput, { + attachTo: document.body, + props: { + modelValue, + placeholder: 'Search datastreams…', + qualifiers, + 'onUpdate:modelValue': (value: string) => + wrapper.setProps({ modelValue: value }), + }, + global: { stubs: { 'v-icon': true } }, + }) + return wrapper + } + + it('keeps suggesting a site name across spaces and quotes the selected name', async () => { + const wrapper = mountSearch() + const input = wrapper.get('input') + for (const value of ['site:L', 'site:Logan ', 'site:Logan R']) { + await input.setValue(value) + expect( + document.querySelector('[role="option"]')?.textContent?.trim() + ).toBe('Logan River') + } + await input.trigger('keydown', { key: 'Enter' }) + expect(wrapper.props('modelValue')).toBe('site:"Logan River" ') + expect( + parseDatastreamQuery(wrapper.props('modelValue')).filters.site + ).toEqual(['Logan River']) + wrapper.unmount() + }) + + it('recognizes a complete unquoted name without accepting a suggestion', async () => { + const wrapper = mountSearch() + await wrapper.get('input').setValue('site:Logan River') + expect(wrapper.get('.hl-value-valid').text()).toBe('Logan River') + expect( + parseDatastreamQuery(wrapper.props('modelValue'), qualifiers).filters.site + ).toEqual(['Logan River']) + wrapper.unmount() + }) + + it('replaces the whole value when editing inside quotes and preserves later filters', async () => { + const wrapper = mountSearch('site:"Logan River" unit:Feet') + const input = wrapper.get('input') + input.element.setSelectionRange(9, 9) + await input.trigger('focus') + await input.trigger('keydown', { key: 'Tab' }) + expect(wrapper.props('modelValue')).toBe('site:"Logan River" unit:Feet') + wrapper.unmount() + }) + + it('keeps free text and later qualifier suggestions separate from a site name', async () => { + const wrapper = mountSearch() + const input = wrapper.get('input') + await input.setValue('site:Logan River temperature un') + expect(document.querySelector('[role="option"]')?.textContent?.trim()).toBe( + 'unit:' + ) + wrapper.unmount() + }) + afterEach(() => { document.body.replaceChildren() document.body.style.removeProperty('font-weight') diff --git a/apps/data-management/src/utils/__tests__/datastreamSearch.spec.ts b/apps/data-management/src/utils/__tests__/datastreamSearch.spec.ts index c470d5017..70ca92dee 100644 --- a/apps/data-management/src/utils/__tests__/datastreamSearch.spec.ts +++ b/apps/data-management/src/utils/__tests__/datastreamSearch.spec.ts @@ -5,6 +5,44 @@ import { } from '@/utils/datastreamSearch' describe('datastream query search', () => { + const qualifiers = [ + { key: 'site', values: ['Logan', 'Logan River', 'Logan River at Mendon'] }, + { key: 'workspace', values: ['Bear River'] }, + ] + + it('recognizes unquoted names with spaces and preserves trailing free text', () => { + const parsed = parseDatastreamQuery( + 'discharge workspace:Bear River site:logan river at mendon recent sort:updated-desc', + qualifiers + ) + expect(parsed.filters.workspace).toEqual(['Bear River']) + expect(parsed.filters.site).toEqual(['logan river at mendon']) + expect(parsed.text).toBe('discharge recent') + expect(parsed.sort).toEqual({ key: 'updated', order: 'desc' }) + }) + + it('preserves explicit quotes, repeated filters, and incomplete quoted values', () => { + const parsed = parseDatastreamQuery( + 'site:"Logan" site:Logan River site:"Logan River at', + qualifiers + ) + expect(parsed.filters.site).toEqual([ + 'Logan', + 'Logan River', + 'Logan River at', + ]) + expect(parsed.text).toBe('') + }) + + it('requires boundaries around qualifier keys and known values', () => { + const parsed = parseDatastreamQuery( + 'website:Logan River site:Logan Riverside', + qualifiers + ) + expect(parsed.filters.site).toEqual(['Logan']) + expect(parsed.text).toBe('website:Logan River Riverside') + }) + it('parses qualifier tags separately from free text', () => { expect( parseDatastreamQuery( diff --git a/apps/data-management/src/utils/datastreamSearch.ts b/apps/data-management/src/utils/datastreamSearch.ts index 1e7e193e1..81a1ea4ce 100644 --- a/apps/data-management/src/utils/datastreamSearch.ts +++ b/apps/data-management/src/utils/datastreamSearch.ts @@ -1,3 +1,8 @@ +import { + queryQualifierTokens, + type QueryQualifier, +} from '@hydroserver/design-system/vue' + export const DATASTREAM_QUALIFIER_KEYS = [ 'workspace', 'site', @@ -19,9 +24,6 @@ export type DatastreamSort = { order: DatastreamSortOrder } -const qualifierPattern = () => - /(workspace|site|observed-property|unit|method|processing-level|sort):(?:"([^"]*)"|(\S+))/gi - const parseDatastreamSort = (value: string): DatastreamSort | null => { const [key, order] = value.toLocaleLowerCase().split('-') if ( @@ -37,7 +39,10 @@ const parseDatastreamSort = (value: string): DatastreamSort | null => { export const quoteDatastreamQualifier = (value: string) => /\s/.test(value) ? `"${value}"` : value -export function parseDatastreamQuery(raw: string) { +export function parseDatastreamQuery( + raw: string, + qualifiers: readonly QueryQualifier[] = [] +) { const filters: DatastreamQueryFilters = { workspace: [], site: [], @@ -48,22 +53,27 @@ export function parseDatastreamQuery(raw: string) { } const textParts: string[] = [] let sort: DatastreamSort | null = null - const pattern = qualifierPattern() let lastIndex = 0 - let match: RegExpExecArray | null + const tokens = queryQualifierTokens( + raw, + [...DATASTREAM_QUALIFIER_KEYS, 'sort'].map((key) => ({ + key, + values: qualifiers.find((item) => item.key === key)?.values ?? [], + })) + ) - while ((match = pattern.exec(raw))) { - textParts.push(raw.slice(lastIndex, match.index)) - const key = match[1].toLocaleLowerCase() - const value = (match[2] ?? match[3] ?? '').trim() + for (const token of tokens) { + textParts.push(raw.slice(lastIndex, token.start)) + const key = token.key.toLocaleLowerCase() + const value = token.value.trim() if (key === 'sort') { const parsedSort = parseDatastreamSort(value) if (parsedSort) sort = parsedSort - else textParts.push(match[0]) + else textParts.push(raw.slice(token.start, token.end)) } else if (value) { filters[key as DatastreamQualifierKey].push(value) } - lastIndex = pattern.lastIndex + lastIndex = token.end } textParts.push(raw.slice(lastIndex)) diff --git a/django/core/iam/auth/adapters.py b/django/core/iam/auth/adapters.py index 4abe66440..486cfb2ff 100644 --- a/django/core/iam/auth/adapters.py +++ b/django/core/iam/auth/adapters.py @@ -8,6 +8,18 @@ class AccountAdapter(DefaultAccountAdapter): + def add_message( + self, + request, + level, + message_template=None, + message_context=None, + extra_tags="", + message=None, + ): + """Account pages use inline feedback instead of queued flash messages.""" + return + def is_safe_url(self, url): """Allow configured frontend destinations as login return URLs.""" allowed_hosts = { diff --git a/django/interfaces/account/views.py b/django/interfaces/account/views.py index 780153eaf..475f50bbf 100644 --- a/django/interfaces/account/views.py +++ b/django/interfaces/account/views.py @@ -1,7 +1,6 @@ from django.contrib.auth import logout from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect -from django.contrib import messages from interfaces.account.forms import ProfileForm, DeleteAccountForm from core.iam.models import Workspace @@ -21,7 +20,6 @@ def profile_edit(request): form = ProfileForm(request.POST, instance=request.user) if form.is_valid(): form.save() - messages.success(request, "Profile updated successfully.") return redirect("account_profile") else: form = ProfileForm(instance=request.user) @@ -48,7 +46,6 @@ def delete_account(request): if form.is_valid(): user.delete() logout(request) - messages.success(request, "Your account has been deleted.") return redirect("account_login") else: form = DeleteAccountForm() diff --git a/django/static/css/auth.css b/django/static/css/auth.css index bac87c88a..a39b9223b 100644 --- a/django/static/css/auth.css +++ b/django/static/css/auth.css @@ -36,7 +36,6 @@ body.auth-page { padding: var(--hs-space-16); } -.auth-flashes, .auth-layout { width: min(100%, 672px); margin: 0 auto; @@ -63,14 +62,6 @@ body.auth-page { flex-direction: column; } -.auth-flashes { - padding-top: var(--hs-space-8); -} - -.auth-flashes .hs-alert { - margin-bottom: var(--hs-space-8); -} - .auth-layout--entrance { display: flex; flex: 1; @@ -217,8 +208,7 @@ body.auth-page { @media (max-width: 640px) { .auth-layout, - .auth-layout--manage, - .auth-flashes { + .auth-layout--manage { width: 100%; } diff --git a/django/templates/allauth/layouts/base.html b/django/templates/allauth/layouts/base.html index ce7f02002..e0a4d5618 100644 --- a/django/templates/allauth/layouts/base.html +++ b/django/templates/allauth/layouts/base.html @@ -15,15 +15,6 @@
- {% if messages %} -
- {% for message in messages %} -
- {{ message }} -
- {% endfor %} -
- {% endif %} {% block content_outer %}{% block content %}{% endblock %}{% endblock %}
diff --git a/django/tests/core/iam/test_account_navigation.py b/django/tests/core/iam/test_account_navigation.py index 563aa15b9..302733ebf 100644 --- a/django/tests/core/iam/test_account_navigation.py +++ b/django/tests/core/iam/test_account_navigation.py @@ -1,10 +1,11 @@ import pytest from allauth.account.models import EmailAddress +from django.contrib.messages import get_messages from django.test import RequestFactory, override_settings from django.urls import reverse -from core.iam.models import User +from core.iam.models import User, UserType from core.iam.templatetags.account_navigation import ( RETURN_URL_SESSION_KEY, account_back_url, @@ -80,6 +81,39 @@ def test_login_returns_to_the_frontend_destination(client): assert response.status_code == 302 assert response["Location"] == destination + assert list(get_messages(response.wsgi_request)) == [] + + +def test_invalid_login_keeps_inline_errors(client): + response = client.post( + reverse("account_login"), + {"login": "missing@example.test", "password": "incorrect"}, + ) + + assert response.status_code == 200 + assert response.context["form"].non_field_errors() + assert b"hs-alert--error" in response.content + assert list(get_messages(response.wsgi_request)) == [] + + +def test_profile_edit_does_not_queue_a_success_message(client): + UserType.objects.update_or_create(name="Other", defaults={"public": True}) + user = User.objects.create_user( + email="edit-without-flash@example.test", + password="password", + user_type="Other", + ) + client.force_login(user) + + response = client.post( + reverse("account_profile_edit"), + {"first_name": "Updated", "last_name": "User", "user_type": "Other"}, + ) + + assert response.status_code == 302 + user.refresh_from_db() + assert user.first_name == "Updated" + assert list(get_messages(response.wsgi_request)) == [] def test_profile_page_loads_the_account_navigation_tag(client): diff --git a/packages/design-system/vue/HsQuerySearchInput.vue b/packages/design-system/vue/HsQuerySearchInput.vue index 5e18eaf7d..c765ee211 100644 --- a/packages/design-system/vue/HsQuerySearchInput.vue +++ b/packages/design-system/vue/HsQuerySearchInput.vue @@ -75,6 +75,7 @@ import { computed, nextTick, ref, watch } from 'vue' import { mdiClose, mdiMagnify } from '@mdi/js' import type { HsQueryQualifier } from './types' +import { queryQualifierTokens } from './querySearch' defineOptions({ name: 'HsQuerySearchInput' }) @@ -98,12 +99,9 @@ const caret = ref(0) const suggestionIndex = ref(0) const suggestionsEnabled = ref(false) -const qualifierPattern = computed(() => { - const keys = props.qualifiers - .map(({ key }) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|') - return keys ? new RegExp(`(${keys}):(?:"([^"]*)"|(\\S+))`, 'gi') : null -}) +const qualifierTokens = computed(() => + queryQualifierTokens(props.modelValue, props.qualifiers) +) const qualifierByKey = computed( () => @@ -125,27 +123,20 @@ function isValidQualifierValue(key: string, value: string) { const highlightSegments = computed(() => { const raw = props.modelValue - const pattern = qualifierPattern.value - if (!pattern) return [{ text: raw, cls: '' }] - const segments: { text: string; cls: string }[] = [] let lastIndex = 0 - let match: RegExpExecArray | null - pattern.lastIndex = 0 - while ((match = pattern.exec(raw))) { - if (match.index > lastIndex) { - segments.push({ text: raw.slice(lastIndex, match.index), cls: '' }) + for (const token of qualifierTokens.value) { + if (token.start > lastIndex) { + segments.push({ text: raw.slice(lastIndex, token.start), cls: '' }) } - const key = match[1] ?? '' - const quoted = match[2] !== undefined - const value = match[2] ?? match[3] ?? '' + const { key, value } = token segments.push({ text: key, cls: 'hl-key' }) segments.push({ text: ':', cls: 'hl-colon' }) segments.push({ - text: quoted ? `"${value}"` : value, + text: raw.slice(token.start + key.length + 1, token.end), cls: value && isValidQualifierValue(key, value) ? 'hl-value-valid' : '', }) - lastIndex = pattern.lastIndex + lastIndex = token.end } if (lastIndex < raw.length) { segments.push({ text: raw.slice(lastIndex), cls: '' }) @@ -190,7 +181,23 @@ function findTokenStart(raw: string, caretPosition: number) { for (let index = 0; index < caretPosition; index += 1) { const character = raw[index] if (character === '"') inQuotes = !inQuotes - else if (character === ' ' && !inQuotes) tokenStart = index + 1 + else if (/\s/.test(character ?? '') && !inQuotes) { + const token = raw.slice(tokenStart, caretPosition) + const colon = token.indexOf(':') + const qualifier = qualifierByKey.value.get( + token.slice(0, colon).toLocaleLowerCase() + ) + const query = token.slice(colon + 1).toLocaleLowerCase() + // Keep suggesting a name as the user types beyond its first word. + if ( + colon >= 0 && + qualifier?.values.some((value) => + value.toLocaleLowerCase().includes(query) + ) + ) + continue + tokenStart = index + 1 + } } return tokenStart } @@ -204,14 +211,11 @@ const currentToken = computed(() => { const selectedQualifierValues = computed(() => { const selected = new Map() - const pattern = qualifierPattern.value - if (!pattern) return selected - - let match: RegExpExecArray | null - pattern.lastIndex = 0 - while ((match = pattern.exec(props.modelValue))) { - const key = (match[1] ?? '').toLocaleLowerCase() - const value = match[2] ?? match[3] ?? '' + for (const token of qualifierTokens.value) { + // The value under the caret is being edited, not a duplicate selection. + if (token.start <= caret.value && caret.value <= token.end) continue + const key = token.key.toLocaleLowerCase() + const value = token.value if (value) selected.set(key, [...(selected.get(key) ?? []), value]) } return selected @@ -265,10 +269,15 @@ watch(activeSuggestion, () => { function replaceCurrentToken(replacement: string) { const { start, end } = currentToken.value + const token = qualifierTokens.value.find((item) => item.start === start) + const replacementEnd = Math.max(end, token?.end ?? end) + const suffix = props.modelValue.slice(replacementEnd) const nextCaret = start + replacement.length emit( 'update:modelValue', - props.modelValue.slice(0, start) + replacement + props.modelValue.slice(end) + props.modelValue.slice(0, start) + + replacement + + (replacement.endsWith(' ') ? suffix.replace(/^\s+/, '') : suffix) ) nextTick(() => { const input = inputEl.value diff --git a/packages/design-system/vue/index.ts b/packages/design-system/vue/index.ts index a1d5d8404..06127fd70 100644 --- a/packages/design-system/vue/index.ts +++ b/packages/design-system/vue/index.ts @@ -9,5 +9,7 @@ export { default as HsSelectionListItem } from './HsSelectionListItem.vue' export { default as HsSelectionSidebar } from './HsSelectionSidebar.vue' export { default as HsStatCard } from './HsStatCard.vue' export type { HsNavRailItem, HsQueryQualifier } from './types' +export { queryQualifierTokens } from './querySearch' +export type { QueryQualifier } from './querySearch' export { default as HsCopyButton } from './HsCopyButton.vue' export { default as HsTableSummary } from './HsTableSummary.vue' diff --git a/packages/design-system/vue/querySearch.ts b/packages/design-system/vue/querySearch.ts new file mode 100644 index 000000000..21b839401 --- /dev/null +++ b/packages/design-system/vue/querySearch.ts @@ -0,0 +1,58 @@ +import type { HsQueryQualifier } from './types' + +export type QueryQualifier = Pick + +// Match known names before falling back to a single word, so spaces do not +// require quotes and text following a complete name stays a separate search. +export function queryQualifierTokens( + raw: string, + qualifiers: readonly QueryQualifier[] +) { + const keys = qualifiers + .map(({ key }) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|') + if (!keys) return [] + + const pattern = new RegExp(`(?:^|\\s)(${keys}):`, 'gi') + const tokens: { + start: number + end: number + key: string + value: string + quoted: boolean + }[] = [] + let match: RegExpExecArray | null + while ((match = pattern.exec(raw))) { + const key = match[1]! + const start = pattern.lastIndex - key.length - 1 + const remainder = raw.slice(pattern.lastIndex) + const quoted = remainder.startsWith('"') + const qualifier = qualifiers.find( + (item) => item.key.toLocaleLowerCase() === key.toLocaleLowerCase() + )! + const knownValue = quoted + ? undefined + : [...qualifier.values] + .sort((a, b) => b.length - a.length) + .find( + (value) => + value.length > 0 && + remainder + .toLocaleLowerCase() + .startsWith(value.toLocaleLowerCase()) && + (!remainder[value.length] || /\s/.test(remainder[value.length]!)) + ) + const value = quoted + ? remainder.slice(1).split('"')[0]! + : remainder.slice( + 0, + knownValue?.length ?? remainder.match(/^\S*/)?.[0].length ?? 0 + ) + const closingQuote = quoted && remainder[value.length + 1] === '"' + const end = + pattern.lastIndex + value.length + Number(quoted) + Number(closingQuote) + tokens.push({ start, end, key, value, quoted }) + pattern.lastIndex = end + } + return tokens +} From 79cd27c68ee79028869b315e30548d1a83a6b067 Mon Sep 17 00:00:00 2001 From: daniel-slaugh Date: Fri, 25 Sep 2026 09:20:06 -0600 Subject: [PATCH 2/2] Fix the table popover menu to the search bar --- .../base/__tests__/HsQuerySearchInput.spec.ts | 27 ++++++++++++++++ packages/design-system/components.css | 31 +++++++++++-------- .../design-system/vue/HsQuerySearchInput.vue | 23 +++++--------- 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts b/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts index e7a33aa91..5503c5c68 100644 --- a/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts +++ b/apps/data-management/src/components/base/__tests__/HsQuerySearchInput.spec.ts @@ -54,6 +54,33 @@ describe('HsQuerySearchInput', () => { wrapper.unmount() }) + it('highlights only the hovered suggestion and selects it with Enter', async () => { + const wrapper = mountSearch() + const input = wrapper.get('input') + await input.setValue('site:') + const options = wrapper.findAll('[role="option"]') + expect(options.map((option) => option.attributes('aria-selected'))).toEqual( + ['true', 'false'] + ) + + await options[1]!.trigger('mouseenter') + expect(options.map((option) => option.attributes('aria-selected'))).toEqual( + ['false', 'true'] + ) + await input.trigger('keydown', { key: 'Enter' }) + expect(wrapper.props('modelValue')).toBe('site:"Bear River" ') + + await input.setValue('site:') + await wrapper.findAll('[role="option"]')[1]!.trigger('mouseenter') + await wrapper.get('[role="listbox"]').trigger('mouseleave') + expect( + wrapper + .findAll('[role="option"]') + .map((option) => option.attributes('aria-selected')) + ).toEqual(['true', 'false']) + wrapper.unmount() + }) + it('replaces the whole value when editing inside quotes and preserves later filters', async () => { const wrapper = mountSearch('site:"Logan River" unit:Feet') const input = wrapper.get('input') diff --git a/packages/design-system/components.css b/packages/design-system/components.css index 627f014c8..8d3a32a1d 100644 --- a/packages/design-system/components.css +++ b/packages/design-system/components.css @@ -160,42 +160,47 @@ label.required-label::after { } .hs-query-search-popover { - position: fixed; + position: absolute; + top: calc(100% + var(--hs-space-4)); + left: 0; z-index: 2000; - min-width: 220px; - max-width: 320px; - max-height: 240px; + width: max-content; + min-width: min(100%, 17.5rem); + max-width: min(22.5rem, calc(100vw - var(--hs-space-32))); + max-height: 20rem; padding: var(--hs-space-8) 0; overflow-y: auto; - background-color: var(--hs-surface, #fff); border: 1px solid var(--hs-border); border-radius: var(--hs-radius-sm); box-shadow: var(--hs-shadow-popover); } .hs-query-search-popover__title { - padding: var(--hs-space-4) var(--hs-space-16) var(--hs-space-8); - color: var(--hs-text-secondary); - font-size: var(--hs-font-2xs); - letter-spacing: 0.04em; - text-transform: uppercase; + padding: var(--hs-space-8) var(--hs-space-16); + color: var(--hs-text-primary); } .hs-query-search-popover__option { display: block; width: 100%; + min-height: calc(var(--hs-space-32) + var(--hs-space-8)); padding: var(--hs-space-8) var(--hs-space-16); color: var(--hs-text-primary); + font-family: inherit; + line-height: 1.4; text-align: left; + overflow-wrap: anywhere; cursor: pointer; background: none; border: 0; } -.hs-query-search-popover__option:hover, .hs-query-search-popover__option--active { - color: var(--hs-text-primary, #1c1b1f); - background-color: var(--hs-surface-muted, #eef4fa); + background-color: color-mix( + in srgb, + rgb(var(--v-theme-on-surface)) 6%, + rgb(var(--v-theme-surface)) + ); } /* At-a-glance metric used in dashboard overview grids. */ diff --git a/packages/design-system/vue/HsQuerySearchInput.vue b/packages/design-system/vue/HsQuerySearchInput.vue index c765ee211..cc2d38d36 100644 --- a/packages/design-system/vue/HsQuerySearchInput.vue +++ b/packages/design-system/vue/HsQuerySearchInput.vue @@ -37,38 +37,36 @@ > - - -
-
+
{{ activeSuggestion.type === 'key' ? 'Filter by…' - : `${activeSuggestion.label} values` + : activeSuggestion.label }}
- +