Merge branch 'issue159_page_acceuil' into 'master'

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

See merge request Chill-Projet/chill-bundles!604
This commit is contained in:
Julien Fastré 2024-03-07 21:08:00 +00:00
commit 96105b101f
45 changed files with 1873 additions and 81 deletions

View File

@ -35,7 +35,7 @@ variables:
# force a timezone # force a timezone
TZ: Europe/Brussels TZ: Europe/Brussels
# avoid direct deprecations (using symfony phpunit bridge: https://symfony.com/doc/4.x/components/phpunit_bridge.html#internal-deprecations # avoid direct deprecations (using symfony phpunit bridge: https://symfony.com/doc/4.x/components/phpunit_bridge.html#internal-deprecations
SYMFONY_DEPRECATIONS_HELPER: max[total]=99999999&max[self]=0&max[direct]=0&verbose=0 SYMFONY_DEPRECATIONS_HELPER: max[total]=99999999&max[self]=0&max[direct]=0&verbose=1
stages: stages:
- Composer install - Composer install

View File

@ -47,4 +47,12 @@ class AdminController extends AbstractController
{ {
return $this->render('@ChillMain/Admin/indexUser.html.twig'); return $this->render('@ChillMain/Admin/indexUser.html.twig');
} }
/**
* @Route("/{_locale}/admin/dashboard", name="chill_main_dashboard_admin")
*/
public function indexDashboardAction()
{
return $this->render('@ChillMain/Admin/indexDashboard.html.twig');
}
} }

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NewsItemRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
final readonly class DashboardApiController
{
public function __construct(
private NewsItemRepository $newsItemRepository,
) {
}
/**
* Get user dashboard config (not yet based on user id and still hardcoded for now).
*
* @Route("/api/1.0/main/dashboard-config-item.json", methods={"get"})
*/
public function getDashboardConfiguration(): JsonResponse
{
$data = [];
if (0 < $this->newsItemRepository->countCurrentNews()) {
// show news only if we have news
// NOTE: maybe this should be done in the frontend...
$data[] =
[
'position' => 'top-left',
'id' => 1,
'type' => 'news',
'metadata' => [
// arbitrary data that will be store "some time"
'only_unread' => false,
],
];
}
return new JsonResponse($data, JsonResponse::HTTP_OK, []);
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NewsItemRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
class NewsItemApiController
{
public function __construct(
private readonly NewsItemRepository $newsItemRepository,
private readonly SerializerInterface $serializer,
private readonly PaginatorFactory $paginatorFactory
) {
}
/**
* Get list of news items filtered on start and end date.
*
* @Route("/api/1.0/main/news/current.json", methods={"get"})
*/
public function listCurrentNewsItems(): JsonResponse
{
$total = $this->newsItemRepository->countCurrentNews();
$paginator = $this->paginatorFactory->create($total);
$newsItems = $this->newsItemRepository->findCurrentNews(
$paginator->getItemsPerPage(),
$paginator->getCurrentPage()->getFirstItemNumber()
);
return new JsonResponse($this->serializer->serialize(
new Collection(array_values($newsItems), $paginator),
'json',
[
AbstractNormalizer::GROUPS => ['read'],
]
), JsonResponse::HTTP_OK, [], true);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
class NewsItemController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
$query->addOrderBy('e.startDate', 'DESC');
$query->addOrderBy('e.id', 'DESC');
return parent::orderQuery($action, $query, $request, $paginator);
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NewsItemRepository;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class NewsItemHistoryController
{
public function __construct(
private readonly NewsItemRepository $newsItemRepository,
private readonly PaginatorFactory $paginatorFactory,
private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
private readonly Environment $environment,
) {
}
/**
* @Route("/{_locale}/news-items/history", name="chill_main_news_items_history")
*/
public function list(): Response
{
$filter = $this->buildFilterOrder();
$total = $this->newsItemRepository->countAllFilteredBySearchTerm($filter->getQueryString());
$newsItems = $this->newsItemRepository->findAllFilteredBySearchTerm($filter->getQueryString());
$pagination = $this->paginatorFactory->create($total);
return new Response($this->environment->render('@ChillMain/NewsItem/news_items_history.html.twig', [
'entities' => $newsItems,
'paginator' => $pagination,
'filter_order' => $filter,
]));
}
/**
* @Route("/{_locale}/news-items/{id}", name="chill_main_single_news_item")
*/
public function showSingleItem(NewsItem $newsItem, Request $request): Response
{
return new Response($this->environment->render(
'@ChillMain/NewsItem/show.html.twig',
[
'entity' => $newsItem,
]
));
}
private function buildFilterOrder(): FilterOrderHelper
{
$filterBuilder = $this->filterOrderHelperFactory
->create(self::class)
->addSearchBox();
return $filterBuilder->build();
}
}

View File

@ -19,6 +19,7 @@ use Chill\MainBundle\Controller\CountryController;
use Chill\MainBundle\Controller\LanguageController; use Chill\MainBundle\Controller\LanguageController;
use Chill\MainBundle\Controller\LocationController; use Chill\MainBundle\Controller\LocationController;
use Chill\MainBundle\Controller\LocationTypeController; use Chill\MainBundle\Controller\LocationTypeController;
use Chill\MainBundle\Controller\NewsItemController;
use Chill\MainBundle\Controller\RegroupmentController; use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController; use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobApiController;
@ -53,6 +54,7 @@ use Chill\MainBundle\Entity\GeographicalUnitLayer;
use Chill\MainBundle\Entity\Language; use Chill\MainBundle\Entity\Language;
use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\LocationType; use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Entity\Regroupment; use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
@ -62,6 +64,7 @@ use Chill\MainBundle\Form\CountryType;
use Chill\MainBundle\Form\LanguageType; use Chill\MainBundle\Form\LanguageType;
use Chill\MainBundle\Form\LocationFormType; use Chill\MainBundle\Form\LocationFormType;
use Chill\MainBundle\Form\LocationTypeType; use Chill\MainBundle\Form\LocationTypeType;
use Chill\MainBundle\Form\NewsItemType;
use Chill\MainBundle\Form\RegroupmentType; use Chill\MainBundle\Form\RegroupmentType;
use Chill\MainBundle\Form\UserJobType; use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType; use Chill\MainBundle\Form\UserType;
@ -544,6 +547,35 @@ class ChillMainExtension extends Extension implements
], ],
], ],
], ],
[
'class' => NewsItem::class,
'name' => 'news_item',
'base_path' => '/admin/news_item',
'form_class' => NewsItemType::class,
'controller' => NewsItemController::class,
'actions' => [
'index' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/index.html.twig',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/new.html.twig',
],
'view' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/view_admin.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/edit.html.twig',
],
'delete' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillMain/NewsItem/delete.html.twig',
],
],
],
], ],
'apis' => [ 'apis' => [
[ [

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*
* @ORM\Table(name="chill_main_dashboard_config_item")
*/
class DashboardConfigItem
{
/**
* @ORM\Id
*
* @ORM\GeneratedValue
*
* @ORM\Column(type="integer")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="string")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*
* @Assert\NotNull
*/
private string $type = '';
/**
* @ORM\Column(type="string")
*
* @Serializer\Groups({"dashboardConfigItem:read", "read"})
*
* @Assert\NotNull
*/
private string $position = '';
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private ?User $user = null;
/**
* @ORM\Column(type="json", options={"default": "[]", "jsonb": true})
*
* @Serializer\Groups({"dashboardConfigItem:read"})
*/
private array $metadata = [];
public function getId(): ?int
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getPosition(): string
{
return $this->position;
}
public function setPosition(string $position): void
{
$this->position = $position;
}
public function getUser(): User
{
return $this->user;
}
public function setUser(User $user): void
{
$this->user = $user;
}
public function getMetadata(): array
{
return $this->metadata;
}
public function setMetadata(array $metadata): void
{
$this->metadata = $metadata;
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
*
* @ORM\Table(name="chill_main_news")
*/
class NewsItem implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Id
*
* @ORM\GeneratedValue
*
* @ORM\Column(type="integer")
*
* @Groups({"read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="text")
*
* @Groups({"read"})
*
* @Assert\NotBlank
*
* @Assert\NotNull
*/
private string $title = '';
/**
* @ORM\Column(type="text")
*
* @Groups({"read"})
*
* @Assert\NotBlank
*
* @Assert\NotNull
*/
private string $content = '';
/**
* @ORM\Column(type="date_immutable", nullable=false)
*
* @Assert\NotNull
*
* @Groups({"read"})
*/
private ?\DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
*
* @Assert\GreaterThanOrEqual(propertyPath="startDate")
*
* @Groups({"read"})
*/
private ?\DateTimeImmutable $endDate = null;
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function getStartDate(): ?\DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?\DateTimeImmutable $startDate): void
{
$this->startDate = $startDate;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate;
}
public function getId(): ?int
{
return $this->id;
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NewsItemType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class, [
'required' => true,
])
->add('content', ChillTextareaType::class, [
'required' => false,
])
->add(
'startDate',
ChillDateType::class,
[
'required' => true,
'input' => 'datetime_immutable',
'label' => 'news.startDate',
]
)
->add('endDate', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'news.endDate',
]);
}
/**
* @return void
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', NewsItem::class);
}
}

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\NewsItem;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use Symfony\Component\Clock\ClockInterface;
class NewsItemRepository implements ObjectRepository
{
private readonly EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager, private readonly ClockInterface $clock)
{
$this->repository = $entityManager->getRepository(NewsItem::class);
}
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder
{
return $this->repository->createQueryBuilder($alias, $indexBy);
}
public function find($id)
{
return $this->repository->find($id);
}
public function findAll()
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null)
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return NewsItem::class;
}
private function buildBaseQuery(
?string $pattern = null
): QueryBuilder {
$qb = $this->createQueryBuilder('n');
$qb->where('n.startDate <= :now');
$qb->setParameter('now', $this->clock->now());
if (null !== $pattern && '' !== $pattern) {
$qb->andWhere($qb->expr()->like('LOWER(UNACCENT(n.title))', 'LOWER(UNACCENT(:pattern))'))
->orWhere($qb->expr()->like('LOWER(UNACCENT(n.content))', 'LOWER(UNACCENT(:pattern))'))
->setParameter('pattern', '%'.$pattern.'%');
}
return $qb;
}
public function findAllFilteredBySearchTerm(?string $pattern = null)
{
$qb = $this->buildBaseQuery($pattern);
$qb
->addOrderBy('n.startDate', 'DESC')
->addOrderBy('n.id', 'DESC');
return $qb->getQuery()->getResult();
}
/**
* @return list<NewsItem>
*/
public function findCurrentNews(?int $limit = null, ?int $offset = null): array
{
$qb = $this->buildQueryCurrentNews();
$qb->addOrderBy('n.startDate', 'DESC');
if (null !== $limit) {
$qb->setMaxResults($limit);
}
if (null !== $offset) {
$qb->setFirstResult($offset);
}
return $qb
->getQuery()
->getResult();
}
public function countAllFilteredBySearchTerm(?string $pattern = null)
{
$qb = $this->buildBaseQuery($pattern);
return $qb
->select('COUNT(n)')
->getQuery()
->getSingleScalarResult();
}
public function countCurrentNews()
{
return $this->buildQueryCurrentNews()
->select('COUNT(n)')
->getQuery()
->getSingleScalarResult();
}
private function buildQueryCurrentNews(): QueryBuilder
{
$now = $this->clock->now();
$qb = $this->createQueryBuilder('n');
$qb
->where(
$qb->expr()->andX(
$qb->expr()->lte('n.startDate', ':now'),
$qb->expr()->orX(
$qb->expr()->gt('n.endDate', ':now'),
$qb->expr()->isNull('n.endDate')
)
)
)
->setParameter('now', $now);
return $qb;
}
}

View File

@ -0,0 +1 @@
import './index.scss';

View File

@ -0,0 +1,7 @@
div.flex-table {
.news-content {
p {
margin-top: 1rem;
}
}
}

View File

@ -160,3 +160,11 @@ export interface LocationType {
contactData: "optional" | "required"; contactData: "optional" | "required";
title: TranslatableString; title: TranslatableString;
} }
export interface NewsItemType {
id: number;
title: string;
content: string;
startDate: DateTime;
endDate: DateTime | null;
}

View File

@ -97,6 +97,8 @@ import MyNotifications from './MyNotifications';
import MyWorkflows from './MyWorkflows.vue'; import MyWorkflows from './MyWorkflows.vue';
import TabCounter from './TabCounter'; import TabCounter from './TabCounter';
import { mapState } from "vuex"; import { mapState } from "vuex";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export default { export default {
name: "App", name: "App",
@ -112,7 +114,7 @@ export default {
}, },
data() { data() {
return { return {
activeTab: 'MyCustoms' activeTab: 'MyCustoms',
} }
}, },
computed: { computed: {
@ -126,8 +128,11 @@ export default {
}, },
methods: { methods: {
selectTab(tab) { selectTab(tab) {
this.$store.dispatch('getByTab', { tab: tab }); if (tab !== 'MyCustoms') {
this.$store.dispatch('getByTab', { tab: tab });
}
this.activeTab = tab; this.activeTab = tab;
console.log(this.activeTab)
} }
}, },
mounted() { 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> <template>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_dashboard') }}</span> <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 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>
<div class="mbloc col col-sm-6 col-lg-4"> <template v-if="this.hasDashboardItems">
<div class="custom1"> <template v-for="dashboardItem in this.dashboardItems">
<ul class="list-unstyled"> <div class="mbloc col-xs-12 col-sm-8 news" v-if="dashboardItem.type === 'news'">
<li v-if="counter.notifications > 0"> <News />
<i18n-t keypath="counter.unread_notifications" tag="span" :class="counterClass" :plural="counter.notifications"> </div>
<template v-slot:n><span>{{ counter.notifications }}</span></template> </template>
</i18n-t> </template>
</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>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from "vuex"; 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 { export default {
name: "MyCustoms", name: "MyCustoms",
components: {
News
},
data() { data() {
return { return {
counterClass: { counterClass: {
counter: true //hack to pass class 'counter' in i18n-t counter: true //hack to pass class 'counter' in i18n-t
} },
dashboardItems: [],
masonry: null,
} }
}, },
computed: { computed: {
@ -78,11 +75,19 @@ export default {
noResults() { noResults() {
return false return false
}, },
hasDashboardItems() {
return this.dashboardItems.length > 0;
}
}, },
mounted() { mounted() {
const elem = document.querySelector('#dashboards'); makeFetch('GET', '/api/1.0/main/dashboard-config-item.json')
const masonry = new Masonry(elem, {}); .then((response) => {
} this.dashboardItems = response;
})
.catch((error) => {
throw error
});
},
} }
</script> </script>
@ -98,4 +103,10 @@ span.counter {
background-color: unset; background-color: unset;
} }
} }
div.news {
max-height: 22rem;
overflow: hidden;
overflow-y: scroll;
}
</style> </style>

View File

@ -63,7 +63,15 @@ const appMessages = {
}, },
emergency: "Urgent", emergency: "Urgent",
confidential: "Confidentiel", 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) { catchError(state, error) {
state.errorMsg.push(error); state.errorMsg.push(error);
} },
}, },
actions: { actions: {
getByTab({ commit, getters }, { tab, param }) { getByTab({ commit, getters }, { tab, param }) {
switch (tab) { switch (tab) {
case 'MyCustoms':
break;
// case 'MyWorks': // case 'MyWorks':
// if (!getters.isWorksLoaded) { // if (!getters.isWorksLoaded) {
// commit('setLoading', true); // commit('setLoading', true);
@ -221,7 +219,7 @@ const store = createStore({
default: default:
throw 'tab '+ tab; throw 'tab '+ tab;
} }
} },
}, },
}); });

