Skip to content

Commit c553e4a

Browse files
committed
refactor(appstore): handle in-app-search of appstore
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 7a9ac47 commit c553e4a

9 files changed

Lines changed: 164 additions & 17 deletions

File tree

apps/appstore/src/AppstoreApp.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const currentCategory = computed(() => {
2323
}
2424
if (route.name === 'apps-bundles') {
2525
return 'bundles'
26+
} else if (route.name === 'apps-search') {
27+
return 'search'
2628
}
2729
return 'discover'
2830
})
@@ -57,6 +59,7 @@ const showSidebar = computed(() => !!route.params.id)
5759
<style module>
5860
.appstoreApp__content {
5961
padding-inline-end: var(--body-container-margin);
62+
position: relative;
6063
}
6164
6265
.appstoreApp__heading {

apps/appstore/src/composables/useFilteredApps.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { MaybeRefOrGetter } from 'vue'
77
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
88

99
import { computed, toValue } from 'vue'
10+
import { useRoute } from 'vue-router'
1011
import { useUserSettingsStore } from '../store/userSettings.ts'
1112

1213
/**
@@ -16,11 +17,21 @@ import { useUserSettingsStore } from '../store/userSettings.ts'
1617
*/
1718
export function useFilteredApps(apps: MaybeRefOrGetter<(IAppstoreApp | IAppstoreExApp)[]>) {
1819
const store = useUserSettingsStore()
20+
const route = useRoute()
1921
return computed(() => {
20-
if (!store.showIncompatible) {
21-
return toValue(apps)
22-
.filter((app) => app.isCompatible !== false)
23-
}
22+
const query = [route.query.q || ''].flat()[0]!
2423
return toValue(apps)
24+
.filter((app) => {
25+
if (!store.showIncompatible && app.isCompatible === false) {
26+
return false
27+
}
28+
if (query) {
29+
const needle = query.trim().toLocaleLowerCase()
30+
return app.name.toLocaleLowerCase().includes(needle)
31+
|| app.id.toLocaleLowerCase().includes(needle)
32+
|| app.summary.toLocaleLowerCase().includes(needle)
33+
}
34+
return true
35+
})
2536
})
2637
}

apps/appstore/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const APPSTORE_CATEGORY_NAMES = Object.freeze({
4343
bundles: t('appstore', 'App bundles'),
4444
featured: t('appstore', 'Featured apps'),
4545
supported: t('appstore', 'Supported apps'), // From subscription
46+
search: t('appstore', 'Search results'),
4647
})
4748

4849
/**

apps/appstore/src/router/routes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const AppstoreDiscover = () => import('../views/AppstoreDiscover.vue')
1414
const AppstoreManage = () => import('../views/AppstoreManage.vue')
1515
const AppstoreBundles = () => import('../views/AppstoreBundles.vue')
1616
const AppstoreBrowse = () => import('../views/AppstoreBrowse.vue')
17+
const AppstoreSearch = () => import('../views/AppstoreSearch.vue')
1718

1819
const routes: RouteRecordRaw[] = [
1920
{
@@ -48,6 +49,11 @@ const routes: RouteRecordRaw[] = [
4849
name: 'apps-category',
4950
component: AppstoreBrowse,
5051
},
52+
{
53+
path: 'search/:id?',
54+
name: 'apps-search',
55+
component: AppstoreSearch,
56+
},
5157
],
5258
},
5359
]

apps/appstore/src/views/AppstoreBrowse.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { t } from '@nextcloud/l10n'
88
import { computed } from 'vue'
99
import { useRoute } from 'vue-router'
10+
import NcButton from '@nextcloud/vue/components/NcButton'
1011
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
1112
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
1213
import AppGrid from '../components/AppGrid/AppGrid.vue'
@@ -51,8 +52,18 @@ const visibleApps = useFilteredApps(apps)
5152

5253
<component
5354
:is="userSettings.isGridView ? AppGrid : AppTable"
55+
v-if="visibleApps.length"
5456
:class="$style.appstoreBrowse"
5557
:apps="visibleApps" />
58+
<NcEmptyContent
59+
v-else
60+
:name="t('appstore', 'No matching apps found')">
61+
<template #action>
62+
<NcButton variant="primary" @click="$router.push({ query: $route.query, name: 'apps-search' })">
63+
{{ t('appstore', 'Search everywhere') }}
64+
</NcButton>
65+
</template>
66+
</NcEmptyContent>
5667
</template>
5768
</template>
5869

apps/appstore/src/views/AppstoreManage.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { t } from '@nextcloud/l10n'
88
import { computed } from 'vue'
99
import { useRoute } from 'vue-router'
10+
import NcButton from '@nextcloud/vue/components/NcButton'
1011
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
1112
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
1213
import AppGrid from '../components/AppGrid/AppGrid.vue'
@@ -50,9 +51,18 @@ const visibleApps = useFilteredApps(apps)
5051

5152
<component
5253
:is="userSettings.isGridView ? AppGrid : AppTable"
53-
v-else
54+
v-else-if="visibleApps.length"
5455
:class="$style.appstoreManage"
5556
:apps="visibleApps" />
57+
<NcEmptyContent
58+
v-else
59+
:name="t('appstore', 'No matching apps found')">
60+
<template #action>
61+
<NcButton variant="primary" @click="$router.push({ query: $route.query, name: 'apps-search' })">
62+
{{ t('appstore', 'Search everywhere') }}
63+
</NcButton>
64+
</template>
65+
</NcEmptyContent>
5666
</template>
5767

5868
<style module>

apps/appstore/src/views/AppstoreNavigation.vue

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
-->
55

66
<script setup lang="ts">
7+
import { emit } from '@nextcloud/event-bus'
78
import { loadState } from '@nextcloud/initial-state'
89
import { t } from '@nextcloud/l10n'
9-
import { computed, ref, watch } from 'vue'
10-
import { useRouter } from 'vue-router'
10+
import { useHotKey } from '@nextcloud/vue'
11+
import { watchDebounced } from '@vueuse/core'
12+
import { computed, ref, useTemplateRef, watch } from 'vue'
13+
import { useRoute, useRouter } from 'vue-router'
1114
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
1215
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
1316
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
@@ -28,26 +31,52 @@ const userSettings = useUserSettingsStore()
2831
const categories = computed(() => store.categories)
2932
const categoriesLoading = computed(() => store.isLoadingCategories)
3033
34+
const route = useRoute()
3135
const router = useRouter()
3236
37+
const searchElement = useTemplateRef('search')
38+
39+
useHotKey('f', () => {
40+
if (!searchElement.value?.$refs.inputElement) {
41+
emit('toggle-navigation', {
42+
open: true,
43+
})
44+
// open animation
45+
window.setTimeout(() => searchElement.value?.$refs.inputElement?.focus(), 400)
46+
}
47+
searchElement.value?.$refs.inputElement?.focus()
48+
}, { ctrl: true, stop: true, prevent: true })
49+
3350
const search = ref('')
34-
watch(search, (newValue, oldValue) => {
51+
// initialize the search value from the query parameter on mount
52+
watch(() => route.query.q, (newQuery) => {
53+
search.value = [newQuery || ''].flat()[0]!
54+
}, { immediate: true })
55+
// update the query parameter when the search value changes, debounced to avoid excessive updates
56+
watchDebounced(search, (newValue, oldValue) => {
3557
if (newValue.trim() === oldValue.trim()) {
3658
return
3759
}
3860
39-
if (router.currentRoute.value.name === 'apps-search') {
40-
router.replace({
61+
if (router.currentRoute.value.name === 'apps-discover' || (router.currentRoute.value.name === 'apps-manage' && route.params.category === 'bundles')) {
62+
router.push({
4163
name: 'apps-search',
42-
query: { q: newValue },
64+
query: {
65+
...route.query,
66+
q: newValue.trim() || undefined,
67+
},
4368
})
4469
return
4570
}
46-
router.push({
47-
name: 'apps-search',
48-
query: { q: newValue },
71+
72+
router.replace({
73+
...route,
74+
query: {
75+
...route.query,
76+
q: newValue.trim() || undefined,
77+
},
4978
})
50-
})
79+
}, { debounce: 500 })
5180
5281
/**
5382
* Check if the current instance has a support subscription from the Nextcloud GmbH
@@ -62,6 +91,7 @@ const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300
6291
<NcAppNavigation :aria-label="t('appstore', 'Apps')">
6392
<template #search>
6493
<NcAppNavigationSearch
94+
ref="search"
6595
v-model="search"
6696
:label="t('appstore', 'Search apps…')" />
6797
</template>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import { t } from '@nextcloud/l10n'
8+
import { watchDebounced } from '@vueuse/core'
9+
import { ref, watch } from 'vue'
10+
import { useRoute, useRouter } from 'vue-router'
11+
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
12+
import NcInputField from '@nextcloud/vue/components/NcInputField'
13+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
14+
import AppGrid from '../components/AppGrid/AppGrid.vue'
15+
import AppTable from '../components/AppTable/AppTable.vue'
16+
import AppToolbar from '../components/AppToolbar.vue'
17+
import { useFilteredApps } from '../composables/useFilteredApps.ts'
18+
import { useAppsStore } from '../store/apps.ts'
19+
import { useUserSettingsStore } from '../store/userSettings.ts'
20+
21+
const route = useRoute()
22+
const router = useRouter()
23+
const store = useAppsStore()
24+
const userSettings = useUserSettingsStore()
25+
26+
const visibleApps = useFilteredApps(() => store.apps)
27+
const search = ref('')
28+
29+
watch(() => route.query.q, (newQuery) => {
30+
search.value = [newQuery || ''].flat()[0]!
31+
}, { immediate: true })
32+
33+
watchDebounced(search, (newValue) => {
34+
router.replace({
35+
...route,
36+
query: {
37+
...route.query,
38+
q: newValue.trim(),
39+
},
40+
})
41+
}, { debounce: 500 })
42+
</script>
43+
44+
<template>
45+
<AppToolbar />
46+
47+
<!-- Apps list -->
48+
<NcEmptyContent
49+
v-if="store.isLoadingApps"
50+
:name="t('appstore', 'Loading app list')">
51+
<template #icon>
52+
<NcLoadingIcon :size="64" />
53+
</template>
54+
</NcEmptyContent>
55+
56+
<component
57+
:is="userSettings.isGridView ? AppGrid : AppTable"
58+
v-else-if="visibleApps.length && search.trim().length > 2"
59+
:class="$style.appstoreSearch"
60+
:apps="visibleApps" />
61+
<NcEmptyContent
62+
v-else
63+
:name="t('appstore', 'No matching apps found')"
64+
:description="search.trim().length <= 2 ? t('appstore', 'Please enter more characters to search.') : undefined">
65+
<template #action>
66+
<NcInputField v-model="search" type="search" :label="t('appstore', 'Search apps')" />
67+
</template>
68+
</NcEmptyContent>
69+
</template>
70+
71+
<style module>
72+
.appstoreSearch {
73+
margin-bottom: var(--body-container-margin);
74+
}
75+
</style>

core/src/views/UnifiedSearch.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export default defineComponent({
8383
*/
8484
supportsLocalSearch() {
8585
// TODO: Make this an API
86-
const providerPaths = ['/apps/deck', '/settings/apps']
86+
const providerPaths = ['/apps/deck']
8787
return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
8888
},
8989
@@ -93,7 +93,7 @@ export default defineComponent({
9393
*/
9494
appHandlesSearchShortcut() {
9595
// TODO: Make this an API
96-
const providerPaths = ['/settings/users']
96+
const providerPaths = ['/settings/users', '/settings/apps']
9797
return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
9898
},
9999
},

0 commit comments

Comments
 (0)