Custom widgets #345

This commit is contained in:
Daniel Grams 2021-12-30 14:55:26 +01:00
parent 2ce0dc46b2
commit daabc2659b
30 changed files with 2270 additions and 109 deletions

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-12-08 13:32+0100\n"
"POT-Creation-Date: 2021-12-30 14:51+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -193,29 +193,29 @@ msgstr ""
msgid "message"
msgstr ""
#: project/api/organization/resources.py:364
#: project/api/organization/resources.py:371
#: project/views/admin_unit_member_invitation.py:85
msgid "You have received an invitation"
msgstr ""
#: project/forms/admin.py:10 project/templates/layout.html:311
#: project/forms/admin.py:10 project/templates/layout.html:312
#: project/views/root.py:37
msgid "Terms of service"
msgstr ""
#: project/forms/admin.py:11 project/templates/layout.html:315
#: project/forms/admin.py:11 project/templates/layout.html:316
#: project/views/root.py:45
msgid "Legal notice"
msgstr ""
#: project/forms/admin.py:12 project/templates/_macros.html:1392
#: project/templates/layout.html:319
#: project/templates/layout.html:320
#: project/templates/widget/event_suggestion/create.html:204
#: project/views/admin_unit.py:73 project/views/root.py:53
msgid "Contact"
msgstr ""
#: project/forms/admin.py:13 project/templates/layout.html:323
#: project/forms/admin.py:13 project/templates/layout.html:324
#: project/views/root.py:61
msgid "Privacy"
msgstr ""
@ -917,7 +917,7 @@ msgstr ""
msgid "Distance"
msgstr ""
#: project/forms/event_date.py:33 project/forms/planing.py:36
#: project/forms/event_date.py:38 project/forms/planing.py:36
#: project/templates/widget/event_date/list.html:82
msgid "Find"
msgstr ""
@ -1342,7 +1342,7 @@ msgid "Manage"
msgstr ""
#: project/templates/home.html:30 project/templates/security/login_user.html:38
#: project/views/widget.py:158
#: project/views/widget.py:159
msgid "Register for free"
msgstr ""
@ -1366,7 +1366,7 @@ msgstr ""
msgid "Planing"
msgstr ""
#: project/templates/layout.html:187 project/templates/layout.html:273
#: project/templates/layout.html:187 project/templates/layout.html:274
#: project/templates/oauth2_client/list.html:10
#: project/templates/oauth2_client/read.html:10
#: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4
@ -1468,17 +1468,22 @@ msgstr ""
msgid "Settings"
msgstr ""
#: project/templates/layout.html:272 project/templates/manage/reviews.html:10
#: project/templates/layout.html:272
#: project/templates/manage/custom_widgets.html:13
msgid "Custom widgets"
msgstr ""
#: project/templates/layout.html:273 project/templates/manage/reviews.html:10
#: project/templates/manage/widgets.html:5
#: project/templates/manage/widgets.html:9
msgid "Widgets"
msgstr ""
#: project/templates/layout.html:283
#: project/templates/layout.html:284
msgid "Switch organization"
msgstr ""
#: project/templates/developer/read.html:4 project/templates/layout.html:333
#: project/templates/developer/read.html:4 project/templates/layout.html:334
#: project/templates/profile.html:29
msgid "Developer"
msgstr ""
@ -1945,7 +1950,7 @@ msgstr ""
msgid "Organization successfully updated"
msgstr ""
#: project/views/admin.py:68 project/views/manage.py:331
#: project/views/admin.py:68 project/views/manage.py:346
msgid "Settings successfully updated"
msgstr ""
@ -2003,27 +2008,27 @@ msgstr ""
msgid "Invitation successfully deleted"
msgstr ""
#: project/views/event.py:179
#: project/views/event.py:178
msgid "Event successfully published"
msgstr ""
#: project/views/event.py:181
#: project/views/event.py:180
msgid "Draft successfully saved"
msgstr ""
#: project/views/event.py:224
#: project/views/event.py:223
msgid "Event successfully updated"
msgstr ""
#: project/views/event.py:250
#: project/views/event.py:249
msgid "Event successfully deleted"
msgstr ""
#: project/views/event.py:409
#: project/views/event.py:408
msgid "Referenced event changed"
msgstr ""
#: project/views/event.py:432
#: project/views/event.py:431
msgid "New event report"
msgstr ""
@ -2179,17 +2184,17 @@ msgid ""
" the invitation was sent to."
msgstr ""
#: project/views/widget.py:150
#: project/views/widget.py:151
msgid "Thank you so much! The event is being verified."
msgstr ""
#: project/views/widget.py:154
#: project/views/widget.py:155
msgid ""
"For more options and your own calendar of events, you can register for "
"free."
msgstr ""
#: project/views/widget.py:212
#: project/views/widget.py:221
msgid "New event review"
msgstr ""

View File