View File

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

View File

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

View File

@ -0,0 +1,13 @@
{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %}
{% block vertical_menu_content %}
{{ chill_menu('admin_news_item', {
'layout': '@ChillMain/Admin/menu_admin_section.html.twig',
}) }}
{% endblock %}
{% block layout_wvm_content %}
{% block admin_content %}<!-- block content empty -->
<h1>{{ 'admin.dashboard.description' | trans }}</h1>
{% endblock %}
{% endblock %}

View File

@ -1 +1 @@
{{ 'crud.%crud_name%.title_view'|trans({'%crud_name%' : crud_name }) }} {{ ('crud.' ~ crud_name ~ '.title_view')|trans({'%crud_name%' : crud_name }) }}

View File

@ -0,0 +1,30 @@
<div class="item-bloc">
<div class="item-row">
<h3>
{{ entity.title }}
</h3>
</div>
<div class="item-row">
<p>
{% if entity.startDate %}
<span>{{ entity.startDate|format_date('long') }}</span>
{% endif %}
{% if entity.endDate %}
<span> - {{ entity.endDate|format_date('long') }}</span>
{% endif %}
</p>
</div>
<div class="item-row separator">
<div>
{{ entity.content|u.truncate(350, '… [' ~ ('news.read_more'|trans) ~ '](' ~ chill_path_add_return_path('chill_main_single_news_item', { 'id': entity.id } ) ~ ')', false)|chill_markdown_to_html }}
</div>
</div>
<div class="item-row">
<ul class="record_actions">
<li>
<a href="{{ chill_path_add_return_path('chill_main_single_news_item', { 'id': entity.id } ) }}" class="btn btn-show btn-mini"></a>
</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,15 @@
<div class="flex-table">
<div class="item-bloc">
<p class="date-label">
<span>{{ entity.startDate|format_date('long') }}</span>
{% if entity.endDate is not null %}
<span> - {{ entity.endDate|format_date('long') }}</span>
{% endif %}
</p>
</div>
<div class="item-bloc">
<div class="news-content">
{{ entity.content|chill_markdown_to_html }}
</div>
</div>
</div>

