Merge branch 'search-events-with-location' into 'master'

Search events with location

See merge request framasoft/mobilizon!521
This commit is contained in:
Thomas Citharel 2020-08-10 15:49:49 +02:00
commit 1121e74a7a
44 changed files with 1506 additions and 789 deletions

View File

@ -25,6 +25,7 @@
"buefy": "^0.8.2", "buefy": "^0.8.2",
"bulma-divider": "^0.2.0", "bulma-divider": "^0.2.0",
"core-js": "^3.6.4", "core-js": "^3.6.4",
"date-fns": "^2.15.0",
"eslint-plugin-cypress": "^2.10.3", "eslint-plugin-cypress": "^2.10.3",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",

View File

@ -26,7 +26,7 @@ input.input {
} }
.section { .section {
padding: 1rem 2rem 4rem; padding: 1rem 1% 4rem;
} }
figure img.is-rounded { figure img.is-rounded {

View File

@ -1,20 +1,8 @@
<template> <template>
<div class="address-autocomplete">
<b-field expanded>
<template slot="label">
{{ $t("Find an address") }}
<b-button
v-if="!gettingLocation"
size="is-small"
icon-right="map-marker"
@click="locateMe"
/>
<span v-else>{{ $t("Getting location") }}</span>
</template>
<b-autocomplete <b-autocomplete
:data="addressData" :data="addressData"
v-model="queryText" v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')" :placeholder="placeholder || $t('e.g. 10 Rue Jangot')"
field="fullName" field="fullName"
:loading="isFetching" :loading="isFetching"
@typing="fetchAsyncData" @typing="fetchAsyncData"
@ -37,67 +25,9 @@
queryText, queryText,
}) })
}}</span> }}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- </p>-->
</div> </div>
</template> </template>
</b-autocomplete> </b-autocomplete>
</b-field>
<div class="map" v-if="selected && selected.geom">
<map-leaflet
:coords="selected.geom"
:marker="{
text: [selected.poiInfos.name, selected.poiInfos.alternativeName],
icon: selected.poiInfos.poiIcon.icon,
}"
:updateDraggableMarkerCallback="reverseGeoCode"
:options="{ zoom: mapDefaultZoom }"
:readOnly="false"
/>
</div>
<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">-->
<!-- <div class="modal-card" style="width: auto">-->
<!-- <header class="modal-card-head">-->
<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>-->
<!-- </header>-->
<!-- <section class="modal-card-body">-->
<!-- <form>-->
<!-- <b-field :label="$t('Name')">-->
<!-- <b-input aria-required="true" required v-model="selected.description" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Street')">-->
<!-- <b-input v-model="selected.street" />-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Postal Code')">-->
<!-- <b-input v-model="selected.postalCode" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Locality')">-->
<!-- <b-input v-model="selected.locality" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Region')">-->
<!-- <b-input v-model="selected.region" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Country')">-->
<!-- <b-input v-model="selected.country" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- </form>-->
<!-- </section>-->
<!-- <footer class="modal-card-foot">-->
<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
<!-- </footer>-->
<!-- </div>-->
<!-- </b-modal>-->
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
@ -109,15 +39,13 @@ import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
@Component({ @Component({
components: {
"map-leaflet": () => import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
},
apollo: { apollo: {
config: CONFIG, config: CONFIG,
}, },
}) })
export default class AddressAutoComplete extends Vue { export default class AddressAutoComplete extends Vue {
@Prop({ required: true }) value!: IAddress; @Prop({ required: true }) value!: IAddress;
@Prop({ required: false }) placeholder!: string;
addressData: IAddress[] = []; addressData: IAddress[] = [];
@ -127,16 +55,6 @@ export default class AddressAutoComplete extends Vue {
queryText: string = (this.value && new Address(this.value).fullName) || ""; queryText: string = (this.value && new Address(this.value).fullName) || "";
addressModalActive = false;
private gettingLocation = false;
private location!: Position;
private gettingLocationError: any;
private mapDefaultZoom = 15;
config!: IConfig; config!: IConfig;
fetchAsyncData!: Function; fetchAsyncData!: Function;
@ -197,76 +115,6 @@ export default class AddressAutoComplete extends Vue {
this.selected = option; this.selected = option;
this.$emit("input", this.selected); this.$emit("input", this.selected);
} }
resetPopup() {
this.selected = new Address();
}
openNewAddressModal() {
this.resetPopup();
this.addressModalActive = true;
}
async reverseGeoCode(e: LatLng, zoom: number) {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.reverseGeocode.map((address: IAddress) => new Address(address));
const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress;
this.$emit("input", this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
checkCurrentPosition(e: LatLng) {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(";")[1]);
const lon = parseFloat(this.selected.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
}
async locateMe(): Promise<void> {
this.gettingLocation = true;
try {
this.gettingLocation = false;
this.location = await AddressAutoComplete.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(
new LatLng(this.location.coords.latitude, this.location.coords.longitude),
12
);
} catch (e) {
this.gettingLocation = false;
this.gettingLocationError = e.message;
}
}
static async getLocation(): Promise<Position> {
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available."));
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
},
(err) => {
reject(err);
}
);
});
}
} }
</script> </script>
<style lang="scss"> <style lang="scss">

View File

@ -1,31 +1,3 @@
<docs>
### EventCard
A simple card for an event
```vue
<EventCard
:event="{
title: 'Vue Styleguidist first meetup: learn the basics!',
beginsOn: new Date(),
tags: [
{
slug: 'test', title: 'Test'
},
{
slug: 'mobilizon', title: 'Mobilizon'
},
],
organizerActor: {
preferredUsername: 'tcit',
name: 'Some Random Dude',
domain: null
}
}"
/>
```
</docs>
<template> <template>
<router-link class="card" :to="{ name: 'Event', params: { uuid: event.uuid } }"> <router-link class="card" :to="{ name: 'Event', params: { uuid: event.uuid } }">
<div class="card-image"> <div class="card-image">
@ -36,9 +8,13 @@ A simple card for an event
}')`" }')`"
> >
<div class="tag-container" v-if="event.tags"> <div class="tag-container" v-if="event.tags">
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-light">{{ <router-link
tag.title :to="{ name: RouteName.TAG, params: { tag: tag.title } }"
}}</b-tag> v-for="tag in event.tags.slice(0, 3)"
:key="tag.slug"
>
<b-tag type="is-light">{{ tag.title }}</b-tag>
</router-link>
</div> </div>
</figure> </figure>
</div> </div>
@ -101,6 +77,7 @@ import { IEvent, IEventCardOptions, ParticipantRole } from "@/types/event.model"
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { Actor, Person } from "@/types/actor"; import { Actor, Person } from "@/types/actor";
import RouteName from "../../router/name";
@Component({ @Component({
components: { components: {
@ -114,6 +91,8 @@ export default class EventCard extends Vue {
ParticipantRole = ParticipantRole; ParticipantRole = ParticipantRole;
RouteName = RouteName;
defaultOptions: IEventCardOptions = { defaultOptions: IEventCardOptions = {
hideDate: false, hideDate: false,
loggedPerson: false, loggedPerson: false,
@ -176,6 +155,9 @@ a.card {
z-index: 10; z-index: 10;
max-width: 40%; max-width: 40%;
a {
text-decoration: none;
span.tag { span.tag {
margin: 5px auto; margin: 5px auto;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -187,6 +169,7 @@ a.card {
color: #3c376e; color: #3c376e;
} }
} }
}
div.card-image { div.card-image {
background: $secondary; background: $secondary;

View File

@ -0,0 +1,307 @@
<template>
<div class="address-autocomplete">
<b-field expanded>
<template slot="label">
{{ actualLabel }}
<b-button
v-if="canShowLocateMeButton && !gettingLocation"
size="is-small"
icon-right="map-marker"
@click="locateMe"
/>
<span v-else-if="gettingLocation">{{ $t("Getting location") }}</span>
</template>
<b-autocomplete
:data="addressData"
v-model="queryText"
:placeholder="$t('e.g. 10 Rue Jangot')"
field="fullName"
:loading="isFetching"
@typing="fetchAsyncData"
icon="map-marker"
expanded
@select="updateSelected"
>
<template slot-scope="{ option }">
<b-icon :icon="option.poiInfos.poiIcon.icon" />
<b>{{ option.poiInfos.name }}</b
><br />
<small>{{ option.poiInfos.alternativeName }}</small>
</template>
<template slot="empty">
<span v-if="isFetching">{{ $t("Searching") }}</span>
<div v-else-if="queryText.length >= 3" class="is-enabled">
<span>{{ $t('No results for "{queryText}"') }}</span>
<span>{{
$t("You can try another search term or drag and drop the marker on the map", {
queryText,
})
}}</span>
<!-- <p class="control" @click="openNewAddressModal">-->
<!-- <button type="button" class="button is-primary">{{ $t('Add') }}</button>-->
<!-- </p>-->
</div>
</template>
</b-autocomplete>
</b-field>
<div class="map" v-if="selected && selected.geom && selected.poiInfos">
<map-leaflet
:coords="selected.geom"
:marker="{
text: [selected.poiInfos.name, selected.poiInfos.alternativeName],
icon: selected.poiInfos.poiIcon.icon,
}"
:updateDraggableMarkerCallback="reverseGeoCode"
:options="{ zoom: mapDefaultZoom }"
:readOnly="false"
/>
</div>
<!-- <b-modal v-if="selected" :active.sync="addressModalActive" :width="640" has-modal-card scroll="keep">-->
<!-- <div class="modal-card" style="width: auto">-->
<!-- <header class="modal-card-head">-->
<!-- <p class="modal-card-title">{{ $t('Add an address') }}</p>-->
<!-- </header>-->
<!-- <section class="modal-card-body">-->
<!-- <form>-->
<!-- <b-field :label="$t('Name')">-->
<!-- <b-input aria-required="true" required v-model="selected.description" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Street')">-->
<!-- <b-input v-model="selected.street" />-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Postal Code')">-->
<!-- <b-input v-model="selected.postalCode" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Locality')">-->
<!-- <b-input v-model="selected.locality" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- <b-field grouped>-->
<!-- <b-field :label="$t('Region')">-->
<!-- <b-input v-model="selected.region" />-->
<!-- </b-field>-->
<!-- <b-field :label="$t('Country')">-->
<!-- <b-input v-model="selected.country" />-->
<!-- </b-field>-->
<!-- </b-field>-->
<!-- </form>-->
<!-- </section>-->
<!-- <footer class="modal-card-foot">-->
<!-- <button class="button" type="button" @click="resetPopup()">{{ $t('Clear') }}</button>-->
<!-- </footer>-->
<!-- </div>-->
<!-- </b-modal>-->
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { LatLng } from "leaflet";
import { debounce } from "lodash";
import { Address, IAddress } from "../../types/address.model";
import { ADDRESS, REVERSE_GEOCODE } from "../../graphql/address";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
@Component({
components: {
"map-leaflet": () => import(/* webpackChunkName: "map" */ "@/components/Map.vue"),
},
apollo: {
config: CONFIG,
},
})
export default class FullAddressAutoComplete extends Vue {
@Prop({ required: true }) value!: IAddress;
@Prop({ required: false, default: "" }) label!: string;
addressData: IAddress[] = [];
selected: IAddress = new Address();
isFetching = false;
queryText: string = (this.value && new Address(this.value).fullName) || "";
addressModalActive = false;
private gettingLocation = false;
private location!: Position;
private gettingLocationError: any;
private mapDefaultZoom = 15;
config!: IConfig;
fetchAsyncData!: Function;
// We put this in data because of issues like
// https://github.com/vuejs/vue-class-component/issues/263
data() {
return {
fetchAsyncData: debounce(this.asyncData, 200),
};
}
async asyncData(query: string) {
if (!query.length) {
this.addressData = [];
this.selected = new Address();
return;
}
if (query.length < 3) {
this.addressData = [];
return;
}
this.isFetching = true;
const result = await this.$apollo.query({
query: ADDRESS,
fetchPolicy: "network-only",
variables: {
query,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.searchAddress.map((address: IAddress) => new Address(address));
this.isFetching = false;
}
@Watch("config")
watchConfig(config: IConfig) {
if (!config.geocoding.autocomplete) {
// If autocomplete is disabled, we put a larger debounce value
// so that we don't request with incomplete address
this.fetchAsyncData = debounce(this.asyncData, 2000);
}
}
@Watch("value")
updateEditing() {
if (!(this.value && this.value.id)) return;
this.selected = this.value;
const address = new Address(this.selected);
if (address.poiInfos) {
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;
}
}
updateSelected(option: IAddress) {
if (option == null) return;
this.selected = option;
this.$emit("input", this.selected);
}
resetPopup() {
this.selected = new Address();
}
openNewAddressModal() {
this.resetPopup();
this.addressModalActive = true;
}
async reverseGeoCode(e: LatLng, zoom: number) {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (this.checkCurrentPosition(e)) return;
const result = await this.$apollo.query({
query: REVERSE_GEOCODE,
variables: {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: this.$i18n.locale,
},
});
this.addressData = result.data.reverseGeocode.map((address: IAddress) => new Address(address));
const defaultAddress = new Address(this.addressData[0]);
this.selected = defaultAddress;
this.$emit("input", this.selected);
this.queryText = `${defaultAddress.poiInfos.name} ${defaultAddress.poiInfos.alternativeName}`;
}
checkCurrentPosition(e: LatLng) {
if (!this.selected || !this.selected.geom) return false;
const lat = parseFloat(this.selected.geom.split(";")[1]);
const lon = parseFloat(this.selected.geom.split(";")[0]);
return e.lat === lat && e.lng === lon;
}
async locateMe(): Promise<void> {
this.gettingLocation = true;
try {
this.gettingLocation = false;
this.location = await FullAddressAutoComplete.getLocation();
this.mapDefaultZoom = 12;
this.reverseGeoCode(
new LatLng(this.location.coords.latitude, this.location.coords.longitude),
12
);
} catch (e) {
this.gettingLocation = false;
this.gettingLocationError = e.message;
}
}
get actualLabel(): string {
return this.label || (this.$t("Find an address") as string);
}
get canShowLocateMeButton(): boolean {
return window.isSecureContext;
}
static async getLocation(): Promise<Position> {
return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) {
reject(new Error("Geolocation is not available."));
}
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve(pos);
},
(err) => {
reject(err);
}
);
});
}
}
</script>
<style lang="scss">
.address-autocomplete {
margin-bottom: 0.75rem;
}
.autocomplete {
.dropdown-menu {
z-index: 2000;
}
.dropdown-item.is-disabled {
opacity: 1 !important;
cursor: auto;
}
}
.read-only {
cursor: pointer;
}
.map {
height: 400px;
width: 100%;
}
</style>

View File

@ -9,21 +9,21 @@
</div> </div>
<div class="media-content"> <div class="media-content">
<router-link <router-link
:to="{ name: RouteName.GROUP, params: { preferredUsername: groupFullUsername } }" :to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
> >
<h3>{{ member.parent.name }}</h3> <h3>{{ group.name }}</h3>
<p class="is-6 has-text-grey"> <p class="is-6 has-text-grey">
<span v-if="member.parent.domain">{{ <span v-if="group.domain">{{ `@${group.preferredUsername}@${group.domain}` }}</span>
`@${member.parent.preferredUsername}@${member.parent.domain}` <span v-else>{{ `@${group.preferredUsername}` }}</span>
}}</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
</p> </p>
<b-tag type="is-info">{{ member.role }}</b-tag>
</router-link> </router-link>
</div> </div>
</div> </div>
<div class="content"> <div class="content">
<p>{{ member.parent.summary }}</p> <p>{{ group.summary }}</p>
</div> </div>
</div> </div>
</div> </div>
@ -31,20 +31,15 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember } from "@/types/actor"; import { IGroup, usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component @Component
export default class GroupCard extends Vue { export default class GroupCard extends Vue {
@Prop({ required: true }) member!: IMember; @Prop({ required: true }) group!: IGroup;
RouteName = RouteName; RouteName = RouteName;
get groupFullUsername() { usernameWithDomain = usernameWithDomain;
if (this.member.parent.domain) {
return `${this.member.parent.preferredUsername}@${this.member.parent.domain}`;
}
return this.member.parent.preferredUsername;
}
} }
</script> </script>

View File

@ -0,0 +1,48 @@
<template>
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image" />
</figure>
</div>
<div class="media-content">
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
}"
>
<h3>{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey">
<span v-if="member.parent.domain">{{
`@${member.parent.preferredUsername}@${member.parent.domain}`
}}</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
</p>
<b-tag type="is-info">{{ member.role }}</b-tag>
</router-link>
</div>
</div>
<div class="content">
<p>{{ member.parent.summary }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IMember, usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name";
@Component
export default class GroupMemberCard extends Vue {
@Prop({ required: true }) member!: IMember;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
}
</script>

View File

@ -137,9 +137,7 @@ import RouteName from "../router/name";
this.handleErrors(graphQLErrors); this.handleErrors(graphQLErrors);
}, },
}, },
config: { config: CONFIG,
query: CONFIG,
},
}, },
components: { components: {
Logo, Logo,

View File

@ -8,7 +8,7 @@
type="search" type="search"
rounded rounded
:placeholder="defaultPlaceHolder" :placeholder="defaultPlaceHolder"
v-model="searchText" v-model="search"
@keyup.native.enter="enter" @keyup.native.enter="enter"
/> />
</label> </label>
@ -21,12 +21,12 @@ import RouteName from "../router/name";
export default class SearchField extends Vue { export default class SearchField extends Vue {
@Prop({ type: String, required: false }) placeholder!: string; @Prop({ type: String, required: false }) placeholder!: string;
searchText = ""; search: string = "";
enter() { enter() {
this.$router.push({ this.$router.push({
name: RouteName.SEARCH, name: RouteName.SEARCH,
params: { searchTerm: this.searchText }, query: { term: this.search },
}); });
} }

View File

@ -465,6 +465,19 @@ export const FETCH_GROUP = gql`
summary summary
preferredUsername preferredUsername
suspended suspended
visibility
physicalAddress {
description
street
locality
postalCode
region
country
geom
type
id
originId
}
avatar { avatar {
url url
} }
@ -588,8 +601,18 @@ export const UPDATE_GROUP = gql`
$summary: String $summary: String
$avatar: PictureInput $avatar: PictureInput
$banner: PictureInput $banner: PictureInput
$visibility: GroupVisibility
$physicalAddress: AddressInput
) {
updateGroup(
id: $id
name: $name
summary: $summary
banner: $banner
avatar: $avatar
visibility: $visibility
physicalAddress: $physicalAddress
) { ) {
createGroup(id: $id, name: $name, summary: $summary, banner: $banner, avatar: $avatar) {
id id
preferredUsername preferredUsername
name name

View File

@ -1,8 +1,22 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
export const SEARCH_EVENTS = gql` export const SEARCH_EVENTS = gql`
query SearchEvents($searchText: String!) { query SearchEvents(
searchEvents(search: $searchText) { $location: String
$radius: Float
$tags: String
$term: String
$beginsOn: DateTime
$endsOn: DateTime
) {
searchEvents(
location: $location
radius: $radius
tags: $tags
term: $term
beginsOn: $beginsOn
endsOn: $endsOn
) {
total total
elements { elements {
title title
@ -22,8 +36,8 @@ export const SEARCH_EVENTS = gql`
`; `;
export const SEARCH_GROUPS = gql` export const SEARCH_GROUPS = gql`
query SearchGroups($searchText: String!) { query SearchGroups($term: String, $location: String, $radius: Float) {
searchGroups(search: $searchText) { searchGroups(term: $term, location: $location, radius: $radius) {
total total
elements { elements {
avatar { avatar {
@ -40,7 +54,7 @@ export const SEARCH_GROUPS = gql`
export const SEARCH_PERSONS = gql` export const SEARCH_PERSONS = gql`
query SearchPersons($searchText: String!, $page: Int, $limit: Int) { query SearchPersons($searchText: String!, $page: Int, $limit: Int) {
searchPersons(search: $searchText, page: $page, limit: $limit) { searchPersons(term: $searchText, page: $page, limit: $limit) {
total total
elements { elements {
id id

View File

@ -730,5 +730,18 @@
"Delete post": "Delete post", "Delete post": "Delete post",
"Update post": "Update post", "Update post": "Update post",
"Posts": "Posts", "Posts": "Posts",
"Register an account on {instanceName}!": "Register an account on {instanceName}!" "Register an account on {instanceName}!": "Register an account on {instanceName}!",
"Key words": "Key words",
"For instance: London": "For instance: London",
"Radius": "Radius",
"Today": "Today",
"Tomorrow": "Tomorrow",
"This weekend": "This weekend",
"This week": "This week",
"Next week": "Next week",
"This month": "This month",
"Next month": "Next month",
"Any day": "Any day",
"{nb} km": "{nb} km",
"any distance": "any distance"
} }

View File

@ -730,5 +730,18 @@
"Delete post": "Supprimer le billet", "Delete post": "Supprimer le billet",
"Update post": "Mettre à jour le billet", "Update post": "Mettre à jour le billet",
"Posts": "Billets", "Posts": "Billets",
"Register an account on {instanceName}!": "S'inscrire sur {instanceName} !" "Register an account on {instanceName}!": "S'inscrire sur {instanceName} !",
"Key words": "Mots clés",
"For instance: London": "Par exemple : Lyon",
"Radius": "Rayon",
"Today": "Aujourd'hui",
"Tomorrow": "Demain",
"This weekend": "Ce weekend",
"This week": "Cette semaine",
"Next week": "La semaine prochaine",
"This month": "Ce mois-ci",
"Next month": "Le mois-prochain",
"Any day": "N'importe quand",
"{nb} km": "{nb} km",
"any distance": "peu importe"
} }

View File

@ -10,6 +10,7 @@ import TimeAgo from "javascript-time-ago";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import { NotifierPlugin } from "./plugins/notifier"; import { NotifierPlugin } from "./plugins/notifier";
import { DateFnsPlugin } from "./plugins/dateFns";
import filters from "./filters"; import filters from "./filters";
import { i18n } from "./utils/i18n"; import { i18n } from "./utils/i18n";
import messages from "./i18n"; import messages from "./i18n";
@ -31,6 +32,7 @@ import(`javascript-time-ago/locale/${locale}`).then((localeFile) => {
Vue.use(Buefy); Vue.use(Buefy);
Vue.use(NotifierPlugin); Vue.use(NotifierPlugin);
Vue.use(DateFnsPlugin, { locale });
Vue.use(filters); Vue.use(filters);
Vue.use(VueMeta); Vue.use(VueMeta);
Vue.use(VueScrollTo); Vue.use(VueScrollTo);

14
js/src/plugins/dateFns.ts Normal file
View File

@ -0,0 +1,14 @@
import Vue from "vue";
import Locale from "date-fns";
declare module "vue/types/vue" {
interface Vue {
$dateFnsLocale: Locale;
}
}
export function DateFnsPlugin(vue: typeof Vue, { locale }: { locale: string }): void {
import(`date-fns/locale/${locale}/index.js`).then((localeEntity) => {
Vue.prototype.$dateFnsLocale = localeEntity;
});
}

View File

@ -1,13 +1,13 @@
import { RouteConfig, Route } from "vue-router"; import { RouteConfig, Route } from "vue-router";
import EventList from "../views/Event/EventList.vue"; import EventList from "../views/Event/EventList.vue";
import Location from "../views/Location.vue"; import Location from "../views/Location.vue";
import Search from "../views/Search.vue";
const participations = () => const participations = () =>
import(/* webpackChunkName: "participations" */ "@/views/Event/Participants.vue"); import(/* webpackChunkName: "participations" */ "@/views/Event/Participants.vue");
const editEvent = () => import(/* webpackChunkName: "edit-event" */ "@/views/Event/Edit.vue"); const editEvent = () => import(/* webpackChunkName: "edit-event" */ "@/views/Event/Edit.vue");
const event = () => import(/* webpackChunkName: "event" */ "@/views/Event/Event.vue"); const event = () => import(/* webpackChunkName: "event" */ "@/views/Event/Event.vue");
const myEvents = () => import(/* webpackChunkName: "my-events" */ "@/views/Event/MyEvents.vue"); const myEvents = () => import(/* webpackChunkName: "my-events" */ "@/views/Event/MyEvents.vue");
const explore = () => import(/* webpackChunkName: "explore" */ "@/views/Event/Explore.vue");
export enum EventRouteName { export enum EventRouteName {
EVENT_LIST = "EventList", EVENT_LIST = "EventList",
@ -42,7 +42,7 @@ export const eventRoutes: RouteConfig[] = [
{ {
path: "/events/explore", path: "/events/explore",
name: EventRouteName.EXPLORE, name: EventRouteName.EXPLORE,
component: explore, redirect: { name: "Search" },
meta: { requiredAuth: false }, meta: { requiredAuth: false },
}, },
{ {
@ -112,6 +112,8 @@ export const eventRoutes: RouteConfig[] = [
{ {
path: "/tag/:tag", path: "/tag/:tag",
name: EventRouteName.TAG, name: EventRouteName.TAG,
redirect: "/search/:tag", component: Search,
props: true,
meta: { requiredAuth: false },
}, },
]; ];

View File

@ -49,7 +49,7 @@ const router = new Router({
...discussionRoutes, ...discussionRoutes,
...errorRoutes, ...errorRoutes,
{ {
path: "/search/:searchTerm/:searchType?", path: "/search",
name: RouteName.SEARCH, name: RouteName.SEARCH,
component: Search, component: Search,
props: true, props: true,

View File

@ -6,6 +6,7 @@ import { IEvent } from "../event.model";
import { IDiscussion } from "../discussions"; import { IDiscussion } from "../discussions";
import { IPerson } from "./person.model"; import { IPerson } from "./person.model";
import { IPost } from "../post.model"; import { IPost } from "../post.model";
import { IAddress, Address } from "../address.model";
export enum MemberRole { export enum MemberRole {
NOT_APPROVED = "NOT_APPROVED", NOT_APPROVED = "NOT_APPROVED",
@ -23,6 +24,7 @@ export interface IGroup extends IActor {
todoLists: Paginate<ITodoList>; todoLists: Paginate<ITodoList>;
discussions: Paginate<IDiscussion>; discussions: Paginate<IDiscussion>;
organizedEvents: Paginate<IEvent>; organizedEvents: Paginate<IEvent>;
physicalAddress: IAddress;
} }
export interface IMember { export interface IMember {
@ -52,6 +54,7 @@ export class Group extends Actor implements IGroup {
this.patch(hash); this.patch(hash);
} }
physicalAddress: IAddress = new Address();
patch(hash: any) { patch(hash: any) {
Object.assign(this, hash); Object.assign(this, hash);

View File

@ -106,10 +106,13 @@ export class Address implements IAddress {
return { name, alternativeName, poiIcon }; return { name, alternativeName, poiIcon };
} }
get fullName() { get fullName(): string {
const { name, alternativeName } = this.poiInfos; const { name, alternativeName } = this.poiInfos;
if (name && alternativeName) {
return `${name}, ${alternativeName}`; return `${name}, ${alternativeName}`;
} }
return "";
}
get iconForPOI(): IPOIIcon { get iconForPOI(): IPOIIcon {
if (this.type == null) { if (this.type == null) {

View File

@ -29,7 +29,7 @@
{{ $t("Date parameters") }} {{ $t("Date parameters") }}
</b-button> </b-button>
<address-auto-complete v-model="event.physicalAddress" /> <full-address-auto-complete v-model="event.physicalAddress" />
<div class="field"> <div class="field">
<label class="label">{{ $t("Description") }}</label> <label class="label">{{ $t("Description") }}</label>
@ -329,7 +329,7 @@ import PictureUpload from "@/components/PictureUpload.vue";
import EditorComponent from "@/components/Editor.vue"; import EditorComponent from "@/components/Editor.vue";
import DateTimePicker from "@/components/Event/DateTimePicker.vue"; import DateTimePicker from "@/components/Event/DateTimePicker.vue";
import TagInput from "@/components/Event/TagInput.vue"; import TagInput from "@/components/Event/TagInput.vue";
import AddressAutoComplete from "@/components/Event/AddressAutoComplete.vue"; import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue"; import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import Subtitle from "@/components/Utils/Subtitle.vue"; import Subtitle from "@/components/Utils/Subtitle.vue";
import GroupPickerWrapper from "@/components/Group/GroupPickerWrapper.vue"; import GroupPickerWrapper from "@/components/Group/GroupPickerWrapper.vue";
@ -370,7 +370,7 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
GroupPickerWrapper, GroupPickerWrapper,
Subtitle, Subtitle,
IdentityPickerWrapper, IdentityPickerWrapper,
AddressAutoComplete, FullAddressAutoComplete,
TagInput, TagInput,
DateTimePicker, DateTimePicker,
PictureUpload, PictureUpload,

View File

@ -1,116 +0,0 @@
<template>
<div class="section container">
<h1 class="title">{{ $t("Explore") }}</h1>
<section class="hero">
<div class="hero-body">
<form @submit.prevent="submit()">
<b-field
:label="$t('Event')"
grouped
group-multiline
label-position="on-border"
label-for="search"
>
<b-input
icon="magnify"
type="search"
id="search"
size="is-large"
expanded
v-model="searchTerm"
:placeholder="$t('For instance: London, Taekwondo, Architecture…')"
/>
<p class="control">
<b-button
@click="submit"
type="is-info"
size="is-large"
v-bind:disabled="searchTerm.trim().length === 0"
>{{ $t("Search") }}</b-button
>
</p>
</b-field>
</form>
</div>
</section>
<section class="events-featured">
<b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.length > 0" class="columns is-multiline">
<div class="column is-one-third-desktop" v-for="event in events" :key="event.uuid">
<EventCard :event="event" />
</div>
</div>
<b-message v-else-if="events.length === 0 && $apollo.loading === false" type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue";
import { FETCH_EVENTS } from "@/graphql/event";
import { IEvent } from "@/types/event.model";
import RouteName from "../../router/name";
@Component({
components: {
EventCard,
},
apollo: {
events: {
query: FETCH_EVENTS,
},
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
title: this.$t("Explore") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Explore extends Vue {
events: IEvent[] = [];
searchTerm = "";
submit() {
this.$router.push({
name: RouteName.SEARCH,
params: { searchTerm: this.searchTerm },
});
}
}
</script>
<style scoped lang="scss">
@import "@/variables.scss";
main > .container {
background: $white;
.hero-body {
padding: 1rem 1.5rem;
}
}
h1.title {
margin-top: 1.5rem;
}
h3.title {
margin-bottom: 1.5rem;
}
.events-featured {
margin: 25px auto;
.columns {
margin: 1rem auto 3rem;
}
}
</style>

View File

@ -3,7 +3,7 @@
<h1>{{ $t("Group List") }} ({{ groups.total }})</h1> <h1>{{ $t("Group List") }} ({{ groups.total }})</h1>
<b-loading :active.sync="$apollo.loading" /> <b-loading :active.sync="$apollo.loading" />
<div class="columns"> <div class="columns">
<GroupCard <GroupMemberCard
v-for="group in groups.elements" v-for="group in groups.elements"
:key="group.uuid" :key="group.uuid"
:group="group" :group="group"
@ -20,7 +20,7 @@
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { LIST_GROUPS } from "@/graphql/actor"; import { LIST_GROUPS } from "@/graphql/actor";
import { Group, IGroup } from "@/types/actor"; import { Group, IGroup } from "@/types/actor";
import GroupCard from "@/components/Group/GroupCard.vue"; import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -30,7 +30,7 @@ import RouteName from "../../router/name";
}, },
}, },
components: { components: {
GroupCard, GroupMemberCard,
}, },
}) })
export default class GroupList extends Vue { export default class GroupList extends Vue {

View File

@ -143,7 +143,6 @@
</section> </section>
</template> </template>
</b-table> </b-table>
<pre>{{ group.members }}</pre>
</section> </section>
</div> </div>
</template> </template>

View File

@ -39,6 +39,58 @@
<b-field :label="$t('Group short description')"> <b-field :label="$t('Group short description')">
<b-input type="textarea" v-model="group.summary" <b-input type="textarea" v-model="group.summary"
/></b-field> /></b-field>
<p class="label">{{ $t("Group visibility") }}</p>
<div class="field">
<b-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.PUBLIC"
>
{{ $t("Visible everywhere on the web") }}<br />
<small>{{
$t(
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
)
}}</small>
</b-radio>
</div>
<div class="field">
<b-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}<br />
<small>{{
$t("You'll need to transmit the group URL so people may access the group's profile.")
}}</small>
</b-radio>
<p class="control">
<code>{{ group.url }}</code>
<b-tooltip
v-if="canShowCopyButton"
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
>
<b-button
type="is-primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
/>
</b-tooltip>
</p>
</div>
<full-address-auto-complete
:label="$t('Group address')"
v-model="group.physicalAddress"
:value="currentAddress"
/>
<b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button> <b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button>
</form> </form>
</section> </section>
@ -50,8 +102,10 @@ import { Component, Vue } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor"; import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { IMember, Group } from "../../types/actor/group.model"; import { IMember, Group } from "../../types/actor/group.model";
import { Paginate } from "../../types/paginate"; import { Paginate } from "../../types/paginate";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
@Component({ @Component({
apollo: { apollo: {
@ -67,6 +121,9 @@ import { Paginate } from "../../types/paginate";
}, },
}, },
}, },
components: {
FullAddressAutoComplete,
},
}) })
export default class GroupSettings extends Vue { export default class GroupSettings extends Vue {
group: IGroup = new Group(); group: IGroup = new Group();
@ -79,13 +136,41 @@ export default class GroupSettings extends Vue {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
GroupVisibility = {
PUBLIC: "PUBLIC",
UNLISTED: "UNLISTED",
};
showCopiedTooltip = false;
async updateGroup() { async updateGroup() {
const variables = { ...this.group };
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
// eslint-disable-next-line
// @ts-ignore
delete variables.physicalAddress.__typename;
await this.$apollo.mutate<{ updateGroup: IGroup }>({ await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP, mutation: UPDATE_GROUP,
variables: { variables,
...this.group,
},
}); });
} }
async copyURL() {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
}
get canShowCopyButton(): boolean {
return window.isSecureContext;
}
get currentAddress(): IAddress {
return new Address(this.group.physicalAddress);
}
} }
</script> </script>

View File

@ -12,7 +12,7 @@
/> />
</section> </section>
<section v-if="memberships && memberships.length > 0"> <section v-if="memberships && memberships.length > 0">
<GroupCard v-for="member in memberships" :key="member.id" :member="member" /> <GroupMemberCard v-for="member in memberships" :key="member.id" :member="member" />
</section> </section>
<b-message v-if="$apollo.loading === false && memberships.length === 0" type="is-danger"> <b-message v-if="$apollo.loading === false && memberships.length === 0" type="is-danger">
{{ $t("No groups found") }} {{ $t("No groups found") }}
@ -23,7 +23,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor"; import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import GroupCard from "@/components/Group/GroupCard.vue"; import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import InvitationCard from "@/components/Group/InvitationCard.vue"; import InvitationCard from "@/components/Group/InvitationCard.vue";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { IGroup, IMember, MemberRole } from "@/types/actor"; import { IGroup, IMember, MemberRole } from "@/types/actor";
@ -32,7 +32,7 @@ import { ACCEPT_INVITATION } from "../../graphql/member";
@Component({ @Component({
components: { components: {
GroupCard, GroupMemberCard,
InvitationCard, InvitationCard,
}, },
apollo: { apollo: {

View File

@ -48,7 +48,7 @@ export default class PageNotFound extends Vue {
enter() { enter() {
this.$router.push({ this.$router.push({
name: RouteName.SEARCH, name: RouteName.SEARCH,
params: { searchTerm: this.searchText }, query: { term: this.searchText },
}); });
} }
} }

View File

@ -1,8 +1,62 @@
<template> <template>
<section class="container"> <div class="section container">
<h1>{{ $t('Search results: "{search}"', { search: this.searchTerm }) }}</h1> <h1 class="title">{{ $t("Explore") }}</h1>
<b-loading :active.sync="$apollo.loading" /> <section class="hero is-light">
<b-tabs v-model="activeTab" type="is-boxed" class="searchTabs" @change="changeTab"> <div class="hero-body">
<form @submit.prevent="submit()">
<b-field :label="$t('Key words')" label-for="search" expanded>
<b-input
icon="magnify"
type="search"
id="search"
size="is-large"
expanded
v-model="search"
:placeholder="$t('For instance: London, Taekwondo, Architecture…')"
/>
</b-field>
<b-field grouped group-multiline position="is-right" expanded>
<b-field :label="$t('Location')" label-for="location">
<address-auto-complete
v-model="location"
id="location"
:placeholder="$t('For instance: London')"
/>
</b-field>
<b-field :label="$t('Radius')" label-for="radius">
<b-select v-model="radius" id="radius">
<option
v-for="(radiusOption, index) in radiusOptions"
:key="index"
:value="radiusOption"
>{{ radiusString(radiusOption) }}</option
>
</b-select>
</b-field>
<b-field :label="$t('Date')" label-for="date">
<b-select v-model="when" id="date" :disabled="activeTab !== 0">
<option v-for="(option, index) in options" :key="index" :value="option">{{
option.label
}}</option>
</b-select>
</b-field>
</b-field>
</form>
</div>
</section>
<section class="events-featured" v-if="searchEvents.initial">
<b-loading :active.sync="$apollo.loading"></b-loading>
<h2 class="title">{{ $t("Featured events") }}</h2>
<div v-if="events.length > 0" class="columns is-multiline">
<div class="column is-one-third-desktop" v-for="event in events" :key="event.uuid">
<EventCard :event="event" />
</div>
</div>
<b-message v-else-if="events.length === 0 && $apollo.loading === false" type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
<b-tabs v-else v-model="activeTab" type="is-boxed" class="searchTabs">
<b-tab-item> <b-tab-item>
<template slot="header"> <template slot="header">
<b-icon icon="calendar"></b-icon> <b-icon icon="calendar"></b-icon>
@ -24,40 +78,68 @@
$t("No events found") $t("No events found")
}}</b-message> }}</b-message>
</b-tab-item> </b-tab-item>
<!-- <b-tab-item>--> <b-tab-item v-if="config && config.features.groups">
<!-- <template slot="header">--> <template slot="header">
<!-- <b-icon icon="account-multiple"></b-icon>--> <b-icon icon="account-multiple"></b-icon>
<!-- <span>--> <span>
<!-- {{ $t('Groups') }} <b-tag rounded>{{ searchGroups.total }}</b-tag>--> {{ $t("Groups") }} <b-tag rounded>{{ searchGroups.total }}</b-tag>
<!-- </span>--> </span>
<!-- </template>-->
<!-- <div v-if="searchGroups.total > 0" class="columns is-multiline">-->
<!-- <div class="column is-one-quarter-desktop is-half-mobile"-->
<!-- v-for="group in groups"-->
<!-- :key="group.uuid">-->
<!-- <group-card :group="group" />-->
<!-- </div>-->
<!-- </div>-->
<!-- <b-message v-else-if="$apollo.loading === false" type="is-danger">-->
<!-- {{ $t('No groups found') }}-->
<!-- </b-message>-->
<!-- </b-tab-item>-->
</b-tabs>
</section>
</template> </template>
<div v-if="searchGroups.total > 0" class="columns is-multiline">
<div
class="column is-one-quarter-desktop"
v-for="group in searchGroups.elements"
:key="group.uuid"
>
<group-card :group="group" />
</div>
</div>
<b-message v-else-if="$apollo.loading === false" type="is-danger">
{{ $t("No groups found") }}
</b-message>
</b-tab-item>
</b-tabs>
</div>
</template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { SEARCH_EVENTS, SEARCH_GROUPS } from "../graphql/search";
import RouteName from "../router/name";
import EventCard from "../components/Event/EventCard.vue"; import EventCard from "../components/Event/EventCard.vue";
import GroupCard from "../components/Group/GroupCard.vue"; import { FETCH_EVENTS } from "../graphql/event";
import { Group, IGroup } from "../types/actor"; import { IEvent } from "../types/event.model";
import RouteName from "../router/name";
import { IAddress, Address } from "../types/address.model";
import { SearchEvent, SearchGroup } from "../types/search.model"; import { SearchEvent, SearchGroup } from "../types/search.model";
import AddressAutoComplete from "../components/Event/AddressAutoComplete.vue";
import ngeohash from "ngeohash";
import { SEARCH_EVENTS, SEARCH_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate";
import {
endOfToday,
addDays,
startOfDay,
endOfDay,
endOfWeek,
addWeeks,
startOfWeek,
endOfMonth,
addMonths,
startOfMonth,
eachWeekendOfInterval,
} from "date-fns";
import { IGroup } from "../types/actor";
import GroupCard from "../components/Group/GroupCard.vue";
import { CONFIG } from "../graphql/config";
interface ISearchTimeOption {
label: string;
start?: Date;
end?: Date | null;
}
enum SearchTabs { enum SearchTabs {
EVENTS = 0, EVENTS = 0,
GROUPS = 1, GROUPS = 1,
PERSONS = 2, // not used right now
} }
const tabsName: { events: number; groups: number } = { const tabsName: { events: number; groups: number } = {
@ -66,109 +148,203 @@ const tabsName: { events: number; groups: number } = {
}; };
@Component({ @Component({
components: {
EventCard,
AddressAutoComplete,
GroupCard,
},
apollo: { apollo: {
config: CONFIG,
events: FETCH_EVENTS,
searchEvents: { searchEvents: {
query: SEARCH_EVENTS, query: SEARCH_EVENTS,
variables() { variables() {
return { return {
searchText: this.searchTerm, term: this.search,
tags: this.actualTag,
location: this.geohash,
beginsOn: this.start,
endsOn: this.end,
radius: this.radius,
}; };
}, },
debounce: 300,
skip() { skip() {
return !this.searchTerm; return !this.search && !this.actualTag && !this.geohash && this.end === null;
}, },
}, },
searchGroups: { searchGroups: {
query: SEARCH_GROUPS, query: SEARCH_GROUPS,
variables() { variables() {
return { return {
searchText: this.searchTerm, term: this.search,
location: this.geohash,
radius: this.radius,
}; };
}, },
skip() { skip() {
return !this.searchTerm || this.isURL(this.searchTerm); return !this.search && !this.geohash;
}, },
}, },
}, },
components: { metaInfo() {
GroupCard, return {
EventCard, // if no subcomponents specify a metaInfo.title, this title will be used
title: this.$t("Explore events") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
}, },
}) })
export default class Search extends Vue { export default class Search extends Vue {
@Prop({ type: String, required: true }) searchTerm!: string; events: IEvent[] = [];
@Prop({ type: String, required: false, default: "events" }) searchType!: "events" | "groups"; searchEvents: Paginate<IEvent> & { initial: boolean } = { total: 0, elements: [], initial: true };
searchGroups: Paginate<IGroup> = { total: 0, elements: [] };
searchEvents: SearchEvent = { total: 0, elements: [] }; search: string = (this.$route.query.term as string) || "";
searchGroups: SearchGroup = { total: 0, elements: [] }; activeTab: SearchTabs = tabsName[this.$route.query.searchType as "events" | "groups"] || 0;
activeTab: SearchTabs = tabsName[this.searchType]; location: IAddress = new Address();
@Watch("searchEvents") options: ISearchTimeOption[] = [
async redirectURLToEvent() { {
if (this.searchEvents.total === 1 && this.isURL(this.searchTerm)) { label: this.$t("Today") as string,
return await this.$router.replace({ start: new Date(),
name: RouteName.EVENT, end: endOfToday(),
params: { uuid: this.searchEvents.elements[0].uuid }, },
}); {
} label: this.$t("Tomorrow") as string,
start: startOfDay(addDays(new Date(), 1)),
end: endOfDay(addDays(new Date(), 1)),
},
{
label: this.$t("This weekend") as string,
start: this.weekend.start,
end: this.weekend.end,
},
{
label: this.$t("This week") as string,
start: new Date(),
end: endOfWeek(new Date(), { locale: this.$dateFnsLocale }),
},
{
label: this.$t("Next week") as string,
start: startOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
end: endOfWeek(addWeeks(new Date(), 1), { locale: this.$dateFnsLocale }),
},
{
label: this.$t("This month") as string,
start: new Date(),
end: endOfMonth(new Date()),
},
{
label: this.$t("Next month") as string,
start: startOfMonth(addMonths(new Date(), 1)),
end: endOfMonth(addMonths(new Date(), 1)),
},
{
label: this.$t("Any day") as string,
start: undefined,
end: undefined,
},
];
when: ISearchTimeOption = {
label: this.$t("Any day") as string,
start: undefined,
end: null,
};
radiusString = (radius: number | null) => {
if (radius) {
return this.$tc("{nb} km", radius, { nb: radius });
} }
return this.$t("any distance");
};
changeTab(index: number) { radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
switch (index) {
case SearchTabs.EVENTS: radius: number = 50;
this.$router.push({
name: RouteName.SEARCH, submit() {
params: { searchTerm: this.searchTerm, searchType: "events" }, this.$apollo.queries.searchEvents.refetch();
});
break;
case SearchTabs.GROUPS:
this.$router.push({
name: RouteName.SEARCH,
params: { searchTerm: this.searchTerm, searchType: "groups" },
});
break;
}
} }
@Watch("search") @Watch("search")
changeTabForResult() { updateSearchTerm() {
if (this.searchEvents.total === 0 && this.searchGroups.total > 0) { this.$router.push({
this.activeTab = SearchTabs.GROUPS; name: RouteName.SEARCH,
} query: Object.assign({}, this.$route.query, { term: this.search }),
if (this.searchGroups.total === 0 && this.searchEvents.total > 0) { });
this.activeTab = SearchTabs.EVENTS;
}
} }
@Watch("search") @Watch("activeTab")
@Watch("$route") updateActiveTab() {
async loadSearch() { const searchType = this.activeTab === tabsName.events ? "events" : "groups";
(await this.$apollo.queries.searchEvents.refetch()) && this.$router.push({
this.$apollo.queries.searchGroups.refetch(); name: RouteName.SEARCH,
query: Object.assign({}, this.$route.query, { searchType }),
});
} }
get groups(): IGroup[] { get weekend(): { start: Date; end: Date } {
return this.searchGroups.elements.map((group) => Object.assign(new Group(), group)); const now = new Date();
const endOfWeekDate = endOfWeek(now, { locale: this.$dateFnsLocale });
const startOfWeekDate = startOfWeek(now, { locale: this.$dateFnsLocale });
const [start, end] = eachWeekendOfInterval({ start: startOfWeekDate, end: endOfWeekDate });
return { start: startOfDay(start), end: endOfDay(end) };
} }
isURL(url: string): boolean { get geohash() {
const a = document.createElement("a"); if (this.location && this.location.geom) {
a.href = url; const [lon, lat] = this.location.geom.split(";");
return (a.host && a.host !== window.location.host) as boolean; return ngeohash.encode(lat, lon, 6);
}
return undefined;
}
get start(): Date | undefined {
return this.when.start;
}
get end(): Date | undefined | null {
return this.when.end;
} }
} }
</script> </script>
<style lang="scss">
@import "~bulma/sass/utilities/_all";
@import "~bulma/sass/components/tabs";
@import "~buefy/src/scss/components/tabs";
@import "~bulma/sass/elements/tag";
.searchTabs .tab-content { <style scoped lang="scss">
background: #fff; @import "@/variables.scss";
min-height: 10em;
main > .container {
background: $white;
.hero-body {
padding: 1rem 1.5rem;
}
}
h1.title {
margin-top: 1.5rem;
}
h3.title {
margin-bottom: 1.5rem;
}
.events-featured {
margin: 25px auto;
.columns {
margin: 1rem auto 3rem;
}
}
form {
/deep/ .field label.label {
margin-bottom: 0;
}
} }
</style> </style>

View File

@ -4406,6 +4406,11 @@ date-fns@^1.27.2:
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==
date-fns@^2.15.0:
version "2.15.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.15.0.tgz#424de6b3778e4e69d3ff27046ec136af58ae5d5f"
integrity sha512-ZCPzAMJZn3rNUvvQIMlXhDr4A+Ar07eLeGsGREoWU19a3Pqf5oYa+ccd+B3F6XVtQY6HANMFdOQ8A+ipFnvJdQ==
de-indent@^1.0.2: de-indent@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"

View File

@ -239,8 +239,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming( def handle_incoming(
%{ %{
"type" => activity_type, "type" => activity_type,
"object" => %{"type" => object_type, "id" => object_url} = object, "object" => %{"type" => object_type, "id" => object_url} = object
"to" => to
} = data } = data
) )
when activity_type in ["Create", "Add"] and when activity_type in ["Create", "Add"] and

View File

@ -15,20 +15,17 @@ defmodule Mobilizon.GraphQL.API.Search do
@doc """ @doc """
Searches actors. Searches actors.
""" """
@spec search_actors(String.t(), integer | nil, integer | nil, ActorType.t()) :: @spec search_actors(map(), integer | nil, integer | nil, ActorType.t()) ::
{:ok, Page.t()} | {:error, String.t()} {:ok, Page.t()} | {:error, String.t()}
def search_actors(search, page \\ 1, limit \\ 10, result_type) do def search_actors(%{term: term} = args, page \\ 1, limit \\ 10, result_type) do
search = String.trim(search) term = String.trim(term)
cond do cond do
search == "" ->
{:error, "Search can't be empty"}
# Some URLs could be domain.tld/@username, so keep this condition above # Some URLs could be domain.tld/@username, so keep this condition above
# the `is_handle` function # the `is_handle` function
is_url(search) -> is_url(term) ->
# skip, if it's not an actor # skip, if it's not an actor
case process_from_url(search) do case process_from_url(term) do
%Page{total: _total, elements: _elements} = page -> %Page{total: _total, elements: _elements} = page ->
{:ok, page} {:ok, page}
@ -36,11 +33,17 @@ defmodule Mobilizon.GraphQL.API.Search do
{:ok, %{total: 0, elements: []}} {:ok, %{total: 0, elements: []}}
end end
is_handle(search) -> is_handle(term) ->
{:ok, process_from_username(search)} {:ok, process_from_username(term)}
true -> true ->
page = Actors.build_actors_by_username_or_name_page(search, [result_type], page, limit) page =
Actors.build_actors_by_username_or_name_page(
Map.put(args, :term, term),
[result_type],
page,
limit
)
{:ok, page} {:ok, page}
end end
@ -51,25 +54,20 @@ defmodule Mobilizon.GraphQL.API.Search do
""" """
@spec search_events(String.t(), integer | nil, integer | nil) :: @spec search_events(String.t(), integer | nil, integer | nil) ::
{:ok, Page.t()} | {:error, String.t()} {:ok, Page.t()} | {:error, String.t()}
def search_events(search, page \\ 1, limit \\ 10) do def search_events(%{term: term} = args, page \\ 1, limit \\ 10) do
search = String.trim(search) term = String.trim(term)
cond do if is_url(term) do
search == "" ->
{:error, "Search can't be empty"}
is_url(search) ->
# skip, if it's w not an actor # skip, if it's w not an actor
case process_from_url(search) do case process_from_url(term) do
%Page{total: _total, elements: _elements} = page -> %Page{total: _total, elements: _elements} = page ->
{:ok, page} {:ok, page}
_ -> _ ->
{:ok, %{total: 0, elements: []}} {:ok, %{total: 0, elements: []}}
end end
else
true -> {:ok, Events.build_events_for_search(Map.put(args, :term, term), page, limit)}
{:ok, Events.build_events_for_search(search, page, limit)}
end end
end end

View File

@ -8,21 +8,21 @@ defmodule Mobilizon.GraphQL.Resolvers.Search do
@doc """ @doc """
Search persons Search persons
""" """
def search_persons(_parent, %{search: search, page: page, limit: limit}, _resolution) do def search_persons(_parent, %{page: page, limit: limit} = args, _resolution) do
Search.search_actors(search, page, limit, :Person) Search.search_actors(args, page, limit, :Person)
end end
@doc """ @doc """
Search groups Search groups
""" """
def search_groups(_parent, %{search: search, page: page, limit: limit}, _resolution) do def search_groups(_parent, %{page: page, limit: limit} = args, _resolution) do
Search.search_actors(search, page, limit, :Group) Search.search_actors(args, page, limit, :Group)
end end
@doc """ @doc """
Search events Search events
""" """
def search_events(_parent, %{search: search, page: page, limit: limit}, _resolution) do def search_events(_parent, %{page: page, limit: limit} = args, _resolution) do
Search.search_events(search, page, limit) Search.search_events(args, page, limit)
end end
end end

View File

@ -5,6 +5,9 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Addresses
alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos} alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
@ -29,11 +32,20 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description: "Whether the actors manually approves followers" description: "Whether the actors manually approves followers"
) )
field(:visibility, :group_visibility,
description: "Whether the group can be found and/or promoted"
)
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture") field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture") field(:banner, :picture, description: "The actor's banner picture")
field(:physical_address, :address,
resolve: dataloader(Addresses),
description: "The type of the event's address"
)
# These one should have a privacy setting # These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings") field(:following, list_of(:follower), description: "List of followings")
field(:followers, list_of(:follower), description: "List of followers") field(:followers, list_of(:follower), description: "List of followers")
@ -155,6 +167,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
"The banner for the group, either as an object or directly the ID of an existing Picture" "The banner for the group, either as an object or directly the ID of an existing Picture"
) )
arg(:physical_address, :address_input)
resolve(&Group.create_group/3) resolve(&Group.create_group/3)
end end
@ -165,6 +179,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
arg(:name, :string, description: "The displayed name for the group") arg(:name, :string, description: "The displayed name for the group")
arg(:summary, :string, description: "The summary for the group", default_value: "") arg(:summary, :string, description: "The summary for the group", default_value: "")
arg(:visibility, :group_visibility, description: "The visibility for the group")
arg(:avatar, :picture_input, arg(:avatar, :picture_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing Picture" "The avatar for the group, either as an object or directly the ID of an existing Picture"
@ -175,6 +191,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
"The banner for the group, either as an object or directly the ID of an existing Picture" "The banner for the group, either as an object or directly the ID of an existing Picture"
) )
arg(:physical_address, :address_input)
resolve(&Group.update_group/3) resolve(&Group.update_group/3)
end end

View File

@ -27,7 +27,7 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
object :search_queries do object :search_queries do
@desc "Search persons" @desc "Search persons"
field :search_persons, :persons do field :search_persons, :persons do
arg(:search, non_null(:string)) arg(:term, :string, default_value: "")
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
@ -36,7 +36,9 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
@desc "Search groups" @desc "Search groups"
field :search_groups, :groups do field :search_groups, :groups do
arg(:search, non_null(:string)) arg(:term, :string, default_value: "")
arg(:location, :string, description: "A geohash for coordinates")
arg(:radius, :float, default_value: 50)
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
@ -45,9 +47,14 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
@desc "Search events" @desc "Search events"
field :search_events, :events do field :search_events, :events do
arg(:search, non_null(:string)) arg(:term, :string, default_value: "")
arg(:tags, :string, description: "A comma-separated string listing the tags")
arg(:location, :string, description: "A geohash for coordinates")
arg(:radius, :float, default_value: 50)
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
arg(:begins_on, :datetime)
arg(:ends_on, :datetime)
resolve(&Search.search_events/3) resolve(&Search.search_events/3)
end end

View File

@ -7,8 +7,9 @@ defmodule Mobilizon.Actors.Actor do
import Ecto.Changeset import Ecto.Changeset
alias Mobilizon.{Actors, Config, Crypto, Mention, Share} alias Mobilizon.{Actors, Addresses, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member} alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File alias Mobilizon.Media.File
@ -55,7 +56,8 @@ defmodule Mobilizon.Actors.Actor do
shares: [Share.t()], shares: [Share.t()],
owner_shares: [Share.t()], owner_shares: [Share.t()],
memberships: [t], memberships: [t],
last_refreshed_at: DateTime.t() last_refreshed_at: DateTime.t(),
physical_address: Address.t()
} }
@required_attrs [:preferred_username, :keys, :suspended, :url] @required_attrs [:preferred_username, :keys, :suspended, :url]
@ -76,12 +78,13 @@ defmodule Mobilizon.Actors.Actor do
:manually_approves_followers, :manually_approves_followers,
:last_refreshed_at, :last_refreshed_at,
:user_id, :user_id,
:physical_address_id,
:visibility :visibility
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@update_required_attrs @required_attrs -- [:url] @update_required_attrs @required_attrs -- [:url]
@update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id] @update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id, :visibility]
@update_attrs @update_required_attrs ++ @update_optional_attrs @update_attrs @update_required_attrs ++ @update_optional_attrs
@registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type] @registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type]
@ -156,6 +159,7 @@ defmodule Mobilizon.Actors.Actor do
embeds_one(:avatar, File, on_replace: :update) embeds_one(:avatar, File, on_replace: :update)
embeds_one(:banner, File, on_replace: :update) embeds_one(:banner, File, on_replace: :update)
belongs_to(:user, User) belongs_to(:user, User)
belongs_to(:physical_address, Address, on_replace: :nilify)
has_many(:followers, Follower, foreign_key: :target_actor_id) has_many(:followers, Follower, foreign_key: :target_actor_id)
has_many(:followings, Follower, foreign_key: :actor_id) has_many(:followings, Follower, foreign_key: :actor_id)
has_many(:organized_events, Event, foreign_key: :organizer_actor_id) has_many(:organized_events, Event, foreign_key: :organizer_actor_id)
@ -228,7 +232,7 @@ defmodule Mobilizon.Actors.Actor do
actor actor
|> cast(attrs, @attrs) |> cast(attrs, @attrs)
|> build_urls() |> build_urls()
|> common_changeset() |> common_changeset(attrs)
|> unique_username_validator() |> unique_username_validator()
|> validate_required(@required_attrs) |> validate_required(@required_attrs)
end end
@ -238,7 +242,7 @@ defmodule Mobilizon.Actors.Actor do
def update_changeset(%__MODULE__{} = actor, attrs) do def update_changeset(%__MODULE__{} = actor, attrs) do
actor actor
|> cast(attrs, @update_attrs) |> cast(attrs, @update_attrs)
|> common_changeset() |> common_changeset(attrs)
|> validate_required(@update_required_attrs) |> validate_required(@update_required_attrs)
end end
@ -263,7 +267,7 @@ defmodule Mobilizon.Actors.Actor do
actor actor
|> cast(attrs, @registration_attrs) |> cast(attrs, @registration_attrs)
|> build_urls() |> build_urls()
|> common_changeset() |> common_changeset(attrs)
|> unique_username_validator() |> unique_username_validator()
|> validate_required(@registration_required_attrs) |> validate_required(@registration_required_attrs)
end end
@ -277,7 +281,7 @@ defmodule Mobilizon.Actors.Actor do
%__MODULE__{} %__MODULE__{}
|> cast(attrs, @remote_actor_creation_attrs) |> cast(attrs, @remote_actor_creation_attrs)
|> validate_required(@remote_actor_creation_required_attrs) |> validate_required(@remote_actor_creation_required_attrs)
|> common_changeset() |> common_changeset(attrs)
|> unique_username_validator() |> unique_username_validator()
|> validate_length(:summary, max: 5000) |> validate_length(:summary, max: 5000)
|> validate_length(:preferred_username, max: 100) |> validate_length(:preferred_username, max: 100)
@ -287,11 +291,12 @@ defmodule Mobilizon.Actors.Actor do
changeset changeset
end end
@spec common_changeset(Ecto.Changeset.t()) :: Ecto.Changeset.t() @spec common_changeset(Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
defp common_changeset(%Ecto.Changeset{} = changeset) do defp common_changeset(%Ecto.Changeset{} = changeset, attrs) do
changeset changeset
|> cast_embed(:avatar) |> cast_embed(:avatar)
|> cast_embed(:banner) |> cast_embed(:banner)
|> put_address(attrs)
|> unique_constraint(:url, name: :actors_url_index) |> unique_constraint(:url, name: :actors_url_index)
|> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index)
|> validate_format(:preferred_username, ~r/[a-z0-9_]+/) |> validate_format(:preferred_username, ~r/[a-z0-9_]+/)
@ -306,7 +311,7 @@ defmodule Mobilizon.Actors.Actor do
actor actor
|> cast(params, @group_creation_attrs) |> cast(params, @group_creation_attrs)
|> build_urls(:Group) |> build_urls(:Group)
|> common_changeset() |> common_changeset(params)
|> put_change(:domain, nil) |> put_change(:domain, nil)
|> put_change(:keys, Crypto.generate_rsa_2048_private_key()) |> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|> put_change(:type, :Group) |> put_change(:type, :Group)
@ -412,4 +417,36 @@ defmodule Mobilizon.Actors.Actor do
|> Ecto.Changeset.cast(data, @attrs) |> Ecto.Changeset.cast(data, @attrs)
|> build_urls() |> build_urls()
end end
# In case the provided addresses is an existing one
@spec put_address(Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
defp put_address(%Ecto.Changeset{} = changeset, %{
physical_address: %{id: id} = _physical_address
})
when not is_nil(id) do
case Addresses.get_address(id) do
%Address{} = address ->
put_assoc(changeset, :physical_address, address)
_ ->
cast_assoc(changeset, :physical_address)
end
end
# In case it's a new address but the origin_id is an existing one
defp put_address(%Ecto.Changeset{} = changeset, %{physical_address: %{origin_id: origin_id}})
when not is_nil(origin_id) do
case Addresses.get_address_by_origin_id(origin_id) do
%Address{} = address ->
put_assoc(changeset, :physical_address, address)
_ ->
cast_assoc(changeset, :physical_address)
end
end
# In case it's a new address without any origin_id (manual)
defp put_address(%Ecto.Changeset{} = changeset, _attrs) do
cast_assoc(changeset, :physical_address)
end
end end

View File

@ -5,10 +5,13 @@ defmodule Mobilizon.Actors do
import Ecto.Query import Ecto.Query
import EctoEnum import EctoEnum
import Geo.PostGIS, only: [st_dwithin_in_meters: 3]
import Mobilizon.Service.Guards
alias Ecto.Multi alias Ecto.Multi
alias Mobilizon.Actors.{Actor, Bot, Follower, Member} alias Mobilizon.Actors.{Actor, Bot, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.{Crypto, Events} alias Mobilizon.{Crypto, Events}
alias Mobilizon.Media.File alias Mobilizon.Media.File
alias Mobilizon.Service.Workers alias Mobilizon.Service.Workers
@ -235,6 +238,7 @@ defmodule Mobilizon.Actors do
@spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} @spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def update_actor(%Actor{} = actor, attrs) do def update_actor(%Actor{} = actor, attrs) do
actor actor
|> Repo.preload([:physical_address])
|> Actor.update_changeset(attrs) |> Actor.update_changeset(attrs)
|> delete_files_if_media_changed() |> delete_files_if_media_changed()
|> Repo.update() |> Repo.update()
@ -422,14 +426,20 @@ defmodule Mobilizon.Actors do
Builds a page struct for actors by their name or displayed name. Builds a page struct for actors by their name or displayed name.
""" """
@spec build_actors_by_username_or_name_page( @spec build_actors_by_username_or_name_page(
String.t(), map(),
[ActorType.t()], [ActorType.t()],
integer | nil, integer | nil,
integer | nil integer | nil
) :: Page.t() ) :: Page.t()
def build_actors_by_username_or_name_page(username, types, page \\ nil, limit \\ nil) do def build_actors_by_username_or_name_page(
username %{term: term} = args,
|> actor_by_username_or_name_query() types,
page \\ nil,
limit \\ nil
) do
Actor
|> actor_by_username_or_name_query(term)
|> actors_for_location(args)
|> filter_by_types(types) |> filter_by_types(types)
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -1129,19 +1139,23 @@ defmodule Mobilizon.Actors do
) )
end end
@spec actor_by_username_or_name_query(String.t()) :: Ecto.Query.t() @spec actor_by_username_or_name_query(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
defp actor_by_username_or_name_query(username) do defp actor_by_username_or_name_query(query, ""), do: query
from(
a in Actor, defp actor_by_username_or_name_query(query, username) do
where: query
|> where(
[a],
fragment( fragment(
"f_unaccent(?) %> f_unaccent(?) or f_unaccent(coalesce(?, '')) %> f_unaccent(?)", "f_unaccent(?) %> f_unaccent(?) or f_unaccent(coalesce(?, '')) %> f_unaccent(?)",
a.preferred_username, a.preferred_username,
^username, ^username,
a.name, a.name,
^username ^username
), )
order_by: )
|> order_by(
[a],
fragment( fragment(
"word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc", "word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc",
a.preferred_username, a.preferred_username,
@ -1152,6 +1166,27 @@ defmodule Mobilizon.Actors do
) )
end end
@spec actors_for_location(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp actors_for_location(query, %{radius: radius}) when is_nil(radius),
do: query
defp actors_for_location(query, %{location: location, radius: radius})
when is_valid_string?(location) and not is_nil(radius) do
with {lon, lat} <- Geohax.decode(location),
point <- Geo.WKT.decode!("SRID=4326;POINT(#{lon} #{lat})") do
query
|> join(:inner, [q], a in Address, on: a.id == q.physical_address_id, as: :address)
|> where(
[q],
st_dwithin_in_meters(^point, as(:address).geom, ^(radius * 1000))
)
else
_ -> query
end
end
defp actors_for_location(query, _args), do: query
@spec person_query :: Ecto.Query.t() @spec person_query :: Ecto.Query.t()
defp person_query do defp person_query do
from(a in Actor, where: a.type == ^:Person) from(a in Actor, where: a.type == ^:Person)

View File

@ -29,6 +29,9 @@ defmodule Mobilizon.Addresses do
@spec get_address_by_url(String.t()) :: Address.t() | nil @spec get_address_by_url(String.t()) :: Address.t() | nil
def get_address_by_url(url), do: Repo.get_by(Address, url: url) def get_address_by_url(url), do: Repo.get_by(Address, url: url)
@spec get_address_by_origin_id(String.t()) :: Address.t() | nil
def get_address_by_origin_id(origin_id), do: Repo.get_by(Address, origin_id: origin_id)
@doc """ @doc """
Creates an address. Creates an address.
""" """

View File

@ -8,6 +8,7 @@ defmodule Mobilizon.Events do
import Ecto.Query import Ecto.Query
import EctoEnum import EctoEnum
import Mobilizon.Service.Guards
import Mobilizon.Storage.Ecto import Mobilizon.Storage.Ecto
alias Ecto.{Changeset, Multi} alias Ecto.{Changeset, Multi}
@ -457,15 +458,17 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Builds a page struct for events by their name. Builds a page struct for events by their name.
""" """
@spec build_events_for_search(String.t(), integer | nil, integer | nil) :: Page.t() @spec build_events_for_search(map(), integer | nil, integer | nil) :: Page.t()
def build_events_for_search(name, page \\ nil, limit \\ nil) def build_events_for_search(%{term: term} = args, page \\ nil, limit \\ nil) do
def build_events_for_search("", _page, _limit), do: %Page{total: 0, elements: []} term
def build_events_for_search(name, page, limit) do
name
|> normalize_search_string() |> normalize_search_string()
|> events_for_search_query() |> events_for_search_query()
|> events_for_begins_on(args)
|> events_for_ends_on(args)
|> events_for_tags(args)
|> events_for_location(args)
|> filter_local_or_from_followed_instances_events() |> filter_local_or_from_followed_instances_events()
|> order_by([q], asc: q.id)
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -1279,10 +1282,13 @@ defmodule Mobilizon.Events do
defp events_for_search_query(search_string) do defp events_for_search_query(search_string) do
Event Event
|> where([e], e.visibility == ^:public) |> where([e], e.visibility == ^:public)
|> distinct([e], e.id)
|> do_event_for_search_query(search_string) |> do_event_for_search_query(search_string)
end end
@spec do_event_for_search_query(Ecto.Query.t(), String.t()) :: Ecto.Query.t() @spec do_event_for_search_query(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
defp do_event_for_search_query(query, ""), do: query
defp do_event_for_search_query(query, search_string) do defp do_event_for_search_query(query, search_string) do
from(event in query, from(event in query,
join: id_and_rank in matching_event_ids_and_ranks(search_string), join: id_and_rank in matching_event_ids_and_ranks(search_string),
@ -1291,6 +1297,60 @@ defmodule Mobilizon.Events do
) )
end end
@spec events_for_begins_on(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_begins_on(query, args) do
begins_on = Map.get(args, :begins_on, DateTime.utc_now())
query
|> where([q], q.begins_on >= ^begins_on)
end
@spec events_for_ends_on(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_ends_on(query, args) do
ends_on = Map.get(args, :ends_on)
if is_nil(ends_on),
do: query,
else:
where(
query,
[q],
(is_nil(q.ends_on) and q.begins_on <= ^ends_on) or
q.ends_on <= ^ends_on
)
end
@spec events_for_tags(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_tags(query, %{tags: tags}) when is_valid_string?(tags) do
query
|> join(:inner, [q], te in "events_tags", on: q.id == te.event_id)
|> join(:inner, [q, ..., te], t in Tag, on: te.tag_id == t.id)
|> where([q, ..., t], t.title in ^String.split(tags, ",", trim: true))
end
defp events_for_tags(query, _args), do: query
@spec events_for_location(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp events_for_location(query, %{radius: radius}) when is_nil(radius),
do: query
defp events_for_location(query, %{location: location, radius: radius})
when is_valid_string?(location) and not is_nil(radius) do
with {lon, lat} <- Geohax.decode(location),
point <- Geo.WKT.decode!("SRID=4326;POINT(#{lon} #{lat})") do
query
|> join(:inner, [q], a in Address, on: a.id == q.physical_address_id, as: :address)
|> where(
[q],
st_dwithin_in_meters(^point, as(:address).geom, ^(radius * 1000))
)
else
_ -> query
end
end
defp events_for_location(query, _args), do: query
@spec normalize_search_string(String.t()) :: String.t() @spec normalize_search_string(String.t()) :: String.t()
defp normalize_search_string(search_string) do defp normalize_search_string(search_string) do
search_string search_string
@ -1523,6 +1583,7 @@ defmodule Mobilizon.Events do
defp filter_future_events(query, false), do: query defp filter_future_events(query, false), do: query
@spec filter_local_or_from_followed_instances_events(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_local_or_from_followed_instances_events(query) do defp filter_local_or_from_followed_instances_events(query) do
from(q in query, from(q in query,
left_join: s in Share, left_join: s in Share,

9
lib/service/guards.ex Normal file
View File

@ -0,0 +1,9 @@
defmodule Mobilizon.Service.Guards do
@moduledoc """
Various guards
"""
defguard is_valid_string?(value) when is_binary(value) and value != ""
defguard is_valid_list?(value) when is_list(value) and length(value) > 0
end

View File

@ -20,7 +20,7 @@ defmodule Mobilizon.Service.HTTP.ActivityPub do
[{"User-Agent", @user_agent}, {"Accept", "application/activity+json"}] ++ headers}, [{"User-Agent", @user_agent}, {"Accept", "application/activity+json"}] ++ headers},
Tesla.Middleware.FollowRedirects, Tesla.Middleware.FollowRedirects,
{Tesla.Middleware.Timeout, timeout: 10_000}, {Tesla.Middleware.Timeout, timeout: 10_000},
{Tesla.Middleware.JSON, decode_content_types: "application/activity+json"} {Tesla.Middleware.JSON, decode_content_types: ["application/activity+json"]}
] ]
adapter = {@adapter, opts} adapter = {@adapter, opts}

View File

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddAddressToActors do
use Ecto.Migration
def change do
alter table(:actors) do
add(:physical_address_id, references(:addresses, on_delete: :nothing))
end
end
end

View File

@ -17,7 +17,7 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
with_mock ActivityPub, with_mock ActivityPub,
find_or_make_actor_from_nickname: fn "toto@domain.tld" -> {:ok, %Actor{id: 42}} end do find_or_make_actor_from_nickname: fn "toto@domain.tld" -> {:ok, %Actor{id: 42}} end do
assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} == assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} ==
Search.search_actors("toto@domain.tld", 1, 10, :Person) Search.search_actors(%{term: "toto@domain.tld"}, 1, 10, :Person)
assert_called(ActivityPub.find_or_make_actor_from_nickname("toto@domain.tld")) assert_called(ActivityPub.find_or_make_actor_from_nickname("toto@domain.tld"))
end end
@ -27,7 +27,7 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
with_mock ActivityPub, with_mock ActivityPub,
fetch_object_from_url: fn "https://social.tcit.fr/users/tcit" -> {:ok, %Actor{id: 42}} end do fetch_object_from_url: fn "https://social.tcit.fr/users/tcit" -> {:ok, %Actor{id: 42}} end do
assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} == assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} ==
Search.search_actors("https://social.tcit.fr/users/tcit", 1, 10, :Person) Search.search_actors(%{term: "https://social.tcit.fr/users/tcit"}, 1, 10, :Person)
assert_called(ActivityPub.fetch_object_from_url("https://social.tcit.fr/users/tcit")) assert_called(ActivityPub.fetch_object_from_url("https://social.tcit.fr/users/tcit"))
end end
@ -35,25 +35,27 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
test "search actors" do test "search actors" do
with_mock Actors, with_mock Actors,
build_actors_by_username_or_name_page: fn "toto", _type, 1, 10 -> build_actors_by_username_or_name_page: fn %{term: "toto"}, _type, 1, 10 ->
%Page{total: 1, elements: [%Actor{id: 42}]} %Page{total: 1, elements: [%Actor{id: 42}]}
end do end do
assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} = assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} =
Search.search_actors("toto", 1, 10, :Person) Search.search_actors(%{term: "toto"}, 1, 10, :Person)
assert_called(Actors.build_actors_by_username_or_name_page("toto", [:Person], 1, 10)) assert_called(
Actors.build_actors_by_username_or_name_page(%{term: "toto"}, [:Person], 1, 10)
)
end end
end end
test "search events" do test "search events" do
with_mock Events, with_mock Events,
build_events_for_search: fn "toto", 1, 10 -> build_events_for_search: fn %{term: "toto"}, 1, 10 ->
%Page{total: 1, elements: [%Event{title: "super toto event"}]} %Page{total: 1, elements: [%Event{title: "super toto event"}]}
end do end do
assert {:ok, %{total: 1, elements: [%Event{title: "super toto event"}]}} = assert {:ok, %{total: 1, elements: [%Event{title: "super toto event"}]}} =
Search.search_events("toto", 1, 10) Search.search_events(%{term: "toto"}, 1, 10)
assert_called(Events.build_events_for_search("toto", 1, 10)) assert_called(Events.build_events_for_search(%{term: "toto"}, 1, 10))
end end
end end
end end

View File

@ -13,107 +13,45 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
{:ok, conn: conn, user: user} {:ok, conn: conn, user: user}
end end
test "search_events/3 finds events with basic search", %{ describe "search events/3" do
conn: conn, @search_events_query """
user: user query SearchEvents($location: String, $radius: Float, $tags: String, $term: String, $beginsOn: DateTime, $endsOn: DateTime) {
} do searchEvents(location: $location, radius: $radius, tags: $tags, term: $term, beginsOn: $beginsOn, endsOn: $endsOn) {
insert(:actor, user: user, preferred_username: "test_person")
insert(:actor, type: :Group, preferred_username: "test_group")
event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event)
query = """
{
search_events(search: "test") {
total, total,
elements { elements {
id
title, title,
uuid, uuid,
__typename __typename
} }
}, }
} }
""" """
res = test "finds events with basic search", %{
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "search"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["search_events"]["total"] == 1
assert json_response(res, 200)["data"]["search_events"]["elements"] |> length == 1
assert hd(json_response(res, 200)["data"]["search_events"]["elements"])["uuid"] ==
to_string(event.uuid)
end
test "search_persons/3 finds persons with basic search", %{
conn: conn,
user: user
} do
actor = insert(:actor, user: user, preferred_username: "test_person")
insert(:actor, type: :Group, preferred_username: "test_group")
event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event)
query = """
{
search_persons(search: "test") {
total,
elements {
preferredUsername,
__typename
}
},
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "search"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["search_persons"]["total"] == 1
assert json_response(res, 200)["data"]["search_persons"]["elements"] |> length == 1
assert hd(json_response(res, 200)["data"]["search_persons"]["elements"])["preferredUsername"] ==
actor.preferred_username
end
test "search_groups/3 finds persons with basic search", %{
conn: conn, conn: conn,
user: user user: user
} do } do
insert(:actor, user: user, preferred_username: "test_person") insert(:actor, user: user, preferred_username: "test_person")
group = insert(:actor, type: :Group, preferred_username: "test_group") insert(:actor, type: :Group, preferred_username: "test_group")
event = insert(:event, title: "test_event") event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event) Workers.BuildSearch.insert_search_event(event)
query = """
{
search_groups(search: "test") {
total,
elements {
preferredUsername,
__typename
}
},
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_events_query,
variables: %{term: "test"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_groups"]["total"] == 1 assert res["data"]["searchEvents"]["total"] == 1
assert json_response(res, 200)["data"]["search_groups"]["elements"] |> length == 1 assert res["data"]["searchEvents"]["elements"] |> length == 1
assert hd(json_response(res, 200)["data"]["search_groups"]["elements"])["preferredUsername"] == assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
group.preferred_username to_string(event.uuid)
end end
test "search_events/3 finds events and actors with word search", %{ test "finds events and actors with word search", %{
conn: conn, conn: conn,
user: user user: user
} do } do
@ -125,37 +63,193 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
Workers.BuildSearch.insert_search_event(event2) Workers.BuildSearch.insert_search_event(event2)
Workers.BuildSearch.insert_search_event(event3) Workers.BuildSearch.insert_search_event(event3)
query = """
{
search_events(search: "pineapple") {
total,
elements {
title,
uuid,
__typename
}
}
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_events_query,
variables: %{term: "pineapple"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_events"]["total"] == 2 assert res["data"]["searchEvents"]["total"] == 2
assert json_response(res, 200)["data"]["search_events"]["elements"] assert res["data"]["searchEvents"]["elements"]
|> length == 2 |> length == 2
assert json_response(res, 200)["data"]["search_events"]["elements"] assert res["data"]["searchEvents"]["elements"]
|> Enum.map(& &1["title"]) == [ |> Enum.map(& &1["title"]) == [
"Pineapple fashion week", "Pineapple fashion week",
"I love pineAPPLE" "I love pineAPPLE"
] ]
end end
test "search_persons/3 finds persons with word search", %{ test "finds events with accented search", %{
conn: conn,
user: user
} do
insert(:actor, user: user, preferred_username: "person", name: "Torréfaction du Kafé")
insert(:actor, type: :Group, preferred_username: "group", name: "Kafé group")
event = insert(:event, title: "Tour du monde des Kafés")
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{term: "Kafés"}
)
assert res["errors"] == nil
assert res["data"]["searchEvents"]["total"] == 1
assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
event.uuid
end
test "finds events by tag", %{conn: conn} do
tag = insert(:tag, title: "Café")
tag2 = insert(:tag, title: "Thé")
event = insert(:event, title: "Tour du monde", tags: [tag, tag2])
insert(:event, title: "Autre événement")
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{tags: "Café,Sirop"}
)
assert res["errors"] == nil
assert res["data"]["searchEvents"]["total"] == 1
assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
event.uuid
end
test "finds events by location", %{conn: conn} do
{lon, lat} = {45.75, 4.85}
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
geohash = Geohax.encode(lon, lat, 6)
address = insert(:address, geom: point)
event = insert(:event, title: "Tour du monde", physical_address: address)
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{location: geohash}
)
assert res["errors"] == nil
assert res["data"]["searchEvents"]["total"] == 1
assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
event.uuid
end
test "finds events by begins_on and ends_on", %{conn: conn} do
now = DateTime.utc_now()
# TODO
event =
insert(:event,
title: "Tour du monde",
begins_on: DateTime.add(now, 3600 * 24 * 3),
ends_on: DateTime.add(now, 3600 * 24 * 10)
)
insert(:event,
title: "Autre événement",
begins_on: DateTime.add(now, 3600 * 24 * 30),
ends_on: nil
)
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{
beginsOn: now |> DateTime.add(86_400) |> DateTime.to_iso8601(),
endsOn: now |> DateTime.add(1_728_000) |> DateTime.to_iso8601()
}
)
assert res["errors"] == nil
assert res["data"]["searchEvents"]["total"] == 1
assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
event.uuid
end
test "finds events with multiple criteria", %{conn: conn} do
{lon, lat} = {45.75, 4.85}
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
geohash = Geohax.encode(lon, lat, 6)
address = insert(:address, geom: point)
tag = insert(:tag, title: "Café")
tag2 = insert(:tag, title: "Thé")
event = insert(:event, title: "Tour du monde", physical_address: address, tags: [tag, tag2])
insert(:event, title: "Autre événement avec même tags", tags: [tag, tag2])
insert(:event, title: "Même endroit", physical_address: address)
insert(:event, title: "Même monde")
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_events_query,
variables: %{location: geohash, radius: 10, tags: "Thé", term: "Monde"}
)
assert res["errors"] == nil
assert res["data"]["searchEvents"]["total"] == 1
assert hd(res["data"]["searchEvents"]["elements"])["uuid"] ==
event.uuid
end
end
describe "search_persons/3" do
@search_persons_query """
query SearchPersons($term: String!, $page: Int, $limit: Int) {
searchPersons(term: $term, page: $page, limit: $limit) {
total
elements {
id
avatar {
url
}
domain
preferredUsername
name
__typename
}
}
}
"""
test "finds persons with basic search", %{
conn: conn,
user: user
} do
actor = insert(:actor, user: user, preferred_username: "test_person")
insert(:actor, type: :Group, preferred_username: "test_group")
event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_persons_query,
variables: %{term: "test"}
)
assert res["errors"] == nil
assert res["data"]["searchPersons"]["total"] == 1
assert res["data"]["searchPersons"]["elements"] |> length == 1
assert hd(res["data"]["searchPersons"]["elements"])["preferredUsername"] ==
actor.preferred_username
end
test "finds persons with word search", %{
conn: conn, conn: conn,
user: user user: user
} do } do
@ -168,65 +262,65 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
Workers.BuildSearch.insert_search_event(event2) Workers.BuildSearch.insert_search_event(event2)
Workers.BuildSearch.insert_search_event(event3) Workers.BuildSearch.insert_search_event(event3)
query = """ res =
{ AbsintheHelpers.graphql_query(conn,
search_persons(search: "pineapple") { query: @search_persons_query,
total, variables: %{term: "pineapple"}
)
assert res["errors"] == nil
assert res["data"]["searchPersons"]["total"] == 1
assert res["data"]["searchPersons"]["elements"]
|> length == 1
assert hd(res["data"]["searchPersons"]["elements"])["preferredUsername"] ==
actor.preferred_username
end
end
describe "search_groups/3" do
@search_groups_query """
query SearchGroups($term: String, $location: String, $radius: Float) {
searchGroups(term: $term, location: $location, radius: $radius) {
total
elements { elements {
preferredUsername, avatar {
url
}
domain
preferredUsername
name
__typename __typename
} }
} }
} }
""" """
res = test "finds persons with basic search", %{
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "search"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["search_persons"]["total"] == 1
assert json_response(res, 200)["data"]["search_persons"]["elements"]
|> length == 1
assert hd(json_response(res, 200)["data"]["search_persons"]["elements"])["preferredUsername"] ==
actor.preferred_username
end
test "search_events/3 finds events with accented search", %{
conn: conn, conn: conn,
user: user user: user
} do } do
insert(:actor, user: user, preferred_username: "person", name: "Torréfaction du Kafé") insert(:actor, user: user, preferred_username: "test_person")
insert(:actor, type: :Group, preferred_username: "group", name: "Kafé group") group = insert(:actor, type: :Group, preferred_username: "test_group")
event = insert(:event, title: "Tour du monde des Kafés") event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event) Workers.BuildSearch.insert_search_event(event)
# Elaborate query
query = """
{
search_events(search: "Kafé") {
total,
elements {
title,
uuid,
__typename
}
}
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_groups_query,
variables: %{term: "test"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_events"]["total"] == 1 assert res["data"]["searchGroups"]["total"] == 1
assert hd(json_response(res, 200)["data"]["search_events"]["elements"])["uuid"] == event.uuid assert res["data"]["searchGroups"]["elements"] |> length == 1
assert hd(res["data"]["searchGroups"]["elements"])["preferredUsername"] ==
group.preferred_username
end end
test "search_groups/3 finds groups with accented search", %{ test "finds groups with accented search", %{
conn: conn, conn: conn,
user: user user: user
} do } do
@ -235,27 +329,54 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
event = insert(:event, title: "Tour du monde des Kafés") event = insert(:event, title: "Tour du monde des Kafés")
Workers.BuildSearch.insert_search_event(event) Workers.BuildSearch.insert_search_event(event)
# Elaborate query
query = """
{
search_groups(search: "Kafé") {
total,
elements {
preferredUsername,
__typename
}
}
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_groups_query,
variables: %{term: "Kafé"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_groups"]["total"] == 1 assert res["data"]["searchGroups"]["total"] == 1
assert hd(json_response(res, 200)["data"]["search_groups"]["elements"])["preferredUsername"] == assert hd(res["data"]["searchGroups"]["elements"])["preferredUsername"] ==
group.preferred_username group.preferred_username
end end
test "finds groups with location", %{conn: conn} do
{lon, lat} = {45.75, 4.85}
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
geohash = Geohax.encode(lon, lat, 6)
geohash_2 = Geohax.encode(25, -19, 6)
address = insert(:address, geom: point)
group =
insert(:actor,
type: :Group,
preferred_username: "want_coffee",
name: "Want coffee ?",
physical_address: address
)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_groups_query,
variables: %{location: geohash}
)
assert res["errors"] == nil
assert res["data"]["searchGroups"]["total"] == 1
assert hd(res["data"]["searchGroups"]["elements"])["preferredUsername"] ==
group.preferred_username
res =
AbsintheHelpers.graphql_query(conn,
query: @search_groups_query,
variables: %{location: geohash_2}
)
assert res["errors"] == nil
assert res["data"]["searchGroups"]["total"] == 0
end
end
end end

View File

@ -188,7 +188,7 @@ defmodule Mobilizon.ActorsTest do
with {:ok, %Actor{id: actor2_id}} <- with {:ok, %Actor{id: actor2_id}} <-
ActivityPub.get_or_fetch_actor_by_url(@remote_account_url) do ActivityPub.get_or_fetch_actor_by_url(@remote_account_url) do
%Page{total: 2, elements: actors} = %Page{total: 2, elements: actors} =
Actors.build_actors_by_username_or_name_page("tcit", [:Person]) Actors.build_actors_by_username_or_name_page(%{term: "tcit"}, [:Person])
actors_ids = actors |> Enum.map(& &1.id) actors_ids = actors |> Enum.map(& &1.id)
@ -199,7 +199,7 @@ defmodule Mobilizon.ActorsTest do
test "test build_actors_by_username_or_name_page/4 returns actors with similar names" do test "test build_actors_by_username_or_name_page/4 returns actors with similar names" do
%{total: 0, elements: actors} = %{total: 0, elements: actors} =
Actors.build_actors_by_username_or_name_page("ohno", [:Person]) Actors.build_actors_by_username_or_name_page(%{term: "ohno"}, [:Person])
assert actors == [] assert actors == []
end end

View File

@ -60,16 +60,18 @@ defmodule Mobilizon.EventsTest do
test "build_events_for_search/1 returns events for a given name", %{ test "build_events_for_search/1 returns events for a given name", %{
event: %Event{title: title} = event event: %Event{title: title} = event
} do } do
assert title == hd(Events.build_events_for_search(event.title).elements).title assert title == hd(Events.build_events_for_search(%{term: event.title}).elements).title
%Event{} = event2 = insert(:event, title: "Special event") %Event{} = event2 = insert(:event, title: "Special event")
Workers.BuildSearch.insert_search_event(event2) Workers.BuildSearch.insert_search_event(event2)
assert event2.title == assert event2.title ==
Events.build_events_for_search("Special").elements |> hd() |> Map.get(:title) Events.build_events_for_search(%{term: "Special"}).elements
|> hd()
|> Map.get(:title)
assert event2.title == assert event2.title ==
Events.build_events_for_search(" Spécïal ").elements Events.build_events_for_search(%{term: " Spécïal "}).elements
|> hd() |> hd()
|> Map.get(:title) |> Map.get(:title)
@ -79,9 +81,9 @@ defmodule Mobilizon.EventsTest do
Workers.BuildSearch.insert_search_event(event3) Workers.BuildSearch.insert_search_event(event3)
assert event3.title == assert event3.title ==
Events.build_events_for_search("hola").elements |> hd() |> Map.get(:title) Events.build_events_for_search(%{term: "hola"}).elements |> hd() |> Map.get(:title)
assert %Page{elements: [], total: 0} == Events.build_events_for_search("") assert %Page{elements: _elements, total: 3} = Events.build_events_for_search(%{term: ""})
end end
test "find_close_events/3 returns events in the area" do test "find_close_events/3 returns events in the area" do