feat(contacts): implement read-only and edit modes

Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
Richard Steinmetz 2023-04-27 16:40:01 +02:00 committed by Christoph Wurst
parent a352bff65f
commit 0972b74411
No known key found for this signature in database
GPG Key ID: CC42AC2A7F0E56D8
20 changed files with 495 additions and 450 deletions

View File

@ -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;

View File

@ -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;
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
width: $property-label-width !important;
min-width: $property-label-min-width !important;
max-width: $property-label-max-width !important;
&: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;
}
}

View File

@ -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;
}

49
package-lock.json generated
View File

@ -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==",

View File

@ -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",

View File

@ -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.'),
}
}
@ -472,22 +508,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
@ -592,14 +615,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
*/
@ -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()
}
},
@ -808,20 +831,6 @@ export default {
this.pickedAddressbook = null
},
/**
* 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
*
@ -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 {

View File

@ -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>

View File

@ -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

View File

@ -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 {
this.property.jCal[0] = this.propGroup[1]
}
}
this.updateContact()
},
},
@ -356,7 +349,6 @@ export default {
this.property.setValue(data)
}
}
this.updateContact()
},
},
@ -418,14 +410,6 @@ export default {
*/
onDelete() {
this.localContact.vCard.removeProperty(this.property)
this.updateContact()
},
/**
* Forward resize event
*/
onResize() {
this.$emit('resize')
},
},
}

View File