View File

@ -0,0 +1,6 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_delete_content.html.twig' %}
{% endembed %}
{% endblock admin_content %}

View File

@ -0,0 +1,11 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock admin_content %}

View File

@ -0,0 +1,43 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block table_entities_thead_tr %}
<th>{{ 'Title'|trans }}</th>
<th>{{ 'news.startDate'|trans }}</th>
<th>{{ 'news.endDate'|trans }}</th>
{% endblock %}
{% block table_entities_tbody %}
{% for entity in entities %}
<tr>
<td>{{ entity.title }}</td>
<td>{{ entity.startDate|format_date('long') }}</td>
{% if entity.endDate is not null %}
<td>{{ entity.endDate|format_date('long') }}</td>
{% else %}
<td>{{ 'news.noDate'|trans }}</td>
{% endif %}
<td>
<ul class="record_actions">
<li>
<a href="{{ path('chill_crud_news_item_view', { 'id': entity.id }) }}" class="btn btn-show" title="{{ 'show'|trans }}"></a>
</li>
<li>
<a href="{{ path('chill_crud_news_item_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'edit'|trans }}"></a>
</li>
<li>
<a href="{{ path('chill_crud_news_item_delete', { 'id': entity.id }) }}" class="btn btn-delete" title="{{ 'delete'|trans }}"></a>
</li>
</ul>
</td>
</tr>
{% endfor %}
{% endblock %}
{% block actions_before %}
<li class='cancel'>
<a href="{{ path('chill_main_admin_central') }}" class="btn btn-cancel">{{'Back to the admin'|trans}}</a>
</li>
{% endblock %}
{% endembed %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_new_title.html.twig') %}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_new_content.html.twig' %}
{% block content_form_actions_save_and_show %}{% endblock %}
{% endembed %}
{% endblock admin_content %}

