Allow users to display news on homepage (+ configuring a dashboard homepage)

This commit is contained in:
2024-03-07 21:08:00 +00:00
committed by Julien Fastré
parent 2ad3bbe96f
commit d29415317b
45 changed files with 1873 additions and 81 deletions

View File

@@ -97,6 +97,8 @@ import MyNotifications from './MyNotifications';
import MyWorkflows from './MyWorkflows.vue';
import TabCounter from './TabCounter';
import { mapState } from "vuex";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export default {
name: "App",
@@ -112,7 +114,7 @@ export default {
},
data() {
return {
activeTab: 'MyCustoms'
activeTab: 'MyCustoms',
}
},
computed: {
@@ -126,8 +128,11 @@ export default {
},
methods: {
selectTab(tab) {
this.$store.dispatch('getByTab', { tab: tab });
if (tab !== 'MyCustoms') {
this.$store.dispatch('getByTab', { tab: tab });
}
this.activeTab = tab;
console.log(this.activeTab)
}
},
mounted() {

View File

@@ -0,0 +1,47 @@
<template>
<div>
<h1>{{ $t('widget.news.title') }}</h1>
<ul v-if="newsItems.length > 0" class="scrollable">
<NewsItem v-for="item in newsItems" :item="item" :key="item.id" />
</ul>
<p v-if="newsItems.length === 0 " class="chill-no-data-statement">{{ $t('widget.news.none') }}</p>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchResults } from '../../../lib/api/apiMethods';
import Modal from '../../_components/Modal.vue';
import { NewsItemType } from '../../../types';
import NewsItem from './NewsItem.vue';
const newsItems = ref<NewsItemType[]>([])
onMounted(() => {
fetchResults<NewsItemType>('/api/1.0/main/news/current.json')
.then((news): Promise<void> => {
// console.log('news articles', response.results)
newsItems.value = news;
return Promise.resolve();
})
.catch((error: string) => {
console.error('Error fetching news items', error);
})
})
</script>
<style scoped>
ul {
list-style: none;
padding: 0;
}
h1 {
text-align: center;
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<li>
<h2>{{ props.item.title }}</h2>
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time>
<div class="content" v-if="shouldTruncate(item.content)">
<div v-html="prepareContent(item.content)"></div>
<div class="float-end">
<button class="btn btn-sm btn-show read-more" @click="() => openModal(item)">{{ $t('widget.news.readMore') }}</button>
</div>
</div>
<div class="content" v-else>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</div>
<modal v-if="showModal" @close="closeModal">
<template #header>
<p class="news-title">{{ item.title }}</p>
</template>
<template #body>
<p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ $d(newsItemStartDate(), 'text') }}</time>
</p>
<div v-html="convertMarkdownToHtml(item.content)"></div>
</template>
</modal>
</li>
</template>
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { DateTime, NewsItemType } from "../../../types";
import type { PropType } from 'vue'
import { ref } from "vue";
import {ISOToDatetime} from '../../../chill/js/date';
const props = defineProps({
item: {
type: Object as PropType<NewsItemType>,
required: true
},
maxLength: {
type: Number,
required: false,
default: 350,
},
maxLines: {
type: Number,
required: false,
default: 3
}
})
const selectedArticle = ref<NewsItemType | null>(null);
const showModal = ref(false);
const openModal = (item: NewsItemType) => {
selectedArticle.value = item;
showModal.value = true;
};
const closeModal = () => {
selectedArticle.value = null;
showModal.value = false;
};
const shouldTruncate = (content: string): boolean => {
const lines = content.split('\n');
// Check if any line exceeds the maximum length
const tooManyLines = lines.length > props.maxLines;
return content.length > props.maxLength || tooManyLines;
};
const truncateContent = (content: string): string => {
let truncatedContent = content.slice(0, props.maxLength);
let linkDepth = 0;
let linkStartIndex = -1;
const lines = content.split('\n');
// Truncate if amount of lines are too many
if (lines.length > props.maxLines && content.length < props.maxLength) {
const truncatedContent = lines.slice(0, props.maxLines).join('\n').trim();
return truncatedContent + '...';
}
for (let i = 0; i < truncatedContent.length; i++) {
const char = truncatedContent[i];
if (char === '[') {
linkDepth++;
if (linkDepth === 1) {
linkStartIndex = i;
}
} else if (char === ']') {
linkDepth = Math.max(0, linkDepth - 1);
} else if (char === '(' && linkDepth === 0) {
truncatedContent = truncatedContent.slice(0, i);
break;
}
}
while (linkDepth > 0) {
truncatedContent += ']';
linkDepth--;
}
// If a link was found, append the URL inside the parentheses
if (linkStartIndex !== -1) {
const linkEndIndex = content.indexOf(')', linkStartIndex);
const url = content.slice(linkStartIndex + 1, linkEndIndex);
truncatedContent = truncatedContent.slice(0, linkStartIndex) + `(${url})`;
}
truncatedContent += '...';
return truncatedContent;
};
const preprocess = (markdown: string): string => {
return markdown;
}
const postprocess = (html: string): string => {
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if ('target' in node) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href'))) {
node.setAttribute('xlink:show', 'new');
}
})
return DOMPurify.sanitize(html);
}
const convertMarkdownToHtml = (markdown: string): string => {
marked.use({'hooks': {postprocess, preprocess}});
const rawHtml = marked(markdown);
return rawHtml;
};
const prepareContent = (content: string): string => {
const htmlContent = convertMarkdownToHtml(content);
return truncateContent(htmlContent);
};
const newsItemStartDate = (): null|Date => {
return ISOToDatetime(props.item?.startDate.datetime);
}
</script>
<style scoped>
li {
margin-bottom: 20px;
overflow: hidden;
padding: .8rem;
background-color: #fbfbfb;
border-radius: 4px;
}
h2 {
font-size: 1rem !important;
text-transform: uppercase;
}
.content {
overflow: hidden;
font-size: .9rem;
position: relative;
}
.news-title {
font-weight: bold;
}
</style>

