summaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
authorVassilis Kritharakis <bkrith@hotmail.com>2022-05-02 02:27:54 +0300
committerChristoph Wurst <christoph@winzerhof-wurst.at>2022-09-22 18:07:46 +0200
commit9038e7d71f2e184fb16b5fafa4b4a6a1f07695a3 (patch)
tree177d788653e918c45dce0f3dd889e533e6c011ca /src/components
parent503b84e6fadd9650c8b688bbe89ff69e2cf85bd3 (diff)
Add org charts
Co-Authored-By: Christoph Wurst <christoph@winzerhof-wurst.at> Signed-off-by: Vassilis Kritharakis <bkrith@hotmail.com> Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Diffstat (limited to 'src/components')
-rw-r--r--src/components/AppContent/ChartContent.vue58
-rw-r--r--src/components/AppContent/ContactsContent.vue2
-rw-r--r--src/components/AppNavigation/RootNavigation.vue18
-rw-r--r--src/components/ChartTemplate.vue123
-rw-r--r--src/components/ContactDetails.vue5
-rw-r--r--src/components/ContactDetails/ContactDetailsAddNewProp.vue18
-rw-r--r--src/components/ContactDetails/ContactDetailsProperty.vue36
-rw-r--r--src/components/OrgChart.vue185
-rw-r--r--src/components/Properties/PropertySelect.vue9
9 files changed, 445 insertions, 9 deletions
diff --git a/src/components/AppContent/ChartContent.vue b/src/components/AppContent/ChartContent.vue
new file mode 100644
index 00000000..b77f61a9
--- /dev/null
+++ b/src/components/AppContent/ChartContent.vue
@@ -0,0 +1,58 @@
+<template>
+ <AppContent>
+ <OrgChart :data="transformData" />
+ </AppContent>
+</template>
+
+<script>
+import AppContent from '@nextcloud/vue/dist/Components/NcAppContent'
+
+import OrgChart from '../OrgChart.vue'
+import { getChart, transformNode } from '../../utils/chartUtils'
+
+export default {
+ name: 'ChartContent',
+ components: {
+ AppContent,
+ OrgChart,
+ },
+ props: {
+ contactsList: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ data: [],
+ searchQuery: '',
+ }
+ },
+ computed: {
+ transformData() {
+ const contactsByUid = {}
+ const contacts = Object.keys(this.contactsList).map(key => {
+ const [uid, addressbook] = key.split('~')
+ if (!contactsByUid[addressbook]) {
+ contactsByUid[addressbook] = {}
+ }
+ return (contactsByUid[addressbook][uid] = this.contactsList[key])
+ })
+
+ const headManagers = []
+ const tempContacts = contacts.filter(contact => contact.managersName).reduce((prev, contact) => {
+ prev.push(transformNode(contact))
+
+ const manager = contactsByUid[contact.addressbook.id][contact.managersName]
+ if (manager && !manager.managersName && !headManagers.includes(manager.uid)) {
+ prev.push(transformNode(manager))
+ headManagers.push(manager.uid)
+ }
+ return prev
+ }, [])
+
+ return headManagers.map(uid => getChart(tempContacts, uid))
+ },
+ },
+}
+</script>
diff --git a/src/components/AppContent/ContactsContent.vue b/src/components/AppContent/ContactsContent.vue
index 9f5b2c1a..cf029546 100644
--- a/src/components/AppContent/ContactsContent.vue
+++ b/src/components/AppContent/ContactsContent.vue
@@ -70,7 +70,7 @@
</template>
<!-- main contacts details -->
- <ContactDetails :contact-key="selectedContact" />
+ <ContactDetails :contact-key="selectedContact" :contacts="sortedContacts" />
</AppContent>
</template>
<script>
diff --git a/src/components/AppNavigation/RootNavigation.vue b/src/components/AppNavigation/RootNavigation.vue
index 8a10be12..630ad090 100644
--- a/src/components/AppNavigation/RootNavigation.vue
+++ b/src/components/AppNavigation/RootNavigation.vue
@@ -45,6 +45,16 @@
</AppNavigationCounter>
</AppNavigationItem>
+ <!-- Organization chart -->
+ <AppNavigationItem v-if="existChart"
+ id="chart"
+ :title="CHART_ALL_CONTACTS"
+ :to="{
+ name: 'chart',
+ params: { selectedChart: GROUP_ALL_CONTACTS },
+ }"
+ icon="icon-category-monitoring" />
+
<!-- Not grouped group -->
<AppNavigationItem
v-if="ungroupedContacts.length > 0"
@@ -170,7 +180,7 @@
</template>
<script>
-import { GROUP_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT, CIRCLE_DESC } from '../../models/constants.ts'
+import { GROUP_ALL_CONTACTS, CHART_ALL_CONTACTS, GROUP_NO_GROUP_CONTACTS, GROUP_RECENTLY_CONTACTED, ELLIPSIS_COUNT, CIRCLE_DESC } from '../../models/constants.ts'
import ActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
import ActionInput from '@nextcloud/vue/dist/Components/NcActionInput'
@@ -243,6 +253,7 @@ export default {
CIRCLE_DESC,
ELLIPSIS_COUNT,
GROUP_ALL_CONTACTS,
+ CHART_ALL_CONTACTS,
GROUP_NO_GROUP_CONTACTS,
GROUP_RECENTLY_CONTACTED,
@@ -286,6 +297,11 @@ export default {
return this.sortedContacts.filter(contact => this.contacts[contact.key].groups && this.contacts[contact.key].groups.length === 0)
},
+ // check if any contact has manager, if not then is no need for organization chart menu
+ existChart() {
+ return !!Object.keys(this.contacts).filter(key => this.contacts[key].managersName).length
+ },
+
// generate groups menu from the groups store
groupsMenu() {
const menu = this.groups.map(group => {
diff --git a/src/components/ChartTemplate.vue b/src/components/ChartTemplate.vue
new file mode 100644
index 00000000..e8585aff
--- /dev/null
+++ b/src/components/ChartTemplate.vue
@@ -0,0 +1,123 @@
+<template>
+ <div class="org-chart-node">
+ <div class="inner-box">
+ <router-link :to="{
+ name: 'contact',
+ params: {
+ selectedGroup: selectedChart,
+ selectedContact: data.key,
+ },
+ }">
+ <Avatar
+ :disable-tooltip="true"
+ :display-name="data.fullName"
+ :is-no-user="true"
+ :size="60"
+ :url="data.photoUrl"
+ class="org-chart-node__avatar" />
+ </router-link>
+ <div class="panel" />
+ <div class="main-container">
+ <h3 class="fullName">
+ {{ data.fullName }}
+ </h3>
+ <h4 class="title">
+ {{ data.title }}
+ </h4>
+ </div>
+ <div class="description">
+ <p v-if="data._directSubordinates">
+ {{ t('contacts', 'Manages') }}: {{ data._directSubordinates }}
+ </p>
+ <p v-if="data._totalSubordinates">
+ {{ t('contacts', 'Oversees') }}: {{ data._totalSubordinates }}
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import Avatar from '@nextcloud/vue/dist/Components/NcAvatar'
+
+export default {
+ name: 'ChartTemplate',
+ components: {
+ Avatar,
+ },
+ props: {
+ data: {
+ type: Object,
+ default: () => {},
+ },
+ onAvatarClick: {
+ type: Function,
+ default: () => {},
+ },
+ },
+ computed: {
+ selectedChart() {
+ return this.$route.params.selectedChart
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.org-chart-node {
+ background-color: var(--color-main-background);
+ padding-top: 1px;
+ height: 175px;
+ border-radius: var(--border-radius-large);
+ overflow: visible;
+
+ &__avatar {
+ margin-top: -30px;
+ margin-left: 95px;
+ border: 1px solid var(--color-border);
+ }
+
+ .inner-box {
+ display: flex;
+ flex-direction: column;
+ height: 175px;
+ padding-top: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-large);
+ background-color: var(--color-main-background);
+
+ .main-container {
+ padding: 20px;
+ text-align: center;
+ flex: 1;
+
+ .fullName {
+ color: var(--color-primary-element);
+ font-weight: bold;
+ }
+
+ .title {
+ margin-top: 4px;
+ }
+ }
+
+ .description {
+ display: flex;
+ justify-content: space-between;
+ padding: 15px;
+ }
+ }
+
+ .inner-box-highlight {
+ border: 2px solid var(--color-primary) !important;
+ }
+
+ .panel {
+ margin: -34px -1px 0 -1px;
+ background-color: var(--color-primary);
+ height: 15px;
+ border-top-left-radius: var(--border-radius-large);
+ border-top-right-radius: var(--border-radius-large);
+ }
+}
+</style>
diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue
index d010812f..97021d70 100644
--- a/src/components/ContactDetails.vue
+++ b/src/components/ContactDetails.vue
@@ -208,6 +208,7 @@
:contact="contact"
:local-contact="localContact"
:update-contact="debounceUpdateContact"
+ :contacts="contacts"
@resize="debounceRedrawMasonry" />
</div>
@@ -311,6 +312,10 @@ export default {
type: String,
default: undefined,
},
+ contacts: {
+ type: Array,
+ default: () => [],
+ },
},
data() {
diff --git a/src/components/ContactDetails/ContactDetailsAddNewProp.vue b/src/components/ContactDetails/ContactDetailsAddNewProp.vue
index b5e613a1..66df1ca2 100644
--- a/src/components/ContactDetails/ContactDetailsAddNewProp.vue
+++ b/src/components/ContactDetails/ContactDetailsAddNewProp.vue
@@ -43,6 +43,7 @@
import Multiselect from '@nextcloud/vue/dist/Components/NcMultiselect'
import rfcProps from '../../models/rfcProps'
import Contact from '../../models/contact'
+import OrgChartsMixin from '../../mixins/OrgChartsMixin'
import PropertyTitle from '../Properties/PropertyTitle'
import ICAL from 'ical.js'
@@ -54,6 +55,10 @@ export default {
PropertyTitle,
},
+ mixins: [
+ OrgChartsMixin,
+ ],
+
props: {
contact: {
type: Contact,
@@ -108,8 +113,17 @@ export default {
* @param {object} data destructuring object
* @param {string} data.id the id of the property. e.g fn
*/
- addProp({ id }) {
- if (this.properties[id] && this.properties[id].defaultjCal
+ async addProp({ id }) {
+ if (id === 'x-managersname') {
+ const others = this.otherContacts(this.contact)
+ if (others.length === 1) {
+ // Pick the first and only other contact
+ this.contact.vCard.addPropertyWithValue(id, others[0].key)
+ await this.$store.dispatch('updateContact', this.contact)
+ } else {
+ this.contact.vCard.addPropertyWithValue(id, '')
+ }
+ } else if (this.properties[id] && this.properties[id].defaultjCal
&& this.properties[id].defaultjCal[this.contact.version]) {
const defaultjCal = this.properties[id].defaultjCal[this.contact.version]
const property = new ICAL.Property([id, ...defaultjCal])
diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue
index 15e1114a..2882853e 100644
--- a/src/components/ContactDetails/ContactDetailsProperty.vue
+++ b/src/components/ContactDetails/ContactDetailsProperty.vue
@@ -37,7 +37,7 @@
:local-contact="localContact"
:prop-name="propName"
:prop-type="propType"
- :options="sortedModelOptions"
+ :options="propName === 'x-managersname' ? contactsOptions : sortedModelOptions"
:is-read-only="isReadOnly"
@delete="onDelete"
@resize="onResize"
@@ -49,6 +49,7 @@ import { Property } from 'ical.js'
import rfcProps from '../../models/rfcProps'
import Contact from '../../models/contact'
+import OrgChartsMixin from '../../mixins/OrgChartsMixin'
import PropertyText from '../Properties/PropertyText'
import PropertyMultipleText from '../Properties/PropertyMultipleText'
import PropertyDateTime from '../Properties/PropertyDateTime'
@@ -57,6 +58,10 @@ import PropertySelect from '../Properties/PropertySelect'
export default {
name: 'ContactDetailsProperty',
+ mixins: [
+ OrgChartsMixin,
+ ],
+
props: {
property: {
type: Property,
@@ -94,6 +99,10 @@ export default {
type: Function,
default: () => {},
},
+ contacts: {
+ type: Array,
+ default: () => [],
+ },
},
computed: {
@@ -204,6 +213,31 @@ export default {
},
/**
+ * Return the options of contacts for x-managersname select
+ *
+ * @return {object[]}
+ */
+ contactsOptions() {
+ if (this.propName !== 'x-managersname') {
+ return []
+ }
+
+ // Only allow contacts of the same address book
+ const otherContacts = this.otherContacts(this.contact)
+ // Reduce to an object to eliminate duplicates
+ return Object.values(otherContacts.reduce((prev, { key, value }) => {
+ const contact = this.$store.getters.getContact(key)
+ return {
+ ...prev,
+ [contact.uid]: {
+ id: contact.uid,
+ name: contact.displayName,
+ },
+ }
+ }, this.selectType ? { [this.selectType.value]: this.selectType } : {}))
+ },
+
+ /**
* Returns the closest match to the selected type
* or return the default selected as a new object if
* none exists
diff --git a/src/components/OrgChart.vue b/src/components/OrgChart.vue
new file mode 100644
index 00000000..debab8ea
--- /dev/null
+++ b/src/components/OrgChart.vue
@@ -0,0 +1,185 @@
+<template>
+ <div class="org-chart">
+ <div v-if="data.length > 1" class="org-chart__menu">
+ <h3>
+ {{ t('contacts', 'Chart') }}:
+ </h3>
+ <Multiselect
+ v-model="chart"
+ class="chart-selection"
+ :disabled="data.length === 1"
+ :options="charts"
+ :allow-empty="false"
+ :searchable="false"
+ :placeholder="placeholder"
+ track-by="id"
+ label="label"
+ @input="chartChanged" />
+ </div>
+ <div ref="svgElementContainer" class="org-chart__container" />
+ </div>
+</template>
+
+<script>
+import * as d3 from 'd3'
+import ChartTemplate from './ChartTemplate.vue'
+import Multiselect from '@nextcloud/vue/dist/Components/NcMultiselect'
+import { OrgChart } from 'd3-org-chart'
+import router from './../router'
+import Vue from 'vue'
+
+export default {
+ name: 'OrgChart',
+ components: {
+ Multiselect,
+ },
+ props: {
+ data: {
+ type: Array,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ chartReference: null,
+ chart: 0,
+ }
+ },
+ computed: {
+ charts() {
+ return this.data.map((nodes, index) => {
+ const head = nodes.find(node => node.parentNodeId === null)
+ return {
+ id: index,
+ label: head.org ? `${head.org} (${head.fullName})` : head.fullName,
+ }
+ })
+ },
+ placeholder() {
+ return t('contacts', 'Select chart...')
+ },
+ },
+ watch: {
+ data() {
+ if (this.data[this.chart]?.length) {
+ this.renderChart(this.data[this.chart])
+ }
+ },
+ },
+ mounted() {
+ if (this.data[this.chart]?.length) {
+ this.renderChart(this.data[this.chart])
+ }
+ },
+ methods: {
+ chartChanged(inputProps) {
+ this.renderChart(this.data[inputProps.id])
+ },
+ goToContact(key) {
+ this.$router.push({
+ name: 'contact',
+ params: {
+ selectedGroup: this.$route.params.selectedChart,
+ selectedContact: key,
+ },
+ })
+ },
+ renderChart(data) {
+ const that = this
+ if (!this.chartReference) {
+ this.chartReference = new OrgChart()
+ }
+
+ this.chartReference
+ .container(this.$refs.svgElementContainer) // node or css selector
+ .data(data)
+ .nodeWidth(() => 250)
+ .initialZoom(1)
+ .nodeHeight(() => 175)
+ .childrenMargin(() => 70)
+ .compactMarginBetween(() => 15)
+ .compactMarginPair(() => 80)
+ .nodeContent(() => {
+ return ''
+ })
+ .nodeUpdate(function(d) {
+ // Render vue component node template for each node
+ const containerHTMLElement = this.querySelector('.node-foreign-object-div')
+ if (containerHTMLElement) {
+ // Avoid re-render if already we have rendered component
+ if (d.data.rendered) {
+ containerHTMLElement.appendChild(d.data.rendered)
+ } else {
+ const ComponentClass = Vue.extend(ChartTemplate)
+ const instance = new ComponentClass({
+ propsData: {
+ data: d.data,
+ onAvatarClick: (uid) => that.goToContact(uid),
+ },
+ router,
+ }).$mount()
+ d.data.rendered = instance.$el
+ containerHTMLElement.appendChild(instance.$el)
+ }
+ }
+
+ d3.select(this)
+ .select('.inner-box')
+ .attr('class', (dRect) => dRect.data._highlighted || dRect.data._upToTheRootHighlighted ? 'inner-box inner-box-highlight' : 'inner-box')
+ })
+ .linkUpdate(function(d) {
+ d3.select(this)
+ .attr('stroke', () => 'var(--color-primary)')
+ .attr('stroke-width', (dRect) =>
+ dRect.data._upToTheRootHighlighted ? 2 : 1
+ )
+
+ if (d.data._upToTheRootHighlighted) {
+ d3.select(this).raise()
+ }
+ })
+ .onNodeClick(d => {
+ if (!this.chartReference.data().filter(item => item.nodeId === d)[0]._upToTheRootHighlighted) {
+ this.chartReference.clearHighlighting()
+ this.chartReference.setUpToTheRootHighlighted(d).render()
+ } else {
+ this.chartReference.clearHighlighting()
+ }
+ })
+ .render()
+ },
+ },
+}
+</script>
+
+<style lang="scss">
+.org-chart {
+ display: flex;
+ flex-direction: column;
+
+ &__menu {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ border: 1px solid var(--color-border);
+
+ h3 {
+ padding-left: 50px;
+ padding-right: 10px;
+ margin: 9px 0;
+ }
+ }
+
+ &__container {
+ display: flex;
+ background-color: var(--color-main-background);
+ }
+
+ ::v-deep .node-button-div {
+ color: var(--color-primary-element);
+ div {
+ background-color: var(--color-main-background) !important;
+ }
+ }
+}
+</style>
diff --git a/src/components/Properties/PropertySelect.vue b/src/components/Properties/PropertySelect.vue
index c0e16576..eaca3116 100644
--- a/src/components/Properties/PropertySelect.vue
+++ b/src/components/Properties/PropertySelect.vue
@@ -39,7 +39,7 @@
</div>
<Multiselect v-model="matchedOptions"
- :options="propModel.options"
+ :options="options || propModel.options"
:placeholder="t('contacts', 'Select option')"
:disabled="isSingleOption || isReadOnly"
class="property__value"
@@ -85,19 +85,20 @@ export default {
computed: {
// is there only one option available
isSingleOption() {
- return this.propModel.options.length <= 1
+ return this.propModel.options.length <= 1 && this.options.length <= 1
},
// matching value to the options we provide
matchedOptions: {
get() {
+ const options = this.options || this.propModel.options
// match lowercase as well
- let selected = this.propModel.options.find(option => option.id === this.localValue
+ let selected = options.find(option => option.id === this.localValue
|| option.id === this.localValue.toLowerCase())
// if the model provided a custom match fallback, use it
if (!selected && this.propModel.greedyMatch) {
- selected = this.propModel.greedyMatch(this.localValue, this.propModel.options)
+ selected = this.propModel.greedyMatch(this.localValue, options)
}
// properly display array as a string