View File

@ -0,0 +1,70 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title %}
{{ 'news.title'|trans }}
{% endblock title %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_news') }}
{% endblock %}
{% block content %}
<div class="col-md-10 asideactivity-list">
<h2>{{ 'news.title'|trans }}</h2>
{{ filter_order|chill_render_filter_order_helper }}
{% if entities|length == 0 %}
<p class="chill-no-data-statement">
{{ "news.no_data"|trans }}
</p>
{% else %}
<div class="flex-table">
{% for entity in entities %}
<div class="item-bloc">
<div class="item-row">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col">
<h2>{{ entity.title }}</h2>
</div>
<div class="wl-col">
<p>
{% if entity.startDate %}
<span>{{ entity.startDate|format_date('long') }}</span>
{% endif %}
{% if entity.endDate %}
<span> - {{ entity.endDate|format_date('long') }}</span>
{% endif %}
</p>
</div>
</div>
</div>
</div>
<div class="item-row separator">
<div>
{{ entity.content|u.truncate(350, '…', false)|chill_markdown_to_html }}
</div>
</div>
{% if entity.content|length > 350 %}
<div class="item-row">
<ul class="record_actions">
<li>
<a href="{{ chill_path_add_return_path('chill_main_single_news_item', { 'id': entity.id } ) }}" class="btn btn-show btn-sm read-more">{{ 'news.read_more'|trans }}</a>
</li>
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{{ chill_pagination(paginator) }}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title 'news.show_details'|trans %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_news') }}
{% endblock %}
{% block content %}
<div class="col-md-10 col-xxl">
<div class="news-item-show">
<h1>{{ entity.title }}</h1>
{% include '@ChillMain/NewsItem/_show.html.twig' with { 'entity': entity } %}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_news_items_history') }}" class="btn btn-cancel">{{ 'Back to the list'|trans|chill_return_path_label }}</a>
</li>
</ul>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_view_title.html.twig') %}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_news') }}
{% endblock %}
{% block admin_content %}
<div class="col-md-10 col-xxl">
<div class="news-item-show">
<h1>{{ entity.title }}</h1>
{% include '@ChillMain/NewsItem/_show.html.twig' with { 'entity': entity } %}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_crud_news_item_index') }}" class="btn btn-cancel">{{ 'Back to the list'|trans|chill_return_path_label }}</a>
</li>
<li>
<a href="{{ path('chill_crud_news_item_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'edit'|trans }}"></a>
</li>
<li>
<a href="{{ path('chill_crud_news_item_delete', { 'id': entity.id }) }}" class="btn btn-delete" title="{{ 'delete'|trans }}"></a>
</li>
</ul>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Routing\MenuBuilder;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
class AdminNewsMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(private readonly AuthorizationCheckerInterface $authorizationChecker)
{
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
{
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
return;
}
$menu->addChild('admin.dashboard.title', [
'route' => 'chill_main_dashboard_admin',
])
->setAttribute('class', 'list-group-item-header')
->setExtras([
'order' => 9000,
]);
$menu->addChild('admin.dashboard.news', [
'route' => 'chill_crud_news_item_index',
])->setExtras(['order' => 9000]);
}
public static function getMenuIds(): array
{
return ['admin_section', 'admin_news_item'];
}
}

