diff options
20 files changed, 501 insertions, 456 deletions
diff --git a/css/ContactDetailsLayout.scss b/css/ContactDetailsLayout.scss index a4c0236b..5fbb45c1 100644 --- a/css/ContactDetailsLayout.scss +++ b/css/ContactDetailsLayout.scss @@ -20,12 +20,6 @@ * */ -$contact-details-label-min-width: 100px; $contact-details-label-max-width: 200px; -$contact-details-label-width: calc(($contact-details-label-min-width + $contact-details-label-max-width) / 2); - -$contact-details-value-min-width: 200px; $contact-details-value-max-width: 300px; -$contact-details-value-width: calc(($contact-details-value-min-width + $contact-details-value-max-width) / 2); - $contact-details-row-gap: 15px; diff --git a/css/Properties/Properties.scss b/css/Properties/Properties.scss index 58cea711..9bd8e99a 100644 --- a/css/Properties/Properties.scss +++ b/css/Properties/Properties.scss @@ -23,13 +23,8 @@ @import '../ContactDetailsLayout.scss'; -$property-label-min-width: $contact-details-label-min-width; $property-label-max-width: $contact-details-label-max-width; -$property-label-width: $contact-details-label-width; - -$property-value-min-width: $contact-details-value-min-width; $property-value-max-width: $contact-details-value-max-width; -$property-value-width: $contact-details-value-width; $property-ext-padding-right: 8px; $property-row-gap: $contact-details-row-gap; @@ -42,13 +37,6 @@ $property-row-gap: $contact-details-row-gap; display: flex; align-items: center; gap: $property-row-gap; - - .property__actions { - // placeholder for the actions menu - &__empty { - width: 44px; - } - } } // property row @@ -57,12 +45,6 @@ $property-row-gap: $contact-details-row-gap; align-items: center; gap: $property-row-gap; - &--without-actions { - .property__value { - margin-right: $property-row-gap + 44px; // actions menu / button - } - } - // fix default margin from server stylesheet causing slight misalignment input { margin-right: 0; @@ -71,88 +53,89 @@ $property-row-gap: $contact-details-row-gap; // property label or multiselect within row &__label { + // Global single column layout display: flex; - justify-content: end; - flex: 1 auto; - - width: $property-label-width !important; - min-width: $property-label-min-width !important; - max-width: $property-label-max-width !important; + flex: 0 1 auto; + justify-content: flex-end; + width: $contact-details-label-max-width; + min-width: 0; // Has to be zero unless we implement wrapping - &:not(.multiselect) { + // Text label styling + > :not(.multiselect):not(.material-design-icon) { overflow: hidden; - overflow-x: hidden; white-space: nowrap; text-overflow: ellipsis; opacity: .7; } } - // Hide delete buttons initially - .action-item.icon-delete { - opacity: 0; - } - // Property value within row, after label &__value { - flex: 1 auto; + align-items: center; - width: $property-value-width !important; - //min-width: $property-value-min-width !important; - min-width: unset !important; - max-width: $property-value-max-width !important; + // Global single column layout + display: flex; + flex: 0 1 auto; + width: $property-value-max-width; + min-width: 0; // Has to be zero unless we implement wrapping textarea { - align-self: flex-start; + // Limit max height to make scrolling the form a bit easier min-height: 2 * $grid-height-unit - 2*$grid-input-margin; max-height: 5 * $grid-height-unit - 2*$grid-input-margin; } - // read-only mode - &:read-only { - border-color: var(--color-border-dark); + input, + textarea { + width: 100%; + + // Remove default input styling for read-only inputs. + // We can't use plain divs because that would cause jumping on switching modes. + &[readonly] { + border: none; + overflow: auto; + outline: none; + resize: none; + padding: 0; + border-radius: 0; + } } - &--with-ext { - // ext icon width + 8px padding - padding-right: 24px; + } + + &__label, + &__value { + // Fix default multiselect styling + > .multiselect { + width: 100%; + min-width: unset; } - // Show ext icon permanently on focus - &:hover, - &:focus, - &:active { - ~ .property__ext { - opacity: .5; - } + // Fix default date time picker styling + > .mx-datepicker { + width: 100%; } } // Show ext buttons on full row hover &:hover { .property__ext { - opacity: .5; + opacity: .7; } } // External link (tel, mailto, http, ftp...) &__ext { - position: absolute; - // 8px padding + actions - right: 44px + $property-ext-padding-right; opacity: 0; + &:hover, - &:focus, - &:active { + &:focus { opacity: .7; } } - .no-move { - right: $property-ext-padding-right; - } - // Delete property button + actions &__actions { z-index: 10; + width: 44px; } } diff --git a/css/contacts.scss b/css/contacts.scss index 60e0513f..116d943a 100644 --- a/css/contacts.scss +++ b/css/contacts.scss @@ -38,17 +38,19 @@ $grid-input-height-with-margin: $grid-height-unit - $grid-input-margin * 2; // various @import 'animations'; - - .app-content-details { padding: calc(var(--default-grid-baseline) * 2); padding-top: 0; height: 100%; overflow: auto; + + // Compensate top padding reserved for the back button on mobile + @media (max-width: 1024px) { + height: calc(100% - 44px); + } } .app-content-list { // Cancel scrolling overflow: visible; - } diff --git a/package-lock.json b/package-lock.json index dd32914b..7437f8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,6 @@ "ical.js": "^1.5.0", "moment": "^2.29.4", "p-limit": "^4.0.0", - "p-queue": "^7.3.4", "qr-image": "^3.2.0", "string-natural-compare": "^3.0.1", "uuid": "^9.0.0", @@ -8268,7 +8267,9 @@ }, "node_modules/eventemitter3": { "version": "4.0.7", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "peer": true }, "node_modules/events": { "version": "3.3.0", @@ -13386,21 +13387,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-queue": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.3.4.tgz", - "integrity": "sha512-esox8CWt0j9EZECFvkFl2WNPat8LN4t7WWeXq73D9ha0V96qPRufApZi4ZhPwXAln1uVVal429HVVKPa2X0yQg==", - "dependencies": { - "eventemitter3": "^4.0.7", - "p-timeout": "^5.0.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -13415,17 +13401,6 @@ "node": ">=8" } }, - "node_modules/p-timeout": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", - "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", @@ -23937,7 +23912,9 @@ }, "eventemitter3": { "version": "4.0.7", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "peer": true }, "events": { "version": "3.3.0", @@ -27631,15 +27608,6 @@ } } }, - "p-queue": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.3.4.tgz", - "integrity": "sha512-esox8CWt0j9EZECFvkFl2WNPat8LN4t7WWeXq73D9ha0V96qPRufApZi4ZhPwXAln1uVVal429HVVKPa2X0yQg==", - "requires": { - "eventemitter3": "^4.0.7", - "p-timeout": "^5.0.2" - } - }, "p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -27651,11 +27619,6 @@ "retry": "^0.13.1" } }, - "p-timeout": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", - "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==" - }, "p-try": { "version": "2.2.0", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", diff --git a/package.json b/package.json index 1e153fec..9633dce0 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "ical.js": "^1.5.0", "moment": "^2.29.4", "p-limit": "^4.0.0", - "p-queue": "^7.3.4", "qr-image": "^3.2.0", "string-natural-compare": "^3.0.1", "uuid": "^9.0.0", diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index ad38b55b..5ccfcad1 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -32,12 +32,15 @@ </template> </EmptyContent> + <!-- TODO: add empty content while this.loadingData === true --> + <template v-else> <!-- contact header --> <DetailsHeader> <!-- avatar and upload photo --> <ContactAvatar slot="avatar" :contact="contact" + :is-read-only="isReadOnly" @update-local-contact="updateLocalContact" /> <!-- fullname --> @@ -55,7 +58,6 @@ autocorrect="off" spellcheck="false" name="fullname" - @input="debounceUpdateContact" @click="selectInput"> </template> @@ -72,8 +74,7 @@ autocomplete="off" autocorrect="off" spellcheck="false" - name="title" - @input="debounceUpdateContact"> + name="title"> <input id="contact-org" v-model="contact.org" :placeholder="t('contacts', 'Company')" @@ -81,23 +82,21 @@ autocomplete="off" autocorrect="off" spellcheck="false" - name="org" - @input="debounceUpdateContact"> + name="org"> </template> </template> <!-- actions --> <template #actions> <!-- warning message --> - <a v-if="loadingUpdate || warning" + <component v-if="warning" v-tooltip.bottom="{ content: warning ? warning.msg : '', trigger: 'hover focus' }" - :class="{'icon-loading-small': loadingUpdate, - [`${warning.icon}`]: warning}" + :is="warning.icon" class="header-icon" - @click="onWarningClick" /> + :classes="warning.classes" /> <!-- conflict message --> <div v-if="conflict" @@ -118,6 +117,28 @@ }" class="header-icon header-icon--pulse icon-up" @click="updateContact" /> + + <!-- edit and save buttons --> + <template v-if="!addressbookIsReadOnly"> + <NcButton v-if="!editMode" + type="tertiary" + @click="editMode = true"> + <template #icon> + <PencilIcon :size="20" /> + </template> + {{ t('contacts', 'Edit') }} + </NcButton> + <NcButton v-else + type="primary" + :disabled="loadingUpdate" + @click="onSave"> + <template #icon> + <IconLoading v-if="loadingUpdate" :size="20" /> + <CheckIcon v-else :size="20" /> + </template> + {{ t('contacts', 'Save') }} + </NcButton> + </template> </template> <!-- menu actions --> @@ -153,7 +174,8 @@ </template> {{ excludeFromBirthdayLabel }} </ActionButton> - <ActionButton v-if="!isReadOnly" @click="deleteContact"> + <ActionButton v-if="!addressbookIsReadOnly" + @click="deleteContact"> <template #icon> <IconDelete :size="20" /> </template> @@ -203,8 +225,6 @@ <section v-else class="contact-details"> <!-- properties iteration --> <!-- using contact.key in the key and index as key to avoid conflicts between similar data and exact key --> - <!-- passing the debounceUpdateContact so that the contact-property component contains the function - and allow us to use it on the rfcProps since the scope is forwarded to the actions --> <div v-for="(properties, name) in groupedProperties" :key="name"> <ContactDetailsProperty v-for="(property, index) in properties" @@ -214,9 +234,9 @@ :property="property" :contact="contact" :local-contact="localContact" - :update-contact="debounceUpdateContact" :contacts="contacts" - :bus="bus" /> + :bus="bus" + :is-read-only="isReadOnly" /> </div> <!-- addressbook change select - no last property because class is not applied here, @@ -235,7 +255,7 @@ <!-- Groups always visible --> <PropertyGroups :prop-model="groupsModel" - :value.sync="groups" + :value.sync="localContact.groups" :contact="contact" :is-read-only="isReadOnly" class="property--groups property--last" /> @@ -255,9 +275,6 @@ <script> import { showError } from '@nextcloud/dialogs' import { stringify } from 'ical.js' -import debounce from 'debounce' -// eslint-disable-next-line import/no-unresolved, n/no-missing-import -import PQueue from 'p-queue' import qr from 'qr-image' import Vue from 'vue' @@ -269,11 +286,16 @@ import IconContact from 'vue-material-design-icons/AccountMultiple.vue' import Modal from '@nextcloud/vue/dist/Components/NcModal.js' import Multiselect from '@nextcloud/vue/dist/Components/NcMultiselect.js' import IconLoading from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' import IconDownload from 'vue-material-design-icons/Download.vue' import IconDelete from 'vue-material-design-icons/Delete.vue' import IconQr from 'vue-material-design-icons/Qrcode.vue' import CakeIcon from 'vue-material-design-icons/Cake.vue' import IconCopy from 'vue-material-design-icons/ContentCopy.vue' +import PencilIcon from 'vue-material-design-icons/Pencil.vue' +import CheckIcon from 'vue-material-design-icons/Check.vue' +import AlertCircleIcon from 'vue-material-design-icons/AlertCircle.vue' +import EyeCircleIcon from 'vue-material-design-icons/EyeCircle.vue' import rfcProps from '../models/rfcProps.js' import validate from '../services/validate.js' @@ -286,8 +308,6 @@ import PropertyGroups from './Properties/PropertyGroups.vue' import PropertyRev from './Properties/PropertyRev.vue' import PropertySelect from './Properties/PropertySelect.vue' -const updateQueue = new PQueue({ concurrency: 1 }) - export default { name: 'ContactDetails', @@ -307,11 +327,14 @@ export default { CakeIcon, IconCopy, IconLoading, + PencilIcon, + CheckIcon, Modal, Multiselect, PropertyGroups, PropertyRev, PropertySelect, + NcButton, }, props: { @@ -338,10 +361,10 @@ export default { localContact: undefined, loadingData: true, loadingUpdate: false, - openedMenu: false, qrcode: '', showPickAddressbookModal: false, pickedAddressbook: null, + editMode: false, contactDetailsSelector: '.contact-details', excludeFromBirthdayKey: 'x-nc-exclude-from-birthday-calendar', @@ -352,11 +375,22 @@ export default { }, computed: { + /** + * The address book is read-only (e.g. shared with me). + * + * @return {boolean} + */ + addressbookIsReadOnly() { + return this.contact.addressbook?.readOnly + }, + + /** + * The address book is read-only or the contact is in read-only mode. + * + * @return {boolean} + */ isReadOnly() { - if (this.contact.addressbook) { - return this.contact.addressbook.readOnly - } - return false + return this.addressbookIsReadOnly || !this.editMode }, /** @@ -367,12 +401,14 @@ export default { warning() { if (!this.contact.dav) { return { - icon: 'icon-error header-icon--pulse', + icon: AlertCircleIcon, + classes: ['header-icon--pulse'], msg: t('contacts', 'This contact is not yet synced. Edit it to save it to the server.'), } - } else if (this.isReadOnly) { + } else if (this.addressbookIsReadOnly) { return { - icon: 'icon-eye', + icon: EyeCircleIcon, + classes: [], msg: t('contacts', 'This contact is in read-only mode. You do not have permission to edit this contact.'), } } @@ -473,22 +509,6 @@ export default { }, /** - * Usable groups object linked to the local contact - * - * @param {string[]} data An array of groups - * @return {Array} - */ - groups: { - get() { - return this.contact.groups - }, - set(data) { - this.contact.groups = data - this.debounceUpdateContact() - }, - }, - - /** * Store getters filtered and mapped to usable object * This is the list of addressbooks that are available * @@ -579,8 +599,11 @@ export default { async updateContact() { this.fixed = false this.loadingUpdate = true - await this.$store.dispatch('updateContact', this.localContact) - this.loadingUpdate = false + try { + await this.$store.dispatch('updateContact', this.localContact) + } finally { + this.loadingUpdate = false + } // if we just created the contact, we need to force update the // localContact to match the proper store contact @@ -593,14 +616,6 @@ export default { }, /** - * Debounce the contact update for the header props - * photo, fn, org, title - */ - debounceUpdateContact: debounce(function(e) { - updateQueue.add(this.updateContact) - }, 500), - - /** * Generate a qrcode for the contact */ showQRcode() { @@ -642,6 +657,7 @@ export default { */ async selectContact(key) { this.loadingData = true + this.editMode = false // local version of the contact const contact = this.$store.getters.getContact(key) @@ -668,6 +684,9 @@ export default { } else { // clone to a local editable variable await this.updateLocalContact(contact) + + // enable edit mode by default when creating a new contact + this.editMode = true } } @@ -780,9 +799,13 @@ export default { }, onCtrlSave(e) { + if (!this.editMode) { + return + } + if (e.keyCode === 83 && (navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey)) { e.preventDefault() - this.debounceUpdateContact() + this.onSave() } }, @@ -809,20 +832,6 @@ export default { }, /** - * The user clicked the warning icon - */ - onWarningClick() { - // if the user clicked the readonly icon, let's focus the clone button - if (this.isReadOnly && this.addressbooksOptions.length > 0) { - this.openedMenu = true - this.$nextTick(() => { - // focus the clone button - this.$refs.actions.onMouseFocusAction({ target: this.$refs.cloneAction.$el }) - }) - } - }, - - /** * Should display the property * * @param {Property} property the property to check @@ -838,6 +847,14 @@ export default { return propModel && propType !== 'unknown' }, + + /** + * Save the contact. This handler is triggered by the save button. + */ + async onSave() { + await this.updateContact() + this.editMode = false + } }, } </script> @@ -848,16 +865,6 @@ section.contact-details { display: flex; flex-direction: column; gap: 40px; - - .property--rev { - position: absolute; - left: 125px; - bottom: -25px; - height: 44px; - opacity: .5; - color: var(--color-text-lighter); - line-height: 44px; - } } #qrcode-modal { diff --git a/src/components/ContactDetails/ContactDetailsAddNewProp.vue b/src/components/ContactDetails/ContactDetailsAddNewProp.vue index 8ff2df2d..e49c16df 100644 --- a/src/components/ContactDetails/ContactDetailsAddNewProp.vue +++ b/src/components/ContactDetails/ContactDetailsAddNewProp.vue @@ -22,7 +22,7 @@ --> <template> - <div class="property__row property__row--without-actions"> + <div class="property__row"> <!-- Dummy label to keep the layout --> <div class="property__label" /> @@ -78,6 +78,9 @@ </template> </Actions> </div> + + <!-- Dummy actions to keep the layout --> + <div class="property__actions" /> </div> </template> diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue index dc7de849..de5746ba 100644 --- a/src/components/ContactDetails/ContactDetailsAvatar.vue +++ b/src/components/ContactDetails/ContactDetailsAvatar.vue @@ -4,6 +4,7 @@ - @author Team Popcorn <teampopcornberlin@gmail.com> - @author John Molakvoæ <skjnldsv@protonmail.com> - @author Matthias Heinisch <nextcloud@matthiasheinisch.de> + - @author Richard Steinmetz <richard@steinmetz.cloud> - - @license GNU AGPL version 3 or any later version - @@ -159,6 +160,10 @@ export default { type: Object, required: true, }, + isReadOnly: { + type: Boolean, + required: true, + }, }, data() { @@ -183,12 +188,6 @@ export default { }, computed: { - isReadOnly() { - if (this.contact.addressbook) { - return this.contact.addressbook.readOnly - } - return false - }, supportedSocial() { const emails = this.contact.vCard.getAllProperties('email') // get social networks set for the current contact diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue index e9282ddf..eed4c291 100644 --- a/src/components/ContactDetails/ContactDetailsProperty.vue +++ b/src/components/ContactDetails/ContactDetailsProperty.vue @@ -2,6 +2,7 @@ - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Richard Steinmetz <richard@steinmetz.cloud> - - @license GNU AGPL version 3 or any later version - @@ -41,9 +42,7 @@ :is-read-only="isReadOnly" :bus="bus" :is-multiple="isMultiple" - @delete="onDelete" - @resize="onResize" - @update="updateContact" /> + @delete="onDelete" /> </template> <script> @@ -93,14 +92,6 @@ export default { type: Contact, default: null, }, - /** - * This is needed so that we can update - * the contact within the rfcProps actions - */ - updateContact: { - type: Function, - default: () => {}, - }, contacts: { type: Array, default: () => [], @@ -109,6 +100,10 @@ export default { type: Object, required: true, }, + isReadOnly: { + type: Boolean, + required: true, + } }, computed: { @@ -140,12 +135,6 @@ export default { isMultiple() { return this.properties[this.property.name].multiple }, - isReadOnly() { - if (this.contact.addressbook) { - return this.contact.addressbook.readOnly - } - return false - }, /** * Return the type of the prop e.g. FN @@ -293,6 +282,11 @@ export default { return null }, set(data) { + // Skip setting type if select is cleared + if (!data) { + return + } + // if a custom label exists and this is the one we selected if (this.propLabel && data.id === this.propLabel.name) { this.propLabel.setValue(data.name) @@ -315,7 +309,6 @@ export default { |