Skip to content

Commit ac86a2e

Browse files
pringelmannnextcloud-command
authored andcommitted
feat(core): link to connected services settings from unified search
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com> Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
1 parent 975fbf0 commit ac86a2e

6 files changed

Lines changed: 127 additions & 34 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 { generateUrl } from '@nextcloud/router'
9+
import { computed } from 'vue'
10+
import NcButton from '@nextcloud/vue/components/NcButton'
11+
import IconCogOutline from 'vue-material-design-icons/CogOutline.vue'
12+
13+
const props = defineProps<{
14+
active: boolean
15+
}>()
16+
17+
const emit = defineEmits<{
18+
toggle: []
19+
}>()
20+
21+
const label = computed(() => (props.active
22+
? t('core', 'Less from connected services')
23+
: t('core', 'More from connected services')))
24+
25+
const settingsUrl = generateUrl('/settings/user/connected-accounts')
26+
const settingsLabel = t('core', 'Connected services settings')
27+
</script>
28+
29+
<template>
30+
<div class="connected-services-bar">
31+
<NcButton
32+
variant="secondary"
33+
wide
34+
@click="emit('toggle')">
35+
{{ label }}
36+
</NcButton>
37+
<NcButton
38+
variant="secondary"
39+
:aria-label="settingsLabel"
40+
:href="settingsUrl"
41+
:title="settingsLabel"
42+
target="_blank">
43+
<template #icon>
44+
<IconCogOutline :size="20" />
45+
</template>
46+
</NcButton>
47+
</div>
48+
</template>
49+
50+
<style lang="scss" scoped>
51+
.connected-services-bar {
52+
display: flex;
53+
gap: var(--default-grid-baseline);
54+
width: 100%;
55+
margin-block-start: calc(var(--default-grid-baseline) * 3);
56+
}
57+
</style>

core/src/components/UnifiedSearch/UnifiedSearchModal.vue

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,10 @@
147147
</template>
148148
</NcEmptyContent>
149149
<!-- Offered even with zero results, so the user can reach external providers. -->
150-
<div v-if="showConnectedServicesButton" class="unified-search-modal__connected-services">
151-
<NcButton variant="secondary" wide @click="toggleExternalResources">
152-
{{ connectedServicesLabel }}
153-
</NcButton>
154-
</div>
150+
<ConnectedServicesBar
151+
v-if="showConnectedServicesButton"
152+
:active="searchExternalResources"
153+
@toggle="toggleExternalResources" />
155154
</div>
156155

