mirror of https://github.com/nextcloud/contacts
feat(contacts): implement read-only and edit modes
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
parent
a352bff65f
commit
0972b74411
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
@ -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==",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue