Adds refresh button to widget, for reloading data

This commit is contained in:
Alicia Sykes 2021-12-13 16:22:24 +00:00
parent ae8179ecd7
commit 2075cbc222
18 changed files with 254 additions and 49 deletions

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="sync" class="svg-inline--fa fa-sync fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.575C418.212 52.043 342.256 8 256 8 134.813 8 33.933 94.924 12.296 209.824 10.908 217.193 16.604 224 24.103 224h28.576c5.674 0 10.542-3.982 11.737-9.529C83.441 126.128 161.917 60 256 60c79.545 0 147.942 47.282 178.676 115.302l-126.39-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12zm-12.103 280h-28.576c-5.674 0-10.542 3.982-11.737 9.529C428.559 385.872 350.083 452 256 452c-79.546 0-147.942-47.282-178.676-115.302l126.39 3.009c6.737.16 12.286-5.257 12.286-11.997V300c0-6.627-5.373-12-12-12H12c-6.627 0-12 5.373-12 12v192c0 6.627 5.373 12 12 12h27.711c6.739 0 12.157-5.548 11.997-12.286l-2.347-98.575C93.788 459.957 169.744 504 256 504c121.187 0 222.067-86.924 243.704-201.824 1.388-7.369-4.308-14.176-11.807-14.176z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -12,11 +12,11 @@
@openContextMenu="openContextMenu" @openContextMenu="openContextMenu"
> >
<!-- If no items, show message --> <!-- If no items, show message -->
<div v-if="(!items || items.length < 1) && !isEditMode" class="no-items"> <div v-if="sectionType === 'empty'" class="no-items">
{{ $t('home.no-items-section') }} {{ $t('home.no-items-section') }}
</div> </div>
<!-- Item Container --> <!-- Item Container -->
<div v-else <div v-else-if="sectionType === 'item'"
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`" :class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
:style="gridStyle" :id="`section-${groupId}`" :style="gridStyle" :id="`section-${groupId}`"
> <!-- Show for each item --> > <!-- Show for each item -->
@ -57,6 +57,14 @@
:itemSize="itemSize" :itemSize="itemSize"
/> />
</div> </div>
<div v-else-if="sectionType === 'widget'">
<WidgetBase
v-for="(widget, widgetIndx) in widgets"
:key="widgetIndx"
:widget="widget"
:index="index"
/>
</div>
<!-- Modal for opening in modal view --> <!-- Modal for opening in modal view -->
<IframeModal <IframeModal
:ref="`iframeModal-${groupId}`" :ref="`iframeModal-${groupId}`"
@ -87,6 +95,7 @@
<script> <script>
import router from '@/router'; import router from '@/router';
import Item from '@/components/LinkItems/Item.vue'; import Item from '@/components/LinkItems/Item.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import Collapsable from '@/components/LinkItems/Collapsable.vue'; import Collapsable from '@/components/LinkItems/Collapsable.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue'; import IframeModal from '@/components/LinkItems/IframeModal.vue';
import EditSection from '@/components/InteractiveEditor/EditSection.vue'; import EditSection from '@/components/InteractiveEditor/EditSection.vue';
@ -107,12 +116,14 @@ export default {
icon: String, icon: String,
displayData: Object, displayData: Object,
items: Array, items: Array,
widgets: Array,
index: Number, index: Number,
}, },
components: { components: {
Collapsable, Collapsable,
ContextMenu, ContextMenu,
Item, Item,
WidgetBase,
IframeModal, IframeModal,
EditSection, EditSection,
}, },
@ -139,6 +150,12 @@ export default {
sortOrder() { sortOrder() {
return this.displayData.sortBy || defaultSortOrder; return this.displayData.sortBy || defaultSortOrder;
}, },
/* A section can contain either items or widgets */
sectionType() {
if (this.widgets && this.widgets.length > 0) return 'widget';
if (this.items && this.items.length > 0) return 'item';
return 'empty';
},
/* If the sortBy attribute is specified, then return sorted data */ /* If the sortBy attribute is specified, then return sorted data */
sortedItems() { sortedItems() {
let { items } = this; let { items } = this;

View File

@ -39,8 +39,12 @@ export default {
}, },
}, },
methods: { methods: {
update() {
this.setTime();
this.setDate();
},
/* Get and format the current time */ /* Get and format the current time */
updateTime() { setTime() {
this.time = Intl.DateTimeFormat(this.timeFormat, { this.time = Intl.DateTimeFormat(this.timeFormat, {
timeZone: this.timeZone, timeZone: this.timeZone,
hour: 'numeric', hour: 'numeric',
@ -49,7 +53,7 @@ export default {
}).format(); }).format();
}, },
/* Get and format the date */ /* Get and format the date */
updateDate() { setDate() {
this.date = new Date().toLocaleDateString(this.timeFormat, { this.date = new Date().toLocaleDateString(this.timeFormat, {
weekday: 'long', day: 'numeric', year: 'numeric', month: 'short', weekday: 'long', day: 'numeric', year: 'numeric', month: 'short',
}); });
@ -57,14 +61,13 @@ export default {
}, },
created() { created() {
// Set initial date and time // Set initial date and time
this.updateTime(); this.update();
this.updateDate();
// Update the date every hour, and the time each second // Update the date every hour, and the time each second
this.timeUpdateInterval = setInterval(() => { this.timeUpdateInterval = setInterval(() => {
this.updateTime(); this.setTime();
const now = new Date(); const now = new Date();
if (now.getMinutes() === 0 && now.getSeconds() === 0) { if (now.getMinutes() === 0 && now.getSeconds() === 0) {
this.updateDate(); this.setDate();
} }
}, 1000); }, 1000);
}, },

View File

@ -65,6 +65,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Create new chart, using the crypto data */ /* Create new chart, using the crypto data */
generateChart() { generateChart() {
return new Chart(`#${this.chartId}`, { return new Chart(`#${this.chartId}`, {

