Skip to content
Snippets Groups Projects
Commit 628f7d62 authored by Nik | Klampfradler's avatar Nik | Klampfradler
Browse files

Merge branch 'calendar-object-feeds' into 'master'

Calendar events and iCal feeds

See merge request !1148
parents ed9ec776 f53d47a7
No related branches found
No related tags found
1 merge request!1148Calendar events and iCal feeds
Pipeline #137907 passed with warnings
Pipeline: AlekSIS

#137926

    Showing
    with 957 additions and 0 deletions
    ...@@ -9,6 +9,8 @@ and this project adheres to `Semantic Versioning`_. ...@@ -9,6 +9,8 @@ and this project adheres to `Semantic Versioning`_.
    Unreleased Unreleased
    ---------- ----------
    Changes
    =======
    The "managed models" feature is mandatory for all models derived from `ExtensibleModel` The "managed models" feature is mandatory for all models derived from `ExtensibleModel`
    and requires creating a migration for all downstream models to add the respective and requires creating a migration for all downstream models to add the respective
    field. field.
    ...@@ -17,6 +19,9 @@ Added ...@@ -17,6 +19,9 @@ Added
    ~~~~~ ~~~~~
    * Frontend for managing rooms. * Frontend for managing rooms.
    * Global calendar system
    * Calendar for birthdays of persons
    * Holiday model to track information about holidays.
    * [Dev] Components for implementing standard CRUD operations in new frontend. * [Dev] Components for implementing standard CRUD operations in new frontend.
    * [Dev] Options for filtering and sorting of GraphQL queries at the server. * [Dev] Options for filtering and sorting of GraphQL queries at the server.
    * [Dev] Managed models for instances handled by other apps. * [Dev] Managed models for instances handled by other apps.
    ......
    ...@@ -6,6 +6,13 @@ const dateTimeFormats = { ...@@ -6,6 +6,13 @@ const dateTimeFormats = {
    month: "short", month: "short",
    day: "numeric", day: "numeric",
    }, },
    shortDateTime: {
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "numeric",
    minute: "numeric",
    },
    long: { long: {
    year: "numeric", year: "numeric",
    month: "long", month: "long",
    ...@@ -37,6 +44,13 @@ const dateTimeFormats = { ...@@ -37,6 +44,13 @@ const dateTimeFormats = {
    month: "short", month: "short",
    day: "numeric", day: "numeric",
    }, },
    shortDateTime: {
    year: "numeric",
    month: "short",
    day: "numeric",
    hour: "numeric",
    minute: "numeric",
    },
    long: { long: {
    year: "numeric", year: "numeric",
    month: "long", month: "long",
    ......
    <template>
    <v-menu
    v-model="model"
    :close-on-content-click="false"
    :activator="selectedElement"
    :offset-x="calendarType !== 'day'"
    min-width="350px"
    :offset-y="calendarType === 'day'"
    >
    <v-card min-width="350px" flat>
    <v-toolbar :color="color || selectedEvent.color" dark dense>
    <v-toolbar-title>
    <slot name="title" :selected-event="selectedEvent">{{
    selectedEvent.name
    }}</slot>
    </v-toolbar-title>
    <v-spacer></v-spacer>
    <slot name="badge" :selected-event="selectedEvent">
    <cancelled-calendar-status-chip
    v-if="selectedEvent.status === 'CANCELLED' && !withoutBadge"
    />
    </slot>
    </v-toolbar>
    <slot name="time" :selected-event="selectedEvent">
    <v-list-item v-if="!withoutTime">
    <v-list-item-icon>
    <v-icon color="primary">mdi-calendar-today-outline</v-icon>
    </v-list-item-icon>
    <v-list-item-content>
    <v-list-item-title>
    <span
    v-if="
    selectedEvent.allDay &&
    selectedEvent.start.getTime() === selectedEvent.end.getTime()
    "
    >
    {{ $d(selectedEvent.start, "short") }}
    </span>
    <span v-else-if="selectedEvent.allDay">
    {{ $d(selectedEvent.start, "short") }}
    {{ $d(selectedEvent.end, "short") }}
    </span>
    <span
    v-else-if="
    dateWithoutTime(selectedEvent.start).getTime() ===
    dateWithoutTime(selectedEvent.end).getTime()
    "
    >
    {{ $d(selectedEvent.start, "shortDateTime") }}
    {{ $d(selectedEvent.end, "shortTime") }}
    </span>
    <span v-else>
    {{ $d(selectedEvent.start, "shortDateTime") }}
    {{ $d(selectedEvent.end, "shortDateTime") }}
    </span>
    </v-list-item-title>
    </v-list-item-content>
    </v-list-item>
    </slot>
    <slot name="description" :selected-event="selectedEvent">
    <v-divider
    inset
    v-if="selectedEvent.description && !withoutDescription"
    />
    <v-list-item v-if="selectedEvent.description && !withoutDescription">
    <v-list-item-icon>
    <v-icon color="primary">mdi-card-text-outline</v-icon>
    </v-list-item-icon>
    <v-list-item-content style="white-space: pre-line">
    {{ selectedEvent.description }}
    </v-list-item-content>
    </v-list-item>
    </slot>
    </v-card>
    </v-menu>
    </template>
    <script>
    import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js";
    import CancelledCalendarStatusChip from "./CancelledCalendarStatusChip.vue";
    export default {
    name: "BaseCalendarFeedDetails",
    components: { CancelledCalendarStatusChip },
    mixins: [calendarFeedDetailsMixin],
    methods: {
    dateWithoutTime(d) {
    d = new Date(d);
    d.setHours(0, 0, 0, 0);
    return d;
    },
    },
    };
    </script>
    <template>
    <div
    class="text-truncate"
    :class="{
    'text-decoration-line-through': event.status === 'CANCELLED',
    'mx-1': withPadding,
    }"
    :style="{ height: '100%' }"
    >
    <slot name="time" v-bind="$props">
    <span
    v-if="
    calendarType === 'month' && eventParsed.start.hasTime && !withoutTime
    "
    class="mr-1 font-weight-bold ml-1"
    >
    {{ eventParsed.start.time }}
    </span>
    </slot>
    <slot name="icon" v-bind="$props">
    <v-icon v-if="icon" x-small color="white" class="mx-1 left">
    {{ icon }}
    </v-icon>
    </slot>
    <slot name="title" v-bind="$props">
    {{ event.name }}
    </slot>
    </div>
    </template>
    <script>
    import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js";
    export default {
    name: "BaseCalendarFeedEventBar",
    mixins: [calendarFeedEventBarMixin],
    props: {
    withPadding: {
    required: false,
    type: Boolean,
    default: true,
    },
    icon: {
    required: false,
    type: String,
    default: "",
    },
    withoutTime: {
    required: false,
    type: Boolean,
    default: false,
    },
    },
    };
    </script>
    <script>
    export default {
    name: "CalendarControlBar",
    emits: ["prev", "next", "today"],
    };
    </script>
    <template>
    <div class="d-flex justify-center">
    <v-btn icon class="mx-2" @click="$emit('prev')">
    <v-icon>mdi-chevron-left</v-icon>
    </v-btn>
    <v-btn outlined text class="mx-2" @click="$emit('today')">
    <v-icon left>mdi-calendar-today-outline</v-icon>
    {{ $t("calendar.today") }}
    </v-btn>
    <v-btn icon class="mx-2" @click="$emit('next')">
    <v-icon>mdi-chevron-right</v-icon>
    </v-btn>
    </div>
    </template>
    <template>
    <div class="mt-4 mb-4">
    <v-skeleton-loader
    v-if="
    $apollo.queries.calendar.loading && calendar.calendarFeeds.length === 0
    "
    type="date-picker-options, actions"
    />
    <div v-else>
    <h1
    class="mb-4 mx-4"
    v-if="$vuetify.breakpoint.mdAndDown && $refs.calendar"
    >
    {{ $refs.calendar.title }}
    </h1>
    <v-row align="stretch">
    <!-- Control bar with prev, next and today buttons -->
    <v-col cols="12" sm="4" lg="3" xl="2" align-self="center">
    <calendar-control-bar
    @prev="$refs.calendar.prev()"
    @next="$refs.calendar.next()"
    @today="calendarFocus = new Date()"
    />
    </v-col>
    <!-- Calendar title with current calendar time range -->
    <v-col v-if="$vuetify.breakpoint.lgAndUp" align-self="center">
    <h1 class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</h1>
    </v-col>
    <!-- Button menu for selecting currently active calendars (only tablets/mobile) -->
    <v-col
    v-if="$vuetify.breakpoint.mdAndDown"
    cols="12"
    sm="4"
    align-self="center"
    class="d-flex justify-center"
    >
    <button-menu
    icon="mdi-calendar-check-outline"
    text-translation-key="calendar.select"
    >
    <calendar-select
    v-model="selectedCalendarFeedNames"
    :calendar-feeds="calendar.calendarFeeds"
    @input="storeActivatedCalendars"
    />
    </button-menu>
    </v-col>
    <v-spacer v-if="$vuetify.breakpoint.lgAndUp" />
    <!-- Calendar type select (month, week, day) -->
    <v-col
    cols="12"
    sm="4"
    lg="3"
    align-self="center"
    :align="$vuetify.breakpoint.smAndUp ? 'right' : 'center'"
    >
    <calendar-type-select v-model="currentCalendarType" />
    </v-col>
    </v-row>
    <v-row>
    <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2">
    <!-- Mini date picker -->
    <v-date-picker
    no-title
    v-model="calendarFocus"
    :style="{ margin: '0px -8px' }"
    :first-day-of-week="1"
    ></v-date-picker>
    <!-- Calendar select (only desktop) -->
    <v-list flat subheader>
    <v-subheader>
    {{ $t("calendar.my_calendars") }}
    </v-subheader>
    <calendar-select
    class="mb-4"
    v-model="selectedCalendarFeedNames"
    :calendar-feeds="calendar.calendarFeeds"
    @input="storeActivatedCalendars"
    />
    <v-btn depressed block v-if="calendar" :href="calendar.allFeedsUrl">
    <v-icon left>mdi-download-outline</v-icon>
    {{ $t("calendar.download_all") }}
    </v-btn>
    </v-list>
    </v-col>
    <!-- Actual calendar -->
    <v-col lg="9" xl="10">
    <v-sheet height="600">
    <v-expand-transition>
    <v-progress-linear
    v-if="$apollo.queries.calendar.loading"
    indeterminate
    />
    </v-expand-transition>
    <v-calendar
    ref="calendar"
    v-model="calendarFocus"
    show-week
    :events="events"
    :type="currentCalendarType"
    :event_color="getColorForEvent"
    @click:date="viewDay"
    @click:day="viewDay"
    @click:more="viewDay"
    @click:event="viewEvent"
    @change="fetchMoreCalendarEvents"
    >
    <template #event="{ event, eventParsed, timed }">
    <component
    :is="eventBarComponentForFeed(event.calendarFeedName)"
    :event="event"
    :event-parsed="eventParsed"
    :calendar-type="currentCalendarType"
    />
    </template>
    </v-calendar>
    <component
    v-if="calendar && calendar.calendarFeeds && selectedEvent"
    :is="detailComponentForFeed(selectedEvent.calendarFeedName)"
    v-model="selectedOpen"
    :selected-element="selectedElement"
    :selected-event="selectedEvent"
    :calendar-type="currentCalendarType"
    />
    </v-sheet>
    </v-col>
    </v-row>
    </div>
    </div>
    </template>
    <script>
    import ButtonMenu from "../generic/ButtonMenu.vue";
    import CalendarSelect from "./CalendarSelect.vue";
    import GenericCalendarFeedDetails from "./GenericCalendarFeedDetails.vue";
    import GenericCalendarFeedEventBar from "./GenericCalendarFeedEventBar.vue";
    import {
    calendarFeedDetailComponents,
    calendarFeedEventBarComponents,
    } from "aleksisAppImporter";
    import gqlCalendarOverview from "./calendarOverview.graphql";
    import gqlSetCalendarStatus from "./setCalendarStatus.graphql";
    import CalendarControlBar from "./CalendarControlBar.vue";
    import CalendarTypeSelect from "./CalendarTypeSelect.vue";
    export default {
    name: "CalendarOverview",
    components: {
    CalendarTypeSelect,
    CalendarControlBar,
    ButtonMenu,
    CalendarSelect,
    },
    data() {
    return {
    calendarFocus: "",
    calendar: {
    calendarFeeds: [],
    },
    selectedCalendarFeedNames: [],
    currentCalendarType: "week",
    selectedEvent: {},
    selectedElement: null,
    selectedOpen: false,
    fetchedDateRange: { start: null, end: null },
    };
    },
    apollo: {
    calendar: {
    query: gqlCalendarOverview,
    skip: true,
    result({ data }) {
    this.selectedCalendarFeedNames = data.calendar.calendarFeeds
    .filter((c) => c.activated)
    .map((c) => c.name);
    },
    },
    },
    computed: {
    events() {
    return this.calendar.calendarFeeds
    .filter((c) => this.selectedCalendarFeedNames.includes(c.name))
    .flatMap((cf) =>
    cf.feed.events.map((event) => ({
    ...event,
    category: cf.verboseName,
    calendarFeedName: cf.name,
    start: new Date(event.start),
    end: new Date(event.end),
    color: event.color ? event.color : cf.color,
    timed: !event.allDay,
    meta: JSON.parse(event.meta),
    }))
    );
    },
    },
    methods: {
    viewDay({ date }) {
    this.calendarFocus = date;
    this.currentCalendarType = "day";
    },
    viewEvent({ 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();
    },
    detailComponentForFeed(feedName) {
    if (
    this.calendar.calendarFeeds &&
    feedName &&
    Object.keys(calendarFeedDetailComponents).includes(feedName + "details")
    ) {
    return calendarFeedDetailComponents[feedName + "details"];
    }
    return GenericCalendarFeedDetails;
    },
    eventBarComponentForFeed(feedName) {
    if (
    this.calendar.calendarFeeds &&
    feedName &&
    Object.keys(calendarFeedEventBarComponents).includes(
    feedName + "eventbar"
    )
    ) {
    return calendarFeedEventBarComponents[feedName + "eventbar"];
    }
    return GenericCalendarFeedEventBar;
    },
    getColorForEvent(event) {
    return event.color;
    },
    storeActivatedCalendars() {
    // Store currently activated calendars in the backend
    this.$apollo.mutate({
    mutation: gqlSetCalendarStatus,
    variables: {
    calendars: this.selectedCalendarFeedNames,
    },
    });
    },
    fetchMoreCalendarEvents({ start, end }) {
    // Get the start and end dates of the current date range shown in the calendar
    let extendedStart = this.$refs.calendar.getStartOfWeek(start).date;
    let extendedEnd = this.$refs.calendar.getEndOfWeek(end).date;
    let olderStart = extendedStart < this.fetchedDateRange.start;
    let youngerEnd = extendedEnd > this.fetchedDateRange.end;
    if (this.calendar.calendarFeeds.length === 0) {
    // No calendar feeds have been fetched yet,
    // so fetch all events in the current date range
    this.$apollo.queries.calendar.setVariables({
    start: extendedStart,
    end: extendedEnd,
    });
    this.$apollo.queries.calendar.skip = false;
    this.fetchedDateRange = { start: extendedStart, end: extendedEnd };
    } else if (olderStart || youngerEnd) {
    // Define newly fetched date range
    let newStart = olderStart ? extendedStart : this.fetchedDateRange.start;
    let newEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.end;
    // Define date range to fetch
    let fetchStart = olderStart ? extendedStart : this.fetchedDateRange.end;
    let fetchEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.start;
    this.$apollo.queries.calendar.fetchMore({
    variables: {
    start: fetchStart,
    end: fetchEnd,
    },
    updateQuery: (previousResult, { fetchMoreResult }) => {
    let previousCalendarFeeds = previousResult.calendar.calendarFeeds;
    let newCalendarFeeds = fetchMoreResult.calendar.calendarFeeds;
    previousCalendarFeeds.forEach((calendarFeed, i, calendarFeeds) => {
    // Get all events except those that are updated
    let keepEvents = calendarFeed.feed.events.filter(
    (event) => event.end < fetchStart || event.start > fetchEnd
    );
    /// Update the events of the calendar feed
    calendarFeeds[i].feed.events = [
    ...keepEvents,
    ...newCalendarFeeds[i].feed.events,
    ];
    });
    return {
    calendar: {
    ...previousResult.calendar,
    calendarFeeds: previousCalendarFeeds,
    },
    };
    },
    });
    this.fetchedDateRange = { start: newStart, end: newEnd };
    }
    },
    },
    mounted() {
    this.$refs.calendar.move(0);
    },
    };
    </script>
    <style scoped></style>
    <template>
    <v-list-item-group multiple v-model="model">
    <v-list-item
    v-for="calendarFeed in calendarFeeds"
    :key="calendarFeed.name"
    :value="calendarFeed.name"
    >
    <template #default="{ active }">
    <v-list-item-action>
    <v-checkbox
    :input-value="active"
    :color="calendarFeed.color"
    ></v-checkbox>
    </v-list-item-action>
    <v-list-item-content>
    <v-list-item-title>
    {{ calendarFeed.verboseName }}
    </v-list-item-title>
    </v-list-item-content>
    <v-list-item-action>
    <v-menu bottom>
    <template v-slot:activator="{ on, attrs }">
    <v-btn fab x-small icon v-bind="attrs" v-on="on">
    <v-icon>mdi-dots-vertical</v-icon>
    </v-btn>
    </template>
    <v-list dense>
    <v-list-item :href="calendarFeed.url">
    <v-list-item-icon>
    <v-icon>mdi-calendar-export</v-icon>
    </v-list-item-icon>
    <v-list-item-title>
    {{ $t("calendar.download_ics") }}
    </v-list-item-title>
    </v-list-item>
    </v-list>
    </v-menu>
    </v-list-item-action>
    </template>
    </v-list-item>
    </v-list-item-group>
    </template>
    <script>
    export default {
    name: "CalendarSelect",
    props: {
    calendarFeeds: {
    type: Array,
    required: true,
    },
    value: {
    type: Array,
    required: true,
    },
    },
    computed: {
    model: {
    get() {
    return this.value;
    },
    set(value) {
    this.$emit("input", value);
    },
    },
    someSelected() {
    return this.model.length > 0 && !this.allSelected;
    },
    allSelected() {
    return this.model.length === this.calendarFeeds.length;
    },
    },
    methods: {
    toggleAll(newValue) {
    if (newValue) {
    this.model = this.calendarFeeds.map((feed) => feed.name);
    } else {
    this.model = [];
    }
    },
    },
    };
    </script>
    <script>
    export default {
    name: "CalendarStatusChip",
    props: {
    color: {
    type: String,
    required: true,
    },
    icon: {
    type: String,
    required: true,
    },
    },
    };
    </script>
    <template>
    <v-chip :color="color" label>
    <v-avatar left>
    <v-icon>{{ icon }}</v-icon>
    </v-avatar>
    <slot></slot>
    </v-chip>
    </template>
    <script>
    export default {
    name: "CalendarTypeSelect",
    props: {
    value: {
    type: String,
    required: true,
    },
    },
    data() {
    return {
    innerValue: this.value,
    availableCalendarTypes: [
    {
    type: "month",
    translationKey: "calendar.month",
    icon: "calendar-month-outline",
    },
    {
    type: "week",
    translationKey: "calendar.week",
    icon: "calendar-week-outline",
    },
    {
    type: "day",
    translationKey: "calendar.day",
    icon: "calendar-today-outline",
    },
    ],
    };
    },
    watch: {
    value(val) {
    this.innerValue = val;
    },
    innerValue(val) {
    this.$emit("input", val);
    },
    },
    methods: {
    nameForMenu(item) {
    return this.$t(item.translationKey);
    },
    },
    };
    </script>
    <template>
    <v-btn-toggle dense v-model="innerValue" class="mx-2">
    <v-btn
    v-for="calendarType in availableCalendarTypes"
    :value="calendarType.type"
    :key="calendarType.type"
    >
    <v-icon v-if="$vuetify.breakpoint.smAndDown">{{
    "mdi-" + calendarType.icon
    }}</v-icon>
    <span class="hidden-sm-and-down">{{ nameForMenu(calendarType) }}</span>
    </v-btn>
    </v-btn-toggle>
    </template>
    <script>
    import CalendarStatusChip from "./CalendarStatusChip.vue";
    export default {
    name: "CancelledCalendarStatusChip",
    components: { CalendarStatusChip },
    };
    </script>
    <template>
    <calendar-status-chip icon="mdi-cancel" color="error">
    {{ $t("calendar.cancelled") }}
    </calendar-status-chip>
    </template>
    <template>
    <div>
    <base-calendar-feed-details v-bind="$props" />
    <v-divider inset v-if="selectedEvent.location" />
    <v-list-item v-if="selectedEvent.location">
    <v-list-item-icon>
    <v-icon color="primary">mdi-map-marker-outline</v-icon>
    </v-list-item-icon>
    <v-list-item-content>
    {{ selectedEvent.location }}
    </v-list-item-content>
    </v-list-item>
    </div>
    </template>
    <script>
    import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js";
    import BaseCalendarFeedDetails from "./BaseCalendarFeedDetails.vue";
    export default {
    name: "GenericCalendarFeedDetails",
    components: { BaseCalendarFeedDetails },
    mixins: [calendarFeedDetailsMixin],
    };
    </script>
    <template>
    <base-calendar-feed-event-bar v-bind="$props" />
    </template>
    <script>
    import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js";
    import BaseCalendarFeedEventBar from "./BaseCalendarFeedEventBar.vue";
    export default {
    name: "GenericCalendarFeedEventBar",
    components: { BaseCalendarFeedEventBar },
    mixins: [calendarFeedEventBarMixin],
    };
    </script>
    query ($start: Date, $end: Date) {
    calendar {
    allFeedsUrl
    calendarFeeds {
    name
    verboseName
    description
    url
    color
    activated
    feed {
    events(start: $start, end: $end) {
    name
    start
    end
    color
    description
    location
    uid
    allDay
    status
    meta
    }
    }
    }
    }
    }
    mutation ($calendars: [String]!) {
    setCalendarStatus(calendars: $calendars) {
    ok
    }
    }
    <template>
    <base-calendar-feed-details v-bind="$props" without-time>
    <template #description="{ selectedEvent }">
    <v-divider />
    <v-card-text>
    <span>
    <v-icon class="mr-2">mdi-cake-variant-outline</v-icon>
    {{ $d(selectedEvent.start) }}
    </span>
    </v-card-text>
    </template>
    </base-calendar-feed-details>
    </template>
    <script>
    import calendarFeedDetailsMixin from "../../../mixins/calendarFeedDetails.js";
    import BaseCalendarFeedDetails from "../../calendar/BaseCalendarFeedDetails.vue";
    export default {
    name: "BirthdaysDetails",
    components: { BaseCalendarFeedDetails },
    mixins: [calendarFeedDetailsMixin],
    };
    </script>
    <template>
    <base-calendar-feed-event-bar
    v-bind="$props"
    icon="mdi-cake-variant-outline"
    />
    </template>
    <script>
    import calendarFeedEventBarMixin from "../../../mixins/calendarFeedEventBar.js";
    import BaseCalendarFeedEventBar from "../../calendar/BaseCalendarFeedEventBar.vue";
    export default {
    name: "BirthdaysEventBar",
    components: { BaseCalendarFeedEventBar },
    mixins: [calendarFeedEventBarMixin],
    };
    </script>
    ...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
    <v-icon center> <v-icon center>
    {{ icon }} {{ icon }}
    </v-icon> </v-icon>
    <span v-if="textTranslationKey">{{ $t(textTranslationKey) }}</span>
    </v-btn> </v-btn>
    </slot> </slot>
    </template> </template>
    ...@@ -25,6 +26,11 @@ export default { ...@@ -25,6 +26,11 @@ export default {
    required: false, required: false,
    default: "mdi-dots-horizontal", default: "mdi-dots-horizontal",
    }, },
    textTranslationKey: {
    type: String,
    required: false,
    default: "",
    },
    }, },
    }; };
    </script> </script>
    ......
    <template>
    <v-tooltip bottom :open-on-hover="hover" v-model="tooltipModel">
    <template #activator="{ on, attrs }">
    <v-layout wrap v-on="on" v-bind="attrs">
    <v-btn fab x-small icon @click.stop="copyToClipboard(text)">
    <v-scroll-x-transition mode="out-in">
    <v-icon :key="clipboardIcon">
    {{ clipboardIcon }}
    </v-icon>
    </v-scroll-x-transition>
    </v-btn>
    </v-layout>
    </template>
    <span>{{ tooltipText }}</span>
    </v-tooltip>
    </template>
    <script>
    export default {
    name: "CopyToClipboardButton",
    data() {
    return {
    copied: false,
    tooltipModel: false,
    hover: true,
    };
    },
    props: {
    text: {
    type: String,
    required: true,
    },
    tooltipHelpText: {
    type: String,
    default: "",
    },
    },
    computed: {
    tooltipText() {
    return this.copied
    ? this.$t("actions.copied")
    : this.tooltipHelpText
    ? this.tooltipHelpText
    : this.$t("actions.copy");
    },
    clipboardIcon() {
    return this.copied
    ? "mdi-clipboard-check-outline"
    : "mdi-clipboard-outline";
    },
    },
    methods: {
    copyToClipboard(text) {
    navigator.clipboard.writeText(text);
    this.tooltipModel = false;
    setTimeout(() => {
    this.tooltipModel = this.copied = true;
    this.hover = false;
    setTimeout(() => {
    this.tooltipModel = this.copied = false;
    this.hover = true;
    }, 1000);
    }, 100);
    },
    },
    };
    </script>
    ...@@ -81,6 +81,10 @@ ...@@ -81,6 +81,10 @@
    "confirm_deletion_multiple": "Are you sure you want to delete these items?", "confirm_deletion_multiple": "Are you sure you want to delete these items?",
    "delete": "Delete", "delete": "Delete",
    "edit": "Edit", "edit": "Edit",
    "close": "Close",
    "select_all": "Select all",
    "copy": "Copy",
    "copied": "Copied",
    "save": "Save", "save": "Save",
    "search": "Search", "search": "Search",
    "stop_editing": "Stop editing", "stop_editing": "Stop editing",
    ...@@ -252,6 +256,19 @@ ...@@ -252,6 +256,19 @@
    "new_version_available": "A new version of the app is available", "new_version_available": "A new version of the app is available",
    "update": "Update" "update": "Update"
    }, },
    "calendar": {
    "menu_title": "Calendar",
    "month": "Month",
    "week": "Week",
    "day": "Day",
    "today": "Today",
    "select": "Select calendars",
    "ics_to_clipboard": "Copy link to calendar ICS to clipboard",
    "cancelled": "Cancelled",
    "download_ics": "Download ICS",
    "my_calendars": "My Calendars",
    "download_all": "Download all"
    },
    "graphql": { "graphql": {
    "snackbar_error_message": "There was an error retrieving the page data. Please try again.", "snackbar_error_message": "There was an error retrieving the page data. Please try again.",
    "snackbar_success_message": "The operation has been finished successfully." "snackbar_success_message": "The operation has been finished successfully."
    ......
    /**
    * Mixin for use with adaptable components showing details for calendar feeds.
    */
    const calendarFeedDetailsMixin = {
    props: {
    selectedElement: {
    required: false,
    default: null,
    },
    selectedEvent: {
    required: true,
    type: Object,
    },
    value: { type: Boolean, required: true },
    withoutTime: {
    required: false,
    type: Boolean,
    default: false,
    },
    withoutDescription: {
    required: false,
    type: Boolean,
    default: false,
    },
    withoutBadge: {
    required: false,
    type: Boolean,
    default: false,
    },
    color: {
    required: false,
    type: String,
    default: null,
    },
    calendarType: {
    required: true,
    type: String,
    },
    },
    computed: {
    model: {
    get() {
    return this.value;
    },
    set(value) {
    this.$emit("input", value);
    },
    },
    },
    };
    export default calendarFeedDetailsMixin;
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Finish editing this message first!
    Please register or to comment