@ -24,17 +24,19 @@
<template>
<!-- contact header -->
<header class="contact-header" :style="cssStyle">
<div class="contact-header__avatar">
<slot name="avatar" :avatar-size="avatarSize" />
</div>
<div class="contact-header__no-wrap">
<div class="contact-header__avatar">
<slot name="avatar" :avatar-size="avatarSize" />
</div>
<!-- fullname, org, title -->
<div class="contact-header__infos">
<h2 class="contact-header__infos-title">
<slot name="title" />
</h2>
<div v-if="$slots.subtitle" class="contact-header__infos-subtitle">
<slot name="subtitle" />
<!-- fullname, org, title -->
<div class="contact-header__infos">
<h2 class="contact-header__infos-title">
<slot name="title" />
</h2>
<div v-if="$slots.subtitle" class="contact-header__infos-subtitle">
<slot name="subtitle" />
</div>
</div>
</div>
@ -82,35 +84,50 @@ export default {
<style lang="scss" scoped>
@import '../../css/ContactDetailsLayout.scss';
$top-padding: 50px;
// Header with avatar, name, position, actions...
.contact-header {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 50px 0 20px;
padding: $top-padding 0 20px;
gap: $contact-details-row-gap;
// Top padding of 44px is already included in AppContent by default on mobile
@media (max-width: 1024px) {
padding-top: calc($top-padding - 44px);
}
&__no-wrap {
display: flex;
flex: 9999 1 auto;
gap: $contact-details-row-gap;
align-items: center;
// Wrap actions to next line before shrinking
min-width: 0;
}
// AVATAR
&__avatar {
display: flex;
flex: 1 auto;
justify-content: flex-end;
// Global single column layout
width: $contact-details-label-width;
min-width: $contact-details-label-min-width;
max-width: $contact-details-label-max-width;
display: flex;
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
}
// ORG-TITLE-NAME
&__infos {
display: flex;
flex: 1 auto;
flex-direction: column;
// Global single column layout
width: $contact-details-value-width;
min-width: $contact-details-value-min-width;
max-width: $contact-details-value-max-width;
display: flex;
flex: 0 1 auto;
width: calc($contact-details-value-max-width + $contact-details-row-gap + 44px);
min-width: 0; // Has to be zero unless we implement wrapping
&-title,
&-subtitle {
@ -131,5 +148,12 @@ export default {
max-width: 20%;
}
}
// ACTIONS
&__actions {
display: flex;
flex: 1 0 auto;
justify-content: space-between;
}
}
</style>

View File

@ -2,6 +2,7 @@
- @copyright Copyright (c) 2019 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
-
@ -21,7 +22,7 @@
-->
<template>
<Actions class="property__actions">
<Actions>
<ActionButton @click="deleteProperty">
<template #icon>
<IconDelete :size="20" />

View File

@ -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
-
@ -26,56 +27,60 @@
<PropertyTitle v-if="isFirstProperty && propModel.icon"
:property="property"
:is-multiple="isMultiple"
:is-read-only="isReadOnly"
:bus="bus"
:icon="propModel.icon"
:readable-name="propModel.readableName" />
<div class="property__row">
<!-- type selector -->
<Multiselect v-if="propModel.options"
v-model="localType"
:options="options"
:searchable="false"
:placeholder="t('contacts', 'Select type')"
:disabled="isReadOnly"
class="property__label"
track-by="id"
label="name"
@input="updateType" />
<div class="property__label">
<!-- type selector -->
<Multiselect v-if="propModel.options"
v-model="localType"
:options="options"
:searchable="false"
:placeholder="t('contacts', 'Select type')"
:disabled="isReadOnly"
track-by="id"
label="name"
@input="updateType" />
<!-- if we do not support any type on our model but one is set anyway -->
<div v-else-if="selectType" class="property__label">
{{ selectType.name }}
<!-- if we do not support any type on our model but one is set anyway -->
<span v-else-if="selectType">
{{ selectType.name }}
</span>
<!-- no options, empty space -->
<span v-else>
{{ propModel.readableName }}
</span>
</div>
<!-- no options, empty space -->
<div v-else class="property__label">
{{ propModel.readableName }}
<div class="property__value">
<!-- Real input where the picker shows -->
<DatetimePicker v-if="!isReadOnly"
:value="vcardTimeLocalValue.toJSDate()"
:minute-step="10"
:lang="lang"
:clearable="false"
:first-day-of-week="firstDay"
:type="inputType"
:readonly="isReadOnly"
:formatter="dateFormat"
@change="debounceUpdateValue" />
<input v-else
:readonly="true"
:value="formatDateTime()">
</div>
<!-- Real input where the picker shows -->
<DatetimePicker v-if="!isReadOnly"
:value="vcardTimeLocalValue.toJSDate()"
:minute-step="10"
:lang="lang"
:clearable="false"
:first-day-of-week="firstDay"
:type="inputType"
:readonly="isReadOnly"
:formatter="dateFormat"
class="property__value"
@change="debounceUpdateValue" />
<input v-else
:readonly="true"
:value="formatDateTime()"
class="property__value">
<!-- props actions -->
<PropertyActions v-if="!isReadOnly"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
<div class="property__actions">
<PropertyActions v-if="!isReadOnly"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
</div>
</div>
</div>
</template>

View File

@ -24,36 +24,41 @@
<template>
<div v-if="propModel" class="property">
<PropertyTitle icon="icon-contacts-dark"
:readable-name="t('contacts', 'Contact groups')" />
:readable-name="t('contacts', 'Contact groups')"
:is-read-only="isReadOnly" />
<div class="property__row property__row--without-actions">
<div class="property__row">
<div class="property__label">
{{ propModel.readableName }}
<span>{{ propModel.readableName }}</span>
</div>
<!-- multiselect taggable groups with a limit to 3 groups shown -->
<Multiselect v-model="localValue"
:options="groups"
:placeholder="t('contacts', 'Add contact in group')"
:multiple="true"
:taggable="true"
:close-on-select="false"
:readonly="isReadOnly"
:tag-width="60"
tag-placeholder="create"
class="property__value"
@input="updateValue"
@tag="validateGroup"
@select="addContactToGroup"
@remove="removeContactToGroup">
<!-- show how many groups are hidden and add tooltip -->
<span slot="limit" v-tooltip.auto="formatGroupsTitle" class="multiselect__limit">
+{{ localValue.length - 3 }}
</span>
<span slot="noResult">
{{ t('contacts', 'No results') }}
</span>
</Multiselect>
<div class="property__value">
<Multiselect v-model="localValue"
:options="groups"
:placeholder="placeholder"
:multiple="true"
:taggable="true"
:close-on-select="false"
:disabled="isReadOnly"
:tag-width="60"
tag-placeholder="create"
@input="updateValue"
@tag="validateGroup"
@select="addContactToGroup"
@remove="removeContactToGroup">
<!-- show how many groups are hidden and add tooltip -->
<span slot="limit" v-tooltip.auto="formatGroupsTitle" class="multiselect__limit">
+{{ localValue.length - 3 }}
</span>
<span slot="noResult">
{{ t('contacts', 'No results') }}
</span>
</Multiselect>
</div>
<!-- empty actions to keep the layout -->
<div class="property__actions" />
</div>
</div>
</template>
@ -92,7 +97,7 @@ export default {
// Is it read-only?
isReadOnly: {
type: Boolean,
default: false,
required: true,
},
},
@ -117,6 +122,17 @@ export default {
formatGroupsTitle() {
return this.localValue.slice(3).join(', ')
},
/**
* @return {string}
*/
placeholder() {
if (this.isReadOnly) {
return t('contacts', 'None')
}
return t('contacts', 'Add contact in group')
}
},
watch: {

View File

@ -27,6 +27,7 @@
<PropertyTitle v-if="isFirstProperty && propModel.icon"
:property="property"
:is-multiple="isMultiple"
:is-read-only="isReadOnly"
:bus="bus"
:icon="propModel.icon"
:readable-name="propModel.readableName">
@ -41,65 +42,69 @@
</PropertyTitle>
<div v-if="showActionsInFirstRow" class="property__row">
<!-- type selector -->
<Multiselect v-if="propModel.options"
v-model="localType"
:options="options"
:placeholder="t('contacts', 'Select type')"
:taggable="true"
tag-placeholder="create"
:disabled="isReadOnly"
class="property__label"
track-by="id"
label="name"
@tag="createLabel"
@input="updateType" />
<div class="property__label">
<!-- read-only type because NcMultiselect can't be styled properly -->
<span v-if="isReadOnly && propModel.options">
{{ (localType && localType.name) || '' }}
</span>
<!-- if we do not support any type on our model but one is set anyway -->
<div v-else-if="selectType" class="property__label">
{{ selectType.name }}
</div>
<!-- type selector -->
<Multiselect v-else-if="!isReadOnly && propModel.options"
v-model="localType"
:options="options"
:placeholder="t('contacts', 'Select type')"
:taggable="true"
tag-placeholder="create"
track-by="id"
label="name"
@tag="createLabel"
@input="updateType" />
<!-- no options, and showing the first input of an unstructured value?
<!-- if we do not support any type on our model but one is set anyway -->
<span v-else-if="selectType">
{{ selectType.name }}
</span>
<!-- no options, and showing the first input of an unstructured value?
then let's put an empty space (or the name again if no title is present) -->
<div v-else-if="!property.isStructuredValue" class="property__label">
{{ isFirstProperty ? '' : propModel.readableName }}
<span v-else-if="!property.isStructuredValue">
{{ isFirstProperty ? '' : propModel.readableName }}
</span>
</div>
<!-- or an empty placeholder to keep the layout -->
<div v-else class="property__label" />
<!-- show the first input if not a structured value -->
<input v-if="!property.isStructuredValue"
v-model.trim="localValue[0]"
:readonly="isReadOnly"
class="property__value"
type="text"
@input="updateValue">
<!-- or an empty placeholder to keep the layout -->
<div v-else class="property__value" />
<div class="property__value">
<!-- show the first input if not a structured value -->
<input v-if="!property.isStructuredValue"
v-model.trim="localValue[0]"
:readonly="isReadOnly"
type="text"
@input="updateValue">
</div>
<!-- props actions -->
<PropertyActions v-if="showActionsInFirstRow && !isReadOnly"
class="property__actions"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
<div class="property__actions">
<PropertyActions v-if="showActionsInFirstRow && !isReadOnly"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
</div>
</div>
<!-- force order based on model -->
<template v-if="propModel.displayOrder && propModel.readableValues">
<div v-for="index in propModel.displayOrder"
:key="index"
class="property__row property__row--without-actions">
class="property__row">
<div class="property__label">
{{ propModel.readableValues[index] }}
<span>{{ propModel.readableValues[index] }}</span>
</div>
<input v-model.trim="localValue[index]"
:readonly="isReadOnly"
class="property__value"
type="text"
@input="updateValue">
<div class="property__value">
<input v-model.trim="localValue[index]"
:readonly="isReadOnly"
type="text"
@input="updateValue">
</div>
<div class="property__actions" />
</div>
</template>
@ -107,13 +112,15 @@
<template v-else>
<div v-for="(value, index) in filteredValue"
:key="index"
class="property__row property__row--without-actions">
class="property__row">
<div class="property__label" />
<input v-model.trim="filteredValue[index]"
:readonly="isReadOnly"
class="property__value"
type="text"
@input="updateValue">
<div class="property__value">
<input v-model.trim="filteredValue[index]"
:readonly="isReadOnly"
type="text"
@input="updateValue">
</div>
<div class="property__actions" />
</div>
</template>
</div>
@ -172,3 +179,17 @@ export default {
}
</script>
<style lang="scss" scoped>
.property {
&__label {
&--read-only {
// Prevent jumping of the label when changing edit/view mode
// FIXME: drop forced height if NcMultiselect is migrated to NcSelect and can be
// properly styled as read-only
height: 42px;
line-height: 42px;
}
}
}
</style>

View File

@ -2,6 +2,7 @@
- @copyright Copyright (c) 2019 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
-
@ -21,8 +22,16 @@
-->
<template>
<div class="property--rev">
{{ t('contacts', 'Last modified') }} {{ relativeDate }}
<div class="property property--rev">
<div class="property__row">
<div class="property__label" />
<div class="property__value">
{{ t('contacts', 'Last modified') }} {{ relativeDate }}
</div>
<div class="property__actions" />
</div>
</div>
</template>
@ -46,3 +55,13 @@ export default {
},
}
</script>
<style lang="scss" scoped>
.property {
&__value {
opacity: .5;
color: var(--color-text-lighter);
line-height: 44px;
}
}
</style>

View File

@ -27,36 +27,41 @@
<PropertyTitle v-if="isFirstProperty && propModel.icon"
:property="property"
:is-multiple="isMultiple"
:is-read-only="isReadOnly"
:bus="bus"
:icon="propModel.icon"
:readable-name="propModel.readableName" />
<div class="property__row"
:class="{'property__row--without-actions': isReadOnly || hideActions}">
<!-- if we do not support any type on our model but one is set anyway -->
<div v-if="selectType" class="property__label">
{{ selectType.name }}
<div class="property__row">
<div class="property__label">
<!-- if we do not support any type on our model but one is set anyway -->
<span v-if="selectType">
{{ selectType.name }}
</span>
<!-- no options, empty space -->
<span v-else>
{{ propModel.readableName }}
</span>
</div>
<!-- no options, empty space -->
<div v-else class="property__label">
{{ propModel.readableName }}
<div class="property__value">
<Multiselect v-model="matchedOptions"
:options="selectableOptions"
:placeholder="t('contacts', 'Select option')"
:disabled="isSingleOption || isReadOnly"
track-by="id"
label="name"
@input="updateValue" />
</div>
<Multiselect v-model="matchedOptions"
:options="selectableOptions"
:placeholder="t('contacts', 'Select option')"
:disabled="isSingleOption || isReadOnly"
class="property__value"
track-by="id"
label="name"
@input="updateValue" />
<!-- props actions -->
<PropertyActions v-if="!isReadOnly && !hideActions"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
<div class="property__actions">
<PropertyActions v-if="!isReadOnly && !hideActions"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
</div>
</div>
</div>
</template>

View File

@ -27,69 +27,80 @@
<PropertyTitle v-if="isFirstProperty && propModel.icon"
:property="property"
:is-multiple="isMultiple"
:is-read-only="isReadOnly"
:bus="bus"
:icon="propModel.icon"
:readable-name="propModel.readableName" />
<div class="property__row">
<!-- type selector -->
<Multiselect v-if="propModel.options"
v-model="localType"
:options="options"
:placeholder="t('contacts', 'Select type')"
:taggable="true"
tag-placeholder="create"
:disabled="isReadOnly"
class="property__label"
track-by="id"
label="name"
@tag="createLabel"
@input="updateType" />
<div class="property__label">
<!-- read-only type -->
<span v-if="isReadOnly && propModel.options">
{{ (localType && localType.name) || '' }}
</span>
<!-- if we do not support any type on our model but one is set anyway -->
<div v-else-if="selectType" class="property__label">
{{ selectType.name }}
</div>
<!-- type selector -->
<Multiselect v-else-if="!isReadOnly && propModel.options"
v-model="localType"
:options="options"
:placeholder="t('contacts', 'Select type')"
:taggable="true"
tag-placeholder="create"
:disabled="isReadOnly"
track-by="id"
label="name"
@tag="createLabel"
@input="updateType" />
<!-- no options, empty space -->
<div v-else class="property__label">
{{ propModel.readableName }}
<!-- if we do not support any type on our model but one is set anyway -->
<span v-else-if="selectType">
{{ selectType.name }}
</span>
<!-- no options, empty space -->
<span v-else>
{{ propModel.readableName }}
</span>
</div>
<!-- textarea for note -->
<textarea v-if="propName === 'note'"
id="textarea"
ref="textarea"
v-model.trim="localValue"
:inputmode="inputmode"
:readonly="isReadOnly"
class="property__value"
@input="updateValueNoDebounce"
@mousemove="resizeHeight"
@keypress="resizeHeight" />
<div class="property__value">
<textarea v-if="propName === 'note'"
id="textarea"
ref="textarea"
v-model.trim="localValue"
:inputmode="inputmode"
:readonly="isReadOnly"
@input="updateValueNoDebounce"
@mousemove="resizeHeight"
@keypress="resizeHeight" />
<!-- OR default to input -->
<input v-else
v-model.trim="localValue"
:inputmode="inputmode"
:readonly="isReadOnly"
:class="{'property__value--with-ext': haveExtHandler}"
type="text"
class="property__value"
:placeholder="placeholder"
@input="updateValue">
<!-- OR default to input -->
<input v-else
v-model.trim="localValue"
:inputmode="inputmode"
:readonly="isReadOnly"
:class="{'property__value--with-ext': haveExtHandler}"
type="text"
:placeholder="placeholder"
@input="updateValue">
<!-- external link -->
<a v-if="haveExtHandler"
:href="externalHandler"
:class="{'property__ext': true, 'icon-external': true, 'no-move': isReadOnly}"
target="_blank" />
<!-- external link -->
<a v-if="haveExtHandler && isReadOnly"
:href="externalHandler"
class="property__ext"
target="_blank">
<OpenInNewIcon :size="20" />
</a>
</div>
<!-- props actions -->
<PropertyActions v-if="!isReadOnly"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
<div class="property__actions">
<PropertyActions v-if="!isReadOnly"
:actions="actions"
:property-component="this"
@delete="deleteProperty" />
</div>
</div>
</div>
</template>
@ -100,6 +111,7 @@ import debounce from 'debounce'
import PropertyMixin from '../../mixins/PropertyMixin.js'
import PropertyTitle from './PropertyTitle.vue'
import PropertyActions from './PropertyActions.vue'
import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
export default {
name: 'PropertyText',
@ -108,6 +120,7 @@ export default {
Multiselect,
PropertyTitle,
PropertyActions,
OpenInNewIcon,
},
mixins: [PropertyMixin],
@ -188,8 +201,6 @@ export default {
if (this.$refs.textarea && this.$refs.textarea.offsetHeight) {
// adjust textarea size to content (2 = border)
this.$refs.textarea.style.height = `${this.$refs.textarea.scrollHeight + 2}px`
// send resize event to warn we changed from the inside
this.$emit('resize')
}
}, 100),
@ -207,3 +218,13 @@ export default {
},
}
</script>
<style lang="scss" scoped>
.property {
&__value {
&--note {
white-space: pre-line;
}
}
}
</style>

View File

@ -26,12 +26,14 @@
<div class="property__label">
<PropertyTitleIcon :icon="icon" />
</div>
<h3 class="property__value">
{{ readableName }}
</h3>
<div class="property__actions">
<slot name="actions">
<Actions v-if="isMultiple" class="property__actions">
<Actions v-if="!isReadOnly && isMultiple" class="property__actions">
<ActionButton @click="onAddProp(property.name)">
<template #icon>
<IconPlus :size="20" />
@ -39,9 +41,6 @@
{{ t('contacts', 'Add property of this type') }}
</ActionButton>
</Actions>
<!-- empty placeholder to keep the layout -->
<div v-else class="property__actions__empty" />
</slot>
</div>
</div>
@ -71,10 +70,13 @@ export default {
default: '',
required: true,
},
isReadOnly: {
type: Boolean,
required: true,
},
property: {
type: Object,
default: () => {},
required: true,
},
isMultiple: {
type: Boolean,

View File

@ -2,6 +2,7 @@
- @copyright Copyright (c) 2022 Greta Doci <gretadoci@gmail.com>
-
- @author Greta Doci
- @author Richard Steinmetz <richard@steinmetz.cloud>
-
- @license GNU AGPL version 3 or any later version
-
@ -109,8 +110,3 @@ export default {
},
}
</script>
<style lang="scss" scope>
.material-design-icon {
opacity: .7;
}
</style>

View File

@ -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
*
@ -61,7 +62,7 @@ export default {
// Is it read-only?
isReadOnly: {
type: Boolean,
default: false,
required: true,
},
// The available TYPE options from the propModel
// not used on the PropertySelect