@ -0,0 +1,55 @@
"""empty message
Revision ID: c5fbefbe9881
Revises: eba21922b9b7
Create Date: 2021-12-29 22:35:29.300556
"""
import sqlalchemy as sa
import sqlalchemy_utils
from alembic import op
from sqlalchemy.dialects import postgresql
from project import dbtypes
# revision identifiers, used by Alembic.
revision = "c5fbefbe9881"
down_revision = "eba21922b9b7"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"customwidget",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("widget_type", sa.Unicode(length=255), nullable=False),
sa.Column("name", sa.Unicode(length=255), nullable=False),
sa.Column("admin_unit_id", sa.Integer(), nullable=False),
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("created_by_id", sa.Integer(), nullable=True),
sa.Column("updated_by_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["admin_unit_id"],
["adminunit.id"],
),
sa.ForeignKeyConstraint(
["created_by_id"],
["user.id"],
),
sa.ForeignKeyConstraint(
["updated_by_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("customwidget")
# ### end Alembic commands ###

View File

@ -125,6 +125,7 @@ scope_list = [
"organization:read",
"organization:write",
"eventlist:write",
"customwidget:write",
]
scopes = {k: get_localized_scope(k) for v, k in enumerate(scope_list)}
@ -188,6 +189,7 @@ def add_oauth2_scheme_with_transport(insecure: bool):
marshmallow_plugin.converter.add_attribute_function(enum_to_properties)
import project.api.custom_widget.resources
import project.api.dump.resources
import project.api.event.resources
import project.api.event_category.resources

View File

View File

@ -0,0 +1,84 @@
from flask import make_response
from flask_apispec import doc, marshal_with, use_kwargs
from project import db
from project.access import access_or_401, login_api_user_or_401
from project.api import add_api_resource
from project.api.custom_widget.schemas import (
CustomWidgetPatchRequestSchema,
CustomWidgetPostRequestSchema,
CustomWidgetSchema,
)
from project.api.resources import BaseResource, require_api_access
from project.models import CustomWidget
class CustomWidgetResource(BaseResource):
@doc(summary="Get custom widget", tags=["Custom Widgets"])
@marshal_with(CustomWidgetSchema)
def get(self, id):
return CustomWidget.query.get_or_404(id)
@doc(
summary="Update custom widget",
tags=["Custom Widgets"],
security=[{"oauth2": ["customwidget:write"]}],
)
@use_kwargs(CustomWidgetPostRequestSchema, location="json", apply=False)
@marshal_with(None, 204)
@require_api_access("customwidget:write")
def put(self, id):
login_api_user_or_401()
customwidget = CustomWidget.query.get_or_404(id)
access_or_401(customwidget.adminunit, "admin_unit:update")
customwidget = self.update_instance(
CustomWidgetPostRequestSchema, instance=customwidget
)
db.session.commit()
return make_response("", 204)
@doc(
summary="Patch custom widget",
tags=["Custom Widgets"],
security=[{"oauth2": ["customwidget:write"]}],
)
@use_kwargs(CustomWidgetPatchRequestSchema, location="json", apply=False)
@marshal_with(None, 204)
@require_api_access("customwidget:write")
def patch(self, id):
login_api_user_or_401()
customwidget = CustomWidget.query.get_or_404(id)
access_or_401(customwidget.adminunit, "admin_unit:update")
customwidget = self.update_instance(
CustomWidgetPatchRequestSchema, instance=customwidget
)
db.session.commit()
return make_response("", 204)
@doc(
summary="Delete custom widget",
tags=["Custom Widgets"],
security=[{"oauth2": ["customwidget:write"]}],
)
@marshal_with(None, 204)
@require_api_access("customwidget:write")
def delete(self, id):
login_api_user_or_401()
customwidget = CustomWidget.query.get_or_404(id)
access_or_401(customwidget.adminunit, "admin_unit:update")
db.session.delete(customwidget)
db.session.commit()
return make_response("", 204)
add_api_resource(
CustomWidgetResource,
"/custom-widgets/<int:id>",
"api_v1_custom_widget",
)

View File

@ -0,0 +1,82 @@
from marshmallow import fields, validate
from project.api import marshmallow
from project.api.organization.schemas import OrganizationRefSchema
from project.api.schemas import (
IdSchemaMixin,
PaginationRequestSchema,
PaginationResponseSchema,
SQLAlchemyBaseSchema,
TrackableSchemaMixin,
WriteIdSchemaMixin,
)
from project.models import CustomWidget
class CustomWidgetModelSchema(SQLAlchemyBaseSchema):
class Meta:
model = CustomWidget
load_instance = True
class CustomWidgetIdSchema(CustomWidgetModelSchema, IdSchemaMixin):
pass
class CustomWidgetDumpIdSchema(CustomWidgetModelSchema, IdSchemaMixin):
pass
class CustomWidgetWriteIdSchema(CustomWidgetModelSchema, WriteIdSchemaMixin):
pass
class CustomWidgetBaseSchemaMixin(TrackableSchemaMixin):
widget_type = marshmallow.auto_field(
required=True, validate=validate.Length(min=3, max=255)
)
name = marshmallow.auto_field(
required=True, validate=validate.Length(min=3, max=255)
)
settings = fields.Dict(keys=fields.Str())
class CustomWidgetSchema(CustomWidgetIdSchema, CustomWidgetBaseSchemaMixin):
organization = fields.Nested(OrganizationRefSchema, attribute="adminunit")
class CustomWidgetDumpSchema(CustomWidgetIdSchema, CustomWidgetBaseSchemaMixin):
organization_id = fields.Int(attribute="admin_unit_id")
class CustomWidgetRefSchema(CustomWidgetIdSchema):
widget_type = marshmallow.auto_field()
name = marshmallow.auto_field()
class CustomWidgetListRequestSchema(PaginationRequestSchema):
name = fields.Str(
metadata={"description": "Looks for name."},
)
class CustomWidgetListResponseSchema(PaginationResponseSchema):
items = fields.List(
fields.Nested(CustomWidgetRefSchema), metadata={"description": "Custom widgets"}
)
class CustomWidgetPostRequestSchema(
CustomWidgetModelSchema, CustomWidgetBaseSchemaMixin
):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.make_post_schema()
class CustomWidgetPatchRequestSchema(
CustomWidgetModelSchema, CustomWidgetBaseSchemaMixin
):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.make_patch_schema()

View File

@ -11,6 +11,12 @@ from project.access import (
login_api_user_or_401,
)
from project.api import add_api_resource
from project.api.custom_widget.schemas import (
CustomWidgetIdSchema,
CustomWidgetListRequestSchema,
CustomWidgetListResponseSchema,
CustomWidgetPostRequestSchema,
)
from project.api.event.resources import api_can_read_private_events
from project.api.event.schemas import (
EventIdSchema,
@ -70,6 +76,7 @@ from project.oauth2 import require_oauth
from project.services.admin_unit import (
get_admin_unit_invitation_query,
get_admin_unit_query,
get_custom_widget_query,
get_event_list_query,
get_event_list_status_query,
get_organizer_query,
@ -422,6 +429,42 @@ class OrganizationEventListStatusListResource(BaseResource):
return pagination
class OrganizationCustomWidgetListResource(BaseResource):
@doc(
summary="List custom widgets of organization",
tags=["Organizations", "Custom Widgets"],
)
@use_kwargs(CustomWidgetListRequestSchema, location=("query"))
@marshal_with(CustomWidgetListResponseSchema)
def get(self, id, **kwargs):
admin_unit = AdminUnit.query.get_or_404(id)
name = kwargs["name"] if "name" in kwargs else None
pagination = get_custom_widget_query(admin_unit.id, name).paginate()
return pagination
@doc(
summary="Add new custom widget",
tags=["Organizations", "CustomWidgets"],
security=[{"oauth2": ["customwidget:write"]}],
)
@use_kwargs(CustomWidgetPostRequestSchema, location="json", apply=False)
@marshal_with(CustomWidgetIdSchema, 201)
@require_api_access("customwidget:write")
def post(self, id):
login_api_user_or_401()
admin_unit = get_admin_unit_for_manage_or_404(id)
access_or_401(admin_unit, "admin_unit:update")
custom_widget = self.create_instance(
CustomWidgetPostRequestSchema, admin_unit_id=admin_unit.id
)
db.session.add(custom_widget)
db.session.commit()
return custom_widget, 201
add_api_resource(OrganizationResource, "/organizations/<int:id>", "api_v1_organization")
add_api_resource(
OrganizationEventDateSearchResource,
@ -479,3 +522,8 @@ add_api_resource(
"/organizations/<int:id>/organization-invitations",
"api_v1_organization_organization_invitation_list",
)
add_api_resource(
OrganizationCustomWidgetListResource,
"/organizations/<int:id>/custom-widgets",
"api_v1_organization_custom_widget_list",
)

View File

@ -29,6 +29,7 @@ class FindEventDateForm(FlaskForm):
choices=distance_choices,
)
event_list_id = HiddenField(validators=[Optional()])
organization_id = HiddenField(validators=[Optional()])
s_ft = HiddenField(validators=[Optional()])
s_bg = HiddenField(validators=[Optional()])
s_pr = HiddenField(validators=[Optional()])

View File

@ -27,6 +27,7 @@ from sqlalchemy import (
func,
select,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.event import listens_for
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property
@ -443,6 +444,11 @@ class AdminUnit(db.Model, TrackableMixin):
cascade="all, delete-orphan",
backref=backref("adminunit", lazy=True),
)
custom_widgets = relationship(
"CustomWidget",
cascade="all, delete-orphan",
backref=backref("adminunit", lazy=True),
)
location_id = deferred(db.Column(db.Integer, db.ForeignKey("location.id")))
location = db.relationship(
"Location", uselist=False, single_parent=True, cascade="all, delete-orphan"
@ -1143,6 +1149,15 @@ class Analytics(db.Model):
created_at = Column(DateTime, default=datetime.datetime.utcnow)
class CustomWidget(db.Model, TrackableMixin):
__tablename__ = "customwidget"
id = Column(Integer(), primary_key=True)
widget_type = Column(Unicode(255), nullable=False)
name = Column(Unicode(255), nullable=False)
admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=False)
settings = Column(JSONB)
# Deprecated begin
class FeaturedEventReviewStatus(IntEnum):
inbox = 1

View File

@ -8,6 +8,7 @@ from project.models import (
AdminUnitMemberInvitation,
AdminUnitMemberRole,
AdminUnitRelation,
CustomWidget,
EventEventLists,
EventList,
EventOrganizer,
@ -195,6 +196,16 @@ def get_organizer_query(admin_unit_id, name=None):
return query.order_by(func.lower(EventOrganizer.name))
def get_custom_widget_query(admin_unit_id, name=None):
query = CustomWidget.query.filter(CustomWidget.admin_unit_id == admin_unit_id)
if name:
like_name = "%" + name + "%"
query = query.filter(CustomWidget.name.ilike(like_name))
return query.order_by(func.lower(CustomWidget.name))
def get_place_query(admin_unit_id, name=None):
query = EventPlace.query.filter(EventPlace.admin_unit_id == admin_unit_id)

View File

@ -127,3 +127,6 @@ class EventSearchParams(object):
if "sort" in request.args:
self.sort = request.args["sort"]
if "organization_id" in request.args:
self.admin_unit_id = request.args["organization_id"]

View File

@ -5,16 +5,17 @@ const CustomTypeahead = {
:name="label"
:detectInput="false"
ref="validationProvider"
rules="required"
:rules="rules"
v-slot="validationContext">
<b-form-group :label="label">
<vue-typeahead-bootstrap
ref="typeahead"
v-model="query"
:data="suggestions"
:minMatchingChars="1"
:disableSort="true"
:showAllResults="true"
:placeholder="$t('shared.autocomplete.instruction')"
:placeholder="$attrs.placeholder != null ? $attrs.placeholder : $t('shared.autocomplete.instruction')"
:inputClass="getInputClass(validationContext)"
@hit="selected = $event"
@input="onInput"
@ -31,12 +32,27 @@ const CustomTypeahead = {
value: {
type: null
},
rules: {
type: [Object, String],
default: "required"
},
fetchURL: {
type: String
},
labelKey: {
type: String
},
labelValue: {
type: String
},
validClass: {
type: String,
default: "is-valid"
},
invalidClass: {
type: String,
default: "is-invalid"
},
},
data: () => ({
query: '',
@ -45,7 +61,7 @@ const CustomTypeahead = {
}),
computed: {
label() {
return this.$t(this.labelKey)
return this.labelValue != null ? this.labelValue : this.$t(this.labelKey);
},
},
methods: {
@ -57,7 +73,7 @@ const CustomTypeahead = {
return "";
}
return valid ? "is-valid" : "is-invalid";
return valid ? this.validClass : this.invalidClass;
},
fetchData(query) {
const vm = this
@ -74,7 +90,16 @@ const CustomTypeahead = {
},
},
mounted() {
this.$refs.validationProvider.syncValue(this.selected)
this.$refs.validationProvider.syncValue(this.selected);
this.$watch(
"$refs.typeahead.isFocused",
(new_value, old_value) => {
if (new_value && this.$refs.typeahead.showOnFocus && this.query == "") {
this.fetchData(this.query);
}
}
);
},
watch: {
selected(newVal) {

View File

@ -0,0 +1,571 @@
const WidgetConfigurator = {
template: `
<b-container fluid class="h-100 d-flex flex-column">
<b-row class="flex-shrink-0 bg-light" align-v="center">
<b-col cols="auto">
<h1 class="my-3">{{ $t("comp.title") }}</h1>
</b-col>
<b-col cols="auto">
<b-overlay :show="isLoading">
<b-form inline>
<b-input-group :prepend="$t('shared.models.customWidget.widgetType')" class="mb-2 mr-sm-2 mb-sm-0">
<b-form-select v-model="widgetType" :options="widgetTypes"></b-form-select>
</b-input-group>
<b-input-group :prepend="$t('shared.models.customWidget.name')" class="mb-2 mr-sm-2 mb-sm-0">
<b-form-input v-model="name"></b-form-input>
</b-input-group>
<b-button variant="secondary" @click="goBack" v-bind:disabled="isSubmitting" class="mb-2 mr-sm-2 mb-sm-0">{{ $t("shared.cancel") }}</b-button>
<b-button variant="primary" @click.prevent="submitForm()" v-bind:disabled="isSubmitting" class="mb-2 mb-sm-0">
<b-spinner small v-if="isSubmitting"></b-spinner>
{{ $t("shared.save") }}
</b-button>
</b-form inline>
</b-overlay>
</b-col>
</b-row>
<b-row class="flex-fill" style="min-height:0;">
<b-col sm="3" class="mh-100 py-3" style="overflow-y: scroll;">
<b-overlay :show="isLoading">
<b-card no-body>
<b-tabs card>
<b-tab :title="$t('comp.tabSettings')" active>
<div v-if="widgetType == 'search'">
<custom-typeahead
v-model="searchEventList"
rules=""
validClass=""
:fetchURL="eventListFetchUrl"
:labelValue="$t('comp.search.eventListId')"
:showOnFocus="true"
:serializer="i => i.name"
/>
<b-form-group :label="$t('comp.search.layout')">
<b-form-select v-model="form.search.layout" :options="searchLayouts"></b-form-select>
</b-form-group>
<b-form-group :label="$t('comp.search.eventsPerPage')">
<b-form-input v-model="form.search.eventsPerPage"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.view')">
<b-form-checkbox v-model="form.search.showFilter">
{{ $t("comp.search.showFilter") }}
</b-form-checkbox>
<b-form-checkbox v-model="form.search.showPagination">
{{ $t("comp.search.pagination") }}
</b-form-checkbox>
<b-form-checkbox v-model="form.search.showOvedaLink">
{{ $t("comp.search.showOvedaLink") }}
</b-form-checkbox>
<b-form-checkbox v-model="form.search.showPrintButton">
{{ $t("comp.search.printButton") }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('comp.search.iFrameMinHeight')">
<b-form-input v-model="form.search.iFrameMinHeight" debounce="500"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.iFrameMaxHeight')">
<b-form-input v-model="form.search.iFrameMaxHeight" debounce="500"></b-form-input>
</b-form-group>
</div>
<div v-if="widgetType == 'calendar'">
<b-form-group :label="$t('comp.calendar.calendarType')">
<b-form-select v-model="form.calendar.calendarType" :options="calendarTypes"></b-form-select>
</b-form-group>
<b-form-group :label="$t('comp.calendar.iFrameHeight')">
<b-form-input v-model="form.calendar.iFrameHeight" debounce="500"></b-form-input>
</b-form-group>
</div>
</b-tab>
<b-tab :title="$t('comp.tabStyles')">
<div v-if="widgetType == 'search'">
<b-form-group :label="$t('comp.generic.fontFamily')">
<b-form-input v-model="form.search.fontFamily"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.generic.padding')">
<b-form-input v-model="form.search.padding"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.background" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.generic.textColor')">
<b-form-input v-model="form.search.textColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.generic.linkColor')">
<b-form-input v-model="form.search.linkColor" type="color"></b-form-input>
</b-form-group>
<template v-if="form.search.layout == 'card'">
<b-form-group :label="$t('comp.search.event') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.eventBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.event') + ' ' + $t('comp.generic.borderColor')">
<b-form-input v-model="form.search.eventBorderColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.eventName') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.eventNameTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.eventDate') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.eventDateTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.eventInfo') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.eventInfoTextColor" type="color"></b-form-input>
</b-form-group>
</template>
<b-form-group :label="$t('comp.search.eventBadgeWarning') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.eventBadgeWarningBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.eventBadgeWarning') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.eventBadgeWarningTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.eventBadgeInfo') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.eventBadgeInfoBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.eventBadgeInfo') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.eventBadgeInfoTextColor" type="color"></b-form-input>
</b-form-group>
<template v-if="form.search.showFilter">
<b-form-group :label="$t('comp.search.filterLabel') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.filterLabelBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterLabel') + ' ' + $t('comp.generic.borderColor')">
<b-form-input v-model="form.search.filterLabelBorderColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterLabel') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.filterLabelTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterInput') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.filterInputBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterInput') + ' ' + $t('comp.generic.borderColor')">
<b-form-input v-model="form.search.filterInputBorderColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterInput') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.filterInputTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterButton') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.buttonBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterButton') + ' ' + $t('comp.generic.borderColor')">
<b-form-input v-model="form.search.buttonBorderColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.filterButton') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.buttonTextColor" type="color"></b-form-input>
</b-form-group>
</template>
<template v-if="form.search.showPagination">
<b-form-group :label="$t('comp.search.pagination') + ' ' + $t('comp.generic.borderColor')">
<b-form-input v-model="form.search.pagingBorderColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.pagination') + ' ' + $t('comp.generic.color')">
<b-form-input v-model="form.search.pagingColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.pagination') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.pagingTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.pagination') + ' ' + $t('comp.generic.disabled') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.pagingDisabledTextColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.pagination') + ' ' + $t('comp.generic.active') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.pagingActiveTextColor" type="color"></b-form-input>
</b-form-group>
</template>
<template v-if="form.search.showPrintButton">
<b-form-group :label="$t('comp.search.printButton') + ' ' + $t('comp.generic.backgroundColor')">
<b-form-input v-model="form.search.printButtonBackgroundColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.printButton') + ' ' + $t('comp.generic.borderColor')">
<b-form-input v-model="form.search.printButtonBorderColor" type="color"></b-form-input>
</b-form-group>
<b-form-group :label="$t('comp.search.printButton') + ' ' + $t('comp.generic.textColor')">
<b-form-input v-model="form.search.printButtonTextColor" type="color"></b-form-input>
</b-form-group>
</template>
</div>
<div v-if="widgetType == 'calendar'">
<b-form-group :label="$t('comp.calendar.eventBackgroundColor')">
<b-form-input v-model="form.calendar.eventBackgroundColor" type="color"></b-form-input>
</b-form-group>
</div>
</b-tab>
</b-tabs>
</b-card>
</b-overlay>
</b-col>
<b-col sm="9" class="mh-100 py-3" style="overflow-y: scroll;">
<b-container fluid class="h-100 d-flex flex-column px-0">
<b-row class="flex-shrink-0">
<b-col class="my-2">
<b-form inline class="float-sm-right">
<b-form-input
v-model="previewBackgroundColor"
type="color"
class="mr-sm-2"
style="min-width:40px;"></b-form-input>
<b-form-radio-group
v-model="previewSize"
:options="previewSizes"
size="sm"
button-variant="outline-secondary"
buttons
></b-form-radio-group>
</b-form>
</b-col>
</b-row>
<b-row class="flex-fill" style="min-height:0;">
<b-col class="mh-100" style="overflow-y: scroll;">
<div ref="previewPage" class="h-100 border p-3 m-auto" :style="{width: previewSize, 'background-color': previewBackgroundColor + '!important', 'overflow-y': 'scroll'}">
<div class="mb-2 font-weight-bold">{{ $t("comp.preview") }}</div>
<iframe v-if="iFrameActive" :key="iFrameCounter" class="preview_iframe" ref="previewIframe" :src="iFrameSource" style="width:100%; height:300px;" frameborder="0"></iframe>
</div>
</b-col>
</b-row>
</b-container>
</b-col>
</b-row>
</b-container>
`,
i18n: {
messages: {
en: {
comp: {
title: "Widget configurator",
successMessage: "Widget successfully saved",
preview: "Preview",
tabSettings: "Serrings",
tabStyles: "Styles",
generic: {
fontFamily: "Font family",
padding: "Padding",
backgroundColor: "Background color",
textColor: "Text color",
linkColor: "Link color",
borderColor: "Border color",
color: "Color",
disabled: "Deactivated",
active: "Activ",
},
calendar: {
iFrameHeight: "Height",
eventBackgroundColor: "Event color",
calendarType: "Calendar type",
},
search: {
iFrameMinHeight: "Min. Height",
iFrameMaxHeight: "Max. Height",
eventListId: "Event list",
view: "Display",
showFilter: "Filter",
showOvedaLink: "Oveda link",
layout: "Layout",
eventsPerPage: "Events per page",
event: "Event",
eventName: "Event name",
eventDate: "Event datum",
eventInfo: "Event info",
eventBadgeWarning: "Event warning badge",
eventBadgeInfo: "Event info badge",
filterLabel: "Filter label",
filterInput: "Filter input",
filterButton: "Filter button",
pagination: "Pagination",
printButton: "Print button",
},
},
},
de: {
comp: {
title: "Widget Konfigurator",
successMessage: "Widget erfolgreich gespeichert",
preview: "Vorschau",
tabSettings: "Einstellungen",
tabStyles: "Styles",
generic: {
fontFamily: "Schriftart",
padding: "Abstand",
backgroundColor: "Hintergrundfarbe",
textColor: "Textfarbe",
linkColor: "Link-Farbe",
borderColor: "Rahmen-Farbe",
color: "Farbe",
disabled: "Deaktiviert",
active: "Aktiv",
},
calendar: {
iFrameHeight: "Höhe",
eventBackgroundColor: "Event Farbe",
calendarType: "Kalender Typ",
},
search: {
iFrameMinHeight: "Min. Höhe",
iFrameMaxHeight: "Max. Höhe",
eventListId: "Veranstaltungsliste",
view: "Anzeige",
showFilter: "Filter",
showOvedaLink: "Oveda-Link",
layout: "Layout",
eventsPerPage: "Events pro Seite",
event: "Event",
eventName: "Event-Name",
eventDate: "Event-Datum",
eventInfo: "Event-Info",
eventBadgeWarning: "Event Warnungszeichen",
eventBadgeInfo: "Event Infozeichen",
filterLabel: "Filter-Label",
filterInput: "Filter-Eingabe",
filterButton: "Filter-Button",
pagination: "Paginierung",
printButton: "Drucken-Button",
},
},
},
},
},
data: () => ({
iFrameActive: true,
iFrameCounter: 0,
previewSize: '100%',
previewSizes: [
{ text: 'Desktop', value: '100%' },
{ text: 'Tablet', value: '768px' },
{ text: 'Mobile', value: '400px' },
],
previewBackgroundColor: '#f8f9fa',
isLoading: false,
isSubmitting: false,
customWidget: null,
form: {
search: {
layout: "card",
iFrameMinHeight: 400,
iFrameMaxHeight: "Infinity",
iFrameAutoResize: true,
organizationId: null,
eventListId: null,
eventsPerPage: 10,
showFilter: true,
showPagination: true,
showPrintButton: false,
showOvedaLink: true,
fontFamily: "",
background: "#ffffff",
textColor: "#212529",
padding: "1rem",
linkColor: "#007bff",
buttonBackgroundColor: "#007bff",
buttonBorderColor: "#007bff",
buttonTextColor: "#ffffff",
printButtonBackgroundColor: "#6c757d",
printButtonBorderColor: "#6c757d",
printButtonTextColor: "#ffffff",
filterLabelBackgroundColor: "#e9ecef",
filterLabelBorderColor: "#ced4da",
filterLabelTextColor: "#495057",
filterInputBackgroundColor: "#ffffff",
filterInputBorderColor: "#ced4da",
filterInputTextColor: "#495057",
eventBackgroundColor: "#ffffff",
eventBorderColor: "#00000020",
eventNameTextColor: "#000000",
eventDateTextColor: "#212529",
eventInfoTextColor: "#6c757d",
pagingBorderColor: "#dee2e6",
pagingColor: "#007bff",
pagingTextColor: "#007bff",
pagingActiveTextColor: "#ffffff",
pagingDisabledTextColor: "#6c757d",
eventBadgeWarningBackgroundColor: "#ffc107",
eventBadgeWarningTextColor: "#212529",
eventBadgeInfoBackgroundColor: "#17a2b8",
eventBadgeInfoTextColor: "#ffffff",
},
calendar: {
iFrameHeight: 600,
iFrameAutoResize: false,
organizationId: null,
eventListId: null,
calendarType: "week",
eventBackgroundColor: "#007bff",
}
},
calendarTypes: [
{ value: "week", text: 'Woche' },
{ value: 'month', text: 'Monat' },
],
searchLayouts: [
{ value: "card", text: 'Karten' },
{ value: 'text', text: 'Text' },
],
widgetType: "search",
widgetTypes: [],
name: "Widget",
searchEventList: null,
}),
computed: {
iFrameSource() {
return `${window.location.origin}/static/widget/${this.widgetType}.html`;
},
organizationId() {
return this.$route.params.organization_id;
},
customWidgetId() {
return this.$route.params.custom_widget_id;
},
settings() {
return this.form[this.widgetType];
},
iFrameResizerOptions() {
return {
autoResize: this.settings.iFrameAutoResize,
minHeight: this.settings.iFrameMinHeight != null ? this.settings.iFrameMinHeight : this.settings.iFrameHeight,
maxHeight: this.settings.iFrameMaxHeight != null ? this.settings.iFrameMaxHeight : this.settings.iFrameHeight,
scrolling: "omit",
};
},
eventListFetchUrl() {
return `/api/v1/organizations/${this.organizationId}/event-lists?name={query}`;
},
},
mounted() {
this.isLoading = false;
this.customWidget = null;
this.widgetTypes = [
{ value: "search", text: this.$t("shared.models.customWidget.widgetTypeSearch") },
{ value: "calendar", text: this.$t("shared.models.customWidget.widgetTypeCalendar") },
]
this.form.search.organizationId = this.organizationId;
this.form.calendar.organizationId = this.organizationId;
if (this.customWidgetId == null) {
this.initResizer();
} else {
this.loadFormData();
}
},
watch: {
settings: {
handler: function (val, oldVal) {
this.updatePreview();
},
deep: true
},
iFrameResizerOptions: {
handler: function (val, oldVal) {
this.reloadIframe();
},
deep: true
},
searchEventList: function(val) {
this.form.search.eventListId = val != null ? val.id : null;
},
previewSize: function(val) {
this.resizePreview();
}
},
methods: {
reloadIframe() {
this.iFrameActive = false;
this.iFrameCounter++;
this.iFrameActive = true;
this.initResizer();
},
initResizer() {
const vm = this;
Vue.nextTick(function () {
iFrameResize({
autoResize: vm.iFrameResizerOptions.autoResize,
minHeight: vm.iFrameResizerOptions.minHeight,
maxHeight: vm.iFrameResizerOptions.maxHeight,
scrolling: vm.iFrameResizerOptions.scrolling,
onMessage: function(m) {},
onInit: function() { vm.updatePreview() },
},
'.preview_iframe');
});
},
updatePreview() {
const resizer = this.$refs.previewIframe.iFrameResizer;
if (resizer === undefined) {
return;
}
resizer.sendMessage({'type': 'OVEDA_WIDGET_SETTINGS_UPDATE_EVENT', 'data': this.settings});
},
resizePreview() {
const resizer = this.$refs.previewIframe.iFrameResizer;
if (resizer === undefined) {
return;
}
resizer.resize();
},
loadFormData() {
axios
.get(`/api/v1/custom-widgets/${this.customWidgetId}`, {
withCredentials: true,
handleLoading: this.handleLoading,
})
.then((response) => {
this.customWidget = response.data;
this.widgetType = this.customWidget.widget_type;
this.name = this.customWidget.name;
for (var key in this.customWidget.settings) {
this.settings[key] = this.customWidget.settings[key];
}
});
},
handleLoading(isLoading) {
this.isLoading = isLoading;
},
submitForm() {
let data = {
'widget_type': this.widgetType,
'name': this.name,
'settings': this.settings,
};
if (this.customWidgetId == null) {
axios
.post(`/api/v1/organizations/${this.organizationId}/custom-widgets`,
data,
{
withCredentials: true,
handleLoading: this.handleSubmitting,
})
.then(() => {
this.$root.makeSuccessToast(this.$t("comp.successMessage"))
this.goBack()
})
} else {
axios
.put(`/api/v1/custom-widgets/${this.customWidgetId}`,
data,
{
withCredentials: true,
handleLoading: this.handleSubmitting,
})
.then(() => {
this.$root.makeSuccessToast(this.$t("comp.successMessage"))
this.goBack()
})
}
},
handleSubmitting(isLoading) {
this.isSubmitting = isLoading;
},
goBack() {
window.location.href = `/manage/admin_unit/${this.organizationId}/custom-widgets`;
},
},
};

View File

@ -0,0 +1,170 @@
const WidgetConfiguratorList = {
template: `
<div>
<h1>{{ $t("shared.models.customWidget.listName") }}</h1>
<div class="my-4">
<b-button variant="outline-secondary" @click.prevent="createItem()"><i class="fa fa-plus"></i> {{ $t("comp.addTitle") }}</b-button>
</div>
<div class="alert alert-danger" role="alert" v-if="errorMessage">
{{ errorMessage }}
</div>
<b-modal id="custom-widget-installation-modal" title="Installation" size="lg" ok-only>
<p>Kopiere den unten stehenden Code und füge ihn auf deiner Website ein.</p>
<p>Füge den folgenden Code im <code>&lt;head&gt;</code> der Seite ein.</p>
<b-form-textarea rows="3" max-rows="8" size="sm" disabled class="text-monospace" style="font-size: 0.7rem;" v-model="installationHeader"></b-form-textarea>
<p class="mt-3">Füge den folgenden Code an der Stelle im <code>&lt;body&gt;</code> der Seite ein, wo das Widget dargestellt werden soll.</p>
<b-form-textarea rows="1" max-rows="8" size="sm" disabled class="text-monospace" style="font-size: 0.7rem;" v-model="installationBody"></b-form-textarea>
</b-modal>
<b-table
ref="table"
id="main-table"
:fields="fields"
:items="loadTableData"
:current-page="currentPage"
:per-page="perPage"
primary-key="id"
thead-class="d-none"
outlined
hover
responsive
show-empty
:empty-text="$t('shared.emptyData')"
style="min-height:120px"
>
<template #cell(name)="data">
<b-dropdown :id="'item-dropdown-' + data.item.id" :text="data.value" variant="link" toggle-class="m-0 p-0">
<b-dropdown-item @click.prevent="installItem(data.item.id)">{{ $t("comp.installation") }}&hellip;</b-dropdown-item>
<b-dropdown-item @click.prevent="editItem(data.item.id)">{{ $t("shared.edit") }}&hellip;</b-dropdown-item>
<b-dropdown-item @click.prevent="deleteItem(data.item.id)">{{ $t("shared.delete") }}&hellip;</b-dropdown-item>
</b-dropdown>
</template>
<template #cell(widget_type)="data">
{{ widgetTypes[data.value] }}
</template>
</b-table>
<b-pagination v-if="totalRows > 0"
v-model="currentPage"
:total-rows="totalRows"
:per-page="perPage"
aria-controls="main-table"
></b-pagination>
</div>
`,
i18n: {
messages: {
en: {
comp: {
addTitle: "Add widget",
installation: "Installation",
deletedMessage: "Widget successfully deleted",
deleteConfirmation: "Do you really want to delete the widget?",
},
},
de: {
comp: {
addTitle: "Widget hinzufügen",
installation: "Installation",
deletedMessage: "Widget erfolgreich gelöscht",
deleteConfirmation: "Möchtest du das Widget wirklich löschen?",
},
},
},
},
data: () => ({
errorMessage: null,
fields: [
{
key: "name",
label: i18n.t("shared.models.customWidget.name"),
},
{
key: "widget_type",
label: i18n.t("shared.models.customWidget.widgetType"),
},
],
totalRows: 0,
currentPage: 1,
perPage: 10,
searchResult: {
items: [],
},
widgetTypes: {
"search": "Search",
"calendar": "Calendar",
},
installationWidgetId: 0,
}),
computed: {
organizationId() {
return this.$route.params.organization_id;
},
installationHeader() {
return "<!-- Oveda Widget -->\n<script>(function(w,d,s,o,f,js,fjs){w['OvedaWidget']=o;w[o]=w[o]||function(){(w[o].q=w[o].q||[]).push(arguments)};js=d.createElement(s),fjs=d.getElementsByTagName(s)[0];js.id=o;js.src=f;js.async=1;fjs.parentNode.insertBefore(js,fjs);}(window,document,'script','oveda','" + window.location.origin + "/static/widget-loader.js'));</script>\n<!-- End Oveda Widget -->";
},
installationBody() {
return '<div class="oveda-widget" data-widget-id="' + this.installationWidgetId + '"></div>';
}
},
mounted() {
this.widgetTypes["search"] = this.$t("shared.models.customWidget.widgetTypeSearch");
this.widgetTypes["calendar"] = this.$t("shared.models.customWidget.widgetTypeCalendar");
},
methods: {
loadTableData(ctx, callback) {
const vm = this;
axios
.get(`/api/v1/organizations/${this.organizationId}/custom-widgets`, {
params: {
page: ctx.currentPage,
per_page: ctx.perPage,
},
withCredentials: true,
handler: this,
})
.then((response) => {
vm.totalRows = response.data.total;
callback(response.data.items);
})
.catch(() => {
callback([]);
});
return null;
},
refreshTableData() {
this.$refs.table.refresh();
},
handleRequestStart() {
this.errorMessage = null;
},
handleRequestError(error, message) {
this.errorMessage = message;
},
createItem() {
window.location.href = `/manage/admin_unit/${this.organizationId}/custom-widgets/create`;
},
editItem(id) {
window.location.href = `/manage/admin_unit/${this.organizationId}/custom-widgets/${id}/update`;
},
deleteItem(id) {
if (confirm(this.$t("comp.deleteConfirmation"))) {
axios
.delete(`/api/v1/custom-widgets/${id}`, {
withCredentials: true,
})
.then(() => {
this.$root.makeSuccessToast(this.$t("comp.deletedMessage"));
this.refreshTableData();
});
}
},
installItem(id) {
this.installationWidgetId = id;
this.$bvModal.show("custom-widget-installation-modal");
},
},
};

View File

@ -3,7 +3,7 @@
var script = document.currentScript || document.querySelector('script[src*="widget-loader.js"]')
var baseUrl = script.src.replace("/static/widget-loader.js", "");
var iFrameElements = [];
var containers = [];
var needsResizer = false;
w.onload = function() {
@ -17,30 +17,69 @@
function initWidgets() {
var elements = d.getElementsByClassName("oveda-widget");
for (var i = 0; i < elements.length; i++) {
initIframeWidget(elements.item(i));
initIframeWidget(elements.item(i), i);
}
}
function initIframeWidget(element) {
var shortName = getWidgetData(element, 'short-name');
var width = getWidgetData(element, 'width', '100%');
var height = getWidgetData(element, 'height', '400px');
var resize = getWidgetBoolData(element, 'resize', false);
var googleTagManager = getWidgetBoolData(element, 'google-tag-manager', false);
function initIframeWidget(element, index) {
var customId = getWidgetData(element, 'id');
var customWidgetData = null;
var src = null;
var resize = true;
var minHeight = "400";
var maxHeight = "Infinity";
var width = "100%";
var googleTagManager = false;
var style = "border: none; width:" + width + ";height:" + height + ";";
if (resize) {
if (customId != null) {
var url = baseUrl + "/api/v1/custom-widgets/" + customId;
customWidgetData = loadJSON(url);
var settings = customWidgetData.settings;
src = baseUrl + "/static/widget/" + customWidgetData.widget_type + ".html";
if (settings.hasOwnProperty('iFrameAutoResize') && settings.iFrameAutoResize != null) {
resize = settings.iFrameAutoResize;
}
if (settings.hasOwnProperty('iFrameHeight') && settings.iFrameHeight != null) {
minHeight = settings.iFrameHeight;
maxHeight = settings.iFrameHeight;
}
if (settings.hasOwnProperty('iFrameMinHeight') && settings.iFrameMinHeight != null) {
minHeight = settings.iFrameMinHeight;
}
if (settings.hasOwnProperty('iFrameMaxHeight') && settings.iFrameMaxHeight != null) {
maxHeight = settings.iFrameMaxHeight;
}
if (settings.hasOwnProperty('googleTagManager') && settings.googleTagManager != null) {
googleTagManager = settings.googleTagManager;
}
} else {
width = getWidgetData(element, 'width', '100%');
minHeight = getWidgetData(element, 'height', '400px');
resize = getWidgetBoolData(element, 'resize', false);
googleTagManager = getWidgetBoolData(element, 'google-tag-manager', false);
var shortName = getWidgetData(element, 'short-name');
var src = baseUrl + "/" + shortName + "/widget/eventdates?";
src = addParamToQuery(element, src, 'event-list', 'event_list_id');
src = addParamToQuery(element, src, 'font', 's_ft');
src = addParamToQuery(element, src, 'background', 's_bg');
src = addParamToQuery(element, src, 'primary', 's_pr');
src = addParamToQuery(element, src, 'link', 's_li');
}
var style = "border: none; width:" + width + ";height:" + minHeight + ";";
if (resize || customWidgetData != null) {
style += "min-width:100%;max-width:100%;";
}
var src = baseUrl + "/" + shortName + "/widget/eventdates?";
src = addParamToQuery(element, src, 'event-list', 'event_list_id');
src = addParamToQuery(element, src, 'font', 's_ft');
src = addParamToQuery(element, src, 'background', 's_bg');
src = addParamToQuery(element, src, 'primary', 's_pr');
src = addParamToQuery(element, src, 'link', 's_li');
var iFrame = d.createElement("iframe");
iFrame.id = "oveda-widget-iframe-" + index;
iFrame.class = "oveda-widget-iframe";
iFrame.src = src;
iFrame.style = style;
@ -48,11 +87,20 @@
iFrame.allowtransparency = "true";
element.appendChild(iFrame);
if (resize || googleTagManager) {
if (resize || googleTagManager || customWidgetData != null) {
needsResizer = true;
}
iFrameElements.push(element);
var container = {
element: element,
iFrame: iFrame,
minHeight: minHeight,
maxHeight: maxHeight,
resize: resize,
googleTagManager: googleTagManager,
customWidgetData: customWidgetData
};
containers.push(container);
}
function addParamToQuery(element, url, attr, param)
@ -85,29 +133,40 @@
}
function startIframeResizer() {
for (var i = 0; i < iFrameElements.length; i++) {
var element = iFrameElements[i];
var iFrame = element.getElementsByTagName("iframe")[0];
var height = getWidgetData(element, 'height', '400px');
var resize = getWidgetBoolData(element, 'resize', false);
var googleTagManager = getWidgetBoolData(element, 'google-tag-manager', false);
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (resize || googleTagManager) {
var config = { autoResize: resize };
if (resize) {
config.minHeight = height;
} else {
config.scrolling = "omit";
config.sizeHeight = false;
config.sizeWidth = false;
}
if (container.resize || container.googleTagManager || container.customWidgetData != null) {
var config = { };
config.autoResize = container.resize;
config.scrolling = "omit";
config.id = "iFrameResizer" + i;
config.onMessage = function(messageData) {
onIframeMessage(messageData, googleTagManager);
onIframeMessage(messageData, container.googleTagManager);
}
iFrameResize(config, iFrame);
if (container.customWidgetData == null) {
if (container.resize) {
config.minHeight = container.minHeight;
config.maxHeight = container.maxHeight;
} else {
config.sizeHeight = false;
config.sizeWidth = false;
}
} else {
config.minHeight = container.minHeight;
config.maxHeight = container.maxHeight;
(function(data) {
config.onInit = function (iFrame) {
iFrame.iFrameResizer.sendMessage({'type': 'OVEDA_WIDGET_SETTINGS_UPDATE_EVENT', 'data': data});
}
})(container.customWidgetData.settings);
}
var resizers = iFrameResize(config, container.iFrame);
container.resizer = resizers[0];
}
}
};
@ -144,4 +203,21 @@
p.parentNode.insertBefore(element, p);
}
function loadJSON(url) {
var json = loadUrlSync(url, "application/json");
return JSON.parse(json);
}
function loadUrlSync(url, mimeType)
{
var xmlhttp=new XMLHttpRequest();
xmlhttp.open("GET", url, false);
if (mimeType != null && xmlhttp.overrideMimeType) {
xmlhttp.overrideMimeType(mimeType);
}
xmlhttp.send();
return xmlhttp.responseText;
}
})(window, document);

View File

@ -0,0 +1,273 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.5.95/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/vuetify@2.6.2/dist/vuetify.min.css" rel="stylesheet">
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>
<div id="app" v-cloak>
<template v-if="widget">
<v-app :styles="{'max-height': widget.settings.iFrameHeight}">
<v-row class="fill-height" style="margin:0;">
<v-col>
<v-sheet height="64">
<v-toolbar flat>
<v-btn fab text small color="grey darken-2" @click="$refs.calendar.prev()">
<v-icon small>
mdi-chevron-left
</v-icon>
</v-btn>
<v-btn fab text small color="grey darken-2" @click="$refs.calendar.next()">
<v-icon small>
mdi-chevron-right
</v-icon>
</v-btn>
<v-toolbar-title>
{{ title }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
</v-sheet>
<v-sheet :height="calendarHeight">
<v-calendar
ref="calendar"
v-model="focus"
locale="de"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
:type="widget.settings.calendarType"
:events="events"
:event-color="widget.settings.eventBackgroundColor"
@click:event="showEvent"
@change="calendarChanged"></v-calendar>
<v-menu
v-model="selectedOpen"
:close-on-content-click="false"
:activator="selectedElement"
offset-x
>
<v-card flat v-if="selectedEvent">
<v-card-title>{{ selectedEvent.name }}</v-card-title>
<v-card-subtitle>
<v-icon small>mdi-calendar</v-icon> {{ render_event_date_instance(selectedEvent.date.start, selectedEvent.date.allday) }}
<event-warning-pills :event="selectedEvent.date.event"></event-warning-pills>
</v-card-subtitle>
<v-card-text>
<div><v-icon small>mdi-database</v-icon> {{ selectedEvent.date.event.organization.name }}</div>
<div v-if="selectedEvent.date.event.organizer.name != selectedEvent.date.event.organization.name"><v-icon small>mdi-server</v-icon> {{ selectedEvent.date.event.organizer.name }}</div>
<div><v-icon small>mdi-map-marker</v-icon> {{ selectedEvent.date.event.place.name }}</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
text
color="secondary"
@click="selectedOpen = false"
>
Schließen
</v-btn>
<v-btn
text
color="primary"
@click="openEventDate(selectedEvent.date)"
>
Details
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-sheet>
</v-col>
</v-row>
</v-app>
</template>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.6.2/dist/vuetify.js"></script>
<script src="https://unpkg.com/axios@0.21.1/dist/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous"></script>
<script>
axios.defaults.baseURL = window.location.origin;
moment.locale("de");
Vue.component('event-warning-pills', {
props: ['event'],
template: `
<span>
<v-chip v-if="event.status && event.status != 'scheduled'" small color="yellow">
<template v-if="event.status == 'cancelled'">Abgesagt</template>
<template v-else-if="event.status == 'movedOnline'">Online verschoben</template>
<template v-else-if="event.status == 'postponed'">Verschoben</template>
<template v-else-if="event.status == 'rescheduled'">Neu angesetzt</template>
</v-chip>
<v-chip v-if="event.booked_up" small color="yellow">Ausgebucht</v-chip>
<v-chip v-if="event.attendance_mode && event.attendance_mode != 'offline'" small color="blue">
<template v-if="event.attendance_mode == 'online'">Online</template>
<template v-else-if="event.attendance_mode == 'mixed'">Präsenzveranstaltung und online</template>
</v-chip>
</span>
`
});
var vue_app_data = {
widget: null,
title: "",
focus: '',
events: [],
selectedEvent: null,
selectedElement: null,
selectedOpen: false,
};
var app = new Vue({
el: '#app',
vuetify: new Vuetify(),
data: vue_app_data,
computed: {
calendarHeight() {
if (!this.widget) {
return 0;
}
return parseInt(this.widget.settings.iFrameHeight) - 88;
}
},
watch: {
'widget.settings.organizationId': function(val) {
this.parameterChanged();
},
'widget.settings.eventListId': function(val) {
this.parameterChanged();
}
},
methods: {
parameterChanged() {
if (this.$refs.calendar) {
this.loadEvents(this.$refs.calendar.start, this.$refs.calendar.end);
}
},
calendarChanged({ start, end }) {
this.loadEvents(start.date, end.date);
},
loadEvents(start, end) {
if (this.widget == null) {
return;
}
this.title = this.$refs.calendar.title;
let params = {
date_from: start,
date_to: end,
per_page: 50,
};
if (this.widget.settings.eventListId) {
params.event_list_id = this.widget.settings.eventListId;
}
else if (this.widget.settings.organizationId) {
params.organization_id = this.widget.settings.organizationId;
}
axios
.get(`/api/v1/event-dates/search`, { params: params })
.then((response) => {
this.events = response.data.items.map(function(date) {
return {
name: date.event.name,
start: moment(date.start).toDate(),
end: date.end != null ? moment(date.end).toDate() : null,
timed: !date.allday,
date: date,
};
});
this.scrollToMinTime();
});
},
scrollToMinTime() {
if (!this.$refs.calendar) {
return;
}
if (this.$refs.calendar.type != 'week') {
return;
}
if (this.events.length < 1) {
return;
}
const min_event = this.events.reduce(function(prev, curr) {
return prev.start.getHours() < curr.start.getHours() ? prev : curr;
});
this.$refs.calendar.scrollToTime({ hour: min_event.start.getHours(), minute: 0 });
},
showEvent ({ nativeEvent, event }) {
const open = () => {
this.selectedEvent = event
this.selectedElement = nativeEvent.target
requestAnimationFrame(() => requestAnimationFrame(() => this.selectedOpen = true))
}
if (this.selectedOpen) {
this.selectedOpen = false
requestAnimationFrame(() => requestAnimationFrame(() => open()))
} else {
open()
}
nativeEvent.stopPropagation()
},
openEventDate(date) {
const url = `${axios.defaults.baseURL}/eventdate/${date.id}`;
this.trackAnalyticsEvent({'event':'linkClick', 'url':url});
window.open(url);
},
render_event_date_instance(value, allday, format = "dd. DD.MM.YYYY LT", alldayFormat = "dd. DD.MM.YYYY") {
const instance = moment(value);
if (allday) {
return instance.format(alldayFormat);
}
return instance.format(format);
},
trackAnalyticsEvent(data) {
if ('parentIFrame' in window) {
parentIFrame.sendMessage({'type': 'OVEDA_ANALYTICS_EVENT', 'data': data});
}
}
}
});
window.iFrameResizer = {
onReady: function() {
app.trackAnalyticsEvent({'event':'pageView', 'url':document.location.href});
},
onMessage: function(message) {
if (message.type != 'OVEDA_WIDGET_SETTINGS_UPDATE_EVENT') {
return;
}
if (vue_app_data.widget == null) {
vue_app_data.widget = { settings: message.data };
return;
}
for (var key in message.data) {
vue_app_data.widget.settings[key] = message.data[key];
}
}
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.contentWindow.min.js" integrity="sha512-14SY6teTzhrLWeL55Q4uCyxr6GQOxF3pEoMxo2mBxXwPRikdMtzKMYWy2B5Lqjr6PHHoGOxZgPaxUYKQrSmu0A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</body>
</html>

View File

@ -0,0 +1,525 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Oveda Widget</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<link type="text/css" rel="stylesheet" href="https://unpkg.com/bootstrap-vue@2.21.2/dist/bootstrap-vue.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.1/css/all.min.css">
<style>
[v-cloak] {
display: none;
}
</style>
<script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
<script src="https://unpkg.com/vue-i18n@8.25.0/dist/vue-i18n.min.js"></script>
<script src="https://unpkg.com/axios@0.21.1/dist/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous"></script>
<script src="https://unpkg.com/bootstrap-vue@2.21.2/dist/bootstrap-vue.min.js"></script>
</head>
<body>
<div id="app" v-cloak>
<template v-if="widget">
<component :is="`style`">
body {
background-color: {{ widget.settings.background }};
color: {{ widget.settings.textColor }};
font-family: {{ widget.settings.fontFamily }};
padding: {{ widget.settings.padding }};
}
.page-link {
background-color: {{ widget.settings.background }};
border-color: {{ widget.settings.pagingBorderColor }};
color: {{ widget.settings.pagingColor }};
}
.page-link:hover {
background-color: {{ widget.settings.pagingColor }};
border-color: {{ widget.settings.pagingColor }};
color: {{ widget.settings.pagingActiveTextColor }};
}
.page-item.active .page-link {
background-color: {{ widget.settings.pagingColor }};
border-color: {{ widget.settings.pagingColor }};
color: {{ widget.settings.pagingActiveTextColor }};
}
.page-item.disabled .page-link {
background-color: {{ widget.settings.background }};
border-color: {{ widget.settings.pagingBorderColor }};
color: {{ widget.settings.pagingDisabledTextColor }};
}
.btn-primary,
.btn-primary:disabled,
.btn-primary:active,
.btn-primary:not(:disabled):not(.disabled):active,
.btn-primary:focus {
background-color: {{ widget.settings.buttonBackgroundColor }};
border-color: {{ widget.settings.buttonBorderColor }};
color: {{ widget.settings.buttonTextColor }};
}
.btn-secondary,
.btn-secondary:disabled,
.btn-secondary:hover,
.btn-secondary:active,
.btn-secondary:not(:disabled):not(.disabled):active,
.btn-secondary:focus {
background-color: {{ widget.settings.printButtonBackgroundColor }};
border-color: {{ widget.settings.printButtonBorderColor }};
color: {{ widget.settings.printButtonTextColor }};
}
.input-group-text {
background-color: {{ widget.settings.filterLabelBackgroundColor }};
border-color: {{ widget.settings.filterLabelBorderColor }};
color: {{ widget.settings.filterLabelTextColor }};
}
.form-control,
.form-control.focus,
.form-control:focus,
.custom-select,
.custom-select:focus {
background-color: {{ widget.settings.filterInputBackgroundColor }};
border-color: {{ widget.settings.filterInputBorderColor }};
color: {{ widget.settings.filterInputTextColor }};
}
.card {
background-color: {{ widget.settings.eventBackgroundColor }};
border-color: {{ widget.settings.eventBorderColor }};
}
.card-title {
color: {{ widget.settings.eventNameTextColor }};
}
.card-subtitle.text-body {
color: {{ widget.settings.eventDateTextColor }}!important;
}
.card .text-muted {
color: {{ widget.settings.eventInfoTextColor }}!important;
}
.badge-warning {
background-color: {{ widget.settings.eventBadgeWarningBackgroundColor }};
color: {{ widget.settings.eventBadgeWarningTextColor }};
}
.badge-info {
background-color: {{ widget.settings.eventBadgeInfoBackgroundColor }};
color: {{ widget.settings.eventBadgeInfoTextColor }};
}
a, a:hover {
color: {{ widget.settings.linkColor }};
}
a.underlined {
text-decoration: underline;
}
</component>
<div v-if="widget.settings.showFilter">
<b-form @submit.stop.prevent="loadData" inline class="mb-4" autocomplete="off">
<b-input-group prepend="Von" class="mb-2 mr-sm-2">
<b-form-datepicker v-model="form.dateFrom" locale="de" start-weekday="1" hide-header :date-format-options="{ year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }"></b-form-datepicker>
</b-input-group>
<b-input-group prepend="bis" class="mb-2 mr-sm-2">
<b-form-datepicker v-model="form.dateTo" locale="de" placeholder="Kein Datum gewählt" :min="form.dateFrom" start-weekday="1" hide-header :date-format-options="{ year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' }"></b-form-datepicker>
</b-input-group>
<b-input-group prepend="Kategorie" class="mb-2 mr-sm-2">
<b-form-select v-model="form.category_id" :options="categories"></b-form-select>
</b-input-group>
<b-input-group prepend="Stichwort" class="mb-2 mr-sm-2">
<b-form-input v-model="form.keyword"></b-form-input>
</b-input-group>
<b-button variant="primary" class="mb-2" type="submit" :disabled="isLoading">
<b-spinner small v-if="isLoading"></b-spinner>
Finden
</b-button>
</b-form-group>
</b-form>
</div>
<b-overlay :show="isLoading" variant="transparent">
<div v-if="error" class="mb-4">
<b-alert show variant="danger">{{ error }}</b-alert>
<button type="button" class="btn btn-outline-secondary" @click="loadData()"><i class="fa fa-sync-alt"></i></button>
</div>
<template v-if="widget.settings.layout == 'card'">
<div v-for="date in dates">
<!-- Desktop -->
<div class="row mb-3 d-none d-sm-block">
<div class="col-sm">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-sm-8">
<h5 class="card-title">{{ date.event.name }} <event-warning-pills :event="date.event"></event-warning-pills></h5>
<h6 class="card-subtitle mb-2 text-body"><i class="fa fa-calendar"></i> {{ render_event_date_instance(date.start, date.allday) }}</h6>
<p class="card-text" v-if="date.event.description" v-html="date.event.description.truncate(200, true)"></p>
<small class="text-muted mr-2"><i class="fa fa-database"></i> {{ date.event.organization.name }}</small>
<small v-if="date.event.organizer.name != date.event.organization.name" class="text-muted mr-2"><i class="fa fa-server"></i> {{ date.event.organizer.name }}</small>
<small class="text-muted"><i class="fa fa-map-marker"></i> {{ date.event.place.name }}</small>
<a href="#" @click.stop.prevent="openEventDate(date)" class="stretched-link"></a>
</div>
<div class="col-sm-4 text-right">
<img v-if="date.event.photo" :src="url_for_image(date.event.photo, 200)" style="object-fit: cover; width: 200px;" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="row mb-3 d-sm-none">
<div class="col-sm">
<div class="card">
<div>
<img v-if="date.event.photo" :src="url_for_image(date.event.photo, 500)" class="card-img-top" style="object-fit: cover; height: 40vw;" />
</div>
<div class="card-body">
<div class="row">
<div class="col-sm-12">
<h5 class="card-title">{{ date.event.name }} <event-warning-pills :event="date.event"></event-warning-pills></h5>
<h6 class="card-subtitle mb-2 text-body"><i class="fa fa-calendar"></i> {{ render_event_date_instance(date.start, date.allday) }}</h6>
<p class="card-text" v-if="date.event.description" v-html="date.event.description.truncate(100, true)"></p>
<small class="text-muted mr-2"><i class="fa fa-database"></i> {{ date.event.organization.name }}</small>
<small v-if="date.event.organizer.name != date.event.organization.name" class="text-muted mr-2"><i class="fa fa-server"></i> {{ date.event.organizer.name }}</small>
<small class="text-muted"><i class="fa fa-map-marker"></i> {{ date.event.place.name }}</small>
<a href="#" @click.stop.prevent="openEventDate(date)" class="stretched-link"></a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="widget.settings.layout == 'text'">
<div class="mb-3">
<div v-for="date in dates">
{{ render_event_date_instance(date.start, date.allday, 'L', 'L') }} <a href="#" @click.stop.prevent="openEventDate(date)">{{ date.event.name }}</a> <event-warning-pills :event="date.event"></event-warning-pills>
</div>
</div>
</template>
<b-pagination v-if="widget.settings.showPagination && totalRows > perPage"
v-model="currentPage"
:total-rows="totalRows"
:per-page="perPage"
></b-pagination>
<div class="d-print-none">
<div v-if="widget.settings.showOvedaLink" class="mb-2">
<a href="#" @click.stop.prevent="openSearch()" class="underlined">Veranstaltungen auf oveda.de anzeigen</a>
</div>
<div v-if="widget.settings.showPrintButton">
<button @click="printPage()" v-if="totalRows > 0" type="button" class="btn btn-secondary btn-print"><i class="fa fa-print"></i> Drucken</button>
</div>
</div>
</b-overlay>
</template>
</div>
<script>
axios.defaults.baseURL = window.location.origin;
moment.locale("de");
const localizedMessages = {
de: {
categories: {
Art: "Kunst",
Book: "Literatur",
Movie: "Film",
Family: "Familie",
Festival: "Festival",
Religious: "Religion",
Shopping: "Shopping",
Comedy: "Comedy",
Music: "Musik",
Dance: "Tanz",
Nightlife: "Party",
Theater: "Theater",
Dining: "Dining",
Conference: "Konferenz",
Meetup: "Networking",
Fitness: "Fitness",
Sports: "Sport",
Other: "Sonstiges",
Exhibition: "Ausstellung",
Culture: "Kultur",
Tour: "Führung",
OpenAir: "Open Air",
Stage: "Bühne",
Lecture: "Vortrag",
},
}
};
const i18n = new VueI18n({
locale: "de",
messages: localizedMessages,
silentFallbackWarn: true,
});
String.prototype.truncate =
String.prototype.truncate ||
function (n, useWordBoundary) {
if (this.length <= n) {
return this;
}
const subString = this.substr(0, n - 1); // the original check
return (
(useWordBoundary
? subString.substr(0, subString.lastIndexOf(" "))
: subString) + "&hellip;"
);
};
Vue.component('event-warning-pills', {
props: ['event'],
template: `
<span>
<span v-if="event.status" class="badge badge-pill badge-warning">
<template v-if="event.status == 'cancelled'">Abgesagt</template>
<template v-else-if="event.status == 'movedOnline'">Online verschoben</template>
<template v-else-if="event.status == 'postponed'">Verschoben</template>
<template v-else-if="event.status == 'rescheduled'">Neu angesetzt</template>
</span>
<span v-if="event.booked_up" class="badge badge-pill badge-warning">Ausgebucht</span>
<span v-if="event.attendance_mode" class="badge badge-pill badge-info">
<template v-if="event.attendance_mode == 'online'">Online</template>
<template v-else-if="event.attendance_mode == 'mixed'">Präsenzveranstaltung und online</template>
</span>
</span>
`
});
var vue_app_data = {
widget: null,
totalRows: 0,
currentPage: 1,
dates: [],
isLoading: false,
isLoadingCategories: false,
initialLoaded: false,
initialLoadedCategories: false,
error: null,
form: {
dateFrom: moment().toDate(),
dateTo: null,
keyword: null,
category_id: null,
},
categories: null,
};
var app = new Vue({
el: '#app',
i18n,
data: vue_app_data,
mounted() {
this.isLoading = false;
this.error = null;
this.dates = [];
this.loadData();
this.loadCategories();
},
computed: {
perPage() {
return this.widget != null ? Math.min(50, this.widget.settings.eventsPerPage) : 10;
}
},
watch: {
currentPage: function (val) {
this.loadData();
},
widget: function (val) {
this.loadData();
this.loadCategories();
},
'widget.settings.organizationId': function(val) {
this.parameterChanged();
},
'widget.settings.eventListId': function(val) {
this.parameterChanged();
},
'widget.settings.eventsPerPage': function(val) {
this.currentPage = 1;
this.parameterChanged();
},
'widget.settings.showFilter': function(val) {
if (!this.initialLoadedCategories) {
return;
}
this.loadCategories();
},
},
methods: {
parameterChanged() {
if (!this.initialLoaded) {
return;
}
this.loadData();
},
getSearchParams() {
let params = {
page: this.currentPage,
per_page: this.perPage,
date_from: moment(this.form.dateFrom).format("YYYY-MM-DD"),
};
if (this.widget.settings.eventListId) {
params.event_list_id = this.widget.settings.eventListId;
}
else if (this.widget.settings.organizationId) {
params.organization_id = this.widget.settings.organizationId;
}
if (this.form.dateTo) {
params.date_to = moment(this.form.dateTo).format("YYYY-MM-DD");
}
if (this.form.keyword) {
params.keyword = this.form.keyword;
}
if (this.form.category_id && this.form.category_id > 0) {
params.category_id = this.form.category_id;
}
return params;
},
loadData() {
if (this.widget == null) {
return;
}
this.isLoading = true;
const params = this.getSearchParams();
axios
.get(`/api/v1/event-dates/search`, { params: params })
.then((response) => {
this.error = null;
this.dates = response.data.items;
this.totalRows = response.data.total;
this.isLoading = false;
this.initialLoaded = true;
this.scrollToTop();
})
.catch(error => {
this.isLoading = false;
this.dates = [];
this.totalRows = 0;
this.error = error.message;
this.scrollToTop();
});
},
loadCategories() {
if (this.widget == null ||
!this.widget.settings.showFilter ||
this.categories != null) {
return;
}
this.isLoadingCategories = true;
axios
.get(`/api/v1/event-categories`)
.then((response) => {
let categories = response.data.items.map(function(c) {
return { value: c.id, text: i18n.t(`categories.${c.name}`) };
});
categories.sort(function (a, b) {
return a.text > b.text ? 1 : -1;
})
categories.unshift({ value: 0, text: "" });
this.categories = categories;
this.isLoadingCategories = false;
this.initialLoadedCategories = true;
})
.catch(error => {
this.isLoadingCategories = false;
});
},
render_event_date_instance(value, allday, format = "dd. DD.MM.YYYY LT", alldayFormat = "dd. DD.MM.YYYY") {
const instance = moment(value);
if (allday) {
return instance.format(alldayFormat);
}
return instance.format(format);
},
url_for_image(image, size) {
return `${axios.defaults.baseURL}${image.image_url}?s=${size}`
},
scrollToTop() {
window.scrollTo(0,0);
if ('parentIFrame' in window) {
parentIFrame.scrollToOffset(0,0);
}
},
printPage() {
window.print();
},
openEventDate(date) {
const url = `${axios.defaults.baseURL}/eventdate/${date.id}`;
this.trackAnalyticsEvent({'event':'linkClick', 'url':url});
window.open(url);
},
openSearch() {
const params = this.getSearchParams();
const url = `${axios.defaults.baseURL}/eventdates`;
const searchUrl = axios.getUri({ method: "get", url: url, params: params });
window.open(searchUrl);
},
trackAnalyticsEvent(data) {
if ('parentIFrame' in window) {
parentIFrame.sendMessage({'type': 'OVEDA_ANALYTICS_EVENT', 'data': data});
}
},
updateSettings(settings) {
if (this.widget == null) {
this.widget = { settings: settings };
}
for (var key in settings) {
this.widget.settings[key] = settings[key];
}
}
}
});
window.iFrameResizer = {
onReady: function() {
app.trackAnalyticsEvent({'event':'pageView', 'url':document.location.href});
},
onMessage: function(message) {
if (message.type != 'OVEDA_WIDGET_SETTINGS_UPDATE_EVENT') {
return;
}
app.updateSettings(message.data);
}
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.contentWindow.min.js" integrity="sha512-14SY6teTzhrLWeL55Q4uCyxr6GQOxF3pEoMxo2mBxXwPRikdMtzKMYWy2B5Lqjr6PHHoGOxZgPaxUYKQrSmu0A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</body>
</html>

View File

@ -269,6 +269,7 @@
<a class="dropdown-item" href="{{ url_for('manage_admin_unit_organization_invitations', id=current_admin_unit.id) }}">{{ _('Organization invitations') }}</a>
{% endif %}
<a class="dropdown-item" href="{{ url_for('admin_unit_update', id=current_admin_unit.id) }}">{{ _('Settings') }}</a>
<a class="dropdown-item" href="{{ url_for('manage_admin_unit_custom_widgets', id=current_admin_unit.id) }}">{{ _('Custom widgets') }}</a>
<a class="dropdown-item" href="{{ url_for('manage_admin_unit_widgets', id=current_admin_unit.id) }}">{{ _('Widgets') }}</a>
<a class="dropdown-item" href="{{ url_for('organizations', path=current_admin_unit.id) }}">{{ _('Profile') }}</a>
</div>

View File

@ -35,7 +35,7 @@
{% block content %}
{% block vue_container %}
<div id="vue-container"><router-view></router-view></div>
<div id="vue-container"{% block vue_container_attribs %}{% endblock vue_container_attribs %}><router-view></router-view></div>
{% endblock %}
<script>
Vue.component("vue-typeahead-bootstrap", VueTypeaheadBootstrap);
@ -76,6 +76,14 @@
relationVerify: "Verify new organization",
relationVerifyDescription: "If set, events of the new organization are publicly visible.",
},
customWidget: {
className: "Custom widget",
listName: "Custom widgets",
widgetType: "Type",
widgetTypeSearch: "Search",
widgetTypeCalendar: "Calendar",
name: "Name",
},
event: {
className: "Event",
listName: "Events",
@ -96,6 +104,7 @@
},
cancel: "Cancel",
decline: "Decline",
save: "Save",
submit: "Submit",
view: "View",
edit: "Edit",
@ -175,6 +184,14 @@
relationVerify: "Neue Organisation verifizieren",
relationVerifyDescription: "Wenn gesetzt, sind Veranstaltungen der neuen Organisation öffentlich sichtbar.",
},
customWidget: {
className: "Custom widget",
listName: "Custom widgets",
widgetTypeSearch: "Suche",
widgetTypeCalendar: "Kalender",
widgetType: "Typ",
name: "Name",
},
event: {
className: "Veranstaltung",
listName: "Veranstaltungen",
@ -195,6 +212,7 @@
},
cancel: "Abbrechen",
decline: "Ablehnen",
save: "Speichern",
submit: "Senden",
view: "Anzeigen",
edit: "Bearbeiten",

View File

@ -0,0 +1,46 @@
{% extends "layout_vue.html" %}
{% block html_attribs %}{% if full_height %} class="h-100"{% else %}{{ super() }}{% endif %}{% endblock html_attribs %}
{% block body_attribs %}{% if full_height %} class="h-100"{% else %}{{ super() }}{% endif %}{% endblock body_attribs %}
{% block body_content__attribs %}{% if full_height %} style="height:100%;"{% else %}{{ super() }}{% endif %}{% endblock %}
{% block content_container_attribs %}{% if full_height %} class="h-100"{% else %}{{ super() }}{% endif %}{% endblock content_container_attribs %}
{% block vue_container_attribs %}{% if full_height %} class="h-100"{% else %}{{ super() }}{% endif %}{% endblock vue_container_attribs %}
{% block navbar %}{% if full_height %} {% else %}{{ super() }}{% endif %}{% endblock navbar %}
{% block managebar %}{% if full_height %} {% else %}{{ super() }}{% endif %}{% endblock managebar %}
{% block footer %}{% if full_height %} {% else %}{{ super() }}{% endif %}{% endblock footer %}
{%- block title -%}
{{ _('Custom widgets') }}
{%- endblock -%}
{% block header_before_site_js %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.min.js" integrity="sha512-dnvR4Aebv5bAtJxDunq3eE8puKAJrY9GBJYl9GC6lTOEC76s1dbDfJFcL9GyzpaDW4vlI/UjR8sKbc1j6Ynx6w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
{%- endblock -%}
{% block component_scripts %}
<script src="{{ url_for('static', filename='vue/widget-configurator/list.vue.js')}}"></script>
<script src="{{ url_for('static', filename='vue/widget-configurator/configurator.vue.js')}}"></script>
{% endblock %}
{% block component_definitions %}
Vue.component("WidgetConfiguratorList", WidgetConfiguratorList);
Vue.component("WidgetConfigurator", WidgetConfigurator);
{% endblock %}
{% block vue_routes %}
const routes = [
{
path: "/manage/admin_unit/:organization_id/custom-widgets",
component: WidgetConfiguratorList,
},
{
path: "/manage/admin_unit/:organization_id/custom-widgets/create",
component: WidgetConfigurator,
},
{
path: "/manage/admin_unit/:organization_id/custom-widgets/:custom_widget_id/update",
component: WidgetConfigurator,
},
];
{% endblock %}

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-12-08 13:32+0100\n"
"POT-Creation-Date: 2021-12-30 14:51+0100\n"
"PO-Revision-Date: 2020-06-07 18:51+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@ -194,29 +194,29 @@ msgstr "."
msgid "message"
msgstr "message"
#: project/api/organization/resources.py:364
#: project/api/organization/resources.py:371
#: project/views/admin_unit_member_invitation.py:85
msgid "You have received an invitation"
msgstr "Du hast eine Einladung erhalten"
#: project/forms/admin.py:10 project/templates/layout.html:311
#: project/forms/admin.py:10 project/templates/layout.html:312
#: project/views/root.py:37
msgid "Terms of service"
msgstr "Nutzungsbedingungen"
#: project/forms/admin.py:11 project/templates/layout.html:315
#: project/forms/admin.py:11 project/templates/layout.html:316
#: project/views/root.py:45
msgid "Legal notice"
msgstr "Impressum"
#: project/forms/admin.py:12 project/templates/_macros.html:1392
#: project/templates/layout.html:319
#: project/templates/layout.html:320
#: project/templates/widget/event_suggestion/create.html:204
#: project/views/admin_unit.py:73 project/views/root.py:53
msgid "Contact"
msgstr "Kontakt"
#: project/forms/admin.py:13 project/templates/layout.html:323
#: project/forms/admin.py:13 project/templates/layout.html:324
#: project/views/root.py:61
msgid "Privacy"
msgstr "Datenschutz"
@ -957,7 +957,7 @@ msgstr "Standort"
msgid "Distance"
msgstr "Distanz"
#: project/forms/event_date.py:33 project/forms/planing.py:36
#: project/forms/event_date.py:38 project/forms/planing.py:36
#: project/templates/widget/event_date/list.html:82
msgid "Find"
msgstr "Finden"
@ -1388,7 +1388,7 @@ msgid "Manage"
msgstr "Verwaltung"
#: project/templates/home.html:30 project/templates/security/login_user.html:38
#: project/views/widget.py:158
#: project/views/widget.py:159
msgid "Register for free"
msgstr "Kostenlos registrieren"
@ -1412,7 +1412,7 @@ msgstr "Organisationen"
msgid "Planing"
msgstr "Planung"
#: project/templates/layout.html:187 project/templates/layout.html:273
#: project/templates/layout.html:187 project/templates/layout.html:274
#: project/templates/oauth2_client/list.html:10
#: project/templates/oauth2_client/read.html:10
#: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4
@ -1514,17 +1514,22 @@ msgstr "Organisationseinladungen"
msgid "Settings"
msgstr "Einstellungen"
#: project/templates/layout.html:272 project/templates/manage/reviews.html:10
#: project/templates/layout.html:272
#: project/templates/manage/custom_widgets.html:13
msgid "Custom widgets"
msgstr "Custom widgets"
#: project/templates/layout.html:273 project/templates/manage/reviews.html:10
#: project/templates/manage/widgets.html:5
#: project/templates/manage/widgets.html:9
msgid "Widgets"
msgstr "Widgets"
#: project/templates/layout.html:283
#: project/templates/layout.html:284
msgid "Switch organization"
msgstr "Organisation wechseln"
#: project/templates/developer/read.html:4 project/templates/layout.html:333
#: project/templates/developer/read.html:4 project/templates/layout.html:334
#: project/templates/profile.html:29
msgid "Developer"
msgstr "Entwickler"
@ -1997,7 +2002,7 @@ msgstr "Vorschau"
msgid "Organization successfully updated"
msgstr "Organisation erfolgreich aktualisiert"
#: project/views/admin.py:68 project/views/manage.py:331
#: project/views/admin.py:68 project/views/manage.py:346
msgid "Settings successfully updated"
msgstr "Einstellungen erfolgreich aktualisiert"
@ -2058,27 +2063,27 @@ msgstr "Die eingegebene Email passt nicht zur Email der Einladung"
msgid "Invitation successfully deleted"
msgstr "Einladung erfolgreich gelöscht"
#: project/views/event.py:179
#: project/views/event.py:178
msgid "Event successfully published"
msgstr "Veranstaltung erfolgreich veröffentlicht"
#: project/views/event.py:181
#: project/views/event.py:180
msgid "Draft successfully saved"
msgstr "Entwurf erfolgreich gespeichert"
#: project/views/event.py:224
#: project/views/event.py:223
msgid "Event successfully updated"
msgstr "Veranstaltung erfolgreich aktualisiert"
#: project/views/event.py:250
#: project/views/event.py:249
msgid "Event successfully deleted"
msgstr "Veranstaltung erfolgreich gelöscht"
#: project/views/event.py:409
#: project/views/event.py:408
msgid "Referenced event changed"
msgstr "Empfohlene Veranstaltung wurde geändert"
#: project/views/event.py:432
#: project/views/event.py:431
msgid "New event report"
msgstr "Neue Meldung zu einer Veranstaltung"
@ -2242,11 +2247,11 @@ msgstr ""
"Die Einladung wurde für einen anderen Nutzer ausgestellt. Melde dich mit "
"der Email-Adresse an, an die die Einladung geschickt wurde."
#: project/views/widget.py:150
#: project/views/widget.py:151
msgid "Thank you so much! The event is being verified."
msgstr "Vielen Dank! Die Veranstaltung wird geprüft."
#: project/views/widget.py:154
#: project/views/widget.py:155
msgid ""
"For more options and your own calendar of events, you can register for "
"free."
@ -2254,7 +2259,7 @@ msgstr ""
"Für mehr Optionen und einen eigenen Veranstaltungskalender, kannst du "
"dich kostenlos registrieren."
#: project/views/widget.py:212
#: project/views/widget.py:221
msgid "New event review"
msgstr "Neue Veranstaltung zu prüfen"

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-12-08 13:32+0100\n"
"POT-Creation-Date: 2021-12-30 14:51+0100\n"
"PO-Revision-Date: 2021-04-30 15:04+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
@ -194,29 +194,29 @@ msgstr ""
msgid "message"
msgstr ""
#: project/api/organization/resources.py:364
#: project/api/organization/resources.py:371
#: project/views/admin_unit_member_invitation.py:85
msgid "You have received an invitation"
msgstr ""
#: project/forms/admin.py:10 project/templates/layout.html:311
#: project/forms/admin.py:10 project/templates/layout.html:312
#: project/views/root.py:37
msgid "Terms of service"
msgstr ""
#: project/forms/admin.py:11 project/templates/layout.html:315
#: project/forms/admin.py:11 project/templates/layout.html:316
#: project/views/root.py:45
msgid "Legal notice"
msgstr ""
#: project/forms/admin.py:12 project/templates/_macros.html:1392
#: project/templates/layout.html:319
#: project/templates/layout.html:320
#: project/templates/widget/event_suggestion/create.html:204
#: project/views/admin_unit.py:73 project/views/root.py:53
msgid "Contact"
msgstr ""
#: project/forms/admin.py:13 project/templates/layout.html:323
#: project/forms/admin.py:13 project/templates/layout.html:324
#: project/views/root.py:61
msgid "Privacy"
msgstr ""
@ -918,7 +918,7 @@ msgstr ""
msgid "Distance"
msgstr ""
#: project/forms/event_date.py:33 project/forms/planing.py:36
#: project/forms/event_date.py:38 project/forms/planing.py:36
#: project/templates/widget/event_date/list.html:82
msgid "Find"
msgstr ""
@ -1343,7 +1343,7 @@ msgid "Manage"
msgstr ""
#: project/templates/home.html:30 project/templates/security/login_user.html:38
#: project/views/widget.py:158
#: project/views/widget.py:159
msgid "Register for free"
msgstr ""
@ -1367,7 +1367,7 @@ msgstr ""
msgid "Planing"
msgstr ""
#: project/templates/layout.html:187 project/templates/layout.html:273
#: project/templates/layout.html:187 project/templates/layout.html:274
#: project/templates/oauth2_client/list.html:10
#: project/templates/oauth2_client/read.html:10
#: project/templates/oauth2_token/list.html:10 project/templates/profile.html:4
@ -1469,17 +1469,22 @@ msgstr ""
msgid "Settings"
msgstr ""
#: project/templates/layout.html:272 project/templates/manage/reviews.html:10
#: project/templates/layout.html:272
#: project/templates/manage/custom_widgets.html:13
msgid "Custom widgets"
msgstr ""
#: project/templates/layout.html:273 project/templates/manage/reviews.html:10
#: project/templates/manage/widgets.html:5
#: project/templates/manage/widgets.html:9
msgid "Widgets"
msgstr ""
#: project/templates/layout.html:283
#: project/templates/layout.html:284
msgid "Switch organization"
msgstr ""
#: project/templates/developer/read.html:4 project/templates/layout.html:333
#: project/templates/developer/read.html:4 project/templates/layout.html:334
#: project/templates/profile.html:29
msgid "Developer"
msgstr ""
@ -1946,7 +1951,7 @@ msgstr ""
msgid "Organization successfully updated"
msgstr ""
#: project/views/admin.py:68 project/views/manage.py:331
#: project/views/admin.py:68 project/views/manage.py:346
msgid "Settings successfully updated"
msgstr ""
@ -2004,27 +2009,27 @@ msgstr ""
msgid "Invitation successfully deleted"
msgstr ""
#: project/views/event.py:179
#: project/views/event.py:178
msgid "Event successfully published"
msgstr ""
#: project/views/event.py:181
#: project/views/event.py:180
msgid "Draft successfully saved"
msgstr ""
#: project/views/event.py:224
#: project/views/event.py:223
msgid "Event successfully updated"
msgstr ""
#: project/views/event.py:250
#: project/views/event.py:249
msgid "Event successfully deleted"
msgstr ""
#: project/views/event.py:409
#: project/views/event.py:408
msgid "Referenced event changed"
msgstr ""
#: project/views/event.py:432
#: project/views/event.py:431
msgid "New event report"
msgstr ""
@ -2180,17 +2185,17 @@ msgid ""
" the invitation was sent to."
msgstr ""
#: project/views/widget.py:150
#: project/views/widget.py:151
msgid "Thank you so much! The event is being verified."
msgstr ""
#: project/views/widget.py:154
#: project/views/widget.py:155
msgid ""
"For more options and your own calendar of events, you can register for "
"free."
msgstr ""
#: project/views/widget.py:212
#: project/views/widget.py:221
msgid "New event review"
msgstr ""

View File

@ -291,6 +291,21 @@ def manage_admin_unit_event_lists(id, path=None):
)
@app.route("/manage/admin_unit/<int:id>/custom-widgets")
@app.route("/manage/admin_unit/<int:id>/custom-widgets/<path:path>")
@auth_required()
def manage_admin_unit_custom_widgets(id, path=None):
admin_unit = get_admin_unit_for_manage_or_404(id)
set_current_admin_unit(admin_unit)
full_height = path is not None and (path == "create" or path.endswith("/update"))
return render_template(
"manage/custom_widgets.html",
admin_unit=admin_unit,
full_height=full_height,
)
@app.route("/manage/admin_unit/<int:id>/widgets", methods=("GET", "POST"))
@auth_required()
def manage_admin_unit_widgets(id):

View File

@ -0,0 +1,55 @@
def test_read(client, seeder, utils):
_, admin_unit_id = seeder.setup_base()
custom_widget_id = seeder.insert_event_custom_widget(admin_unit_id)
url = utils.get_url("api_v1_custom_widget", id=custom_widget_id)
response = utils.get_json(url)
utils.assert_response_ok(response)
assert response.json["settings"]["color"] == "black"
def test_put(client, seeder, utils, app):
_, admin_unit_id = seeder.setup_api_access()
custom_widget_id = seeder.insert_event_custom_widget(admin_unit_id)
url = utils.get_url("api_v1_custom_widget", id=custom_widget_id)
response = utils.put_json(url, {"widget_type": "search", "name": "Neuer Name"})
utils.assert_response_no_content(response)
with app.app_context():
from project.models import CustomWidget
custom_widget = CustomWidget.query.get(custom_widget_id)
assert custom_widget.name == "Neuer Name"
assert custom_widget.widget_type == "search"
def test_patch(client, seeder, utils, app):
_, admin_unit_id = seeder.setup_api_access()
custom_widget_id = seeder.insert_event_custom_widget(admin_unit_id)
url = utils.get_url("api_v1_custom_widget", id=custom_widget_id)
response = utils.patch_json(url, {"name": "Neuer Name"})
utils.assert_response_no_content(response)
with app.app_context():
from project.models import CustomWidget
custom_widget = CustomWidget.query.get(custom_widget_id)
assert custom_widget.name == "Neuer Name"
assert custom_widget.widget_type == "search"
def test_delete(client, seeder, utils, app):
_, admin_unit_id = seeder.setup_api_access()
custom_widget_id = seeder.insert_event_custom_widget(admin_unit_id)
url = utils.get_url("api_v1_custom_widget", id=custom_widget_id)
response = utils.delete(url)
utils.assert_response_no_content(response)
with app.app_context():
from project.models import CustomWidget
custom_widget = CustomWidget.query.get(custom_widget_id)
assert custom_widget is None

View File

@ -81,6 +81,9 @@ def test_search(client, seeder, utils):
url = utils.get_url("api_v1_event_date_search", organizer_id=organizer_id)
response = utils.get_ok(url)
url = utils.get_url("api_v1_event_date_search", organization_id=admin_unit_id)
response = utils.get_ok(url)
listed_event_id = seeder.create_event(admin_unit_id)
event_list_id = seeder.create_event_list(admin_unit_id, listed_event_id)
url = utils.get_url("api_v1_event_date_search", event_list_id=event_list_id)

View File

@ -544,3 +544,41 @@ def test_organization_invitation_list_post(client, app, seeder, utils, mocker):
id=invitation_id,
)
utils.assert_send_mail_called(mail_mock, "invited@test.de", invitation_url)
def test_custom_widgets(client, seeder, utils):
_, admin_unit_id = seeder.setup_base()
seeder.insert_event_custom_widget(admin_unit_id)
url = utils.get_url(
"api_v1_organization_custom_widget_list", id=admin_unit_id, name="mein"
)
utils.get_ok(url)
def test_custom_widgets_post(client, seeder, utils, app):
user_id, admin_unit_id = seeder.setup_api_access()
url = utils.get_url("api_v1_organization_custom_widget_list", id=admin_unit_id)
response = utils.post_json(
url,
{
"widget_type": "search",
"name": "Neues Widget",
"settings": {"color": "black"},
},
)
utils.assert_response_created(response)
assert "id" in response.json
with app.app_context():
from project.models import CustomWidget
custom_widget = (
CustomWidget.query.filter(CustomWidget.admin_unit_id == admin_unit_id)
.filter(CustomWidget.name == "Neues Widget")
.first()
)
assert custom_widget is not None
assert custom_widget.widget_type == "search"
assert custom_widget.settings["color"] == "black"

View File

@ -200,6 +200,28 @@ class Seeder(object):
return organizer_id
def insert_event_custom_widget(
self,
admin_unit_id,
widget_type="search",
name="Mein Widget",
settings={"color": "black"},
):
from project.models import CustomWidget
with self._app.app_context():
custom_widget = CustomWidget()
custom_widget.admin_unit_id = admin_unit_id
custom_widget.widget_type = widget_type
custom_widget.name = name
custom_widget.settings = settings
self._db.session.add(custom_widget)
self._db.session.commit()
custom_widget_id = custom_widget.id
return custom_widget_id
def insert_default_oauth2_client(self, user_id):
from project.api import scope_list
from project.models import OAuth2Client

View File

@ -202,3 +202,10 @@ def test_admin_unit_event_lists(client, seeder, utils):
url = utils.get_url("manage_admin_unit_event_lists", id=admin_unit_id)
utils.get_ok(url)
def test_admin_unit_custom_widgets(client, seeder, utils):
_, admin_unit_id = seeder.setup_base()
url = utils.get_url("manage_admin_unit_custom_widgets", id=admin_unit_id)
utils.get_ok(url)