Skip to content

Commit 8d66704

Browse files
[WIP] feat(files_sharing): add ExternalShareeSearch
Inspired (initially copied) from SearchInput, implemented to search only for external (remote) sharees. WIP: changes and further TODOs mentioned in the comments at the beginning of the file Refs: nextcloud#48925 Signed-off-by: Thomas Lehmann <[email protected]>
1 parent 0ba6f7b commit 8d66704

File tree

1 file changed

+376
-0
lines changed

1 file changed

+376
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
<!--
2+
- SPDX-FileLicenseText: 2019, 2024 Nextcloud GmbH and Nextcloud contributors, STRATO AG
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<!--
7+
TODO: Initially copied from ShareInput.vue for differentiation of sharee seatch by internal/external
8+
9+
- externalResults (OCA.Sharing.ShareSearch.state) already dropped (not needed here)
10+
- shareType is passed with the request
11+
- inconsistent use of trim() on shareWith in reducer alreay fixed here
12+
- Needs more refactoring esp. with filterOutExistingShares(), formatForMultiselect()
13+
- filterOutExistingShares() should be solved with filter(predicateFunctions) instead
14+
- formatForMultiselect() could/shoul be common to both components
15+
- shareTypeToIcon() does not need some types here, if not refactored out, drop them
16+
-->
17+
18+
<!--
19+
Search field to look up internal sharees (shares with other internal Nextcloud users)
20+
-->
21+
22+
<template>
23+
<div class="sharing-search">
24+
<NcSelect ref="select"
25+
v-model="value"
26+
input-id="sharing-search-external-input"
27+
class="sharing-search-external__input"
28+
:disabled="!canReshare"
29+
:loading="loading"
30+
:filterable="false"
31+
:placeholder="inputPlaceholder"
32+
:clear-search-on-blur="() => false"
33+
:user-select="true"
34+
:options="options"
35+
:label-outside="true"
36+
@search="asyncFind"
37+
@option:selected="onSelected">
38+
<template #no-options="{ search }">
39+
{{ search ? noResultText : t('files_sharing', 'No recommendations. Start typing.') }}
40+
</template>
41+
</NcSelect>
42+
</div>
43+
</template>
44+
45+
<script>
46+
import { generateOcsUrl } from '@nextcloud/router'
47+
import { getCurrentUser } from '@nextcloud/auth'
48+
import { getCapabilities } from '@nextcloud/capabilities'
49+
import axios from '@nextcloud/axios'
50+
import debounce from 'debounce'
51+
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
52+
53+
import Config from '../services/ConfigService.ts'
54+
import formatForMultiselect from '../utils/formatForMultiselect.js'
55+
import Share from '../models/Share.ts'
56+
import ShareRequests from '../mixins/ShareRequests.js'
57+
import ShareDetails from '../mixins/ShareDetails.js'
58+
import { ShareType } from '@nextcloud/sharing'
59+
import { external as externalShareTypes, externalAllowed } from '../utils/ShareTypes.js';
60+
61+
export default {
62+
name: 'InternalShareeSearch',
63+
64+
components: {
65+
NcSelect,
66+
},
67+
68+
mixins: [ShareRequests, ShareDetails],
69+
70+
props: {
71+
shares: {
72+
type: Array,
73+
default: () => [],
74+
required: true,
75+
},
76+
linkShares: {
77+
type: Array,
78+
default: () => [],
79+
required: true,
80+
},
81+
fileInfo: {
82+
type: Object,
83+
default: () => {},
84+
required: true,
85+
},
86+
reshare: {
87+
type: Share,
88+
default: null,
89+
},
90+
canReshare: {
91+
type: Boolean,
92+
required: true,
93+
},
94+
},
95+
96+
data() {
97+
return {
98+
config: new Config(),
99+
loading: false,
100+
query: '',
101+
recommendations: [],
102+
ShareSearch: OCA.Sharing.ShareSearch.state,
103+
suggestions: [],
104+
value: null,
105+
}
106+
},
107+
108+
computed: {
109+
inputPlaceholder() {
110+
if (!this.canReshare) {
111+
return t('files_sharing', 'Resharing is not allowed')
112+
}
113+
114+
if (getCapabilities().files_sharing.public.enabled !== true) {
115+
return t('files_sharing', 'Federated Cloud ID …')
116+
}
117+
118+
return t('files_sharing', 'Email, or Federated Cloud ID …')
119+
},
120+
121+
isValidQuery() {
122+
return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength
123+
},
124+
125+
options() {
126+
if (this.isValidQuery) {
127+
return this.suggestions
128+
}
129+
return this.recommendations
130+
},
131+
132+
noResultText() {
133+
if (this.loading) {
134+
return t('files_sharing', 'Searching …')
135+
}
136+
return t('files_sharing', 'No elements found.')
137+
},
138+
},
139+
140+
mounted() {
141+
this.getRecommendations()
142+
},
143+
144+
methods: {
145+
onSelected(option) {
146+
this.value = null // Reset selected option
147+
this.openSharingDetails(option)
148+
},
149+
150+
async asyncFind(query) {
151+
// save current query to check if we display
152+
// recommendations or search results
153+
this.query = query.trim()
154+
if (this.isValidQuery) {
155+
// start loading now to have proper ux feedback
156+
// during the debounce
157+
this.loading = true
158+
await this.debounceGetSuggestions(query)
159+
}
160+
},
161+
162+
/**
163+
* Get suggestions
164+
*
165+
* @param {string} search the search query
166+
*/
167+
async getSuggestions(search) {
168+
this.loading = true
169+
170+
const lookup = getCapabilities().files_sharing.sharee.query_lookup_default === true;
171+
172+
let request = null
173+
try {
174+
request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
175+
params: {
176+
format: 'json',
177+
itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file',
178+
search,
179+
lookup,
180+
perPage: this.config.maxAutocompleteResults,
181+
shareType: externalAllowed,
182+
},
183+
})
184+
} catch (error) {
185+
console.error('Error fetching suggestions', error)
186+
return
187+
}
188+
189+
const data = request.data.ocs.data
190+
const exact = request.data.ocs.data.exact
191+
data.exact = [] // removing exact from general results
192+
193+
// flatten array of arrays
194+
const rawExactSuggestions = Object.values(exact).flat()
195+
const rawSuggestions = Object.values(data).flat()
196+
197+
const shouldAlwaysShowUnique = this.config.shouldAlwaysShowUnique
198+
199+
// remove invalid data and format to user-select layout
200+
const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
201+
.map(share => formatForMultiselect(share, shouldAlwaysShowUnique))
202+
// sort by type so we can get user&groups first...
203+
.sort((a, b) => a.shareType - b.shareType)
204+
const suggestions = this.filterOutExistingShares(rawSuggestions)
205+
.map(share => formatForMultiselect(share, shouldAlwaysShowUnique))
206+
// sort by type so we can get user&groups first...
207+
.sort((a, b) => a.shareType - b.shareType)
208+
209+
// lookup clickable entry
210+
// show if enabled and not already requested
211+
const lookupEntry = []
212+
if (data.lookupEnabled && !lookup) {
213+
lookupEntry.push({
214+
id: 'global-lookup',
215+
isNoUser: true,
216+
displayName: t('files_sharing', 'Search globally'),
217+
lookup: true,
218+
})
219+
}
220+
221+
const allSuggestions = exactSuggestions.concat(suggestions).concat(lookupEntry)
222+
223+
// Count occurrences of display names in order to provide a distinguishable description if needed
224+
const nameCounts = allSuggestions.reduce((nameCounts, result) => {
225+
if (!result.displayName) {
226+
return nameCounts
227+
}
228+
if (!nameCounts[result.displayName]) {
229+
nameCounts[result.displayName] = 0
230+
}
231+
nameCounts[result.displayName]++
232+
return nameCounts
233+
}, {})
234+
235+
this.suggestions = allSuggestions.map(item => {
236+
// Make sure that items with duplicate displayName get the shareWith applied as a description
237+
if (nameCounts[item.displayName] > 1 && !item.desc) {
238+
return { ...item, desc: item.shareWithDisplayNameUnique }
239+
}
240+
return item
241+
})
242+
243+
this.loading = false
244+
console.info('suggestions', this.suggestions)
245+
},
246+
247+
/**
248+
* Debounce getSuggestions
249+
*
250+
* @param {...*} args the arguments
251+
*/
252+
debounceGetSuggestions: debounce(function(...args) {
253+
this.getSuggestions(...args)
254+
}, 300),
255+
256+
/**
257+
* Get the sharing recommendations
258+
*/
259+
async getRecommendations() {
260+
this.loading = true
261+
262+
let request = null
263+
try {
264+
request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), {
265+
params: {
266+
format: 'json',
267+
itemType: this.fileInfo.type,
268+
shareType: externalAllowed
269+
},
270+
})
271+
} catch (error) {
272+
console.error('Error fetching external share recommendations', error)
273+
return
274+
}
275+
276+
const shouldAlwaysShowUnique = this.config.shouldAlwaysShowUnique
277+
278+
const rawRecommendations = Object.values(request.data.ocs.data.exact).flat()
279+
280+
// remove invalid data and format to user-select layout
281+
this.recommendations = this.filterOutExistingShares(rawRecommendations)
282+
.map(share => formatForMultiselect(share, shouldAlwaysShowUnique));
283+
284+
this.loading = false
285+
console.info('external recommendations', this.recommendations)
286+
},
287+
288+
/**
289+
* Filter out existing shares from
290+
* the provided shares search results
291+
*
292+
* @param {object[]} shares the array of shares object
293+
* @return {object[]}
294+
*/
295+
filterOutExistingShares(shares) {
296+
console.log("external: filterOutExistingShares()", shares);
297+
return shares.reduce((arr, share) => {
298+
// only check proper objects
299+
if (typeof share !== 'object') {
300+
return arr
301+
}
302+
303+
const shareType = share.value.shareType
304+
305+
try {
306+
// Here we care only about external share types
307+
if (!externalShareTypes.includes(shareType)) {
308+
return arr
309+
}
310+
311+
// filter out existing mail shares
312+
if (shareType === ShareType.Email) {
313+
const emails = this.linkShares.map(elem => elem.shareWith)
314+
if (emails.indexOf(share.value.shareWith.trim()) !== -1) {
315+
return arr
316+
}
317+
} else { // filter out existing shares
318+
console.log("non-email share with (shareWith): ", this.shares)
319+
// creating an object of uid => type
320+
const sharesObj = this.shares.reduce((obj, elem) => {
321+
obj[elem.shareWith.trim()] = elem.type
322+
return obj
323+
}, {})
324+
325+
// if shareWith is the same and the share type too, ignore it
326+
const key = share.value.shareWith.trim()
327+
if (key in sharesObj
328+
&& sharesObj[key] === shareType) {
329+
return arr
330+
}
331+
}
332+
333+
// ALL GOOD
334+
// let's add the suggestion
335+
arr.push(share)
336+
} catch (e) {
337+
return arr
338+
}
339+
return arr
340+
}, [])
341+
},
342+
},
343+
}
344+
</script>
345+
346+
<style lang="scss">
347+
.sharing-search {
348+
display: flex;
349+
flex-direction: column;
350+
margin-bottom: 4px;
351+
352+
label[for="sharing-search-external-input"] {
353+
margin-bottom: 2px;
354+
}
355+
356+
&__input {
357+
width: 100%;
358+
margin: 10px 0;
359+
}
360+
}
361+
362+
.vs__dropdown-menu {
363+
// properly style the lookup entry
364+
span[lookup] {
365+
.avatardiv {
366+
background-image: var(--icon-search-white);
367+
background-repeat: no-repeat;
368+
background-position: center;
369+
background-color: var(--color-text-maxcontrast) !important;
370+
.avatardiv__initials-wrapper {
371+
display: none;
372+
}
373+
}
374+
}
375+
}
376+
</style>

0 commit comments

Comments
 (0)