Skip to content

Commit 35784df

Browse files
authored
Improve UX for search items that don't have anything to navigate to (baserow#4367)
Improve UX for search items that don't have anything to navigate to
1 parent e7842df commit 35784df

File tree

14 files changed

+342
-94
lines changed

14 files changed

+342
-94
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "bug",
3+
"message": "Improve UX for search items that don't have anything to navigate to",
4+
"issue_origin": "github",
5+
"issue_number": 4182,
6+
"domain": "database",
7+
"bullet_points": [],
8+
"created_at": "2025-12-01"
9+
}

web-frontend/modules/automation/plugin.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export default (context) => {
165165
)
166166

167167
// Automation search type
168-
searchTypeRegistry.register(new AutomationSearchType())
168+
searchTypeRegistry.register(new AutomationSearchType(context))
169169

170170
// Automation guided tour.
171171
app.$registry.register('guidedTour', new AutomationGuidedTourType(context))
Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,27 @@
1-
import { BaseSearchType } from '@baserow/modules/core/search/types/base'
1+
import { ApplicationSearchType } from '@baserow/modules/core/search/types/base'
22

3-
export class AutomationSearchType extends BaseSearchType {
4-
constructor() {
5-
super()
3+
export class AutomationSearchType extends ApplicationSearchType {
4+
constructor(context = {}) {
5+
super(context)
66
this.type = 'automation'
77
this.name = 'Automation'
88
this.icon = 'baserow-icon-automation'
99
this.priority = 4
1010
}
1111

12-
buildUrl(result, context = null) {
13-
const appId = result?.metadata?.application_id || result?.id
14-
if (!appId) {
15-
return null
16-
}
12+
_getApplicationId(result) {
13+
const id = parseInt(result?.metadata?.application_id || result?.id)
14+
return isNaN(id) ? null : id
15+
}
1716

18-
if (context && context.store) {
19-
const automation = context.store.getters['application/get'](appId)
20-
if (
21-
automation &&
22-
automation.workflows &&
23-
automation.workflows.length > 0
24-
) {
25-
const workflows = [...automation.workflows].sort(
26-
(a, b) => a.order - b.order
27-
)
28-
if (workflows.length > 0) {
29-
return `/automation/${appId}/workflow/${workflows[0].id}`
30-
}
31-
}
32-
}
17+
_getApplicationChildren(application) {
18+
return application.workflows
19+
}
3320

34-
return null
21+
_getApplicationPath(application, children) {
22+
return {
23+
name: 'automation-workflow',
24+
params: { automationId: application.id, workflowId: children[0].id },
25+
}
3526
}
3627
}

web-frontend/modules/builder/plugin.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,5 +443,5 @@ export default (context) => {
443443

444444
app.$registry.register('guidedTour', new BuilderGuidedTourType(context))
445445

446-
searchTypeRegistry.register(new BuilderSearchType())
446+
searchTypeRegistry.register(new BuilderSearchType(context))
447447
}
Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,42 @@
11
import { BaseSearchType } from '@baserow/modules/core/search/types/base'
22

33
export class BuilderSearchType extends BaseSearchType {
4-
constructor() {
5-
super()
4+
constructor(context = {}) {
5+
super(context)
66
this.type = 'builder'
77
this.name = 'Builder'
88
this.icon = 'baserow-icon-application'
99
this.priority = 2
1010
}
1111

12-
buildUrl(result, context = null) {
13-
if (!context || !context.store) {
12+
_getApplicationWithPages(result, context) {
13+
const appId = this._getApplicationId(result)
14+
if (!appId || !context?.store) {
1415
return null
1516
}
16-
const application = context.store.getters['application/get'](
17-
parseInt(result.id)
18-
)
17+
const application = context.store.getters['application/get'](appId)
1918
if (!application) {
2019
return null
2120
}
2221
const pages = context.store.getters['page/getVisiblePages'](application)
2322
if (pages && pages.length > 0) {
24-
return `/builder/${application.id}/page/${pages[0].id}`
23+
return { application, pages }
2524
}
2625
return null
2726
}
27+
28+
buildUrl(result, context = null) {
29+
const data = this._getApplicationWithPages(result, context)
30+
if (!data) {
31+
return null
32+
}
33+
return {
34+
name: 'builder-page',
35+
params: { builderId: data.application.id, pageId: data.pages[0].id },
36+
}
37+
}
38+
39+
isNavigable(result, context = null) {
40+
return this._getApplicationWithPages(result, context) !== null
41+
}
2842
}

web-frontend/modules/core/assets/scss/components/workspace_search.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,10 @@
292292
.workspace-search__result-highlight {
293293
background-color: $palette-yellow-100;
294294
}
295+
296+
.workspace-search__result-empty-badge {
297+
margin-left: 4px;
298+
color: $palette-neutral-600;
299+
font-style: italic;
300+
font-weight: 400;
301+
}

web-frontend/modules/core/components/workspace/WorkspaceSearchModal.vue

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,13 @@
6464
</div>
6565
<div class="workspace-search__result-main">
6666
<div class="workspace-search__result-title">
67-
{{ displayFor(result).title }}
67+
{{ displayFor(result).title
68+
}}<span
69+
v-if="getEmptyLabel(result)"
70+
class="workspace-search__result-empty-badge"
71+
>
72+
{{ getEmptyLabel(result) }}</span
73+
>
6874
</div>
6975
<div
7076
v-if="displayFor(result).subtitle"
@@ -470,9 +476,23 @@ export default {
470476
if (url) {
471477
this.$router.push(url)
472478
this.hide()
479+
} else {
480+
// Try to focus in sidebar as fallback if there is no navigable route
481+
const focused = searchTypeRegistry.focusInSidebar(result.type, result, {
482+
store: this.$store,
483+
})
484+
if (focused) {
485+
this.hide()
486+
}
473487
}
474488
},
475489
490+
getEmptyLabel(result) {
491+
return searchTypeRegistry.getEmptyLabel(result.type, result, {
492+
store: this.$store,
493+
})
494+
},
495+
476496
buildResultUrl(result) {
477497
return searchTypeRegistry.buildUrl(result.type, result, {
478498
store: this.$store,

web-frontend/modules/core/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"navigate": "Navigate",
1515
"select": "Select",
1616
"close": "Close",
17+
"empty": "(empty)",
1718
"types": {
1819
"applications": "Applications",
1920
"tables": "Tables",

web-frontend/modules/core/search/types/base.js

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
export class BaseSearchType {
2-
constructor() {
2+
constructor(context = {}) {
3+
this.app = context.app
34
this.type = null
45
this.name = null
56
this.icon = 'iconoir-search'
67
this.priority = 10
78
}
89

10+
/**
11+
* Builds the URL for navigating to this search result.
12+
* Must be implemented by subclasses.
13+
* @param {Object} result - The search result object
14+
* @param {Object} context - Context containing store reference
15+
* @returns {string|Object|null} URL string, route object, or null if not navigable
16+
*/
917
buildUrl(result, context = null) {
1018
throw new Error('buildUrl must be implemented by subclass')
1119
}
@@ -26,12 +34,134 @@ export class BaseSearchType {
2634
return this.priority
2735
}
2836

29-
// Default formatting returns plain title/subtitle and no description segments
37+
/**
38+
* Formats the result for display in the search modal.
39+
* Override in subclasses to provide custom formatting.
40+
* @param {Object} result - The search result object
41+
* @param {Object} context - Context (e.g., searchTerm for highlighting)
42+
* @returns {Object} Object with title, subtitle, and descriptionSegments
43+
*/
3044
formatResultDisplay(result, context = null) {
3145
return {
3246
title: result.title,
3347
subtitle: result.subtitle,
3448
descriptionSegments: [],
3549
}
3650
}
51+
52+
/**
53+
* Returns true if the result can be navigated to (has a valid URL).
54+
* Override in subclasses to provide custom logic.
55+
* @param {Object} result - The search result object
56+
* @param {Object} context - Context containing store reference
57+
* @returns {boolean}
58+
*/
59+
isNavigable(result, context = null) {
60+
return true
61+
}
62+
63+
/**
64+
* Gets the application ID from a result.
65+
* Override in subclasses for custom ID extraction logic.
66+
* @param {Object} result - The search result object
67+
* @returns {number|null}
68+
*/
69+
_getApplicationId(result) {
70+
const id = parseInt(result?.id)
71+
return isNaN(id) ? null : id
72+
}
73+
74+
/**
75+
* Attempts to focus/select the application in the sidebar as a fallback action.
76+
* @param {Object} result - The search result object
77+
* @param {Object} context - Context containing store reference
78+
* @returns {boolean} True if the action was taken, false otherwise
79+
*/
80+
focusInSidebar(result, context = null) {
81+
const appId = this._getApplicationId(result)
82+
if (!appId || !context?.store) {
83+
return false
84+
}
85+
const application = context.store.getters['application/get'](appId)
86+
if (application) {
87+
context.store.dispatch('application/select', application)
88+
89+
const applicationType = this.app.$registry.get(
90+
'application',
91+
application.type
92+
)
93+
applicationType.select(application, {
94+
$router: this.app.router,
95+
$store: context.store,
96+
$i18n: this.app.i18n,
97+
})
98+
return true
99+
}
100+
return false
101+
}
102+
103+
/**
104+
* Returns the i18n key suffix for the label to display when item is not navigable.
105+
* Returns null if no label should be shown.
106+
* @param {Object} result - The search result object
107+
* @param {Object} context - Context containing store reference
108+
* @returns {string|null}
109+
*/
110+
getEmptyLabel(result, context = null) {
111+
if (this.isNavigable(result, context)) {
112+
return null
113+
}
114+
return this.app.i18n.t('workspaceSearch.empty')
115+
}
116+
}
117+
118+
export class ApplicationSearchType extends BaseSearchType {
119+
constructor(context = {}) {
120+
super(context)
121+
}
122+
123+
_getApplicationChildren(application) {
124+
throw new Error('_getApplicationChildren must be implemented')
125+
}
126+
127+
_getApplicationPath(application, children) {
128+
throw new Error('_getApplicationPath must be implemented')
129+
}
130+
131+
_getApplicationId(result) {
132+
throw new Error('_getApplicationId must be implemented')
133+
}
134+
135+
_getApplicationWithChildren(result, context) {
136+
const applicationId = this._getApplicationId(result)
137+
if (!applicationId || !context?.store) {
138+
return null
139+
}
140+
const application = context.store.getters['application/get'](applicationId)
141+
if (!application) {
142+
return null
143+
}
144+
const children = this._getApplicationChildren(application)
145+
if (children && children.length > 0) {
146+
return application
147+
}
148+
return null
149+
}
150+
151+
buildUrl(result, context = null) {
152+
const application = this._getApplicationWithChildren(result, context)
153+
if (!application) {
154+
return null
155+
}
156+
157+
const children = [...this._getApplicationChildren(application)].sort(
158+
(a, b) => a.order - b.order
159+
)
160+
161+
return this._getApplicationPath(application, children)
162+
}
163+
164+
isNavigable(result, context = null) {
165+
return this._getApplicationWithChildren(result, context) !== null
166+
}
37167
}

web-frontend/modules/core/search/types/registry.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,30 @@ export class SearchTypeRegistry {
5252
const searchType = this.get(type)
5353
return searchType.formatResultDisplay(result, context)
5454
}
55+
56+
isNavigable(type, result, context = null) {
57+
const searchType = this.get(type)
58+
if (!searchType) {
59+
return true
60+
}
61+
return searchType.isNavigable(result, context)
62+
}
63+
64+
focusInSidebar(type, result, context = null) {
65+
const searchType = this.get(type)
66+
if (!searchType) {
67+
return false
68+
}
69+
return searchType.focusInSidebar(result, context)
70+
}
71+
72+
getEmptyLabel(type, result, context = null) {
73+
const searchType = this.get(type)
74+
if (!searchType) {
75+
return null
76+
}
77+
return searchType.getEmptyLabel(result, context)
78+
}
5579
}
5680

5781
export const searchTypeRegistry = new SearchTypeRegistry()

0 commit comments

Comments
 (0)