diff options
author | Vassilis Kritharakis <bkrith@hotmail.com> | 2022-05-02 02:27:54 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2022-09-22 18:07:46 +0200 |
commit | 9038e7d71f2e184fb16b5fafa4b4a6a1f07695a3 (patch) | |
tree | 177d788653e918c45dce0f3dd889e533e6c011ca /src/components | |
parent | 503b84e6fadd9650c8b688bbe89ff69e2cf85bd3 (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.vue | 58 | ||||
-rw-r--r-- | src/components/AppContent/ContactsContent.vue | 2 | ||||
-rw-r--r-- | src/components/AppNavigation/RootNavigation.vue | 18 | ||||
-rw-r--r-- | src/components/ChartTemplate.vue | 123 | ||||
-rw-r--r-- | src/components/ContactDetails.vue | 5 | ||||
-rw-r--r-- | src/components/ContactDetails/ContactDetailsAddNewProp.vue | 18 | ||||
-rw-r--r-- | src/components/ContactDetails/ContactDetailsProperty.vue | 36 | ||||
-rw-r--r-- | src/components/OrgChart.vue | 185 | ||||
-rw-r--r-- | src/components/Properties/PropertySelect.vue | 9 |
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 |