157156
<div
@@ -237,11 +236,10 @@
237236
<!-- Last, so results that land are never pushed down. -->
238237
<SearchResultSkeleton v-if="skeletonRows > 0" :rows="skeletonRows" />
239238
<!-- Connected-services opt-in. Toggling re-runs find() (searchExternalResources watcher). Hidden in detail view. -->
240-
<div v-if="showConnectedServicesButton" class="unified-search-modal__connected-services">
241-
<NcButton variant="secondary" wide @click="toggleExternalResources">
242-
{{ connectedServicesLabel }}
243-
</NcButton>
244-
</div>
239+
<ConnectedServicesBar
240+
v-if="showConnectedServicesButton"
241+
:active="searchExternalResources"
242+
@toggle="toggleExternalResources" />
245243
</div>
246244
</div>
247245
<!-- `modal-mask` is how @nextcloud/vue's useHotKey guard recognises an open modal and
@@ -279,6 +277,7 @@ import IconClose from 'vue-material-design-icons/Close.vue'
279277
import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
280278
import IconMagnify from 'vue-material-design-icons/Magnify.vue'
281279
import IconShapeOutline from 'vue-material-design-icons/ShapeOutline.vue'
280+
import ConnectedServicesBar from './ConnectedServicesBar.vue'
282281
import CustomDateRangeModal from './CustomDateRangeModal.vue'
283282
import SearchableList from './SearchableList.vue'
284283
import FilterChip from './SearchFilterChip.vue'
@@ -324,6 +323,7 @@ export default defineComponent({
324323
IconMagnify,
325324
IconShapeOutline,
326325
326+
ConnectedServicesBar,
327327
CustomDateRangeModal,
328328
FilterChip,
329329
NcActions,
@@ -663,12 +663,6 @@ export default defineComponent({
663663
&& !this.isBusy
664664
},
665665
666-
connectedServicesLabel() {
667-
return this.searchExternalResources
668-
? t('core', 'Less from connected services')
669-
: t('core', 'More from connected services')
670-
},
671-
672666
// The rendered rows flattened into a single list in visual order (filtered
673667
// groups first, then the partial-matches groups), each with the DOM id of its
674668
// option element. This is the index space the arrow keys walk; it must stay in
@@ -1785,16 +1779,6 @@ export default defineComponent({
17851779
justify-content: center;
17861780
}
17871781
1788-
// End-of-list (and empty-state) connected-services opt-in.
1789-
&__connected-services {
1790-
display: flex;
1791-
flex-wrap: wrap;
1792-
// Stretch to panel width so the wide button fills it (the empty-state's centred column
1793-
// would otherwise shrink it to content width).
1794-
width: 100%;
1795-
margin-block-start: calc(var(--default-grid-baseline) * 3);
1796-
}
1797-
17981782
// Directional glyphs (back arrow, more-from chevron) point the other way in RTL.
17991783
// :dir(rtl) tracks the computed direction, unlike an [dir=rtl] attribute selector.
18001784
&__rtl-icon:dir(rtl) {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import { mount } from '@vue/test-utils'
6+
import { describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@nextcloud/l10n', () => ({
9+
t: (_app: string, text: string) => text,
10+
}))
11+
vi.mock('@nextcloud/router', () => ({
12+
generateUrl: (path: string) => path,
13+
}))
14+
15+
import ConnectedServicesBar from '../../components/UnifiedSearch/ConnectedServicesBar.vue'
16+
17+
function factory(active = false) {
18+
return mount(ConnectedServicesBar, { propsData: { active } })
19+
}
20+
21+
describe('ConnectedServicesBar', () => {
22+
// Matched by label, not position, so it still finds the toggle if the gear ever
23+
// stops rendering as an <a>.
24+
const toggle = (wrapper: ReturnType<typeof factory>) => wrapper.findAll('button').wrappers
25+
.find((button) => button.text().includes('connected services'))!
26+
27+
it('offers to opt in while connected services are off', () => {
28+
expect(factory().text()).toContain('More from connected services')
29+
})
30+
31+
it('offers to opt back out once they are on', () => {
32+
expect(factory(true).text()).toContain('Less from connected services')
33+
})
34+
35+
it('asks the parent to flip the opt-in', async () => {
36+
const wrapper = factory()
37+
38+
await toggle(wrapper).trigger('click')
39+
40+
expect(wrapper.emitted('toggle')).toHaveLength(1)
41+
})
42+
43+
it('links to the connected accounts settings in a new tab', () => {
44+
const link = factory().find('a')
45+
46+
expect(link.attributes('href')).toBe('/settings/user/connected-accounts')
47+
expect(link.attributes('target')).toBe('_blank')
48+
expect(link.attributes('aria-label')).toBe('Connected services settings')
49+
})
50+
})

core/src/tests/components/UnifiedSearchModal.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ vi.mock('../../logger.js', () => ({
4343
unifiedSearchLogger: { debug: vi.fn(), error: vi.fn() },
4444
}))
4545

46+
import ConnectedServicesBar from '../../components/UnifiedSearch/ConnectedServicesBar.vue'
4647
import UnifiedSearchModal from '../../components/UnifiedSearch/UnifiedSearchModal.vue'
4748
import { isCategoryVisible } from '../../services/UnifiedSearchController.ts'
4849

@@ -1047,7 +1048,7 @@ describe('UnifiedSearchModal result presentation', () => {
10471048
expect(wrapper.vm.detailCategory).toBeNull()
10481049
})
10491050

1050-
it('labels the connected-services button by the toggle state and re-runs find on toggle', async () => {
1051+
it('offers the connected-services opt-in and re-runs find when it is flipped', async () => {
10511052
const wrapper = factory()
10521053
wrapper.vm.providers = [
10531054
{ id: 'files', name: 'Files', order: 0 },
@@ -1061,13 +1062,14 @@ describe('UnifiedSearchModal result presentation', () => {
10611062
wrapper.vm.find('query')
10621063
await wrapper.vm.$nextTick()
10631064

1064-
expect(buttonWithText(wrapper, 'More from connected services')).toBeTruthy()
1065+
const bar = wrapper.findComponent(ConnectedServicesBar)
1066+
expect(bar.props('active')).toBe(false)
10651067

1066-
wrapper.vm.toggleExternalResources()
1068+
bar.vm.$emit('toggle')
10671069
await wrapper.vm.$nextTick()
10681070

10691071
expect(searchSpy).toHaveBeenCalled()
1070-
expect(buttonWithText(wrapper, 'Less from connected services')).toBeTruthy()
1072+
expect(bar.props('active')).toBe(true)
10711073
})
10721074

10731075
it('returns focus to the search input after toggling connected services', async () => {
@@ -1105,7 +1107,7 @@ describe('UnifiedSearchModal result presentation', () => {
11051107
await wrapper.vm.$nextTick()
11061108

11071109
expect(wrapper.vm.showEmptyContentInfo).toBe(true)
1108-
expect(buttonWithText(wrapper, 'connected services')).toBeTruthy()
1110+
expect(wrapper.findComponent(ConnectedServicesBar).exists()).toBe(true)
11091111
})
11101112

11111113
it('no longer renders the connected-services switch in the filter row', async () => {

dist/core-unified-search.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-unified-search.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)