This commit is contained in:
parent
36846164bd
commit
ca666c9ef8
8 changed files with 559 additions and 138 deletions
frontend/src
components
elements
mixins
models
styles
src/requests
|
@ -48,6 +48,7 @@
|
|||
<div class="controls">
|
||||
<div class="form-container" v-if="showControls">
|
||||
<FilterForm :value="locationQuery"
|
||||
:countries="countries"
|
||||
:devices="devices"
|
||||
:disabled="loading"
|
||||
:resolution="resolutionMeters"
|
||||
|
@ -110,6 +111,7 @@ import { useGeographic } from 'ol/proj';
|
|||
import type { Optional } from '../models/Types';
|
||||
import Api from '../mixins/Api.vue';
|
||||
import ConfirmDialog from '../elements/ConfirmDialog.vue';
|
||||
import Country from '../models/Country';
|
||||
import Dates from '../mixins/Dates.vue';
|
||||
import Feature from 'ol/Feature';
|
||||
import FilterButton from './filter/ToggleButton.vue';
|
||||
|
@ -118,11 +120,13 @@ import FloatingButton from '../elements/FloatingButton.vue';
|
|||
import GPSPoint from '../models/GPSPoint';
|
||||
import LocationQuery from '../models/LocationQuery';
|
||||
import LocationQueryMixin from '../mixins/LocationQuery.vue';
|
||||
import LocationStats from '../models/LocationStats';
|
||||
import MapSelectOverlay from './MapSelectOverlay.vue';
|
||||
import MapView from '../mixins/MapView.vue';
|
||||
import Paginate from '../mixins/Paginate.vue';
|
||||
import Points from '../mixins/Points.vue';
|
||||
import Routes from '../mixins/Routes.vue';
|
||||
import StatsRequest from '../models/StatsRequest';
|
||||
import Timeline from './Timeline.vue';
|
||||
import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration';
|
||||
import URLQueryHandler from '../mixins/URLQueryHandler.vue';
|
||||
|
@ -155,6 +159,7 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
countries: [] as string[],
|
||||
devices: [] as UserDevice[],
|
||||
loading: false,
|
||||
map: null as Optional<Map>,
|
||||
|
@ -302,6 +307,21 @@ export default {
|
|||
await this.processQueryChange(this.locationQuery, oldQuery)
|
||||
},
|
||||
|
||||
async getCountries() {
|
||||
return (
|
||||
await this.getStats(
|
||||
new StatsRequest({
|
||||
userId: this.$root.user.id,
|
||||
groupBy: ['country'],
|
||||
order: 'desc',
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((record: LocationStats) => !!record.key.country)
|
||||
.map((record: LocationStats) => Country.fromCode(record.key.country))
|
||||
.filter((country: Optional<Country>) => !!country)
|
||||
},
|
||||
|
||||
createMap(): Map {
|
||||
this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[])
|
||||
this.routesLayer = this.createRoutesLayer(Object.values(this.mappedPoints) as Point[])
|
||||
|
@ -575,6 +595,7 @@ export default {
|
|||
])
|
||||
|
||||
this.map = this.createMap()
|
||||
this.countries = await this.getCountries()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -608,6 +629,7 @@ main {
|
|||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
max-height: calc(100% + 4em);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5em;
|
||||
|
@ -618,6 +640,7 @@ main {
|
|||
}
|
||||
|
||||
.form-container {
|
||||
max-height: calc(100% - 8em);
|
||||
margin-bottom: 0.5em;
|
||||
animation: unroll 0.25s ease-out;
|
||||
}
|
||||
|
|
|
@ -1,97 +1,99 @@
|
|||
<template>
|
||||
<form class="filter-view" @submit.prevent.stop="handleSubmit">
|
||||
<h2>Filter</h2>
|
||||
<header>
|
||||
<h2>Filter</h2>
|
||||
</header>
|
||||
|
||||
<div class="date-range-toggle">
|
||||
<input type="checkbox"
|
||||
id="date-range-toggle"
|
||||
name="date-range-toggle"
|
||||
v-model="enableDateRange"
|
||||
:disabled="disabled" />
|
||||
<label for="date-range-toggle">Enable Date Range</label>
|
||||
</div>
|
||||
<main>
|
||||
<div class="date-range-toggle">
|
||||
<input type="checkbox"
|
||||
id="date-range-toggle"
|
||||
name="date-range-toggle"
|
||||
v-model="enableDateRange"
|
||||
:disabled="disabled" />
|
||||
<label for="date-range-toggle">Set Date Range</label>
|
||||
</div>
|
||||
|
||||
<div class="date-selectors" v-if="enableDateRange">
|
||||
<div class="date-selector">
|
||||
<label for="start-date">
|
||||
<font-awesome-icon icon="fas fa-calendar-day" />
|
||||
Start Date
|
||||
</label>
|
||||
<input type="datetime-local"
|
||||
id="start-date"
|
||||
name="start-date"
|
||||
@input="newFilter.startDate = startPlusHours($event, 0)"
|
||||
@change="newFilter.startDate = startPlusHours($event, 0)"
|
||||
:value="toLocalString(newFilter.startDate)"
|
||||
:disabled="disabled"
|
||||
:max="maxDate" />
|
||||
<div class="date-selectors" v-if="enableDateRange">
|
||||
<div class="date-selector">
|
||||
<label for="start-date">
|
||||
<font-awesome-icon icon="fas fa-calendar-day" />
|
||||
Start Date
|
||||
</label>
|
||||
<input type="datetime-local"
|
||||
id="start-date"
|
||||
name="start-date"
|
||||
@input="newFilter.startDate = startPlusHours($event, 0)"
|
||||
@change="newFilter.startDate = startPlusHours($event, 0)"
|
||||
:value="toLocalString(newFilter.startDate)"
|
||||
:disabled="disabled"
|
||||
:max="maxDate" />
|
||||
|
||||
<div class="footer">
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
|
||||
:disabled="disabled || !newFilter.startDate">-1w</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
|
||||
:disabled="disabled || !newFilter.startDate">-1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
|
||||
:disabled="disabled || !newFilter.startDate">-1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
|
||||
:disabled="disabled || !newFilter.startDate">Now</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
|
||||
:disabled="disabled || !newFilter.startDate">+1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
|
||||
:disabled="disabled || !newFilter.startDate">+1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
|
||||
:disabled="disabled || !newFilter.startDate">+1w</button>
|
||||
<div class="footer">
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
|
||||
:disabled="disabled || !newFilter.startDate">-1w</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
|
||||
:disabled="disabled || !newFilter.startDate">-1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
|
||||
:disabled="disabled || !newFilter.startDate">-1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
|
||||
:disabled="disabled || !newFilter.startDate">Now</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
|
||||
:disabled="disabled || !newFilter.startDate">+1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
|
||||
:disabled="disabled || !newFilter.startDate">+1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
|
||||
:disabled="disabled || !newFilter.startDate">+1w</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-selector">
|
||||
<label for="end-date">
|
||||
<font-awesome-icon icon="fas fa-calendar-day" />
|
||||
End Date
|
||||
</label>
|
||||
<input type="datetime-local"
|
||||
id="end-date"
|
||||
name="end-date"
|
||||
@input="newFilter.endDate = endPlusHours($event, 0)"
|
||||
@change="newFilter.endDate = endPlusHours($event, 0)"
|
||||
:value="toLocalString(newFilter.endDate)"
|
||||
:disabled="disabled"
|
||||
:max="maxDate" />
|
||||
|
||||
<div class="footer">
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
|
||||
:disabled="disabled || !newFilter.endDate">-1w</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
|
||||
:disabled="disabled || !newFilter.endDate">-1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
|
||||
:disabled="disabled || !newFilter.endDate">-1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(new Date(), 0)"
|
||||
:disabled="disabled || !newFilter.endDate">Now</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
|
||||
:disabled="disabled || !newFilter.endDate">+1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
|
||||
:disabled="disabled || !newFilter.endDate">+1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
|
||||
:disabled="disabled || !newFilter.endDate">+1w</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-selector">
|
||||
<label for="end-date">
|
||||
<font-awesome-icon icon="fas fa-calendar-day" />
|
||||
End Date
|
||||
</label>
|
||||
<input type="datetime-local"
|
||||
id="end-date"
|
||||
name="end-date"
|
||||
@input="newFilter.endDate = endPlusHours($event, 0)"
|
||||
@change="newFilter.endDate = endPlusHours($event, 0)"
|
||||
:value="toLocalString(newFilter.endDate)"
|
||||
:disabled="disabled"
|
||||
:max="maxDate" />
|
||||
|
||||
<div class="footer">
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)"
|
||||
:disabled="disabled || !newFilter.endDate">-1w</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)"
|
||||
:disabled="disabled || !newFilter.endDate">-1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)"
|
||||
:disabled="disabled || !newFilter.endDate">-1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(new Date(), 0)"
|
||||
:disabled="disabled || !newFilter.endDate">Now</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)"
|
||||
:disabled="disabled || !newFilter.endDate">+1h</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)"
|
||||
:disabled="disabled || !newFilter.endDate">+1d</button>
|
||||
<button type="button"
|
||||
@click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)"
|
||||
:disabled="disabled || !newFilter.endDate">+1w</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="container limit-container">
|
||||
<label for="limit">
|
||||
<font-awesome-icon icon="fas fa-list-ol" />
|
||||
|
@ -120,9 +122,7 @@
|
|||
<option value="desc">Newest points first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="container resolution-container">
|
||||
<label for="resolution">
|
||||
<p class="title">
|
||||
|
@ -158,19 +158,105 @@
|
|||
<option v-for="device in devices" :key="device.id" :value="device.id">{{ device.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="container country-container">
|
||||
<label for="country">
|
||||
<p class="title">
|
||||
<font-awesome-icon icon="fas fa-globe" />
|
||||
Country
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<Autocomplete
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Filter by country"
|
||||
allow-only-values
|
||||
v-model="newFilter.country"
|
||||
:values="autocompleteCountries"
|
||||
:disabled="disabled"
|
||||
@input="newFilter.country = $event" />
|
||||
</div>
|
||||
|
||||
<div class="container locality-container">
|
||||
<label for="locality">
|
||||
<p class="title">
|
||||
<font-awesome-icon icon="fas fa-map-marker-alt" />
|
||||
Locality
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<input type="text"
|
||||
id="locality"
|
||||
name="locality"
|
||||
placeholder="Filter by locality"
|
||||
v-model="newFilter.locality"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
|
||||
<div class="container address-container">
|
||||
<label for="address">
|
||||
<p class="title">
|
||||
<font-awesome-icon icon="fas fa-home" />
|
||||
Address
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<input type="text"
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Filter by address"
|
||||
v-model="newFilter.address"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
|
||||
<div class="container postal-code-container col-s-12">
|
||||
<label for="postal-code">
|
||||
<p class="title">
|
||||
<font-awesome-icon icon="fas fa-mail-bulk" />
|
||||
Postal Code
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<input type="text"
|
||||
id="postal-code"
|
||||
name="postal-code"
|
||||
placeholder="Filter by postal code"
|
||||
v-model="newFilter.postalCode"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
|
||||
<div class="container description-container">
|
||||
<label for="description">
|
||||
<p class="title">
|
||||
<font-awesome-icon icon="fas fa-comment" />
|
||||
Description
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<input type="text"
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Filter by description"
|
||||
v-model="newFilter.description"
|
||||
:disabled="disabled" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<button type="submit" :disabled="disabled || !changed">
|
||||
<font-awesome-icon icon="fas fa-check" /> Apply
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash'
|
||||
|
||||
import Autocomplete from '../../elements/Autocomplete.vue'
|
||||
import AutocompleteValue from '../../models/AutocompleteValue'
|
||||
import Country from '../../models/Country'
|
||||
import LocationQuery from '../../models/LocationQuery'
|
||||
import LocationQueryMixin from '../../mixins/LocationQuery.vue'
|
||||
import UserDevice from '../../models/UserDevice'
|
||||
|
@ -182,8 +268,16 @@ export default {
|
|||
'set-resolution',
|
||||
],
|
||||
|
||||
components: {
|
||||
Autocomplete,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: LocationQuery,
|
||||
countries: {
|
||||
type: Array as () => Country[],
|
||||
default: () => [],
|
||||
},
|
||||
devices: {
|
||||
type: Array as () => UserDevice[],
|
||||
default: () => [],
|
||||
|
@ -199,6 +293,13 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
autocompleteCountries(): AutocompleteValue[] {
|
||||
return this.countries.map((country: Country) => ({
|
||||
value: country.code,
|
||||
label: `${country.flag} ${country.name}`,
|
||||
data: country,
|
||||
}))
|
||||
},
|
||||
maxDate() {
|
||||
return this.toLocalString(this.endPlusHours(new Date(), 0))
|
||||
}
|
||||
|
@ -340,26 +441,95 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
@use "@/styles/common.scss" as *;
|
||||
|
||||
$header-height: 2.5em;
|
||||
$footer-height: 3.5em;
|
||||
|
||||
.filter-view {
|
||||
height: 100%;
|
||||
background: var(--color-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1em 0;
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 0.25em;
|
||||
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
||||
|
||||
@include media(mobile) {
|
||||
@include until(desktop) {
|
||||
width: calc(100vw - 2em);
|
||||
}
|
||||
|
||||
@include media(tablet) {
|
||||
@include from(desktop) {
|
||||
width: 50em;
|
||||
}
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
min-width: 45em;
|
||||
height: $header-height;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: 100%;
|
||||
height: $footer-height;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
height: 2.5em;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
height: calc(100% - $header-height - $footer-height - 5em);
|
||||
max-height: 25em;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
padding: 1em;
|
||||
|
||||
@include until(tablet) {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
@include from(tablet) {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5em 1em;
|
||||
flex-direction: column;
|
||||
|
||||
@include until(tablet) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@include from(tablet) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0.25em;
|
||||
@include until(tablet) {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
@include until(tablet) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-selectors {
|
||||
|
@ -403,40 +573,16 @@ export default {
|
|||
}
|
||||
|
||||
.date-range-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.5em 0 -0.5em 0;
|
||||
justify-content: center;
|
||||
|
||||
input {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0.5em;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.limit-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -444,11 +590,6 @@ export default {
|
|||
}
|
||||
|
||||
.resolution-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0.5em;
|
||||
|
||||
label {
|
||||
margin-bottom: 0.25em;
|
||||
display: flex;
|
||||
|
|
179
frontend/src/elements/Autocomplete.vue
Normal file
179
frontend/src/elements/Autocomplete.vue
Normal file
|
@ -0,0 +1,179 @@
|
|||
<template>
|
||||
<div class="autocomplete">
|
||||
<div class="input">
|
||||
<input
|
||||
type="text"
|
||||
:id="id"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
v-model="newValue"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
ref="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="values" v-if="showValues" @click.stop>
|
||||
<ul>
|
||||
<li v-for="value in filteredValues"
|
||||
:key="value.value"
|
||||
@click.stop="onItemClick(value)">
|
||||
{{ value.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import AutocompleteValue from '../models/AutocompleteValue';
|
||||
|
||||
export default {
|
||||
emits: ['input'],
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
values: {
|
||||
type: Array as () => AutocompleteValue[],
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
allowOnlyValues: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Search...',
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
newValue: '' + this.value,
|
||||
showValues: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredValues() {
|
||||
if (!this.newValue?.length) {
|
||||
return this.values;
|
||||
}
|
||||
|
||||
let matches = this.values.filter((value: AutocompleteValue) =>
|
||||
value.value.toLowerCase() === this.newValue.toLowerCase()
|
||||
) as AutocompleteValue[];
|
||||
|
||||
if (!matches.length) {
|
||||
matches = this.values.filter((value: AutocompleteValue) =>
|
||||
value.label.toLowerCase().includes(this.newValue.toLowerCase())
|
||||
) as AutocompleteValue[];
|
||||
}
|
||||
|
||||
return matches;
|
||||
},
|
||||
|
||||
indexedValues() {
|
||||
return Object.fromEntries(
|
||||
this.values.map((value: AutocompleteValue) => [
|
||||
value.value,
|
||||
value,
|
||||
])
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onFocus() {
|
||||
this.showValues = true;
|
||||
},
|
||||
|
||||
onBlur() {
|
||||
setTimeout(() => {
|
||||
this.showValues = false;
|
||||
this.emitInput();
|
||||
}, 500);
|
||||
},
|
||||
|
||||
onItemClick(value: AutocompleteValue) {
|
||||
this.newValue = value.value;
|
||||
this.showValues = false;
|
||||
this.emitInput();
|
||||
},
|
||||
|
||||
emitInput() {
|
||||
this.$emit(
|
||||
'input',
|
||||
this.allowOnlyValues
|
||||
? this.indexedValues[this.newValue]?.value
|
||||
: this.newValue
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(newValue: string) {
|
||||
this.newValue = newValue;
|
||||
},
|
||||
|
||||
newValue() {
|
||||
this.emitInput();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.autocomplete {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.input {
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 20em;
|
||||
overflow: auto;
|
||||
background-color: var(--color-background);
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 0.5rem 0 rgba(0, 0, 0, 0.5);
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
padding: 0.8em;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-accent-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -14,18 +14,22 @@ export default {
|
|||
newValue?: LocationQuery,
|
||||
oldValue?: LocationQuery,
|
||||
}): boolean {
|
||||
return !_.isEqual(
|
||||
{
|
||||
...(oldValue || {}),
|
||||
startDate: this.normalizeDate(oldValue?.startDate),
|
||||
endDate: this.normalizeDate(oldValue?.endDate),
|
||||
},
|
||||
{
|
||||
...(newValue || {}),
|
||||
startDate: this.normalizeDate(newValue?.startDate),
|
||||
endDate: this.normalizeDate(newValue?.endDate),
|
||||
}
|
||||
const values = [oldValue, newValue].map((value) =>
|
||||
Object.entries(value || {}).reduce((acc, [key, val]) => {
|
||||
// Replace all undefined values with null to avoid the comparison from breaking
|
||||
// when an attribute is not set and it's undefined on one side and null on the other
|
||||
acc[key] = val === undefined ? null : val
|
||||
|
||||
// Normalize dates to avoid issues with different date formats
|
||||
if (key === 'startDate' || key === 'endDate') {
|
||||
acc[key] = this.normalizeDate(val)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
)
|
||||
|
||||
return !_.isEqual(values[0], values[1])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
21
frontend/src/models/AutocompleteValue.ts
Normal file
21
frontend/src/models/AutocompleteValue.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
class AutocompleteValue {
|
||||
value: string;
|
||||
label: string;
|
||||
data?: any | null = undefined;
|
||||
|
||||
constructor(record: {
|
||||
value: string;
|
||||
label: string;
|
||||
data?: any | null;
|
||||
}) {
|
||||
this.value = record.value;
|
||||
this.label = record.label;
|
||||
this.data = record.data;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.label;
|
||||
}
|
||||
}
|
||||
|
||||
export default AutocompleteValue;
|
36
frontend/src/models/Country.ts
Normal file
36
frontend/src/models/Country.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { TCountryCode } from 'countries-list';
|
||||
import { getCountryData, getEmojiFlag } from 'countries-list';
|
||||
|
||||
class Country {
|
||||
name: string;
|
||||
code: string;
|
||||
continent: string;
|
||||
flag: string;
|
||||
|
||||
constructor(data: {
|
||||
name: string;
|
||||
code: string;
|
||||
continent: string;
|
||||
flag: string;
|
||||
}) {
|
||||
this.name = data.name;
|
||||
this.code = data.code;
|
||||
this.continent = data.continent;
|
||||
this.flag = data.flag;
|
||||
}
|
||||
|
||||
public static fromCode(code: string | TCountryCode): Country | null {
|
||||
const cc = code.toUpperCase() as TCountryCode;
|
||||
const countryData = getCountryData(cc);
|
||||
if (!countryData) return null;
|
||||
|
||||
return new Country({
|
||||
name: countryData.name,
|
||||
code: code,
|
||||
continent: countryData.continent,
|
||||
flag: getEmojiFlag(cc),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Country;
|
|
@ -16,6 +16,23 @@ $breakpoints: (
|
|||
}
|
||||
}
|
||||
|
||||
// Generate col-<breakpoint>-<number> classes
|
||||
$screen-size-to-breakpoint: (
|
||||
s:mobile,
|
||||
m:tablet,
|
||||
l:desktop
|
||||
);
|
||||
|
||||
@for $i from 1 through 12 {
|
||||
@each $screen-size, $breakpoint in $screen-size-to-breakpoint {
|
||||
.col-#{$screen-size}-#{$i} {
|
||||
@media (min-width: map.get($breakpoints, $breakpoint)) {
|
||||
width: (100% / 12) * $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ class LocationRequest {
|
|||
this.initNumber('maxLatitude', req, parseFloat);
|
||||
this.initNumber('minLongitude', req, parseFloat);
|
||||
this.initNumber('maxLongitude', req, parseFloat);
|
||||
this.country = req.country;
|
||||
this.country = req.country?.toLowerCase();
|
||||
this.locality = req.locality;
|
||||
this.postalCode = req.postalCode;
|
||||
this.description = req.description;
|
||||
|
|
Loading…
Add table
Reference in a new issue