View File

@@ -1,76 +1,73 @@
<template>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_dashboard') }}</span>
<div v-else id="dashboards" class="row g-3" data-masonry='{"percentPosition": true }'>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="counter.notifications > 0">
<i18n-t keypath="counter.unread_notifications" tag="span" :class="counterClass" :plural="counter.notifications">
<template v-slot:n><span>{{ counter.notifications }}</span></template>
</i18n-t>
</li>
<li v-if="counter.accompanyingCourses > 0">
<i18n-t keypath="counter.assignated_courses" tag="span" :class="counterClass" :plural="counter.accompanyingCourses">
<template v-slot:n><span>{{ counter.accompanyingCourses }}</span></template>
</i18n-t>
</li>
<li v-if="counter.works > 0">
<i18n-t keypath="counter.assignated_actions" tag="span" :class="counterClass" :plural="counter.works">
<template v-slot:n><span>{{ counter.works }}</span></template>
</i18n-t>
</li>
<li v-if="counter.evaluations > 0">
<i18n-t keypath="counter.assignated_evaluations" tag="span" :class="counterClass" :plural="counter.evaluations">
<template v-slot:n><span>{{ counter.evaluations }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksAlert > 0">
<i18n-t keypath="counter.alert_tasks" tag="span" :class="counterClass" :plural="counter.tasksAlert">
<template v-slot:n><span>{{ counter.tasksAlert }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksWarning > 0">
<i18n-t keypath="counter.warning_tasks" tag="span" :class="counterClass" :plural="counter.tasksWarning">
<template v-slot:n><span>{{ counter.tasksWarning }}</span></template>
</i18n-t>
</li>
</ul>
</div>
</div>
<!--
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom2">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom3">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom4">
Mon dashboard personnalisé
</div>
</div>
-->
<div v-else id="dashboards" class="container g-3">
<div class="row">
<div class="mbloc col-xs-12 col-sm-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="counter.notifications > 0">
<i18n-t keypath="counter.unread_notifications" tag="span" :class="counterClass" :plural="counter.notifications">
<template v-slot:n><span>{{ counter.notifications }}</span></template>
</i18n-t>
</li>
<li v-if="counter.accompanyingCourses > 0">
<i18n-t keypath="counter.assignated_courses" tag="span" :class="counterClass" :plural="counter.accompanyingCourses">
<template v-slot:n><span>{{ counter.accompanyingCourses }}</span></template>
</i18n-t>
</li>
<li v-if="counter.works > 0">
<i18n-t keypath="counter.assignated_actions" tag="span" :class="counterClass" :plural="counter.works">
<template v-slot:n><span>{{ counter.works }}</span></template>
</i18n-t>
</li>
<li v-if="counter.evaluations > 0">
<i18n-t keypath="counter.assignated_evaluations" tag="span" :class="counterClass" :plural="counter.evaluations">
<template v-slot:n><span>{{ counter.evaluations }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksAlert > 0">
<i18n-t keypath="counter.alert_tasks" tag="span" :class="counterClass" :plural="counter.tasksAlert">
<template v-slot:n><span>{{ counter.tasksAlert }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksWarning > 0">
<i18n-t keypath="counter.warning_tasks" tag="span" :class="counterClass" :plural="counter.tasksWarning">
<template v-slot:n><span>{{ counter.tasksWarning }}</span></template>
</i18n-t>
</li>
</ul>
</div>
</div>
<template v-if="this.hasDashboardItems">
<template v-for="dashboardItem in this.dashboardItems">
<div class="mbloc col-xs-12 col-sm-8 news" v-if="dashboardItem.type === 'news'">
<News />
</div>
</template>
</template>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Masonry from 'masonry-layout/masonry';
import {makeFetch} from "ChillMainAssets/lib/api/apiMethods";
import News from './DashboardWidgets/News.vue';
export default {
name: "MyCustoms",
components: {
News
},
data() {
return {
counterClass: {
counter: true //hack to pass class 'counter' in i18n-t
}
},
dashboardItems: [],
masonry: null,
}
},
computed: {
@@ -78,11 +75,19 @@ export default {
noResults() {
return false
},
hasDashboardItems() {
return this.dashboardItems.length > 0;
}
},
mounted() {
const elem = document.querySelector('#dashboards');
const masonry = new Masonry(elem, {});
}
makeFetch('GET', '/api/1.0/main/dashboard-config-item.json')
.then((response) => {
this.dashboardItems = response;
})
.catch((error) => {
throw error
});
},
}
</script>
@@ -98,4 +103,10 @@ span.counter {
background-color: unset;
}
}
</style>
div.news {
max-height: 22rem;
overflow: hidden;
overflow-y: scroll;
}
</style>

View File

@@ -63,7 +63,15 @@ const appMessages = {
},
emergency: "Urgent",
confidential: "Confidentiel",
automatic_notification: "Notification automatique"
automatic_notification: "Notification automatique",
widget: {
news: {
title: "Actualités",
readMore: "Lire la suite",
date: "Date",
none: "Aucune actualité"
}
}
}
};