View File

@ -60,6 +60,14 @@ class SectionMenuBuilder implements LocalMenuBuilderInterface
'order' => 20, 'order' => 20,
]); ]);
} }
$menu->addChild($this->translator->trans('news.menu'), [
'route' => 'chill_main_news_items_history',
])
->setExtras([
'icons' => ['newspaper-o'],
'order' => 5,
]);
} }
public static function getMenuIds(): array public static function getMenuIds(): array

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Templating\Entity;
use Chill\MainBundle\Entity\NewsItem;
/**
* @implements ChillEntityRenderInterface<NewsItem>
*/
final readonly class NewsItemRender implements ChillEntityRenderInterface
{
public function renderBox($entity, array $options): string
{
return '';
}
public function renderString($entity, array $options): string
{
return $entity->getTitle();
}
public function supports($newsItem, array $options): bool
{
return $newsItem instanceof NewsItem;
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Controller;
use Chill\MainBundle\Test\PrepareClientTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*
* @coversNothing
*/
class NewsItemApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
public function testListCurrentNewsItems()
{
$client = $this->getClientAuthenticated();
$client->request('GET', '/api/1.0/main/news/current.json');
$this->assertResponseIsSuccessful('Testing whether the GET request to the news item Api endpoint was successful');
$responseContent = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
if (!empty($responseContent['data'][0])) {
$this->assertArrayHasKey('title', $responseContent['data'][0]);
}
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Controller;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Test\PrepareClientTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Tests the admin pages for news items.
*
* @internal
*
* @coversNothing
*/
class NewsItemControllerTest extends WebTestCase
{
use PrepareClientTrait;
/**
* @var list<array{0: class-string, 1: int}>
*/
private static array $entitiesToDelete = [];
private readonly EntityManagerInterface $em;
protected function tearDown(): void
{
self::ensureKernelShutdown();
}
public static function tearDownAfterClass(): void
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
foreach (self::$entitiesToDelete as [$class, $id]) {
$entity = $em->find($class, $id);
if (null !== $entity) {
$em->remove($entity);
}
}
$em->flush();
}
public static function generateNewsItemIds(): iterable
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$newsItem = new NewsItem();
$newsItem->setTitle('Lorem Ipsum');
$newsItem->setContent('some text');
$newsItem->setStartDate(new \DateTimeImmutable('now'));
$em->persist($newsItem);
$em->flush();
self::$entitiesToDelete[] = [NewsItem::class, $newsItem];
self::ensureKernelShutdown();
yield [$newsItem];
}
public function testList()
{
$client = $this->getClientAuthenticated('admin', 'password');
$client->request('GET', '/fr/admin/news_item');
self::assertResponseIsSuccessful('News item admin page shows');
}
/**
* @dataProvider generateNewsItemIds
*/
public function testShowSingleItem(NewsItem $newsItem)
{
$client = $this->getClientAuthenticated('admin', 'password');
$client->request('GET', "/fr/admin/news_item/{$newsItem->getId()}/view");
self::assertResponseIsSuccessful('Single news item admin page loads successfully');
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Controller;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Test\PrepareClientTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*
* @coversNothing
*/
class NewsItemsHistoryControllerTest extends WebTestCase
{
use PrepareClientTrait;
/**
* @var list<array{0: class-string, 1: NewsItem}>
*/
private static array $toDelete = [];
public static function tearDownAfterClass(): void
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
foreach (static::$toDelete as [$class, $entity]) {
$query = $em->createQuery(sprintf('DELETE FROM %s e WHERE e.id = :id', $class))
->setParameter('id', $entity->getId());
$query->execute();
}
static::$toDelete = [];
self::ensureKernelShutdown();
}
protected function tearDown(): void
{
self::ensureKernelShutdown();
}
public static function generateNewsItemIds(): iterable
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$news = new NewsItem();
$news->setContent('test content');
$news->setTitle('Title');
$news->setStartDate(new \DateTimeImmutable('yesterday'));
$em->persist($news);
$em->flush();
static::$toDelete[] = [NewsItem::class, $news];
self::ensureKernelShutdown();
yield [$news->getId()];
}
public function testList()
{
self::ensureKernelShutdown();
$client = $this->getClientAuthenticated();
$client->request('GET', '/fr/news-items/history');
self::assertResponseIsSuccessful('Test that /fr/news-items history shows');
}
/**
* @dataProvider generateNewsItemIds
*/
public function testShowSingleItem(int $newsItemId)
{
self::ensureKernelShutdown();
$client = $this->getClientAuthenticated();
$client->request('GET', "/fr/news-items/{$newsItemId}");
$this->assertResponseIsSuccessful('test that single news item page loads successfully');
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Repository;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Repository\NewsItemRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
class NewsItemRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
/**
* @var list<array{0: class-string, 1: NewsItem}>
*/
private array $toDelete = [];
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::$container->get(EntityManagerInterface::class);
}
protected function tearDown(): void
{
foreach ($this->toDelete as [$class, $entity]) {
$query = $this->entityManager->createQuery(sprintf('DELETE FROM %s e WHERE e.id = :id', $class))
->setParameter('id', $entity->getId());
$query->execute();
}
$this->toDelete = [];
}
private function getNewsItemsRepository(ClockInterface $clock): NewsItemRepository
{
return new NewsItemRepository($this->entityManager, $clock);
}
public function testFindCurrentNews()
{
$clock = new MockClock($now = new \DateTimeImmutable('2023-01-10'));
$repository = $this->getNewsItemsRepository($clock);
$newsItem1 = new NewsItem();
$newsItem1->setTitle('This is a mock news item');
$newsItem1->setContent('We are testing that the repository returns the correct news items');
$newsItem1->setStartDate(new \DateTimeImmutable('2023-01-01'));
$newsItem1->setEndDate(new \DateTimeImmutable('2023-01-05'));
$newsItem2 = new NewsItem();
$newsItem2->setTitle('This is a mock news item');
$newsItem2->setContent('We are testing that the repository returns the correct news items');
$newsItem2->setStartDate(new \DateTimeImmutable('2023-01-01'));
$newsItem2->setEndDate($now->add(new \DateInterval('P1D')));
$newsItem3 = new NewsItem();
$newsItem3->setTitle('This is a mock news item');
$newsItem3->setContent('We are testing that the repository returns the correct news items');
$newsItem3->setStartDate(new \DateTimeImmutable('2033-11-03'));
$newsItem3->setEndDate(null);
$newsItem4 = new NewsItem();
$newsItem4->setTitle('This is a mock news item');
$newsItem4->setContent('We are testing that the repository returns the correct news items');
$newsItem4->setStartDate(new \DateTimeImmutable('2023-01-03'));
$newsItem4->setEndDate(null);
$this->entityManager->persist($newsItem1);
$this->entityManager->persist($newsItem2);
$this->entityManager->persist($newsItem3);
$this->entityManager->persist($newsItem4);
$this->entityManager->flush();
$this->toDelete = [
[NewsItem::class, $newsItem1],
[NewsItem::class, $newsItem2],
[NewsItem::class, $newsItem3],
[NewsItem::class, $newsItem4],
];
// Call the method to test
$result = $repository->findCurrentNews();
// Assertions
$this->assertCount(2, $result);
$this->assertInstanceOf(NewsItem::class, $result[0]);
$this->assertContains($newsItem2, $result);
$this->assertContains($newsItem4, $result);
}
}

View File

@ -10,6 +10,12 @@ servers:
components: components:
schemas: schemas:
Date:
type: object
properties:
datetime:
type: string
format: date-time
User: User:
type: object type: object
properties: properties:
@ -131,6 +137,35 @@ components:
id: id:
type: integer type: integer
DashboardConfigItem:
type: object
properties:
id:
type: integer
type:
type: string
metadata:
type: object
userId:
type: integer
position:
type: string
NewsItem:
type: object
properties:
id:
type: integer
title:
type: string
content:
type: string
startDate:
$ref: "#/components/schemas/Date"
endDate:
$ref: "#/components/schemas/Date"
paths: paths:
/1.0/search.json: /1.0/search.json:
get: get:
@ -842,4 +877,34 @@ paths:
$ref: '#/components/schemas/Workflow' $ref: '#/components/schemas/Workflow'
403: 403:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/dashboard-config-item.json:
get:
tags:
- dashboard config item
summary: Returns the dashboard configuration for the current user.
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardConfigItem'
403:
description: "Unauthorized"
/1.0/main/news/current.json:
get:
tags:
- news items
summary: Returns a list of news items which are valid
responses:
200:
description: "ok"
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/NewsItem'
403:
description: "Unauthorized"

View File

@ -76,6 +76,7 @@ module.exports = function(encore, entries)
encore.addEntry('mod_pick_postal_code', __dirname + '/Resources/public/module/pick-postal-code/index.js'); encore.addEntry('mod_pick_postal_code', __dirname + '/Resources/public/module/pick-postal-code/index.js');
encore.addEntry('mod_pick_rolling_date', __dirname + '/Resources/public/module/pick-rolling-date/index.js'); encore.addEntry('mod_pick_rolling_date', __dirname + '/Resources/public/module/pick-rolling-date/index.js');
encore.addEntry('mod_address_details', __dirname + '/Resources/public/module/address-details/index'); encore.addEntry('mod_address_details', __dirname + '/Resources/public/module/address-details/index');
encore.addEntry('mod_news', __dirname + '/Resources/public/module/news/index.js');
// Vue entrypoints // Vue entrypoints
encore.addEntry('vue_address', __dirname + '/Resources/public/vuejs/Address/index.js'); encore.addEntry('vue_address', __dirname + '/Resources/public/vuejs/Address/index.js');

View File

@ -47,6 +47,8 @@ services:
Chill\MainBundle\Templating\Entity\AddressRender: ~ Chill\MainBundle\Templating\Entity\AddressRender: ~
Chill\MainBundle\Templating\Entity\NewsItemRender: ~
Chill\MainBundle\Templating\Entity\UserRender: ~ Chill\MainBundle\Templating\Entity\UserRender: ~
Chill\MainBundle\Templating\Listing\: Chill\MainBundle\Templating\Listing\:

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Create dashboard config item and news item.
*/
final class Version20231108141141 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create dashboard config item and news item';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_main_dashboard_config_item_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_main_news_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_main_dashboard_config_item (id INT NOT NULL, user_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, position VARCHAR(255) NOT NULL, metadata JSONB DEFAULT \'{}\'::jsonb, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_CF59DFD6A76ED395 ON chill_main_dashboard_config_item (user_id)');
$this->addSql('CREATE TABLE chill_main_news (id INT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, startDate DATE NOT NULL, endDate DATE DEFAULT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_96922AFB3174800F ON chill_main_news (createdBy_id)');
$this->addSql('CREATE INDEX IDX_96922AFB65FF1AEC ON chill_main_news (updatedBy_id)');
$this->addSql('COMMENT ON COLUMN chill_main_news.startDate IS \'(DC2Type:date_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_main_news.endDate IS \'(DC2Type:date_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_main_news.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_main_news.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_main_dashboard_config_item ADD CONSTRAINT FK_CF59DFD6A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_news ADD CONSTRAINT FK_96922AFB3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_news ADD CONSTRAINT FK_96922AFB65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_main_dashboard_config_item_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_main_news_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_main_dashboard_config_item DROP CONSTRAINT FK_CF59DFD6A76ED395');
$this->addSql('ALTER TABLE chill_main_news DROP CONSTRAINT FK_96922AFB3174800F');
$this->addSql('ALTER TABLE chill_main_news DROP CONSTRAINT FK_96922AFB65FF1AEC');
$this->addSql('DROP TABLE chill_main_dashboard_config_item');
$this->addSql('DROP TABLE chill_main_news');
}
}