View File

@ -75,6 +75,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Make GET request to CoinGecko API endpoint */ /* Make GET request to CoinGecko API endpoint */
fetchData() { fetchData() {
axios.get(this.endpoint) axios.get(this.endpoint)

View File

@ -52,6 +52,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Make GET request to CoinGecko API endpoint */ /* Make GET request to CoinGecko API endpoint */
fetchData() { fetchData() {
axios.get(this.endpoint) axios.get(this.endpoint)

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="iframe-widget"> <div class="iframe-widget">
<iframe v-if="frameUrl" :src="frameUrl" /> <iframe v-if="frameUrl" :src="frameUrl" :id="frameId" />
</div> </div>
</template> </template>
@ -11,6 +11,7 @@ import ErrorHandler from '@/utils/ErrorHandler';
export default { export default {
mixins: [WidgetMixin], mixins: [WidgetMixin],
computed: { computed: {
/* Gets users specified URL to load into the iframe */
frameUrl() { frameUrl() {
const usersChoice = this.options.url; const usersChoice = this.options.url;
if (!usersChoice || typeof usersChoice !== 'string') { if (!usersChoice || typeof usersChoice !== 'string') {
@ -19,6 +20,16 @@ export default {
} }
return usersChoice; return usersChoice;
}, },
/* Generates an ID for the iframe */
frameId() {
return `iframe-${btoa(this.frameUrl || 'empty').substring(0, 16)}`;
},
},
methods: {
/* Refreshes iframe contents, called by parent */
update() {
(document.getElementById(this.frameId) || {}).src = this.frameUrl;
},
}, },
}; };
</script> </script>

View File

@ -54,6 +54,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Make GET request to Jokes API endpoint */ /* Make GET request to Jokes API endpoint */
fetchData() { fetchData() {
axios.get(this.endpoint) axios.get(this.endpoint)

View File

@ -72,6 +72,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Create new chart, using the crypto data */ /* Create new chart, using the crypto data */
generateChart() { generateChart() {
return new Chart(`#${this.chartId}`, { return new Chart(`#${this.chartId}`, {

View File

@ -50,6 +50,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Makes GET request to the TFL API */ /* Makes GET request to the TFL API */
fetchData() { fetchData() {
axios.get(widgetApiEndpoints.tflStatus) axios.get(widgetApiEndpoints.tflStatus)

View File

@ -1,5 +1,6 @@
<template> <template>
<div class="weather"> <LoadingAnimation v-if="loading" class="loader" />
<div v-else class="weather">
<!-- Icon + Temperature --> <!-- Icon + Temperature -->
<div class="intro"> <div class="intro">
<p class="temp">{{ temp }}</p> <p class="temp">{{ temp }}</p>
@ -65,6 +66,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchWeather();
},
/* Adds units symbol to temperature, depending on metric or imperial */ /* Adds units symbol to temperature, depending on metric or imperial */
processTemp(temp) { processTemp(temp) {
return `${Math.round(temp)}${this.tempDisplayUnits}`; return `${Math.round(temp)}${this.tempDisplayUnits}`;
@ -73,6 +78,7 @@ export default {
fetchWeather() { fetchWeather() {
axios.get(this.endpoint) axios.get(this.endpoint)
.then((response) => { .then((response) => {
this.loading = false;
const { data } = response; const { data } = response;
this.icon = data.weather[0].icon; this.icon = data.weather[0].icon;
this.description = data.weather[0].description; this.description = data.weather[0].description;
@ -141,6 +147,10 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/styles/weather-icons.scss'; @import '@/styles/weather-icons.scss';
.loader {
margin: 0 auto;
display: flex;
}
p { p {
color: var(--widget-text-color); color: var(--widget-text-color);
} }

View File

@ -75,6 +75,10 @@ export default {
}, },
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchWeather();
},
/* Adds units symbol to temperature, depending on metric or imperial */ /* Adds units symbol to temperature, depending on metric or imperial */
processTemp(temp) { processTemp(temp) {
return `${Math.round(temp)}${this.tempDisplayUnits}`; return `${Math.round(temp)}${this.tempDisplayUnits}`;

View File

@ -1,19 +1,71 @@
<template> <template>
<Clock v-if="widgetType === 'clock'" :options="widgetOptions" /> <div class="widget-base">
<Weather v-else-if="widgetType === 'weather'" :options="widgetOptions" /> <Button :click="update" class="update-btn">
<WeatherForecast v-else-if="widgetType === 'weather-forecast'" :options="widgetOptions" /> <UpdateIcon />
<TflStatus v-else-if="widgetType === 'tfl-status'" :options="widgetOptions" /> </Button>
<CryptoPriceChart v-else-if="widgetType === 'crypto-price-chart'" :options="widgetOptions" /> <Clock
<CryptoWatchList v-else-if="widgetType === 'crypto-watch-list'" :options="widgetOptions" /> v-if="widgetType === 'clock'"
<XkcdComic v-else-if="widgetType === 'xkcd-comic'" :options="widgetOptions" /> :options="widgetOptions"
<ExchangeRates v-else-if="widgetType === 'exchange-rates'" :options="widgetOptions" /> :ref="widgetRef"
<StockPriceChart v-else-if="widgetType === 'stock-price-chart'" :options="widgetOptions" /> />
<Jokes v-else-if="widgetType === 'joke'" :options="widgetOptions" /> <Weather
<IframeWidget v-else-if="widgetType === 'iframe'" :options="widgetOptions" /> v-else-if="widgetType === 'weather'"
:options="widgetOptions"
:ref="widgetRef"
/>
<WeatherForecast
v-else-if="widgetType === 'weather-forecast'"
:options="widgetOptions"
:ref="widgetRef"
/>
<TflStatus
v-else-if="widgetType === 'tfl-status'"
:options="widgetOptions"
:ref="widgetRef"
/>
<CryptoPriceChart
v-else-if="widgetType === 'crypto-price-chart'"
:options="widgetOptions"
:ref="widgetRef"
/>
<CryptoWatchList
v-else-if="widgetType === 'crypto-watch-list'"
:options="widgetOptions"
:ref="widgetRef"
/>
<XkcdComic
v-else-if="widgetType === 'xkcd-comic'"
:options="widgetOptions"
:ref="widgetRef"
/>
<ExchangeRates
v-else-if="widgetType === 'exchange-rates'"
:options="widgetOptions"
:ref="widgetRef"
/>
<StockPriceChart
v-else-if="widgetType === 'stock-price-chart'"
:options="widgetOptions"
:ref="widgetRef"
/>
<Jokes
v-else-if="widgetType === 'joke'"
:options="widgetOptions"
:ref="widgetRef"
/>
<IframeWidget
v-else-if="widgetType === 'iframe'"
:options="widgetOptions"
:ref="widgetRef"
/>
</div>
</template> </template>
<script> <script>
import ErrorHandler from '@/utils/ErrorHandler'; import ErrorHandler from '@/utils/ErrorHandler';
import Button from '@/components/FormElements/Button';
import UpdateIcon from '@/assets/interface-icons/widget-update.svg';
import Clock from '@/components/Widgets/Clock.vue'; import Clock from '@/components/Widgets/Clock.vue';
import Weather from '@/components/Widgets/Weather.vue'; import Weather from '@/components/Widgets/Weather.vue';
import WeatherForecast from '@/components/Widgets/WeatherForecast.vue'; import WeatherForecast from '@/components/Widgets/WeatherForecast.vue';
@ -29,6 +81,8 @@ import IframeWidget from '@/components/Widgets/IframeWidget.vue';
export default { export default {
name: 'Widget', name: 'Widget',
components: { components: {
Button,
UpdateIcon,
Clock, Clock,
Weather, Weather,
WeatherForecast, WeatherForecast,
@ -46,9 +100,7 @@ export default {
index: Number, index: Number,
}, },
computed: { computed: {
groupId() { /* Returns the widget type, shows error if not specified */
return `widget-${this.index}`;
},
widgetType() { widgetType() {
if (!this.widget.type) { if (!this.widget.type) {
ErrorHandler('Missing type attribute for widget'); ErrorHandler('Missing type attribute for widget');
@ -56,14 +108,44 @@ export default {
} }
return this.widget.type.toLowerCase(); return this.widget.type.toLowerCase();
}, },
/* Returns the users specified widget options, or empty object */
widgetOptions() { widgetOptions() {
return this.widget.options || {}; return this.widget.options || {};
}, },
widgetRef() {
return `widget-${this.widgetType}-${this.index}`;
},
},
methods: {
update() {
this.$refs[this.widgetRef].update();
},
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import '@/styles/media-queries.scss'; @import '@/styles/media-queries.scss';
.widget-base {
position: relative;
padding-top: 0.75rem;
button.update-btn {
height: 1.5rem;
min-width: auto;
width: 2rem;
margin: 0;
padding: 0.1rem 0;
position: absolute;
right: -0.25rem;
top: -0.25rem;
border: none;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
&:hover {
opacity: 1;
color: var(--widget-background-color);
}
}
}
</style> </style>

View File

@ -0,0 +1,18 @@
<template>
<div class="widget-error"></div>
</template>
<script>
export default {
name: 'WidgetError',
props: {
errorMessage: String,
},
computed: {},
};
</script>
<style scoped lang="scss">
.widget-error {}
</style>

View File

@ -40,11 +40,18 @@ export default {
} }
return 'latest'; return 'latest';
}, },
endpoint() {
return `${widgetApiEndpoints.xkcdComic}?comic=${this.comicNumber}`;
},
}, },
methods: { methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.fetchData();
},
/* Make GET request to CoinGecko API endpoint */ /* Make GET request to CoinGecko API endpoint */
fetchData() { fetchData() {
axios.get(`${widgetApiEndpoints.xkcdComic}?comic=${this.comicNumber}`) axios.get(this.endpoint)
.then((response) => { .then((response) => {
this.processData(response.data); this.processData(response.data);
}) })

View File

@ -1,10 +1,31 @@
import LoadingAnimation from '@/assets/interface-icons/loader.svg';
const WidgetMixin = { const WidgetMixin = {
components: {
LoadingAnimation,
},
props: { props: {
/* The options prop is an object of settings for a given widget */
options: { options: {
type: Object, type: Object,
default: {}, default: {},
}, },
}, },
data: () => ({
loading: true, // Indicates current loading status, to display spinner
}),
methods: {
/* Overridden by widget component. Re-fetches and renders any external data *
* Called by parent component, and triggered either by user or time interval */
update() {
// eslint-disable-next-line no-console
console.log('No update method configured for this widget');
},
},
mounted() {
// If the mounted function isn't overridden,then hide loader
this.loading = false;
},
}; };
export default WidgetMixin; export default WidgetMixin;

View File

@ -331,6 +331,9 @@ html[data-theme='colorful'] {
div.context-menu { div.context-menu {
border-color: var(--primary); border-color: var(--primary);
} }
.collapsable.is-open {
height: -webkit-fill-available;
}
} }
html[data-theme='minimal-light'], html[data-theme='minimal-dark'], html[data-theme='vaporware'] { html[data-theme='minimal-light'], html[data-theme='minimal-dark'], html[data-theme='vaporware'] {

View File

@ -28,23 +28,25 @@
+ (this.colCount ? `col-count-${this.colCount} ` : '')" + (this.colCount ? `col-count-${this.colCount} ` : '')"
> >
<!-- Display any dynamic widget content --> <!-- Display any dynamic widget content -->
<WidgetGroup v-if="!singleSectionView" :widgets="widgets" /> <!-- <WidgetGroup v-if="!singleSectionView" :widgets="widgets" /> -->
<Section <template v-for="(section, index) in filteredTiles">
v-for="(section, index) in filteredTiles" <Section
:key="index" :key="index"
:index="index" :index="index"
:title="section.name" :title="section.name"
:icon="section.icon || undefined" :icon="section.icon || undefined"
:displayData="getDisplayData(section)" :displayData="getDisplayData(section)"
:groupId="`section-${index}`" :groupId="`section-${index}`"
:items="filterTiles(section.items, searchValue)" :items="filterTiles(section.items, searchValue)"
:searchTerm="searchValue" :widgets="section.widgets"
:itemSize="itemSizeBound" :searchTerm="searchValue"
@itemClicked="finishedSearching()" :itemSize="itemSizeBound"
@change-modal-visibility="updateModalVisibility" @itemClicked="finishedSearching()"
:class=" @change-modal-visibility="updateModalVisibility"
(searchValue && filterTiles(section.items, searchValue).length === 0) ? 'no-results' : ''" :class="
/> (searchValue && filterTiles(section.items, searchValue).length === 0) ? 'no-results' : ''"
/>
</template>
<!-- Show add new section button, in edit mode --> <!-- Show add new section button, in edit mode -->
<AddNewSection v-if="isEditMode" /> <AddNewSection v-if="isEditMode" />
</div> </div>
@ -63,7 +65,7 @@
import SettingsContainer from '@/components/Settings/SettingsContainer.vue'; import SettingsContainer from '@/components/Settings/SettingsContainer.vue';
import Section from '@/components/LinkItems/Section.vue'; import Section from '@/components/LinkItems/Section.vue';
import WidgetGroup from '@/components/Widgets/WidgetGroup'; // import WidgetGroup from '@/components/Widgets/WidgetGroup';
import EditModeSaveMenu from '@/components/InteractiveEditor/EditModeSaveMenu.vue'; import EditModeSaveMenu from '@/components/InteractiveEditor/EditModeSaveMenu.vue';
import ExportConfigMenu from '@/components/InteractiveEditor/ExportConfigMenu.vue'; import ExportConfigMenu from '@/components/InteractiveEditor/ExportConfigMenu.vue';
import AddNewSection from '@/components/InteractiveEditor/AddNewSectionLauncher.vue'; import AddNewSection from '@/components/InteractiveEditor/AddNewSectionLauncher.vue';
@ -77,7 +79,7 @@ export default {
name: 'home', name: 'home',
components: { components: {
SettingsContainer, SettingsContainer,
WidgetGroup, // WidgetGroup,
EditModeSaveMenu, EditModeSaveMenu,
ExportConfigMenu, ExportConfigMenu,
AddNewSection, AddNewSection,
@ -160,7 +162,7 @@ export default {
}, },
/* Returns only the tiles that match the users search query */ /* Returns only the tiles that match the users search query */
filterTiles(allTiles, searchTerm) { filterTiles(allTiles, searchTerm) {
return searchTiles(allTiles, searchTerm); return searchTiles(allTiles, searchTerm) || [];
}, },
/* Returns optional section display preferences if available */ /* Returns optional section display preferences if available */
getDisplayData(section) { getDisplayData(section) {
@ -216,10 +218,12 @@ export default {
let isNeeded = false; let isNeeded = false;
if (!this.sections) return false; if (!this.sections) return false;
this.sections.forEach((section) => { this.sections.forEach((section) => {
if (section.icon && section.icon.includes(prefix)) isNeeded = true; if (section && section.items) {
section.items.forEach((item) => { if (section.icon && section.icon.includes(prefix)) isNeeded = true;
if (item.icon && item.icon.includes(prefix)) isNeeded = true; section.items.forEach((item) => {
}); if (item.icon && item.icon.includes(prefix)) isNeeded = true;
});
}
}); });
return isNeeded; return isNeeded;
}, },