View File

@@ -96,13 +96,11 @@ const store = createStore({
},
catchError(state, error) {
state.errorMsg.push(error);
}
},
},
actions: {
getByTab({ commit, getters }, { tab, param }) {
switch (tab) {
case 'MyCustoms':
break;
// case 'MyWorks':
// if (!getters.isWorksLoaded) {
// commit('setLoading', true);
@@ -221,8 +219,8 @@ const store = createStore({
default:
throw 'tab '+ tab;
}
}
},
},
});
export { store };
export { store };

View File

@@ -15,18 +15,20 @@ import AddressModal from "./AddressModal.vue";
export interface AddressModalContentProps {
address_id: number;
address_ref_status: AddressRefStatus | null;
address_ref_status: AddressRefStatus;
}
const data = reactive<{
loading: boolean,
working_address: Address | null,
working_ref_status: AddressRefStatus | null,
}>({
interface AddressModalData {
loading: boolean,
working_address: Address | null,
working_ref_status: AddressRefStatus | null,
}
const data: AddressModalData = reactive({
loading: false,
working_address: null,
working_ref_status: null,
});
} as AddressModalData);
const props = defineProps<AddressModalContentProps>();

View File

@@ -51,7 +51,7 @@ const messages = {
years_old: "1 an | {n} an | {n} ans",
residential_address: "Adresse de résidence",
located_at: "réside chez"
}
},
}
};