View File

@ -82,7 +82,6 @@ Comment: Commentaire
Comments: Commentaires Comments: Commentaires
Pinned comment: Commentaire épinglé Pinned comment: Commentaire épinglé
Any comment: Aucun commentaire Any comment: Aucun commentaire
Read more: Lire la suite
(more...): (suite...) (more...): (suite...)
# comment embeddable # comment embeddable
@ -438,6 +437,16 @@ crud:
add_new: Ajouter un centre add_new: Ajouter un centre
title_new: Nouveau centre title_new: Nouveau centre
title_edit: Modifier un centre title_edit: Modifier un centre
news_item:
index:
title: Liste des actualités
add_new: Créer une nouvelle actualité
title_new: Nouvelle actualité
title_view: Voir l'actualité
title_edit: Modifier une actualité
title_delete: Supprimer une actualité
button_delete: Supprimer
confirm_message_delete: Êtes-vous sûr de vouloir supprimer l'actualité, "%as_string%" ?
No entities: Aucun élément No entities: Aucun élément
@ -679,3 +688,20 @@ admin:
undefined: non défini undefined: non défini
user: Utilisateur user: Utilisateur
scope: Service scope: Service
dashboard:
title: Tableau de bord
news: Actualités
description: Configuration du tableau de bord
news:
noDate: Pas de date de fin
startDate: Date de début de publication
endDate: Date de fin de publication sur la page d'accueil
title: Historique des actualités
menu: Actualités
no_data: Aucune actualité
read_more: Lire la suite
show_details: Voir l'actualité