-
Notifications
You must be signed in to change notification settings - Fork 28
Replace the 'new collective' form with a modal #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
0e8a9c1
Move form for a new collective into a Modal
mejo- cc37c39
Add member picker for new collective modal
mejo- 5a17bef
Add EmptyContent to first step of the dialog
mejo- 47d4e86
Allow to advance to next step by pressing return
mejo- 363b23e
Fix scrolling and cropped bottom in member search results
mejo- cc45245
Address more design review feedback
mejo- b3f653f
Adjust Cypress tests to use new dialog for new collective
mejo- 086c996
Cypress: adjust request timeout when leaving a collective
mejo- 7db9554
Address review feedback
mejo- eca453b
Cypress: Add test for picking members and fix create collective tests
mejo- File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Add member picker for new collective modal
Fixes: #464 Signed-off-by: Jonas <[email protected]>
- Loading branch information
commit cc37c393c475a7faccb0397bca69ef7bcc59c2a6
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,338 @@ | ||
| <template> | ||
| <div class="member-picker"> | ||
| <!-- Search --> | ||
| <NcTextField ref="memberSearch" | ||
| :value.sync="searchQuery" | ||
| type="text" | ||
| :show-trailing-button="searchQuery !== ''" | ||
| :label="t('collectives', 'Search users, groups, circles…')" | ||
| @trailing-button-click="clearSearch" | ||
| @input="onSearch"> | ||
| <MagnifyIcon :size="16" /> | ||
| </NcTextField> | ||
|
|
||
| <!-- Loading --> | ||
| <NcEmptyContent v-if="membersLoading" :title="t('collectives', 'Loading …')"> | ||
| <template #icon> | ||
| <NcLoadingIcon :size="20" /> | ||
| </template> | ||
| </NcEmptyContent> | ||
|
|
||
| <template v-else> | ||
| <!-- Selected members --> | ||
| <transition-group v-if="hasSelectedMembers" | ||
| name="zoom" | ||
| tag="div" | ||
| class="selected-members"> | ||
| <NcUserBubble v-for="member in selectionSet" | ||
| :key="member.key || `member-${member.type}-${member.id}`" | ||
| :margin="0" | ||
| :size="22" | ||
| :display-name="member.label" | ||
| class="selected-member-bubble"> | ||
| <template #title> | ||
| <a href="#" | ||
| :title="t('collectives', 'Remove {type} {name}', { type: member.type, name: member.label })" | ||
| class="selected-member-bubble-delete" | ||
| @click="deleteMember(member)"> | ||
| <CloseIcon :size="16" /> | ||
| </a> | ||
| </template> | ||
| </NcUserBubble> | ||
| </transition-group> | ||
|
|
||
| <!-- No search yet --> | ||
| <NcEmptyContent v-if="!searchQuery" | ||
| :title="t('collectives', 'Search for members to add')" | ||
| class="empty-content"> | ||
| <template #icon> | ||
| <MagnifyIcon :size="20" /> | ||
| </template> | ||
| </NcEmptyContent> | ||
|
|
||
| <!-- Searched and picked members --> | ||
| <div v-else-if="availableEntities.length > 0" | ||
| class="search-results"> | ||
| <MemberSearchResult v-for="entity in availableEntities" | ||
| :key="entity.id" | ||
| :entity="entity" | ||
| :is-selected="entity.id in selectionSet" | ||
| @click="onClickMember" /> | ||
| </div> | ||
|
|
||
| <!-- No results --> | ||
| <NcEmptyContent v-else | ||
| :title="t('collectives', 'No results')" | ||
| class="empty-content"> | ||
| <template #icon> | ||
| <MagnifyIcon :size="20" /> | ||
| </template> | ||
| </NcEmptyContent> | ||
| </template> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script> | ||
| import axios from '@nextcloud/axios' | ||
| import debounce from 'debounce' | ||
| import { pickerTypeGrouping, shareTypes } from '../../constants.js' | ||
| import { generateOcsUrl } from '@nextcloud/router' | ||
| import { showError } from '@nextcloud/dialogs' | ||
| import { NcEmptyContent, NcLoadingIcon, NcTextField, NcUserBubble } from '@nextcloud/vue' | ||
| import CloseIcon from 'vue-material-design-icons/Close.vue' | ||
| import MagnifyIcon from 'vue-material-design-icons/Magnify.vue' | ||
| import MemberSearchResult from '../Member/MemberSearchResult.vue' | ||
|
|
||
| export default { | ||
| name: 'MemberPicker', | ||
|
|
||
| components: { | ||
| CloseIcon, | ||
| MagnifyIcon, | ||
| MemberSearchResult, | ||
| NcEmptyContent, | ||
| NcLoadingIcon, | ||
| NcTextField, | ||
| NcUserBubble, | ||
| }, | ||
|
|
||
| props: { | ||
| selectionSet: { | ||
| type: Object, | ||
| default() { | ||
| return {} | ||
| }, | ||
| }, | ||
| }, | ||
|
|
||
| data() { | ||
| return { | ||
| searchQuery: '', | ||
| searchResults: [], | ||
| membersLoading: false, | ||
| } | ||
| }, | ||
|
|
||
| computed: { | ||
| hasSelectedMembers() { | ||
| return this.selectionSet.length !== 0 | ||
| }, | ||
|
|
||
| /** | ||
| * Returns available entities grouped by types | ||
| */ | ||
| availableEntities() { | ||
| return pickerTypeGrouping.map(type => { | ||
| const dataSet = this.searchResults.filter(entity => entity.typeId === type.id) | ||
| const dataList = [ | ||
| { | ||
| id: type.id, | ||
| label: type.label, | ||
| heading: true, | ||
| }, | ||
| ...dataSet, | ||
| ] | ||
|
|
||
| // If no results, hide the type | ||
| if (dataSet.length === 0) { | ||
| return [] | ||
| } | ||
|
|
||
| return dataList | ||
| }).flat() | ||
| }, | ||
| }, | ||
|
|
||
| mounted() { | ||
| this.$nextTick(() => { | ||
| this.$refs.memberSearch.$el.getElementsByTagName('input')[0]?.focus() | ||
| }) | ||
| }, | ||
|
|
||
| methods: { | ||
| clearSearch() { | ||
| this.searchQuery = '' | ||
| }, | ||
|
|
||
| async fetchSearchResults() { | ||
| // Search for users, groups and circles | ||
| const shareType = [shareTypes.TYPE_USER, shareTypes.TYPE_GROUP, shareTypes.TYPE_CIRCLE] | ||
| const maxAutocompleteResults = parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 25 | ||
|
|
||
| let response = null | ||
| try { | ||
| this.membersLoading = true | ||
| response = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), { | ||
| params: { | ||
| format: 'json', | ||
| itemType: 'file', | ||
| search: this.searchQuery, | ||
| perPage: maxAutocompleteResults, | ||
| shareType, | ||
| lookup: false, | ||
| }, | ||
| }) | ||
| this.membersLoading = false | ||
| } catch (e) { | ||
| console.error(e) | ||
| showError(t('collectives', 'An error occured while performing the search')) | ||
| this.membersLoading = false | ||
| return [] | ||
| } | ||
|
|
||
| const data = response.data.ocs.data | ||
| const exact = response.data.ocs.data.exact | ||
| data.exact = [] // removing exact from general results | ||
|
|
||
| // flatten array of arrays | ||
| const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) | ||
| const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), []) | ||
|
|
||
| // remove invalid data and format to user-select layout | ||
| const exactSuggestions = rawExactSuggestions | ||
| .filter(result => typeof result === 'object') | ||
| .map(share => this.formatResults(share)) | ||
| // sort by type so we can get user&groups first... | ||
| .sort((a, b) => a.shareType - b.shareType) | ||
| const suggestions = rawSuggestions | ||
| .filter(result => typeof result === 'object') | ||
| .map(share => this.formatResults(share)) | ||
| // sort by type so we can get user&groups first... | ||
| .sort((a, b) => a.shareType - b.shareType) | ||
|
|
||
| const allSuggestions = exactSuggestions.concat(suggestions) | ||
|
|
||
| // Count occurances of display names in order to provide a distinguishable description if needed | ||
| const nameCounts = allSuggestions.reduce((nameCounts, result) => { | ||
| if (!result.displayName) { | ||
| return nameCounts | ||
| } | ||
mejo- marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if (!nameCounts[result.displayName]) { | ||
| nameCounts[result.displayName] = 0 | ||
| } | ||
| nameCounts[result.displayName]++ | ||
| return nameCounts | ||
| }, {}) | ||
|
|
||
| const finalResults = allSuggestions.map(item => { | ||
| // Make sure that items with duplicate displayName get the shareWith applied as a description | ||
| if (nameCounts[item.displayName] > 1 && !item.desc) { | ||
| return { ...item, desc: item.shareWithDisplayNameUnique } | ||
| } | ||
| return item | ||
| }) | ||
|
|
||
| this.searchResults = finalResults | ||
| }, | ||
|
|
||
| formatResults(result) { | ||
| const type = pickerTypeGrouping.find(t => t.share === result.value.shareType).type | ||
| const typeId = `picker-${result.value.shareType}` | ||
| return { | ||
| label: result.label, | ||
| id: `${type}-${result.value.shareWith}`, | ||
| // If this is a user, set as user for avatar display by NcUserBubble | ||
| user: [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_REMOTE].indexOf(result.value.shareType) > -1 | ||
| ? result.value.shareWith | ||
| : null, | ||
| type, | ||
| typeId, | ||
| ...result.value, | ||
| } | ||
| }, | ||
|
|
||
| addMember(member) { | ||
| this.$set(this.selectionSet, member.id, member) | ||
| this.$emit('update-selection', this.selectionSet) | ||
| }, | ||
|
|
||
| deleteMember(member) { | ||
| this.$delete(this.selectionSet, member.id, member) | ||
| this.$emit('update-selection', this.selectionSet) | ||
| }, | ||
|
|
||
| onClickMember(member) { | ||
| if (member.id in this.selectionSet) { | ||
| this.deleteMember(member) | ||
| return | ||
| } | ||
| this.addMember(member) | ||
| }, | ||
|
|
||
| onSearch: debounce(function() { | ||
| this.fetchSearchResults() | ||
| }, 250), | ||
| }, | ||
| } | ||
| </script> | ||
|
|
||
| <style lang="scss" scoped> | ||
| .member-picker { | ||
| position: relative; | ||
| display: flex; | ||
| flex-direction: column; | ||
| // TODO: Fix cropped bottom | ||
| height: 100%; | ||
| } | ||
|
|
||
| .selected-members { | ||
| display: flex; | ||
| overflow-y: auto; | ||
| align-content: flex-start; | ||
| flex: 1 0 auto; | ||
| flex-wrap: wrap; | ||
| justify-content: flex-start; | ||
| // Half a line height to know there is more lines | ||
| max-height: 6.5em; | ||
| padding: 4px 0; | ||
| border-bottom: 1px solid var(--color-background-darker); | ||
| background: var(--color-main-background); | ||
|
|
||
| .selected-member-bubble { | ||
| max-width: calc(50% - 4px); | ||
| margin-right: 4px; | ||
| margin-bottom: 4px; | ||
|
|
||
| :deep(.user-bubble__content) { | ||
| align-items: center; | ||
| } | ||
|
|
||
| &-delete { | ||
| display: block; | ||
| margin-right: -4px; | ||
| opacity: .7; | ||
|
|
||
| &:hover, &active, &focus { | ||
| opacity: 1; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| .zoom-enter-active { | ||
| animation: zoom-in var(--animation-quick); | ||
| } | ||
|
|
||
| .zoom-leave-active { | ||
| animation: zoom-in var(--animation-quick) reverse; | ||
| will-change: transform; | ||
| } | ||
|
|
||
| @keyframes zoom-in { | ||
| 0% { | ||
| transform: scale(0); | ||
| } | ||
| 100% { | ||
| transform: scale(1); | ||
| } | ||
| } | ||
|
|
||
| .search-results { | ||
| height: 100%; | ||
| overflow-y: auto; | ||
| } | ||
|
|
||
| .empty-content { | ||
| height: 100%; | ||
| } | ||
| </style> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.