Compare commits

..

17 Commits

Author SHA1 Message Date
Jürgen Mummert b525f37862 Wrap select filters in dedicated container 2026-02-22 12:14:24 +01:00
Jürgen Mummert 1b477c24da Wrap event filter tag buttons in dedicated container 2026-02-22 12:11:46 +01:00
Jürgen Mummert 85b669d214 Improve event filter accessibility semantics and live status 2026-02-22 11:32:59 +01:00
Jürgen Mummert e3cc85115b Add configurable target event list ID for event filter module 2026-02-22 11:27:26 +01:00
Jürgen Mummert 3a24b24b84 Fix event filter location and tags column mapping 2026-02-22 11:13:03 +01:00
Jürgen Mummert 68d7f9ef66 Fix calendar query for instances without tl_calendar.published 2026-02-22 11:08:07 +01:00
Jürgen Mummert 9668a455f3 Fix DBAL parameter types in event filter queries 2026-02-22 11:04:09 +01:00
Jürgen Mummert 0685f8eeb1 Add event filter frontend module with server-side aggregates 2026-02-22 11:01:12 +01:00
Jürgen Mummert ba10d1c403 Refactor event full data provider for Twig layout 2026-02-22 10:36:15 +01:00
Jürgen Mummert d1258ff5f1 Add event full template data hook 2026-02-22 09:53:31 +01:00
Jürgen Mummert a59c4d6146 Add location title to event teaser payload 2026-02-22 09:44:16 +01:00
Jürgen Mummert b328b36e14 Refactor teaser listener to batch prefetch 2026-02-22 09:29:27 +01:00
Jürgen Mummert 717d243cec Ensure teaser data hook runs after tags listener 2026-02-22 09:11:05 +01:00
Jürgen Mummert 181445e1e3 Add event teaser data attribute listener 2026-02-22 08:56:17 +01:00
Jürgen Mummert 43579dd44b Set lat/lng SQL precision to DECIMAL(11,8) 2026-02-22 07:04:43 +01:00
Jürgen Mummert f93ed0d0c6 Refactor type handling to contao-tags and add module tag filters 2026-02-21 22:23:53 +01:00
Jürgen Mummert a6440c74a2 Update composer metadata 2026-02-17 19:08:34 +01:00
26 changed files with 1403 additions and 284 deletions
-216
View File
@@ -1,216 +0,0 @@
Create a Contao 5.7 bundle (PHP 8.4)
Vendor: MummertMedia
Bundle: EventManagerBundle
Composer name: mummert-media/eventmanager-bundle
Type: contao-bundle
This bundle is internal only.
IMPORTANT:
Only implement backend functionality.
No frontend logic.
No controllers.
No services.
No access logic.
No event listeners.
No future planning.
Only backend entities and relations.
====================================================
GOAL
====================================================
Extend Contao backend with:
1) New entity: tl_organization named Organisationen, just german translation
2) New entity: tl_location named Veranstaltungsorte, just german translation
3) Join table: tl_member_organization
4) Join table: tl_calendar_events_organization
5) Extend tl_calendar_events with:
- location_id (exactly one location) with dropdown in Backend (title ASC)
- organization assignment (multiple via join table)
6) Extend tl_member so that:
- A member can belong to multiple organizations
7) Extend tl_organization so that:
- Multiple members can be assigned to it
Everything manageable via Contao backend only.
====================================================
DATABASE TABLES
====================================================
Use Contao DCA SQL definitions (DBAL 4 compatible).
Do NOT use serialized fields.
Do NOT use string length values like "255".
Use integer values.
------------------------------------
tl_organization + add german translation
------------------------------------
id int unsigned auto_increment
pid int unsigned default 0
tstamp int unsigned default 0
dateAdded int unsigned default 0
title varchar(255)
alias varchar(128)
logo binary(16) nullable
street varchar(255)
street2 varchar(255)
postal varchar(255)
city varchar(255)
state varchar(255)
country varchar(2)
phone varchar(64)
email varchar(255)
website varchar(255)
lat decimal(10,8)
lng decimal(11,8)
description text nullable
published char(1)
isExternal char(1)
type varchar(32)
------------------------------------
tl_location + add german translation
------------------------------------
id int unsigned auto_increment
tstamp int unsigned default 0
dateAdded int unsigned default 0
title varchar(255)
alias varchar(128)
description text nullable
street varchar(255)
street2 varchar(255)
postal varchar(255)
city varchar(255)
state varchar(255)
country varchar(2) default 'de'
lat decimal(10,8)
lng decimal(11,8)
image binary(16) nullable
published char(1)
------------------------------------
tl_member_organization
------------------------------------
id int unsigned auto_increment
tstamp int unsigned default 0
member_id int unsigned
organization_id int unsigned
------------------------------------
tl_calendar_events_organization
------------------------------------
id int unsigned auto_increment
tstamp int unsigned default 0
event_id int unsigned
organization_id int unsigned
====================================================
DCA REQUIREMENTS
====================================================
1) tl_organization:
- Backend list view
- Editable fields
- Published toggle
- Alias field
- Type as select field with static options:
accommodation, shopping, culture
- Add multi-relation field for members
(store relation in tl_member_organization)
2) tl_location:
- Backend list view
- Editable fields
- Published toggle
- Alias field
3) tl_calendar_events:
- Add field location_id (select, mandatory)
foreignKey: tl_location.title
- Add multi-organization relation
(store relation in tl_calendar_events_organization)
4) tl_member:
- Add multi-organization relation
(store relation in tl_member_organization)
====================================================
RELATION HANDLING
====================================================
Use proper many-to-many handling via join tables.
Use relation definitions in DCA.
Do NOT use serialized arrays.
Do NOT store IDs as comma-separated strings.
====================================================
BACKEND INTEGRATION
====================================================
Register backend modules for:
- Organisationen
- Veranstaltungsorte
Under backend group content
Use standard Contao backend module registration.
====================================================
PROJECT STRUCTURE
====================================================
Generate:
src/
MummertMediaEventManagerBundle.php
DependencyInjection/
MummertMediaEventManagerExtension.php
Contao/
Manager/
Plugin.php
contao/
dca/
tl_organization.php
tl_location.php
tl_member.php
tl_calendar_events.php
config/
config.php
config/
services.yaml
composer.json
====================================================
QUALITY RULES
====================================================
- declare(strict_types=1);
- PHP 8.4
- DBAL 4 compatible SQL
- no deprecated Contao 4 syntax
- no frontend logic
- no controllers
- no services
- no placeholders
- complete working DCA
- ready to install via path repository
====================================================
OUTPUT
====================================================
Generate full file contents for all required files.
Code must be production-ready.
+10 -3
View File
@@ -9,9 +9,10 @@
"eventmanager" "eventmanager"
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.3",
"contao/core-bundle": "^5.7", "contao/core-bundle": "^5.7",
"contao/manager-plugin": "^2.0" "contao/manager-plugin": "^2.0",
"numero2/contao-tags": "^0.5"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -21,5 +22,11 @@
"extra": { "extra": {
"contao-manager-plugin": "MummertMedia\\EventManagerBundle\\Contao\\Manager\\Plugin" "contao-manager-plugin": "MummertMedia\\EventManagerBundle\\Contao\\Manager\\Plugin"
}, },
"prefer-stable": true "prefer-stable": true,
"config": {
"allow-plugins": {
"contao-components/installer": true,
"contao/manager-plugin": true
}
}
} }
+10 -6
View File
@@ -22,7 +22,7 @@ $GLOBALS['TL_DCA']['tl_calendar_events']['config']['onload_callback'][] = static
PaletteManipulator::create() PaletteManipulator::create()
->addLegend('organization_legend', 'details_legend', PaletteManipulator::POSITION_AFTER) ->addLegend('organization_legend', 'details_legend', PaletteManipulator::POSITION_AFTER)
->addField(['location_id', 'type', 'organizations'], 'organization_legend', PaletteManipulator::POSITION_APPEND) ->addField(['location_id', 'tags', 'organizations'], 'organization_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_calendar_events'); ->applyToPalette((string) $paletteName, 'tl_calendar_events');
PaletteManipulator::create() PaletteManipulator::create()
@@ -71,14 +71,18 @@ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['location_id'] = [
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['type'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['tags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type'], 'label' => &$GLOBALS['TL_LANG']['tl_calendar_events']['tags'],
'exclude' => true, 'exclude' => true,
'filter' => true,
'inputType' => 'select', 'inputType' => 'select',
'options' => ['accommodation', 'shopping', 'culture'], 'foreignKey' => 'tl_tags.tag',
'reference' => &$GLOBALS['TL_LANG']['tl_calendar_events']['type_options'], 'options_callback' => ['numero2_tags.listener.data_container.tags', 'getTagOptions'],
'eval' => ['multiple' => true, 'chosen' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'], 'load_callback' => [['numero2_tags.listener.data_container.tags', 'loadTags']],
'save_callback' => [['numero2_tags.listener.data_container.tags', 'saveTags']],
'eval' => ['multiple' => true, 'size' => 8, 'tl_class' => 'w50 tags', 'chosen' => true, 'groupTagsByField' => true, 'tagGroup' => 'event_type'],
'sql' => ['type' => 'blob', 'notnull' => false], 'sql' => ['type' => 'blob', 'notnull' => false],
'relation' => ['type' => 'hasMany', 'load' => 'eager'],
]; ];
$GLOBALS['TL_DCA']['tl_calendar_events']['fields']['organizations'] = [ $GLOBALS['TL_DCA']['tl_calendar_events']['fields']['organizations'] = [
+2 -2
View File
@@ -148,8 +148,8 @@ $GLOBALS['TL_DCA']['tl_location'] = [
'label' => &$GLOBALS['TL_LANG']['tl_location']['lat'], 'label' => &$GLOBALS['TL_LANG']['tl_location']['lat'],
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 10, 'tl_class' => 'w50'], 'eval' => ['maxlength' => 11, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 8, 'default' => '0.00000000'], 'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'],
], ],
'lng' => [ 'lng' => [
'label' => &$GLOBALS['TL_LANG']['tl_location']['lng'], 'label' => &$GLOBALS['TL_LANG']['tl_location']['lng'],
+97 -2
View File
@@ -2,10 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
use Contao\Database;
use Contao\StringUtil;
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_organizations'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['member_organizations'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_events'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['member_events'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['organization_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,logoFolder;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['event_filter'] = '{title_legend},name,headline,type;{eventmanager_legend},cal_calendar,eventListDomId;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,eventFolder,termsPage,frontendAuthorId,frontendArchiveId;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID'; $GLOBALS['TL_DCA']['tl_module']['palettes']['organization_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,logoFolder,organizationTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,eventFolder,termsPage,frontendAuthorId,frontendArchiveId,eventTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['fields']['editPage'] = [ $GLOBALS['TL_DCA']['tl_module']['fields']['editPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['editPage'], 'label' => &$GLOBALS['TL_LANG']['tl_module']['editPage'],
@@ -62,3 +66,94 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['frontendArchiveId'] = [
'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'], 'eval' => ['mandatory' => true, 'rgxp' => 'digit', 'maxlength' => 10, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
]; ];
$GLOBALS['TL_DCA']['tl_module']['fields']['organizationTypeTags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'],
'exclude' => true,
'inputType' => 'checkbox',
'options_callback' => static function () {
$rows = Database::getInstance()
->prepare('SELECT DISTINCT t.id, t.tag FROM tl_tags t LEFT JOIN tl_tags_rel r ON r.tag_id=t.id AND r.ptable=? AND r.field=? ORDER BY t.tag ASC')
->execute('tl_organization', 'tags')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['tag'];
}
return $options;
},
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
'sql' => ['type' => 'blob', 'notnull' => false],
'save_callback' => [
static function ($value): array {
return array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
},
],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['eventTypeTags'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['eventTypeTags'],
'exclude' => true,
'inputType' => 'checkbox',
'options_callback' => static function () {
$rows = Database::getInstance()
->prepare('SELECT DISTINCT t.id, t.tag FROM tl_tags t LEFT JOIN tl_tags_rel r ON r.tag_id=t.id AND r.ptable=? AND r.field=? ORDER BY t.tag ASC')
->execute('tl_calendar_events', 'tags')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['tag'];
}
return $options;
},
'eval' => ['multiple' => true, 'tl_class' => 'clr'],
'sql' => ['type' => 'blob', 'notnull' => false],
'save_callback' => [
static function ($value): array {
return array_values(array_unique(array_map('intval', StringUtil::deserialize($value, true))));
},
],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['eventListDomId'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['eventListDomId'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => static function (): array {
$rows = Database::getInstance()
->prepare('SELECT id, name, cssID FROM tl_module WHERE type=? ORDER BY name ASC, id ASC')
->execute('eventlist')
->fetchAllAssoc();
$options = [];
foreach ($rows as $row) {
$moduleId = (int) ($row['id'] ?? 0);
if ($moduleId <= 0) {
continue;
}
$cssId = StringUtil::deserialize($row['cssID'] ?? null, true);
$domId = trim((string) ($cssId[0] ?? ''));
$domId = '' !== $domId ? $domId : sprintf('mod_eventlist_%d', $moduleId);
$moduleName = trim((string) ($row['name'] ?? ''));
if ('' === $moduleName) {
$moduleName = sprintf('Eventliste %d', $moduleId);
}
$options[$domId] = sprintf('%s [%s]', $moduleName, $domId);
}
return $options;
},
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 128, 'default' => ''],
];
+11 -8
View File
@@ -70,7 +70,7 @@ $GLOBALS['TL_DCA']['tl_organization'] = [
], ],
'palettes' => [ 'palettes' => [
'__selector__' => [], '__selector__' => [],
'default' => '{title_legend},title,alias,type,isExternal,logo;{address_legend},street,street2,postal,city,state,country;{contact_legend},phone,email,website;{geo_legend},lat,lng;{description_legend},description;{relation_legend},members;{publish_legend},published', 'default' => '{title_legend},title,alias,tags,isExternal,logo;{address_legend},street,street2,postal,city,state,country;{contact_legend},phone,email,website;{geo_legend},lat,lng;{description_legend},description;{relation_legend},members;{publish_legend},published',
], ],
'fields' => [ 'fields' => [
'id' => [ 'id' => [
@@ -178,8 +178,8 @@ $GLOBALS['TL_DCA']['tl_organization'] = [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['lat'], 'label' => &$GLOBALS['TL_LANG']['tl_organization']['lat'],
'exclude' => true, 'exclude' => true,
'inputType' => 'text', 'inputType' => 'text',
'eval' => ['maxlength' => 10, 'tl_class' => 'w50'], 'eval' => ['maxlength' => 11, 'tl_class' => 'w50'],
'sql' => ['type' => 'decimal', 'precision' => 10, 'scale' => 8, 'default' => '0.00000000'], 'sql' => ['type' => 'decimal', 'precision' => 11, 'scale' => 8, 'default' => '0.00000000'],
], ],
'lng' => [ 'lng' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['lng'], 'label' => &$GLOBALS['TL_LANG']['tl_organization']['lng'],
@@ -211,14 +211,17 @@ $GLOBALS['TL_DCA']['tl_organization'] = [
'eval' => ['tl_class' => 'w50'], 'eval' => ['tl_class' => 'w50'],
'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''],
], ],
'type' => [ 'tags' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['type'], 'label' => &$GLOBALS['TL_LANG']['tl_organization']['tags'],
'exclude' => true, 'exclude' => true,
'inputType' => 'select', 'inputType' => 'select',
'options' => ['accommodation', 'shopping', 'culture'], 'foreignKey' => 'tl_tags.tag',
'reference' => &$GLOBALS['TL_LANG']['tl_organization']['type_options'], 'options_callback' => ['numero2_tags.listener.data_container.tags', 'getTagOptions'],
'eval' => ['multiple' => true, 'chosen' => true, 'includeBlankOption' => false, 'tl_class' => 'w50'], 'load_callback' => [['numero2_tags.listener.data_container.tags', 'loadTags']],
'save_callback' => [['numero2_tags.listener.data_container.tags', 'saveTags']],
'eval' => ['multiple' => true, 'size' => 8, 'tl_class' => 'w50 tags', 'chosen' => true, 'groupTagsByField' => true, 'tagGroup' => 'organization_type'],
'sql' => ['type' => 'blob', 'notnull' => false], 'sql' => ['type' => 'blob', 'notnull' => false],
'relation' => ['type' => 'hasMany', 'load' => 'eager'],
], ],
'members' => [ 'members' => [
'label' => &$GLOBALS['TL_LANG']['tl_organization']['members'], 'label' => &$GLOBALS['TL_LANG']['tl_organization']['members'],
+1
View File
@@ -9,4 +9,5 @@ $GLOBALS['TL_LANG']['FMD']['eventmanager'] = 'Event-Manager';
$GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['Meine Organisationen', 'Listet Organisationen des eingeloggten Mitglieds auf.']; $GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['Meine Organisationen', 'Listet Organisationen des eingeloggten Mitglieds auf.'];
$GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Organisation bearbeiten', 'Bearbeitungsformular für eine Organisation.']; $GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Organisation bearbeiten', 'Bearbeitungsformular für eine Organisation.'];
$GLOBALS['TL_LANG']['FMD']['member_events'] = ['Meine Veranstaltungen', 'Listet Veranstaltungen der zugeordneten Organisationen auf.']; $GLOBALS['TL_LANG']['FMD']['member_events'] = ['Meine Veranstaltungen', 'Listet Veranstaltungen der zugeordneten Organisationen auf.'];
$GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Eventfilter', 'Filter für kommende Veranstaltungen (Tags, Orte, Veranstalter).'];
$GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Veranstaltung bearbeiten', 'Bearbeitungsformular für eine Veranstaltung.']; $GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Veranstaltung bearbeiten', 'Bearbeitungsformular für eine Veranstaltung.'];
+1 -5
View File
@@ -4,13 +4,9 @@ declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_calendar_events']['organization_legend'] = 'Organisationen und Ort'; $GLOBALS['TL_LANG']['tl_calendar_events']['organization_legend'] = 'Organisationen und Ort';
$GLOBALS['TL_LANG']['tl_calendar_events']['location_id'] = ['Veranstaltungsort', 'Zugeordneter Veranstaltungsort']; $GLOBALS['TL_LANG']['tl_calendar_events']['location_id'] = ['Veranstaltungsort', 'Zugeordneter Veranstaltungsort'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type'] = ['Typ', 'Mehrere Typen auswählbar.']; $GLOBALS['TL_LANG']['tl_calendar_events']['tags'] = ['Typen', 'Vorhandene Tags für Veranstaltungstypen auswählen.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['organizations'] = ['Organisationen', 'Zugeordnete Organisationen']; $GLOBALS['TL_LANG']['tl_calendar_events']['organizations'] = ['Organisationen', 'Zugeordnete Organisationen'];
$GLOBALS['TL_LANG']['tl_calendar_events']['photographer'] = ['Urheber/Fotograf', 'Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.']; $GLOBALS['TL_LANG']['tl_calendar_events']['photographer'] = ['Urheber/Fotograf', 'Die Angabe des Urhebers ist notwendig. Ihnen muss eine Genehmigung zur Verwendung des Bildes vorliegen.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'] = ['Ausverkauft', 'Diese Veranstaltung ist ausverkauft.']; $GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'] = ['Ausverkauft', 'Diese Veranstaltung ist ausverkauft.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'] = ['Abgesagt', 'Diese Veranstaltung wurde abgesagt.']; $GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'] = ['Abgesagt', 'Diese Veranstaltung wurde abgesagt.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'] = ['Nutzungsbedingungen akzeptiert', 'Die Nutzungsbedingungen wurden akzeptiert.']; $GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'] = ['Nutzungsbedingungen akzeptiert', 'Die Nutzungsbedingungen wurden akzeptiert.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['accommodation'] = 'Unterkunft';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['culture'] = 'Kultur';
+3
View File
@@ -10,3 +10,6 @@ $GLOBALS['TL_LANG']['tl_module']['eventFolder'] = ['Event-Ordner', 'Bitte wähle
$GLOBALS['TL_LANG']['tl_module']['termsPage'] = ['Seite mit Nutzungsbedingungen', 'Optional: Seite mit den Nutzungsbedingungen, die im Frontend beim Zustimmungs-Label verlinkt wird.']; $GLOBALS['TL_LANG']['tl_module']['termsPage'] = ['Seite mit Nutzungsbedingungen', 'Optional: Seite mit den Nutzungsbedingungen, die im Frontend beim Zustimmungs-Label verlinkt wird.'];
$GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'] = ['Backend Benutzer ID', 'ID des Backend-Benutzers, der als Autor für frontendseitig angelegte Events gesetzt wird.']; $GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'] = ['Backend Benutzer ID', 'ID des Backend-Benutzers, der als Autor für frontendseitig angelegte Events gesetzt wird.'];
$GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['ID des Newsarchivs', 'Archiv-ID (pid), in das frontendseitig angelegte Events gespeichert werden.']; $GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['ID des Newsarchivs', 'Archiv-ID (pid), in das frontendseitig angelegte Events gespeichert werden.'];
$GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Eventlisten-ID (DOM)', 'Wählen Sie die ID der Eventliste, die durch das Event-Filter-Modul gefiltert werden soll.'];
$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Anzuzeigende Organisationstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Organisationstyp-Tags. Leer = alle.'];
$GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Anzuzeigende Veranstaltungstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Veranstaltungstyp-Tags. Leer = alle.'];
+1 -5
View File
@@ -19,13 +19,9 @@ $GLOBALS['TL_LANG']['tl_organization']['lng'] = ['Längengrad', 'Längengrad'];
$GLOBALS['TL_LANG']['tl_organization']['description'] = ['Beschreibung', 'Beschreibung']; $GLOBALS['TL_LANG']['tl_organization']['description'] = ['Beschreibung', 'Beschreibung'];
$GLOBALS['TL_LANG']['tl_organization']['published'] = ['Veröffentlicht', 'Organisation veröffentlichen']; $GLOBALS['TL_LANG']['tl_organization']['published'] = ['Veröffentlicht', 'Organisation veröffentlichen'];
$GLOBALS['TL_LANG']['tl_organization']['isExternal'] = ['Extern', 'Externe Organisation']; $GLOBALS['TL_LANG']['tl_organization']['isExternal'] = ['Extern', 'Externe Organisation'];
$GLOBALS['TL_LANG']['tl_organization']['type'] = ['Typ', 'Mehrere Typen auswählbar.']; $GLOBALS['TL_LANG']['tl_organization']['tags'] = ['Typen', 'Vorhandene Tags für Organisationstypen auswählen.'];
$GLOBALS['TL_LANG']['tl_organization']['members'] = ['Mitglieder', 'Zugeordnete Mitglieder']; $GLOBALS['TL_LANG']['tl_organization']['members'] = ['Mitglieder', 'Zugeordnete Mitglieder'];
$GLOBALS['TL_LANG']['tl_organization']['type_options']['accommodation'] = 'Unterkunft';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['culture'] = 'Kultur';
$GLOBALS['TL_LANG']['tl_organization']['title_legend'] = 'Titel'; $GLOBALS['TL_LANG']['tl_organization']['title_legend'] = 'Titel';
$GLOBALS['TL_LANG']['tl_organization']['address_legend'] = 'Adresse'; $GLOBALS['TL_LANG']['tl_organization']['address_legend'] = 'Adresse';
$GLOBALS['TL_LANG']['tl_organization']['contact_legend'] = 'Kontakt'; $GLOBALS['TL_LANG']['tl_organization']['contact_legend'] = 'Kontakt';
+1
View File
@@ -9,4 +9,5 @@ $GLOBALS['TL_LANG']['FMD']['eventmanager'] = 'Event manager';
$GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['My organizations', 'Lists organizations of the logged-in member.']; $GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['My organizations', 'Lists organizations of the logged-in member.'];
$GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Edit organization', 'Edit form for one organization.']; $GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Edit organization', 'Edit form for one organization.'];
$GLOBALS['TL_LANG']['FMD']['member_events'] = ['My events', 'Lists events of the member organizations.']; $GLOBALS['TL_LANG']['FMD']['member_events'] = ['My events', 'Lists events of the member organizations.'];
$GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Event filter', 'Filters upcoming events (tags, locations, organizers).'];
$GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Edit event', 'Edit form for one event.']; $GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Edit event', 'Edit form for one event.'];
+1 -5
View File
@@ -4,13 +4,9 @@ declare(strict_types=1);
$GLOBALS['TL_LANG']['tl_calendar_events']['organization_legend'] = 'Organizations and location'; $GLOBALS['TL_LANG']['tl_calendar_events']['organization_legend'] = 'Organizations and location';
$GLOBALS['TL_LANG']['tl_calendar_events']['location_id'] = ['Location', 'Assigned location']; $GLOBALS['TL_LANG']['tl_calendar_events']['location_id'] = ['Location', 'Assigned location'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type'] = ['Type', 'Multiple types can be selected.']; $GLOBALS['TL_LANG']['tl_calendar_events']['tags'] = ['Types', 'Select existing tags for event types.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['organizations'] = ['Organizations', 'Assigned organizations']; $GLOBALS['TL_LANG']['tl_calendar_events']['organizations'] = ['Organizations', 'Assigned organizations'];
$GLOBALS['TL_LANG']['tl_calendar_events']['photographer'] = ['Author/Photographer', 'Please provide the image author/photographer and make sure you have permission to use the image.']; $GLOBALS['TL_LANG']['tl_calendar_events']['photographer'] = ['Author/Photographer', 'Please provide the image author/photographer and make sure you have permission to use the image.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'] = ['Sold out', 'This event is sold out.']; $GLOBALS['TL_LANG']['tl_calendar_events']['isSoldOut'] = ['Sold out', 'This event is sold out.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'] = ['Canceled', 'This event has been canceled.']; $GLOBALS['TL_LANG']['tl_calendar_events']['isCanceled'] = ['Canceled', 'This event has been canceled.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'] = ['Terms accepted', 'The terms of use have been accepted.']; $GLOBALS['TL_LANG']['tl_calendar_events']['termsAccepted'] = ['Terms accepted', 'The terms of use have been accepted.'];
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['accommodation'] = 'Accommodation';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_calendar_events']['type_options']['culture'] = 'Culture';
+3
View File
@@ -10,3 +10,6 @@ $GLOBALS['TL_LANG']['tl_module']['eventFolder'] = ['Event folder', 'Please selec
$GLOBALS['TL_LANG']['tl_module']['termsPage'] = ['Terms page', 'Optional: page containing the terms of use linked from the frontend consent label.']; $GLOBALS['TL_LANG']['tl_module']['termsPage'] = ['Terms page', 'Optional: page containing the terms of use linked from the frontend consent label.'];
$GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'] = ['Backend user ID', 'Backend user ID that should be set as author for events created from the frontend.']; $GLOBALS['TL_LANG']['tl_module']['frontendAuthorId'] = ['Backend user ID', 'Backend user ID that should be set as author for events created from the frontend.'];
$GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['News archive ID', 'Archive ID (pid) where events created from the frontend should be stored.']; $GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['News archive ID', 'Archive ID (pid) where events created from the frontend should be stored.'];
$GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Event list ID (DOM)', 'Select the event list ID that should be filtered by the event filter module.'];
$GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Displayed organization types', 'Optional: Limit which organization type tags can be selected in the frontend. Empty = all.'];
$GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Displayed event types', 'Optional: Limit which event type tags can be selected in the frontend. Empty = all.'];
+1 -5
View File
@@ -19,13 +19,9 @@ $GLOBALS['TL_LANG']['tl_organization']['lng'] = ['Longitude', 'Longitude'];
$GLOBALS['TL_LANG']['tl_organization']['description'] = ['Description', 'Description']; $GLOBALS['TL_LANG']['tl_organization']['description'] = ['Description', 'Description'];
$GLOBALS['TL_LANG']['tl_organization']['published'] = ['Published', 'Publish organization']; $GLOBALS['TL_LANG']['tl_organization']['published'] = ['Published', 'Publish organization'];
$GLOBALS['TL_LANG']['tl_organization']['isExternal'] = ['External', 'External organization']; $GLOBALS['TL_LANG']['tl_organization']['isExternal'] = ['External', 'External organization'];
$GLOBALS['TL_LANG']['tl_organization']['type'] = ['Type', 'Multiple types can be selected.']; $GLOBALS['TL_LANG']['tl_organization']['tags'] = ['Types', 'Select existing tags for organization types.'];
$GLOBALS['TL_LANG']['tl_organization']['members'] = ['Members', 'Assigned members']; $GLOBALS['TL_LANG']['tl_organization']['members'] = ['Members', 'Assigned members'];
$GLOBALS['TL_LANG']['tl_organization']['type_options']['accommodation'] = 'Accommodation';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['shopping'] = 'Shopping';
$GLOBALS['TL_LANG']['tl_organization']['type_options']['culture'] = 'Culture';
$GLOBALS['TL_LANG']['tl_organization']['title_legend'] = 'Title'; $GLOBALS['TL_LANG']['tl_organization']['title_legend'] = 'Title';
$GLOBALS['TL_LANG']['tl_organization']['address_legend'] = 'Address'; $GLOBALS['TL_LANG']['tl_organization']['address_legend'] = 'Address';
$GLOBALS['TL_LANG']['tl_organization']['contact_legend'] = 'Contact'; $GLOBALS['TL_LANG']['tl_organization']['contact_legend'] = 'Contact';
@@ -32,7 +32,7 @@
{{ form_row(form.organization_ids) }} {{ form_row(form.organization_ids) }}
{% endif %} {% endif %}
{{ form_row(form.location_id) }} {{ form_row(form.location_id) }}
{{ form_row(form.type) }} {{ form_row(form.tags) }}
{{ form_row(form.teaser) }} {{ form_row(form.teaser) }}
{{ form_row(form.description) }} {{ form_row(form.description) }}
{{ form_row(form.url) }} {{ form_row(form.url) }}
@@ -59,7 +59,7 @@
(function () { (function () {
const locationSelect = document.querySelector('select.js-location-choice'); const locationSelect = document.querySelector('select.js-location-choice');
const organizationSelect = document.querySelector('select.js-organization-choice'); const organizationSelect = document.querySelector('select.js-organization-choice');
const typeSelect = document.querySelector('select.js-event-type-choice'); const typeSelect = document.querySelector('select.js-event-tags-choice');
if (organizationSelect && typeof window.Choices === 'function') { if (organizationSelect && typeof window.Choices === 'function') {
new window.Choices(organizationSelect, { new window.Choices(organizationSelect, {
@@ -0,0 +1,261 @@
<div id="eventfilters" data-eventlist-id="{{ targetEventListId|default('eventlist')|e('html_attr') }}">
<div class="tag-buttons">
<button type="button" data-filter-tag="all" class="active" aria-pressed="true">Alle</button>
{% for tag in tagButtons|default([]) %}
<button type="button" data-filter-tag="{{ tag.id }}" aria-pressed="false">{{ tag.title }} ({{ tag.count }})</button>
{% endfor %}
</div>
<div class="select-filters">
<div class="widget-select places">
<label for="location-filter" class="visually-hidden">Orte:</label>
<select id="location-filter">
<option value="all">Orte</option>
{% for location in locations|default([]) %}
<option value="location-{{ location.id }}">{{ location.title }} ({{ location.count }})</option>
{% endfor %}
</select>
</div>
<div class="widget-select org">
<label for="org-filter" class="visually-hidden">Veranstalter:</label>
<select id="org-filter">
<option value="all">Veranstalter</option>
{% for organization in organizations|default([]) %}
<option value="org-{{ organization.id }}">{{ organization.title }} ({{ organization.count }})</option>
{% endfor %}
</select>
</div>
</div>
<p id="eventfilter-status" class="visually-hidden" aria-live="polite"></p>
</div>
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.event-filter-target-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.event-filter-target-list .event {
opacity: 1;
transform: translateY(0);
transition: opacity 220ms ease, transform 220ms ease;
will-change: opacity, transform;
}
.event-filter-target-list .event.is-filtered-out {
opacity: 0;
transform: translateY(6px);
pointer-events: none;
}
#eventfilters .active,
#eventfilters select.active {
outline: 2px solid currentColor;
outline-offset: 2px;
}
#eventfilters button:focus-visible,
#eventfilters select:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>
<script type="module">
const filters = document.getElementById('eventfilters');
if (!filters) {
throw new Error('Event filter requires #eventfilters.');
}
const targetEventListId = filters.dataset.eventlistId || 'eventlist';
const list = document.getElementById(targetEventListId);
if (!list) {
console.warn(`[event_filter] Target event list #${targetEventListId} was not found.`);
}
list?.classList.add('event-filter-target-list');
const events = list ? Array.from(list.querySelectorAll(':scope > .event')) : [];
const tagButtons = Array.from(filters.querySelectorAll('button[data-filter-tag]'));
const locationSelect = filters.querySelector('#location-filter');
const orgSelect = filters.querySelector('#org-filter');
const status = filters.querySelector('#eventfilter-status');
const animationMs = 220;
let hideTimers = new WeakMap();
let currentFilter = { type: 'tag', value: 'all' };
const clearHideTimer = (eventItem) => {
const timer = hideTimers.get(eventItem);
if (timer) {
window.clearTimeout(timer);
hideTimers.delete(eventItem);
}
};
const showEvent = (eventItem) => {
clearHideTimer(eventItem);
eventItem.hidden = false;
requestAnimationFrame(() => {
eventItem.classList.remove('is-filtered-out');
});
};
const hideEvent = (eventItem) => {
clearHideTimer(eventItem);
eventItem.classList.add('is-filtered-out');
const timer = window.setTimeout(() => {
eventItem.hidden = true;
hideTimers.delete(eventItem);
}, animationMs);
hideTimers.set(eventItem, timer);
};
const setActiveControl = ({ type, value }) => {
tagButtons.forEach((button) => {
const isActive = type === 'tag' && button.dataset.filterTag === value;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
if (locationSelect) {
locationSelect.classList.toggle('active', type === 'location');
}
if (orgSelect) {
orgSelect.classList.toggle('active', type === 'org');
}
};
const parseIdList = (rawValue) => (rawValue ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const matches = (eventItem, filterState) => {
if (filterState.value === 'all') {
return true;
}
if (filterState.type === 'tag') {
return parseIdList(eventItem.dataset.tags).includes(filterState.value);
}
if (filterState.type === 'location') {
return eventItem.dataset.location === filterState.value;
}
if (filterState.type === 'org') {
return parseIdList(eventItem.dataset.org).includes(filterState.value);
}
return true;
};
const updateStatus = (filterState) => {
if (!status) {
return;
}
const visibleCount = events.filter((eventItem) => !eventItem.hidden).length;
let filterText = 'alle';
if (filterState.type === 'tag' && filterState.value !== 'all') {
const activeButton = tagButtons.find((button) => button.dataset.filterTag === filterState.value);
filterText = activeButton ? activeButton.textContent.trim() : `Tag ${filterState.value}`;
}
if (filterState.type === 'location' && locationSelect) {
const option = locationSelect.options[locationSelect.selectedIndex];
filterText = option ? option.textContent.trim() : 'Ort';
}
if (filterState.type === 'org' && orgSelect) {
const option = orgSelect.options[orgSelect.selectedIndex];
filterText = option ? option.textContent.trim() : 'Veranstalter';
}
status.textContent = `${visibleCount} Veranstaltungen angezeigt, Filter: ${filterText}.`;
};
const applyFilter = (filterState) => {
currentFilter = filterState;
setActiveControl(filterState);
events.forEach((eventItem) => {
if (matches(eventItem, filterState)) {
showEvent(eventItem);
} else {
hideEvent(eventItem);
}
});
updateStatus(filterState);
};
tagButtons.forEach((button) => {
button.addEventListener('click', () => {
if (locationSelect) {
locationSelect.value = 'all';
}
if (orgSelect) {
orgSelect.value = 'all';
}
applyFilter({ type: 'tag', value: button.dataset.filterTag ?? 'all' });
});
});
locationSelect?.addEventListener('change', () => {
const selectedValue = locationSelect.value;
if (orgSelect) {
orgSelect.value = 'all';
}
applyFilter(
selectedValue === 'all'
? { type: 'tag', value: 'all' }
: { type: 'location', value: selectedValue.replace('location-', '') },
);
});
orgSelect?.addEventListener('change', () => {
const selectedValue = orgSelect.value;
if (locationSelect) {
locationSelect.value = 'all';
}
applyFilter(
selectedValue === 'all'
? { type: 'tag', value: 'all' }
: { type: 'org', value: selectedValue.replace('org-', '') },
);
});
applyFilter(currentFilter);
</script>
@@ -25,7 +25,7 @@
<script> <script>
(function () { (function () {
const typeSelect = document.querySelector('select.js-organization-type-choice'); const typeSelect = document.querySelector('select.js-organization-tags-choice');
if (typeSelect && typeof window.Choices === 'function') { if (typeSelect && typeof window.Choices === 'function') {
new window.Choices(typeSelect, { new window.Choices(typeSelect, {
@@ -73,7 +73,7 @@ class EventEditController extends AbstractFrontendModuleController
'startTime' => null, 'startTime' => null,
'endTime' => null, 'endTime' => null,
'location_id' => 0, 'location_id' => 0,
'type' => null, 'tags' => null,
'teaser' => '', 'teaser' => '',
'description' => '', 'description' => '',
'url' => '', 'url' => '',
@@ -88,9 +88,11 @@ class EventEditController extends AbstractFrontendModuleController
} }
$memberOrganizationIds = $this->eventRepository->getOrganizationIdsForMember((int) $user->id); $memberOrganizationIds = $this->eventRepository->getOrganizationIdsForMember((int) $user->id);
$allowedEventTagIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($model->eventTypeTags ?? null, true))));
$organizationChoices = $this->eventRepository->getOrganizationChoicesForMember((int) $user->id); $organizationChoices = $this->eventRepository->getOrganizationChoicesForMember((int) $user->id);
$showOrganization = count($memberOrganizationIds) > 1; $showOrganization = count($memberOrganizationIds) > 1;
$currentOrganizationIds = $this->eventRepository->getOrganizationIdsForEvent($eventId); $currentOrganizationIds = $this->eventRepository->getOrganizationIdsForEvent($eventId);
$currentTagIds = $this->eventRepository->getTagIdsForEvent($eventId);
$formData = [ $formData = [
'title' => (string) ($event['title'] ?? ''), 'title' => (string) ($event['title'] ?? ''),
@@ -100,7 +102,7 @@ class EventEditController extends AbstractFrontendModuleController
'startTime' => ('1' === (string) ($event['addTime'] ?? '') && !empty($event['startTime'])) ? date('H:i', (int) $event['startTime']) : '', 'startTime' => ('1' === (string) ($event['addTime'] ?? '') && !empty($event['startTime'])) ? date('H:i', (int) $event['startTime']) : '',
'endTime' => $this->resolveFormEndTime($event), 'endTime' => $this->resolveFormEndTime($event),
'location_id' => (int) ($event['location_id'] ?? 0), 'location_id' => (int) ($event['location_id'] ?? 0),
'type' => StringUtil::deserialize($event['type'] ?? null, true), 'tags' => $currentTagIds,
'teaser' => (string) ($event['teaser'] ?? ''), 'teaser' => (string) ($event['teaser'] ?? ''),
'description' => (string) ($event['description'] ?? ''), 'description' => (string) ($event['description'] ?? ''),
'url' => (string) ($event['url'] ?? ''), 'url' => (string) ($event['url'] ?? ''),
@@ -135,6 +137,7 @@ class EventEditController extends AbstractFrontendModuleController
$form = $this->createForm(EventType::class, $formData, [ $form = $this->createForm(EventType::class, $formData, [
'location_choices' => $this->eventRepository->getLocationChoices(), 'location_choices' => $this->eventRepository->getLocationChoices(),
'tag_choices' => $this->eventRepository->getTagChoicesForEventType($allowedEventTagIds),
'organization_choices' => $organizationChoices, 'organization_choices' => $organizationChoices,
'selected_organization_ids' => $showOrganization ? ($formData['organization_ids'] ?? []) : [], 'selected_organization_ids' => $showOrganization ? ($formData['organization_ids'] ?? []) : [],
'show_organization' => $showOrganization, 'show_organization' => $showOrganization,
@@ -213,6 +216,14 @@ class EventEditController extends AbstractFrontendModuleController
$this->eventRepository->update($eventId, $submittedData); $this->eventRepository->update($eventId, $submittedData);
} }
$selectedTagIds = array_values(array_unique(array_map('intval', (array) ($submittedData['tags'] ?? []))));
if ([] !== $allowedEventTagIds) {
$selectedTagIds = array_values(array_intersect($selectedTagIds, $allowedEventTagIds));
}
$this->eventRepository->assignTagsToEvent($eventId, $selectedTagIds);
$useImage = !empty($submittedData['addImage']); $useImage = !empty($submittedData['addImage']);
$removeImage = '1' === (string) $request->request->get('remove_image', '0'); $removeImage = '1' === (string) $request->request->get('remove_image', '0');
$uploadedImage = $form->get('eventUpload')->getData(); $uploadedImage = $form->get('eventUpload')->getData();
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Contao\StringUtil;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'event_filter', category: 'eventmanager', template: 'frontend/event_filter')]
class EventFilterController extends AbstractFrontendModuleController
{
public function __construct(
private readonly Connection $connection,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$calendarIds = array_map('intval', StringUtil::deserialize($model->cal_calendar, true));
$eventIds = $this->findUpcomingEventIds($calendarIds);
$targetEventListId = trim((string) ($model->eventListDomId ?? ''));
$template->set('tagButtons', $this->findTagButtons($eventIds));
$template->set('locations', $this->findLocations($eventIds));
$template->set('organizations', $this->findOrganizations($eventIds));
$template->set('targetEventListId', '' !== $targetEventListId ? $targetEventListId : 'eventlist');
return $template->getResponse();
}
/**
* @param list<int> $calendarIds
*
* @return list<int>
*/
private function findUpcomingEventIds(array $calendarIds): array
{
$today = strtotime('today');
if ([] === $calendarIds) {
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT e.id
FROM tl_calendar_events e
WHERE e.published='1'
AND (e.startTime>=? OR e.endTime>=?)
ORDER BY e.startTime ASC
SQL,
[$today, $today],
[ParameterType::INTEGER, ParameterType::INTEGER],
)->fetchFirstColumn();
} else {
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT e.id
FROM tl_calendar_events e
WHERE e.pid IN (?)
AND e.published='1'
AND (e.startTime>=? OR e.endTime>=?)
ORDER BY e.startTime ASC
SQL,
[$calendarIds, $today, $today],
[ArrayParameterType::INTEGER, ParameterType::INTEGER, ParameterType::INTEGER],
)->fetchFirstColumn();
}
return array_values(array_unique(array_map('intval', $rows)));
}
/**
* @param list<int> $eventIds
*
* @return list<array{id:int,title:string,count:int}>
*/
private function findTagButtons(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT t.id, t.tag, COUNT(DISTINCT r.pid) AS total
FROM tl_tags_rel r
INNER JOIN tl_tags t ON t.id=r.tag_id
WHERE r.ptable='tl_calendar_events'
AND r.field='tags'
AND r.pid IN (?)
GROUP BY t.id, t.tag
ORDER BY t.tag ASC
SQL,
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['tag'] ?? ''),
'count' => (int) ($row['total'] ?? 0),
];
}
return $items;
}
/**
* @param list<int> $eventIds
*
* @return list<array{id:int,title:string,count:int}>
*/
private function findLocations(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT l.id, l.title, COUNT(e.id) AS total
FROM tl_calendar_events e
INNER JOIN tl_location l ON l.id=e.location_id
WHERE e.id IN (?)
AND e.location_id>0
GROUP BY l.id, l.title
ORDER BY l.title ASC
SQL,
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'count' => (int) ($row['total'] ?? 0),
];
}
return $items;
}
/**
* @param list<int> $eventIds
*
* @return list<array{id:int,title:string,count:int}>
*/
private function findOrganizations(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
<<<'SQL'
SELECT o.id, o.title, COUNT(DISTINCT rel.event_id) AS total
FROM tl_calendar_events_organization rel
INNER JOIN tl_organization o ON o.id=rel.organization_id
WHERE rel.event_id IN (?)
GROUP BY o.id, o.title
ORDER BY o.title ASC
SQL,
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => (string) ($row['title'] ?? ''),
'count' => (int) ($row['total'] ?? 0),
];
}
return $items;
}
}
@@ -58,6 +58,7 @@ class OrganizationEditController extends AbstractFrontendModuleController
} }
$organization = $this->organizationRepository->findById($organizationId); $organization = $this->organizationRepository->findById($organizationId);
$allowedOrganizationTagIds = array_values(array_unique(array_map('intval', StringUtil::deserialize($model->organizationTypeTags ?? null, true))));
if (null === $organization) { if (null === $organization) {
$template->set('error', 'Organisation nicht gefunden.'); $template->set('error', 'Organisation nicht gefunden.');
@@ -77,7 +78,7 @@ class OrganizationEditController extends AbstractFrontendModuleController
'email' => (string) ($organization['email'] ?? ''), 'email' => (string) ($organization['email'] ?? ''),
'website' => (string) ($organization['website'] ?? ''), 'website' => (string) ($organization['website'] ?? ''),
'description' => (string) ($organization['description'] ?? ''), 'description' => (string) ($organization['description'] ?? ''),
'type' => StringUtil::deserialize($organization['type'] ?? null, true), 'tags' => $this->organizationRepository->getTagIdsForOrganization($organizationId),
]; ];
$currentLogoPath = null; $currentLogoPath = null;
@@ -90,13 +91,22 @@ class OrganizationEditController extends AbstractFrontendModuleController
} }
} }
$form = $this->createForm(OrganizationType::class, $formData); $form = $this->createForm(OrganizationType::class, $formData, [
'tag_choices' => $this->organizationRepository->getTagChoicesForOrganizationType($allowedOrganizationTagIds),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$submittedData = (array) $form->getData(); $submittedData = (array) $form->getData();
$this->organizationRepository->update($organizationId, $submittedData); $this->organizationRepository->update($organizationId, $submittedData);
$selectedTagIds = array_values(array_unique(array_map('intval', (array) ($submittedData['tags'] ?? []))));
if ([] !== $allowedOrganizationTagIds) {
$selectedTagIds = array_values(array_intersect($selectedTagIds, $allowedOrganizationTagIds));
}
$this->organizationRepository->assignTagsToOrganization($organizationId, $selectedTagIds);
$deleteLogo = '1' === (string) $request->request->get('remove_logo', '0'); $deleteLogo = '1' === (string) $request->request->get('remove_logo', '0');
$uploadedLogo = $form->get('logoUpload')->getData(); $uploadedLogo = $form->get('logoUpload')->getData();
@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\StringUtil;
use Contao\Template;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
#[AsHook('parseTemplate', method: 'onParseTemplate')]
class EventFullTemplateDataListener
{
public function __construct(
private readonly Connection $connection,
) {
}
public function onParseTemplate(Template $template): void
{
$templateName = (string) $template->getName();
if (!str_starts_with($templateName, 'event_full')) {
return;
}
$data = $template->getData();
$eventId = (int) ($data['id'] ?? 0);
if ($eventId <= 0) {
return;
}
$locationId = isset($data['location_id']) ? (int) $data['location_id'] : 0;
$locationRow = $this->fetchLocationRow($locationId);
$organizations = $this->fetchOrganizations($eventId);
$tags = $this->fetchTags($eventId);
$template->location_row = $locationRow;
$template->organizations = $organizations;
$template->tags = $tags;
$template->has_tag_21 = \in_array(21, array_map(static fn (array $tag): int => (int) ($tag['id'] ?? 0), $tags), true);
if ($locationRow && '' !== ($locationRow['lng'] ?? '') && '' !== ($locationRow['lat'] ?? '') && 58 !== (int) ($locationRow['id'] ?? 0)) {
$this->registerMapAssets();
$template->show_location_map = true;
} else {
$template->show_location_map = false;
}
$template->location_fields = $this->toFlatLocationFields($locationRow);
$template->organization_fields = $this->toFlatOrganizationFields($organizations);
$template->tag_fields = $this->toFlatTagFields($tags);
}
/** @return array<string, string> */
private function fetchLocationRow(int $locationId): array
{
if ($locationId <= 0) {
return [];
}
$row = $this->connection->createQueryBuilder()
->select('*')
->from('tl_location')
->where('id = :id')
->setParameter('id', $locationId, ParameterType::INTEGER)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (false === $row || null === $row) {
return [];
}
$normalized = [];
foreach ($row as $column => $value) {
$normalized[$column] = $this->toString($value);
}
if ('' !== ($normalized['image'] ?? '')) {
$normalized['image_uuid'] = $this->normalizeUuid($normalized['image']);
}
return $normalized;
}
/** @return list<array<string, string>> */
private function fetchOrganizations(int $eventId): array
{
$rows = $this->connection->createQueryBuilder()
->select('o.*')
->from('tl_calendar_events_organization', 'ceo')
->innerJoin('ceo', 'tl_organization', 'o', 'o.id = ceo.organization_id')
->where('ceo.event_id = :eventId')
->setParameter('eventId', $eventId, ParameterType::INTEGER)
->orderBy('ceo.organization_id', 'ASC')
->executeQuery()
->fetchAllAssociative();
$organizations = [];
foreach ($rows as $row) {
$normalized = [];
foreach ($row as $column => $value) {
$normalized[$column] = $this->toString($value);
}
if ('' !== ($normalized['logo'] ?? '')) {
$normalized['logo_uuid'] = $this->normalizeUuid($normalized['logo']);
}
$organizations[] = $normalized;
}
return $organizations;
}
/** @return list<array<string, string>> */
private function fetchTags(int $eventId): array
{
$rows = $this->connection->createQueryBuilder()
->select('t.*')
->from('tl_tags_rel', 'r')
->innerJoin('r', 'tl_tags', 't', 't.id = r.tag_id')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->andWhere('r.pid = :pid')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->setParameter('pid', $eventId, ParameterType::INTEGER)
->orderBy('r.tag_id', 'ASC')
->executeQuery()
->fetchAllAssociative();
$tags = [];
foreach ($rows as $row) {
$title = '';
if (isset($row['title']) && '' !== (string) $row['title']) {
$title = (string) $row['title'];
} elseif (isset($row['tag'])) {
$title = (string) $row['tag'];
}
$tags[] = [
'id' => (string) ((int) ($row['id'] ?? 0)),
'title' => $title,
'tag' => (string) ($row['tag'] ?? ''),
];
}
return $tags;
}
/** @param array<string, string> $locationRow
* @return array<string, string>
*/
private function toFlatLocationFields(array $locationRow): array
{
$fields = [];
foreach ($locationRow as $column => $value) {
$fields['location_'.$column] = $value;
}
return $fields;
}
/** @param list<array<string, string>> $organizations
* @return array<string, string>
*/
private function toFlatOrganizationFields(array $organizations): array
{
$fields = [];
$index = 1;
foreach ($organizations as $organization) {
foreach ($organization as $column => $value) {
$fields['organization'.$index.'_'.$column] = $value;
}
++$index;
}
return $fields;
}
/** @param list<array<string, string>> $tags
* @return array<string, string>
*/
private function toFlatTagFields(array $tags): array
{
$fields = [];
$index = 1;
foreach ($tags as $tag) {
$fields['tag'.$index.'_title'] = (string) ($tag['title'] ?? '');
++$index;
}
return $fields;
}
private function normalizeUuid(string $value): string
{
$trimmed = trim($value);
if (preg_match('/^[0-9a-fA-F-]{36}$/', $trimmed)) {
return strtolower($trimmed);
}
if (16 === strlen($trimmed)) {
return StringUtil::binToUuid($trimmed);
}
return $trimmed;
}
private function registerMapAssets(): void
{
$GLOBALS['TL_JAVASCRIPT'] ??= [];
$GLOBALS['TL_CSS'] ??= [];
$jsAssets = [
'https://maps.mummert.media/libraries/maplibre-gl.js',
'https://maps.mummert.media/libraries/pmtiles.js',
];
foreach ($jsAssets as $asset) {
if (!\in_array($asset, $GLOBALS['TL_JAVASCRIPT'], true)) {
$GLOBALS['TL_JAVASCRIPT'][] = $asset;
}
}
$cssAsset = 'https://maps.mummert.media/libraries/maplibre-gl.css';
if (!\in_array($cssAsset, $GLOBALS['TL_CSS'], true)) {
$GLOBALS['TL_CSS'][] = $cssAsset;
}
}
private function toString(mixed $value): string
{
if (null === $value) {
return '';
}
if (\is_bool($value)) {
return $value ? '1' : '0';
}
if (\is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
}
}
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\EventListener;
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
use Contao\Module;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\ParameterType;
#[AsHook('getAllEvents', method: 'onGetAllEvents', priority: -512)]
class EventTeaserDataAttributesListener
{
public function __construct(
private readonly Connection $connection,
) {
}
public function onGetAllEvents(array $events, array $calendars, int $start, int $end, Module $module): array
{
$eventIds = $this->collectEventIds($events);
$tagMap = $this->fetchTagMap($eventIds);
$organizationMap = $this->fetchOrganizationMap($eventIds);
$locationMap = $this->fetchLocationMap($eventIds);
$locationTitleMap = $this->fetchLocationTitleMap($locationMap);
foreach ($events as $dayKey => $eventsPerDay) {
foreach ($eventsPerDay as $timeKey => $eventsPerTime) {
foreach ($eventsPerTime as $index => $event) {
if (!isset($event['id'])) {
$events[$dayKey][$timeKey][$index]['location_id'] = null;
$events[$dayKey][$timeKey][$index]['location_title'] = '';
$events[$dayKey][$timeKey][$index]['event_tags'] = '';
$events[$dayKey][$timeKey][$index]['event_org'] = '';
continue;
}
$eventId = (int) $event['id'];
if (isset($event['location_id']) && '' !== (string) $event['location_id']) {
$events[$dayKey][$timeKey][$index]['location_id'] = (int) $event['location_id'];
} else {
$events[$dayKey][$timeKey][$index]['location_id'] = $locationMap[$eventId] ?? null;
}
$locationId = $events[$dayKey][$timeKey][$index]['location_id'];
$events[$dayKey][$timeKey][$index]['location_title'] = null !== $locationId
? ($locationTitleMap[(int) $locationId] ?? '')
: '';
$events[$dayKey][$timeKey][$index]['event_tags'] = implode(',', $tagMap[$eventId] ?? []);
$events[$dayKey][$timeKey][$index]['event_org'] = implode(',', $organizationMap[$eventId] ?? []);
}
}
}
return $events;
}
/** @return list<int> */
private function collectEventIds(array $events): array
{
$eventIds = [];
foreach ($events as $eventsPerDay) {
foreach ($eventsPerDay as $eventsPerTime) {
foreach ($eventsPerTime as $event) {
if (!isset($event['id'])) {
continue;
}
$eventIds[] = (int) $event['id'];
}
}
}
return array_values(array_unique(array_map('intval', $eventIds)));
}
/** @param list<int> $eventIds
* @return array<int, list<int>>
*/
private function fetchTagMap(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT pid, tag_id FROM tl_tags_rel WHERE ptable = ? AND field = ? AND pid IN (?) ORDER BY pid ASC, tag_id ASC',
['tl_calendar_events', 'tags', $eventIds],
[ParameterType::STRING, ParameterType::STRING, ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$eventId = (int) ($row['pid'] ?? 0);
$tagId = (int) ($row['tag_id'] ?? 0);
if ($eventId <= 0 || $tagId <= 0) {
continue;
}
$map[$eventId][] = $tagId;
}
foreach ($map as $eventId => $tagIds) {
$map[$eventId] = array_values(array_unique(array_map('intval', $tagIds)));
}
return $map;
}
/** @param list<int> $eventIds
* @return array<int, list<int>>
*/
private function fetchOrganizationMap(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$tables = ['tl_events_organization', 'tl_calendar_events_organization'];
foreach ($tables as $table) {
try {
$rows = $this->connection->executeQuery(
sprintf('SELECT event_id, organization_id FROM %s WHERE event_id IN (?) ORDER BY event_id ASC, organization_id ASC', $table),
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$eventId = (int) ($row['event_id'] ?? 0);
$organizationId = (int) ($row['organization_id'] ?? 0);
if ($eventId <= 0 || $organizationId <= 0) {
continue;
}
$map[$eventId][] = $organizationId;
}
foreach ($map as $eventId => $organizationIds) {
$map[$eventId] = array_values(array_unique(array_map('intval', $organizationIds)));
}
return $map;
} catch (Exception) {
continue;
}
}
return [];
}
/** @param list<int> $eventIds
* @return array<int, int|null>
*/
private function fetchLocationMap(array $eventIds): array
{
if ([] === $eventIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT id, location_id FROM tl_calendar_events WHERE id IN (?)',
[$eventIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$eventId = (int) ($row['id'] ?? 0);
if ($eventId <= 0) {
continue;
}
$map[$eventId] = null !== ($row['location_id'] ?? null) ? (int) $row['location_id'] : null;
}
return $map;
}
/** @param array<int, int|null> $locationMap
* @return array<int, string>
*/
private function fetchLocationTitleMap(array $locationMap): array
{
$locationIds = array_values(array_unique(array_filter(array_map('intval', $locationMap), static fn (int $id): bool => $id > 0)));
if ([] === $locationIds) {
return [];
}
$rows = $this->connection->executeQuery(
'SELECT id, title FROM tl_location WHERE id IN (?)',
[$locationIds],
[ArrayParameterType::INTEGER],
)->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$locationId = (int) ($row['id'] ?? 0);
if ($locationId <= 0) {
continue;
}
$map[$locationId] = (string) ($row['title'] ?? '');
}
return $map;
}
}
+8 -10
View File
@@ -39,7 +39,7 @@ class EventType extends AbstractType
]) ])
->add('startDate', DateType::class, [ ->add('startDate', DateType::class, [
'label' => 'Startdatum', 'label' => 'Startdatum',
'required' => false, 'required' => true,
'widget' => 'single_text', 'widget' => 'single_text',
'input' => 'string', 'input' => 'string',
'html5' => false, 'html5' => false,
@@ -110,7 +110,7 @@ class EventType extends AbstractType
->add('location_id', ChoiceType::class, [ ->add('location_id', ChoiceType::class, [
'label' => 'Veranstaltungsort', 'label' => 'Veranstaltungsort',
'choices' => $options['location_choices'], 'choices' => $options['location_choices'],
'required' => false, 'required' => true,
'choice_value' => static fn ($value) => null !== $value ? (string) $value : '', 'choice_value' => static fn ($value) => null !== $value ? (string) $value : '',
'placeholder' => 'Bitte auswählen', 'placeholder' => 'Bitte auswählen',
'attr' => [ 'attr' => [
@@ -118,17 +118,13 @@ class EventType extends AbstractType
'data-placeholder' => 'Veranstaltungsort suchen …', 'data-placeholder' => 'Veranstaltungsort suchen …',
], ],
]) ])
->add('type', ChoiceType::class, [ ->add('tags', ChoiceType::class, [
'label' => 'Typ', 'label' => 'Typen',
'choices' => [ 'choices' => $options['tag_choices'],
'Unterkunft' => 'accommodation',
'Einkaufen' => 'shopping',
'Kultur' => 'culture',
],
'multiple' => true, 'multiple' => true,
'required' => false, 'required' => false,
'attr' => [ 'attr' => [
'class' => 'js-event-type-choice', 'class' => 'js-event-tags-choice',
'data-placeholder' => 'Typen suchen …', 'data-placeholder' => 'Typen suchen …',
], ],
]) ])
@@ -191,6 +187,7 @@ class EventType extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'csrf_protection' => true, 'csrf_protection' => true,
'location_choices' => [], 'location_choices' => [],
'tag_choices' => [],
'organization_choices' => [], 'organization_choices' => [],
'selected_organization_ids' => [], 'selected_organization_ids' => [],
'show_organization' => false, 'show_organization' => false,
@@ -198,6 +195,7 @@ class EventType extends AbstractType
]); ]);
$resolver->setAllowedTypes('location_choices', 'array'); $resolver->setAllowedTypes('location_choices', 'array');
$resolver->setAllowedTypes('tag_choices', 'array');
$resolver->setAllowedTypes('organization_choices', 'array'); $resolver->setAllowedTypes('organization_choices', 'array');
$resolver->setAllowedTypes('selected_organization_ids', 'array'); $resolver->setAllowedTypes('selected_organization_ids', 'array');
$resolver->setAllowedTypes('show_organization', 'bool'); $resolver->setAllowedTypes('show_organization', 'bool');
+14 -8
View File
@@ -11,6 +11,7 @@ use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OrganizationType extends AbstractType class OrganizationType extends AbstractType
{ {
@@ -27,17 +28,13 @@ class OrganizationType extends AbstractType
->add('email', EmailType::class, ['label' => 'E-Mail', 'required' => false]) ->add('email', EmailType::class, ['label' => 'E-Mail', 'required' => false])
->add('website', TextType::class, ['label' => 'Webseite', 'required' => false]) ->add('website', TextType::class, ['label' => 'Webseite', 'required' => false])
->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false]) ->add('description', TextareaType::class, ['label' => 'Beschreibung', 'required' => false])
->add('type', ChoiceType::class, [ ->add('tags', ChoiceType::class, [
'label' => 'Typ', 'label' => 'Typen',
'choices' => [ 'choices' => $options['tag_choices'],
'Unterkunft' => 'accommodation',
'Einkaufen' => 'shopping',
'Kultur' => 'culture',
],
'multiple' => true, 'multiple' => true,
'required' => false, 'required' => false,
'attr' => [ 'attr' => [
'class' => 'js-organization-type-choice', 'class' => 'js-organization-tags-choice',
'data-placeholder' => 'Typ auswählen …', 'data-placeholder' => 'Typ auswählen …',
], ],
]) ])
@@ -51,4 +48,13 @@ class OrganizationType extends AbstractType
], ],
]); ]);
} }
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'tag_choices' => [],
]);
$resolver->setAllowedTypes('tag_choices', 'array');
}
} }
+140 -1
View File
@@ -131,6 +131,12 @@ class EventRepository
); );
} }
$tagIds = $this->getTagIdsForEvent($eventId);
if ([] !== $tagIds) {
$this->assignTagsToEvent($newEventId, $tagIds);
}
return $newEventId; return $newEventId;
} }
@@ -257,6 +263,12 @@ class EventRepository
public function delete(int $eventId): void public function delete(int $eventId): void
{ {
$this->connection->delete(
'tl_tags_rel',
['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId],
['pid' => ParameterType::INTEGER],
);
$this->connection->delete( $this->connection->delete(
'tl_calendar_events_organization', 'tl_calendar_events_organization',
['event_id' => $eventId], ['event_id' => $eventId],
@@ -324,6 +336,73 @@ class EventRepository
return array_values(array_unique(array_map('intval', $rows))); return array_values(array_unique(array_map('intval', $rows)));
} }
/**
* @return array<string, int>
*/
public function getTagChoicesForEventType(array $allowedTagIds = []): array
{
$rows = $this->connection->createQueryBuilder()
->select('DISTINCT t.id', 't.tag')
->from('tl_tags', 't')
->innerJoin('t', 'tl_tags_rel', 'r', 'r.tag_id = t.id')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ([] === $rows) {
$rows = $this->connection->createQueryBuilder()
->select('t.id', 't.tag')
->from('tl_tags', 't')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
$choices = [];
foreach ($rows as $row) {
$choices[(string) ($row['tag'] ?? '')] = (int) $row['id'];
}
$allowedTagIds = array_values(array_unique(array_map('intval', $allowedTagIds)));
if ([] !== $allowedTagIds) {
$choices = array_filter(
$choices,
static fn (int $id): bool => in_array($id, $allowedTagIds, true),
);
}
return $choices;
}
/** @return array<int> */
public function getTagIdsForEvent(int $eventId): array
{
if ($eventId <= 0) {
return [];
}
$ids = $this->connection->createQueryBuilder()
->select('tag_id')
->from('tl_tags_rel')
->where('ptable = :ptable')
->andWhere('field = :field')
->andWhere('pid = :pid')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->setParameter('pid', $eventId, ParameterType::INTEGER)
->orderBy('tag_id', 'ASC')
->executeQuery()
->fetchFirstColumn();
return array_values(array_unique(array_map('intval', $ids)));
}
/** /**
* @return array<string, int> * @return array<string, int>
*/ */
@@ -496,6 +575,41 @@ class EventRepository
return $eventId; return $eventId;
} }
/** @param array<int|string> $tagIds */
public function assignTagsToEvent(int $eventId, array $tagIds): void
{
$this->connection->delete(
'tl_tags_rel',
['ptable' => 'tl_calendar_events', 'field' => 'tags', 'pid' => $eventId],
['pid' => ParameterType::INTEGER],
);
$tagIds = array_values(array_unique(array_map('intval', $tagIds)));
if ([] === $tagIds) {
return;
}
$allowedTagIds = $this->getAllowedTagIdsForEvents();
$tagIds = array_values(array_intersect($tagIds, $allowedTagIds));
foreach ($tagIds as $tagId) {
$this->connection->insert(
'tl_tags_rel',
[
'tag_id' => $tagId,
'pid' => $eventId,
'ptable' => 'tl_calendar_events',
'field' => 'tags',
],
[
'tag_id' => ParameterType::INTEGER,
'pid' => ParameterType::INTEGER,
],
);
}
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* *
@@ -533,7 +647,6 @@ class EventRepository
'addTime' => $addTime ? 1 : 0, 'addTime' => $addTime ? 1 : 0,
'startTime' => $startTimeTimestamp, 'startTime' => $startTimeTimestamp,
'endTime' => $endTimeTimestamp, 'endTime' => $endTimeTimestamp,
'type' => serialize($data['type'] ?? []),
'teaser' => $data['teaser'] ?? null, 'teaser' => $data['teaser'] ?? null,
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'url' => $url, 'url' => $url,
@@ -616,4 +729,30 @@ class EventRepository
$types, $types,
); );
} }
/** @return array<int> */
private function getAllowedTagIdsForEvents(): array
{
$ids = $this->connection->createQueryBuilder()
->select('DISTINCT r.tag_id')
->from('tl_tags_rel', 'r')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_calendar_events')
->setParameter('field', 'tags')
->executeQuery()
->fetchFirstColumn();
$ids = array_values(array_unique(array_map('intval', $ids)));
if ([] !== $ids) {
return $ids;
}
return array_values(array_unique(array_map('intval', $this->connection->createQueryBuilder()
->select('id')
->from('tl_tags')
->executeQuery()
->fetchFirstColumn())));
}
} }
+128 -1
View File
@@ -79,7 +79,6 @@ class OrganizationRepository
'email' => $data['email'] ?? '', 'email' => $data['email'] ?? '',
'website' => $data['website'] ?? '', 'website' => $data['website'] ?? '',
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'type' => serialize($data['type'] ?? []),
'tstamp' => time(), 'tstamp' => time(),
], ],
['id' => $organizationId], ['id' => $organizationId],
@@ -109,4 +108,132 @@ class OrganizationRepository
$types, $types,
); );
} }
/**
* @return array<string, int>
*/
public function getTagChoicesForOrganizationType(array $allowedTagIds = []): array
{
$rows = $this->connection->createQueryBuilder()
->select('DISTINCT t.id', 't.tag')
->from('tl_tags', 't')
->innerJoin('t', 'tl_tags_rel', 'r', 'r.tag_id = t.id')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_organization')
->setParameter('field', 'tags')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
if ([] === $rows) {
$rows = $this->connection->createQueryBuilder()
->select('t.id', 't.tag')
->from('tl_tags', 't')
->orderBy('t.tag', 'ASC')
->executeQuery()
->fetchAllAssociative();
}
$choices = [];
foreach ($rows as $row) {
$choices[(string) ($row['tag'] ?? '')] = (int) $row['id'];
}
$allowedTagIds = array_values(array_unique(array_map('intval', $allowedTagIds)));
if ([] !== $allowedTagIds) {
$choices = array_filter(
$choices,
static fn (int $id): bool => in_array($id, $allowedTagIds, true),
);
}
return $choices;
}
/** @return array<int> */
public function getTagIdsForOrganization(int $organizationId): array
{
if ($organizationId <= 0) {
return [];
}
$ids = $this->connection->createQueryBuilder()
->select('tag_id')
->from('tl_tags_rel')
->where('ptable = :ptable')
->andWhere('field = :field')
->andWhere('pid = :pid')
->setParameter('ptable', 'tl_organization')
->setParameter('field', 'tags')
->setParameter('pid', $organizationId, ParameterType::INTEGER)
->orderBy('tag_id', 'ASC')
->executeQuery()
->fetchFirstColumn();
return array_values(array_unique(array_map('intval', $ids)));
}
/** @param array<int|string> $tagIds */
public function assignTagsToOrganization(int $organizationId, array $tagIds): void
{
$this->connection->delete(
'tl_tags_rel',
['ptable' => 'tl_organization', 'field' => 'tags', 'pid' => $organizationId],
['pid' => ParameterType::INTEGER],
);
$tagIds = array_values(array_unique(array_map('intval', $tagIds)));
if ([] === $tagIds) {
return;
}
$allowedTagIds = $this->getAllowedTagIdsForOrganization();
$tagIds = array_values(array_intersect($tagIds, $allowedTagIds));
foreach ($tagIds as $tagId) {
$this->connection->insert(
'tl_tags_rel',
[
'tag_id' => $tagId,
'pid' => $organizationId,
'ptable' => 'tl_organization',
'field' => 'tags',
],
[
'tag_id' => ParameterType::INTEGER,
'pid' => ParameterType::INTEGER,
],
);
}
}
/** @return array<int> */
private function getAllowedTagIdsForOrganization(): array
{
$ids = $this->connection->createQueryBuilder()
->select('DISTINCT r.tag_id')
->from('tl_tags_rel', 'r')
->where('r.ptable = :ptable')
->andWhere('r.field = :field')
->setParameter('ptable', 'tl_organization')
->setParameter('field', 'tags')
->executeQuery()
->fetchFirstColumn();
$ids = array_values(array_unique(array_map('intval', $ids)));
if ([] !== $ids) {
return $ids;
}
return array_values(array_unique(array_map('intval', $this->connection->createQueryBuilder()
->select('id')
->from('tl_tags')
->executeQuery()
->fetchFirstColumn())));
}
} }