Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
78 changes: 42 additions & 36 deletions apps/data-management/src/components/Datastream/DatastreamTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Expand All @@ -1004,42 +1006,46 @@ const uniqueSorted = (values: Array<string | null | undefined>) =>
(a, b) => a.localeCompare(b)
)
const option = (value: string): FilterOption => ({ value, label: value })
const filterDefinitions = computed<FilterDefinition[]>(() => [
{
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<Omit<FilterDefinition, 'selectedCount'>[]>(
() => [
{
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<FilterDefinition[]>(() =>
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,103 @@ 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('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')
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')
Expand Down
38 changes: 38 additions & 0 deletions apps/data-management/src/utils/__tests__/datastreamSearch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
34 changes: 22 additions & 12 deletions apps/data-management/src/utils/datastreamSearch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
queryQualifierTokens,
type QueryQualifier,
} from '@hydroserver/design-system/vue'

export const DATASTREAM_QUALIFIER_KEYS = [
'workspace',
'site',
Expand All @@ -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 (
Expand All @@ -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: [],
Expand All @@ -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))

Expand Down
12 changes: 12 additions & 0 deletions django/core/iam/auth/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading