@@ -171,10 +174,11 @@
import axios from 'axios'
import GroupsRequiringModeration from './GroupsRequiringModeration.vue'
import EventsRequiringModeration from './EventsRequiringModeration.vue'
+import GroupMapAndList from './GroupMapAndList.vue'
import images from '../mixins/images'
export default {
- components: { GroupsRequiringModeration, EventsRequiringModeration },
+ components: { GroupsRequiringModeration, EventsRequiringModeration, GroupMapAndList },
mixins: [images],
props: {
network: {
@@ -230,6 +234,16 @@ export default {
}
},
computed: {
+ worldBounds() {
+ // The inverted whole-world box: GroupMap treats it as "no location", so
+ // it frames all the (network-filtered) groups instead.
+ return [[90, 180], [-90, -180]]
+ },
+ groupFilterTags() {
+ // The network tags API uses `name`; the group tag filter multiselect
+ // labels by `tag_name`. Provide both.
+ return this.tags.map(t => ({ ...t, tag_name: t.name }))
+ },
truncatedDescription() {
if (!this.network.description) return ''
const stripped = this.network.description.replace(/<[^>]*>/g, '')
@@ -430,12 +444,6 @@ export default {
}
}
-.groups-section {
- .groups-info {
- background: $white;
- }
-}
-
.tags-management {
.tag-item {
background: $brand-grey;
diff --git a/resources/js/constants.js b/resources/js/constants.js
index 6e4519d314..ad0a847f7e 100644
--- a/resources/js/constants.js
+++ b/resources/js/constants.js
@@ -60,4 +60,9 @@ export const UNKNOWN_STRINGS = [
'no brand',
'no model',
'none'
-]
\ No newline at end of file
+]
+
+export const MIN_MAP_ZOOM = 1
+// 14 was too shallow to read street names or tell close-together pins apart
+// (user feedback on the groups map); CARTO's raster tiles serve up to z18.
+export const MAX_MAP_ZOOM = 18
\ No newline at end of file
diff --git a/resources/js/misc/groupFilter.js b/resources/js/misc/groupFilter.js
new file mode 100644
index 0000000000..60d75a9361
--- /dev/null
+++ b/resources/js/misc/groupFilter.js
@@ -0,0 +1,37 @@
+// A group's networks arrive in two shapes: the names index that draws the map
+// sends plain ids, while the summary API sends objects. Understanding only one
+// of them leaves the network pages with an empty map and an empty list.
+export function inNetwork(group, networkId) {
+ if (!networkId) {
+ return true
+ }
+
+ return (group.networks || []).some(n => (n && n.id !== undefined ? n.id : n) === networkId)
+}
+
+// The map and the list must agree on what a filter means: the list shows what
+// the map shows, so if they disagree you get a count that doesn't match the
+// pins, or pins for groups that aren't listed.
+export function matchesFilters(group, filters) {
+ if (!filters) {
+ return true
+ }
+
+ if (filters.name) {
+ const name = filters.name.toLowerCase()
+
+ if (!group.name || !group.name.toLowerCase().includes(name)) {
+ return false
+ }
+ }
+
+ if (filters.tags && filters.tags.length) {
+ const groupTags = group.group_tags_full || []
+
+ // Every selected tag must be present, so choosing more tags narrows the
+ // results rather than widening them.
+ return filters.tags.every(t => groupTags.some(gt => gt.id === t.id))
+ }
+
+ return true
+}
diff --git a/resources/js/misc/groupFilter.test.js b/resources/js/misc/groupFilter.test.js
new file mode 100644
index 0000000000..f38f94d3a3
--- /dev/null
+++ b/resources/js/misc/groupFilter.test.js
@@ -0,0 +1,65 @@
+import { matchesFilters, inNetwork } from './groupFilter'
+
+// A group's networks arrive in two shapes: the names index that draws the map
+// sends plain ids, while the summary API sends objects. Matching only one of
+// them empties the network pages.
+describe('inNetwork', () => {
+ test('matches plain ids, as sent by the names index', () => {
+ expect(inNetwork({ networks: [5, 6] }, 5)).toBe(true)
+ expect(inNetwork({ networks: [6] }, 5)).toBe(false)
+ })
+
+ test('matches objects, as sent by the summary API', () => {
+ expect(inNetwork({ networks: [{ id: 5 }, { id: 6 }] }, 5)).toBe(true)
+ expect(inNetwork({ networks: [{ id: 6 }] }, 5)).toBe(false)
+ })
+
+ test('a group in no networks matches nothing', () => {
+ expect(inNetwork({}, 5)).toBe(false)
+ expect(inNetwork({ networks: [] }, 5)).toBe(false)
+ })
+
+ test('everything is in scope when no network is asked for', () => {
+ expect(inNetwork({ networks: [] }, null)).toBe(true)
+ })
+})
+
+const group = {
+ id: 1,
+ name: 'Hackney Fixing Factory',
+ group_tags_full: [{ id: 1, name: 'Lille' }, { id: 2, name: 'Repair Cafe' }],
+}
+
+test('everything matches when nothing is filtered', () => {
+ expect(matchesFilters(group, null)).toBe(true)
+ expect(matchesFilters(group, {})).toBe(true)
+})
+
+test('matches part of a name, ignoring case', () => {
+ expect(matchesFilters(group, { name: 'hackney' })).toBe(true)
+ expect(matchesFilters(group, { name: 'Fixing' })).toBe(true)
+ expect(matchesFilters(group, { name: 'Ulverston' })).toBe(false)
+})
+
+test('a group with no name matches nothing', () => {
+ expect(matchesFilters({ id: 2 }, { name: 'anything' })).toBe(false)
+})
+
+// Choosing more tags should narrow the results, not widen them.
+test('requires every selected tag, not just one of them', () => {
+ expect(matchesFilters(group, { tags: [{ id: 1 }] })).toBe(true)
+ expect(matchesFilters(group, { tags: [{ id: 1 }, { id: 2 }] })).toBe(true)
+ expect(matchesFilters(group, { tags: [{ id: 1 }, { id: 99 }] })).toBe(false)
+})
+
+test('a group with no tags matches only an empty tag filter', () => {
+ const untagged = { id: 3, name: 'Untagged' }
+ expect(matchesFilters(untagged, { tags: [] })).toBe(true)
+ expect(matchesFilters(untagged, { tags: [{ id: 1 }] })).toBe(false)
+})
+
+test('name and tags both have to match', () => {
+ expect(matchesFilters(group, { name: 'hackney', tags: [{ id: 1 }] })).toBe(true)
+ expect(matchesFilters(group, { name: 'ulverston', tags: [{ id: 1 }] })).toBe(false)
+ expect(matchesFilters(group, { name: 'hackney', tags: [{ id: 99 }] })).toBe(false)
+})
diff --git a/resources/js/misc/placeSearchGeocoder.js b/resources/js/misc/placeSearchGeocoder.js
new file mode 100644
index 0000000000..16cd1eed49
--- /dev/null
+++ b/resources/js/misc/placeSearchGeocoder.js
@@ -0,0 +1,55 @@
+// The map's place-search geocoder. Photon never ranks administrative
+// boundaries into a plain query's results - searching "Haringey" (a London
+// borough) returned only streets and bus stops while "Muswell Hill" (a
+// suburb, which Photon treats as a place) worked (user feedback,
+// 2026-08-20). So the search runs twice: once filtered to Photon's place
+// layers (district/city/county/state - boroughs are `layer=district`), once
+// unfiltered, and the dropdown lists the places first.
+
+// Places first, then everything else, deduped by display name (the
+// place-layer copy wins - it carries the boundary's full extent, so
+// selecting it frames the whole area). Capped at 10 like Photon's own
+// default result list.
+export function mergePlaceSearchResults(places, general) {
+ const seen = {}
+
+ return places.concat(general)
+ .filter((result) => {
+ if (seen[result.name]) {
+ return false
+ }
+ seen[result.name] = true
+ return true
+ })
+ .slice(0, 10)
+}
+
+// Wraps two leaflet-control-geocoder v1 geocoders (callback API) into one.
+// A failure on either side degrades to the other's results rather than
+// failing the search.
+export function buildPlaceSearchGeocoder({ places, general }) {
+ const collect = (geocoder, query, context) =>
+ new Promise((resolve) => {
+ try {
+ geocoder.geocode(query, (results) => resolve(results || []), context)
+ } catch (e) {
+ resolve([])
+ }
+ })
+
+ return {
+ geocode(query, cb, context) {
+ Promise.all([collect(places, query, context), collect(general, query, context)]).then(([p, g]) => {
+ cb.call(context, mergePlaceSearchResults(p, g))
+ })
+ },
+ suggest(query, cb, context) {
+ return this.geocode(query, cb, context)
+ },
+ }
+}
+
+// Photon's layers that represent named places rather than addresses or
+// venues. `district` covers both suburbs and London-borough-style
+// administrative areas.
+export const PLACE_LAYERS = ['district', 'city', 'county', 'state']
diff --git a/resources/js/misc/placeSearchGeocoder.test.js b/resources/js/misc/placeSearchGeocoder.test.js
new file mode 100644
index 0000000000..c4859f6924
--- /dev/null
+++ b/resources/js/misc/placeSearchGeocoder.test.js
@@ -0,0 +1,65 @@
+import { mergePlaceSearchResults, buildPlaceSearchGeocoder } from './placeSearchGeocoder'
+
+// User feedback: searching "Haringey" (a borough) found nothing while
+// "Muswell Hill" (a suburb) worked. Photon never ranks administrative
+// boundaries into a plain query's results, so the search runs twice - once
+// filtered to place layers, once unfiltered - and shows the places first.
+describe('placeSearchGeocoder', () => {
+ const r = (name) => ({ name, center: { lat: 0, lng: 0 } })
+
+ describe('mergePlaceSearchResults', () => {
+ test('puts place-layer results ahead of general ones', () => {
+ const merged = mergePlaceSearchResults([r('London Borough of Haringey')], [r('Haringey Park')])
+ expect(merged.map((x) => x.name)).toEqual(['London Borough of Haringey', 'Haringey Park'])
+ })
+
+ test('drops duplicates by name, keeping the place-layer copy', () => {
+ const place = r('Muswell Hill')
+ const merged = mergePlaceSearchResults([place], [r('Muswell Hill'), r('Muswell Hill Library')])
+ expect(merged.map((x) => x.name)).toEqual(['Muswell Hill', 'Muswell Hill Library'])
+ expect(merged[0]).toBe(place)
+ })
+
+ test('caps the merged list at 10', () => {
+ const many = Array.from({ length: 8 }, (_, i) => r(`P${i}`))
+ const more = Array.from({ length: 8 }, (_, i) => r(`G${i}`))
+ expect(mergePlaceSearchResults(many, more)).toHaveLength(10)
+ })
+ })
+
+ describe('buildPlaceSearchGeocoder (v1 callback API)', () => {
+ const cbGeocoder = (results) => ({
+ geocode: jest.fn((query, cb, context) => cb.call(context, results)),
+ })
+
+ test('queries both geocoders and calls back with places first', (done) => {
+ const places = cbGeocoder([r('The Borough')])
+ const general = cbGeocoder([r('A Street')])
+
+ buildPlaceSearchGeocoder({ places, general }).geocode('borough', function (results) {
+ expect(results.map((x) => x.name)).toEqual(['The Borough', 'A Street'])
+ done()
+ })
+ })
+
+ test('still returns the other list when one side throws', (done) => {
+ const places = { geocode: jest.fn(() => { throw new Error('boom') }) }
+ const general = cbGeocoder([r('A Street')])
+
+ buildPlaceSearchGeocoder({ places, general }).geocode('q', function (results) {
+ expect(results.map((x) => x.name)).toEqual(['A Street'])
+ done()
+ })
+ })
+
+ test('suggest() is the same search', (done) => {
+ const places = cbGeocoder([])
+ const general = cbGeocoder([r('X')])
+
+ buildPlaceSearchGeocoder({ places, general }).suggest('q', function (results) {
+ expect(results.map((x) => x.name)).toEqual(['X'])
+ done()
+ })
+ })
+ })
+})
diff --git a/resources/js/store/groups.js b/resources/js/store/groups.js
index e467948f0c..b27ed7328b 100644
--- a/resources/js/store/groups.js
+++ b/resources/js/store/groups.js
@@ -10,6 +10,8 @@ function newToOld(e) {
// new API completely, we can then migrate the Vue components to use the new field names and retire this function.
// Similar code in event and device store.
let ret = {
+ // Keep both id styles: components (e.g. GroupsTable) match rows on `id`.
+ id: e.id,
idgroups: e.id,
name: e.name,
location: e.location,
@@ -31,12 +33,20 @@ function getLocale() {
return el.innerText.trim()
}
+// Module-scoped map of in-flight `groups/fetch` promises, keyed by
+// `${id}|${includeStats}`. Used by the action to de-dup concurrent callers.
+const inFlight = new Map()
+
export default {
namespaced: true,
state: {
// List of groups indexed by group id. Use object rather than array so that it's sparse.
list: {},
+ // Ids with a full-row fetch in flight, so watchers firing repeatedly
+ // don't duplicate hydration calls.
+ hydrating: {},
+
// Groups requiring moderation.
moderate: {},
@@ -62,8 +72,17 @@ export default {
}
},
mutations: {
+ setHydrating(state, params) {
+ params.ids.forEach(id => {
+ if (params.value) {
+ Vue.set(state.hydrating, id, true)
+ } else {
+ Vue.delete(state.hydrating, id)
+ }
+ })
+ },
set(state, params) {
- Vue.set(state.list, params.idgroups, params)
+ Vue.set(state.list, params.id || params.idgroups, params)
},
setList(state, params) {
let list = {}
@@ -123,23 +142,84 @@ export default {
commit('set', group)
}
},
- async getModerationRequired({commit, rootGetters}, params) {
+ async getModerationRequired({commit, rootGetters, state}, params) {
const apiToken = rootGetters['auth/apiToken']
let ret = await axios.get('/api/v2/moderate/groups?api_token=' + apiToken + '&locale=' + getLocale())
if (ret && ret.data) {
commit('setModerate', ret.data)
+
+ // GroupsTable renders its rows from the list store, so moderation
+ // groups must be there too. Don't clobber a richer entry (e.g. from
+ // the summary fetch).
+ ret.data.forEach(e => {
+ if (!state.list[e.id]) {
+ commit('set', newToOld(e))
+ }
+ })
}
},
- async list({commit}) {
- let ret = await axios.get('/api/v2/groups/names?locale=' + getLocale())
- if (ret && ret.data) {
+ async list({commit}, params) {
+ // The names index is enough to draw the map and run the client-side
+ // filters; the table hydrates the rows it actually shows via
+ // hydrate(). Archived groups are included: the list badges them, as
+ // the old server-rendered page did.
+ const url = '/api/v2/groups/names?locale=' + getLocale() +
+ (params && params.details ? '&includeArchived=true' : '')
+
+ let ret = await axios.get(url)
+
+ if (ret) {
commit('setList', {
- groups: ret.data.data
+ groups: ret.data.data.map(g => ({
+ id: g.id,
+ name: g.name,
+ lat: g.lat,
+ lng: g.lng,
+ location: {
+ location: null,
+ country: g.country,
+ lat: g.lat,
+ lng: g.lng,
+ },
+ networks: g.network_ids || [],
+ group_tags_full: (g.tag_ids || []).map(id => ({ id })),
+ archived_at: g.archived_at,
+ }))
})
}
},
+ async hydrate({commit, state}, params) {
+ // Fetch full rows (image, location text, counts, next event, tag names)
+ // for just the given ids - one batched call per visible page of the
+ // list, instead of serialising every group up front.
+ const ids = params.ids.filter(id => {
+ const g = state.list[id]
+ return (!g || !g.summary) && !state.hydrating[id]
+ })
+
+ if (!ids.length) {
+ return
+ }
+
+ commit('setHydrating', { ids, value: true })
+
+ try {
+ // The API caps ids at 200 per call.
+ for (let i = 0; i < ids.length; i += 200) {
+ const chunk = ids.slice(i, i + 200)
+ const ret = await axios.get('/api/v2/groups/summary?locale=' + getLocale() +
+ '&includeNextEvent=true&includeCounts=true&archived=true&ids=' + chunk.join(','))
+
+ if (ret && ret.data) {
+ ret.data.data.forEach(g => commit('set', g))
+ }
+ }
+ } finally {
+ commit('setHydrating', { ids, value: false })
+ }
+ },
async listTags({commit, rootGetters}) {
const apiToken = rootGetters['auth/apiToken']
let url = '/api/v2/groups/tags?locale=' + getLocale()
@@ -213,14 +293,38 @@ export default {
return id
},
async fetch({ rootGetters, commit }, params) {
- try {
- let ret = await axios.get('/api/v2/groups/' + params.id + '?api_token=' + rootGetters['auth/apiToken'] + '&locale=' + getLocale())
+ // De-dup concurrent fetches for the same (id, includeStats) so callers
+ // like GroupsPage's mounted loop don't fire N parallel requests when
+ // the user has many groups. The cache key includes includeStats because
+ // the two responses have different shapes.
+ const key = params.id + '|' + (params.hasOwnProperty('includeStats') ? String(params.includeStats) : '')
+ if (inFlight.has(key)) {
+ return inFlight.get(key)
+ }
- commit('set', ret.data.data)
+ const request = (async () => {
+ try {
+ let url = '/api/v2/groups/' + params.id + '?api_token=' + rootGetters['auth/apiToken'] + '&locale=' + getLocale()
- return ret.data.data
- } catch (e) {
- console.error("Group fetch failed", e)
+ if (params.hasOwnProperty('includeStats')) {
+ url += '&includeStats=' + params.includeStats
+ }
+
+ let ret = await axios.get(url)
+
+ commit('set', ret.data.data)
+
+ return ret.data.data
+ } catch (e) {
+ console.error("Group fetch failed", e)
+ }
+ })()
+
+ inFlight.set(key, request)
+ try {
+ return await request
+ } finally {
+ inFlight.delete(key)
}
}
},
diff --git a/resources/js/store/groups.test.js b/resources/js/store/groups.test.js
new file mode 100644
index 0000000000..95c60a99cc
--- /dev/null
+++ b/resources/js/store/groups.test.js
@@ -0,0 +1,222 @@
+// Tests in-flight de-duplication of the groups/fetch action.
+//
+// The action is called eagerly by GroupsPage (one dispatch per yourGroups id)
+// and again whenever a user navigates to a group page. Without de-dup, the
+// same id can be in flight twice concurrently.
+
+jest.mock('axios', () => ({
+ __esModule: true,
+ default: {
+ get: jest.fn(),
+ post: jest.fn(),
+ patch: jest.fn(),
+ delete: jest.fn(),
+ },
+}))
+import axios from 'axios'
+
+import groups from './groups'
+import { INDEX_API_ENTRY, INDEX_STORE_ENTRY } from '../testFixtures/groups'
+
+// store/groups.js reads locale via document.getElementById('language-current').innerText.
+// jsdom doesn't populate innerText reliably, so set the property explicitly.
+beforeEach(() => {
+ document.body.innerHTML = '
'
+ const el = document.getElementById('language-current')
+ Object.defineProperty(el, 'innerText', { value: 'en', configurable: true })
+ axios.get.mockReset()
+})
+
+function commit() {}
+const rootGetters = { 'auth/apiToken': 'TEST' }
+
+function deferred() {
+ let resolve, reject
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej })
+ return { promise, resolve, reject }
+}
+
+test('two concurrent fetches for the same group share a single in-flight request', async () => {
+ const d = deferred()
+ axios.get.mockReturnValueOnce(d.promise)
+
+ const a = groups.actions.fetch({ rootGetters, commit }, { id: 42 })
+ const b = groups.actions.fetch({ rootGetters, commit }, { id: 42 })
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+
+ d.resolve({ data: { data: { id: 42, name: 'G' } } })
+ await Promise.all([a, b])
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+})
+
+test('a new fetch after the previous one settled hits the network again', async () => {
+ axios.get.mockResolvedValueOnce({ data: { data: { id: 7, name: 'G7' } } })
+ await groups.actions.fetch({ rootGetters, commit }, { id: 7 })
+ expect(axios.get).toHaveBeenCalledTimes(1)
+
+ axios.get.mockResolvedValueOnce({ data: { data: { id: 7, name: 'G7 again' } } })
+ await groups.actions.fetch({ rootGetters, commit }, { id: 7 })
+ expect(axios.get).toHaveBeenCalledTimes(2)
+})
+
+test('concurrent fetches for different groups each get their own request', async () => {
+ const d1 = deferred()
+ const d2 = deferred()
+ axios.get.mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise)
+
+ const a = groups.actions.fetch({ rootGetters, commit }, { id: 1 })
+ const b = groups.actions.fetch({ rootGetters, commit }, { id: 2 })
+
+ expect(axios.get).toHaveBeenCalledTimes(2)
+
+ d1.resolve({ data: { data: { id: 1 } } })
+ d2.resolve({ data: { data: { id: 2 } } })
+ await Promise.all([a, b])
+})
+
+test('a fetch that throws clears its in-flight slot so retries can run', async () => {
+ axios.get.mockRejectedValueOnce(new Error('boom'))
+ await groups.actions.fetch({ rootGetters, commit }, { id: 9 })
+
+ // The previous fetch is no longer in flight, so a new one re-hits the network.
+ axios.get.mockResolvedValueOnce({ data: { data: { id: 9 } } })
+ await groups.actions.fetch({ rootGetters, commit }, { id: 9 })
+
+ expect(axios.get).toHaveBeenCalledTimes(2)
+})
+
+test('fetches with the same id but different includeStats are independent requests', async () => {
+ axios.get.mockResolvedValue({ data: { data: { id: 3 } } })
+
+ await groups.actions.fetch({ rootGetters, commit }, { id: 3, includeStats: false })
+ await groups.actions.fetch({ rootGetters, commit }, { id: 3, includeStats: true })
+
+ expect(axios.get).toHaveBeenCalledTimes(2)
+ expect(axios.get.mock.calls[0][0]).toContain('includeStats=false')
+ expect(axios.get.mock.calls[1][0]).toContain('includeStats=true')
+})
+
+test('the details list fetch asks for archived groups (shown with a badge, as the old page did)', async () => {
+ axios.get.mockResolvedValueOnce({ data: { data: [] } })
+
+ await groups.actions.list({ commit }, { details: true })
+
+ expect(axios.get.mock.calls[0][0]).toContain('includeArchived=true')
+})
+
+// GroupsTable renders its rows from the groups/list store; groups that only
+// exist in the moderate store never showed up, so the "groups requiring
+// moderation" section rendered an empty table.
+describe('getModerationRequired', () => {
+ const Vue = require('vue')
+ const Vuex = require('vuex')
+ Vue.use(Vuex)
+
+ function makeRealStore() {
+ return new Vuex.Store({
+ modules: {
+ groups: {
+ ...groups,
+ state: { list: {}, moderate: {}, tags: {}, stats: {} },
+ },
+ auth: {
+ namespaced: true,
+ getters: { apiToken: () => 'TEST' },
+ },
+ },
+ })
+ }
+
+ test('moderation groups become renderable by GroupsTable (in the list store, with an id)', async () => {
+ axios.get.mockResolvedValueOnce({
+ data: [{ id: 7, name: 'Mod Group', location: 'Somewhere', country: 'UK', networks: [] }],
+ })
+ const store = makeRealStore()
+
+ await store.dispatch('groups/getModerationRequired')
+
+ expect(store.getters['groups/getModerate'][7]).toBeTruthy()
+ const listed = store.getters['groups/list'].find(g => g.id === 7)
+ expect(listed).toBeTruthy()
+ expect(listed.idgroups).toBe(7)
+ })
+
+ test('does not clobber a richer entry already in the list store', async () => {
+ const store = makeRealStore()
+ store.commit('groups/set', { id: 7, name: 'Rich', location: { lat: 1, lng: 2 } })
+ axios.get.mockResolvedValueOnce({
+ data: [{ id: 7, name: 'Mod Group', networks: [] }],
+ })
+
+ await store.dispatch('groups/getModerationRequired')
+
+ expect(store.getters['groups/get'](7).location).toEqual({ lat: 1, lng: 2 })
+ })
+})
+
+describe('hydrate', () => {
+ test('fetches full rows for un-hydrated ids in one batched call', async () => {
+ axios.get.mockResolvedValueOnce({ data: { data: [{ id: 1, summary: true }, { id: 2, summary: true }] } })
+ const commits = []
+ const state = { list: {}, hydrating: {} }
+
+ await groups.actions.hydrate(
+ { commit: (type, params) => commits.push([type, params]), state },
+ { ids: [1, 2] }
+ )
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ const url = axios.get.mock.calls[0][0]
+ expect(url).toContain('/api/v2/groups/summary')
+ expect(url).toContain('ids=1,2')
+ expect(url).toContain('includeNextEvent=true')
+ expect(url).toContain('includeCounts=true')
+ expect(commits.filter(c => c[0] === 'set').map(c => c[1].id)).toEqual([1, 2])
+ })
+
+ test('skips ids already hydrated or in flight', async () => {
+ const state = {
+ // A full row has summary: true; a bare index entry does not.
+ list: { 1: { id: 1, summary: true }, 2: { id: 2 } },
+ hydrating: { 2: true },
+ }
+
+ await groups.actions.hydrate({ commit: () => {}, state }, { ids: [1, 2] })
+
+ expect(axios.get).not.toHaveBeenCalled()
+ })
+
+ test('chunks requests at the API cap of 200 ids', async () => {
+ axios.get.mockResolvedValue({ data: { data: [] } })
+ const state = { list: {}, hydrating: {} }
+ const ids = Array.from({ length: 250 }, (_, i) => i + 1)
+
+ await groups.actions.hydrate({ commit: () => {}, state }, { ids })
+
+ expect(axios.get).toHaveBeenCalledTimes(2)
+ expect(axios.get.mock.calls[0][0]).toContain('ids=1,')
+ expect(axios.get.mock.calls[1][0]).toContain('ids=201,')
+ })
+})
+
+test('the index fetch shapes entries for the map, filters and table', async () => {
+ axios.get.mockResolvedValueOnce({
+ data: { data: [INDEX_API_ENTRY] }
+ })
+ const commits = []
+
+ await groups.actions.list(
+ { commit: (type, params) => commits.push([type, params]) },
+ { details: true }
+ )
+
+ expect(axios.get.mock.calls[0][0]).toContain('/api/v2/groups/names')
+ const g = commits.find(c => c[0] === 'setList')[1].groups[0]
+
+ // The whole entry, with nothing overridden: this is what ties the fixture
+ // that component tests build on to what the store really produces. If this
+ // fails, the mapping changed - update the fixture, not the assertion.
+ expect(g).toEqual(INDEX_STORE_ENTRY)
+})
diff --git a/resources/js/testFixtures/groups.js b/resources/js/testFixtures/groups.js
new file mode 100644
index 0000000000..8057067792
--- /dev/null
+++ b/resources/js/testFixtures/groups.js
@@ -0,0 +1,70 @@
+// One definition of what a group looks like once it is in the store, so that
+// component tests exercise the shape the store actually produces rather than
+// one someone imagined while writing the test.
+//
+// Inventing a group inline in a component test is what let the network pages
+// ship empty: those fixtures said networks were [{id}] objects, while the map
+// is drawn from the names index, which sends plain ids.
+//
+// INDEX_API_ENTRY and INDEX_STORE_ENTRY are a matched pair - the response the
+// names index sends, and exactly what the store turns it into.
+// store/groups.test.js feeds the first in and asserts the whole of the second
+// comes out, with nothing overridden, so a change to the mapping fails there
+// and this file has to be updated with it.
+
+export const INDEX_API_ENTRY = {
+ id: 7,
+ name: 'G',
+ lat: 51,
+ lng: 0,
+ country: 'United Kingdom',
+ network_ids: [3],
+ tag_ids: [9],
+ archived_at: null,
+}
+
+export const INDEX_STORE_ENTRY = {
+ id: 7,
+ name: 'G',
+ // The index puts coordinates at the top level and inside location.
+ lat: 51,
+ lng: 0,
+ location: {
+ location: null,
+ country: 'United Kingdom',
+ lat: 51,
+ lng: 0,
+ },
+ // Plain ids, not objects.
+ networks: [3],
+ // Tag ids wrapped as objects; the names arrive with hydration.
+ group_tags_full: [{ id: 9 }],
+ archived_at: null,
+}
+
+// A group as the names index leaves it: enough to draw the map and run the
+// client-side filters, with no image, location text, counts or next event.
+export function indexGroup(overrides = {}) {
+ return { ...INDEX_STORE_ENTRY, ...overrides }
+}
+
+// A group after hydrate() has filled in the rest from the summary API. Note
+// that the summary API sends networks as objects, unlike the index - both
+// shapes are live at once, because the table hydrates only its visible rows.
+export function hydratedGroup(overrides = {}) {
+ return {
+ ...INDEX_STORE_ENTRY,
+ image: null,
+ location: {
+ location: 'Townsville',
+ country: 'United Kingdom',
+ lat: 51,
+ lng: 0,
+ distance: null,
+ },
+ networks: [{ id: 3 }],
+ next_event: null,
+ summary: true,
+ ...overrides,
+ }
+}
diff --git a/resources/views/group/index.blade.php b/resources/views/group/index.blade.php
index c5f7b94e9f..2fe1adb739 100644
--- a/resources/views/group/index.blade.php
+++ b/resources/views/group/index.blade.php
@@ -21,8 +21,6 @@
@endif
diff --git a/routes/api.php b/routes/api.php
index 5564ad0dcc..5c6c7f97f5 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -72,6 +72,7 @@
Route::prefix('v2')->group(function() {
Route::middleware(\App\Http\Middleware\APISetLocale::class)->group(function() {
Route::prefix('/groups')->group(function() {
+ Route::get('/summary', [API\GroupController::class, 'listSummaryv2']);
Route::get('/names', [API\GroupController::class, 'listNamesv2']);
Route::get('/tags', [API\GroupController::class, 'listTagsv2']);
Route::get('{id}/events', [API\GroupController::class, 'getEventsForGroupv2']);
diff --git a/routes/web.php b/routes/web.php
index 78480bd7de..5f81d1bae1 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -360,6 +360,7 @@
Route::get('/all', [GroupController::class, 'all']);
Route::get('/mine', [GroupController::class, 'mine']);
Route::get('/nearby', [GroupController::class, 'nearby']);
+ Route::get('/other', [GroupController::class, 'nearby']);
Route::get('/network/{id}', [GroupController::class, 'network']);
Route::post('/delete/{id}', [GroupController::class, 'delete']);
});
diff --git a/tests/Feature/Groups/BasicTest.php b/tests/Feature/Groups/BasicTest.php
index fc6d969caa..ee61e982cf 100644
--- a/tests/Feature/Groups/BasicTest.php
+++ b/tests/Feature/Groups/BasicTest.php
@@ -3,7 +3,6 @@
namespace Tests\Feature\Groups;
use App\Group;
-use App\Network;
use App\User;
use DB;
use Hash;
@@ -39,26 +38,21 @@ public function testPageLoads($url, $tab): void
// Can't assert on all-group-tags dev systems might have varying info.
'your-area' => 'London',
':can-create' => 'true',
- ':user-id' => $user->id,
'tab' => $tab,
':network' => 'null',
- ':networks' => '[{"id":' . Network::first()->id . ',"name":"Restarters","description":null,"website":null,"default_language":"en","timezone":"Europe\\/London","created_at":"2021-05-24 12:19:37","updated_at":"2021-05-24 12:19:37","events_push_to_wordpress":0,"include_in_zapier":0,"users_push_to_drip":0,"shortname":"restarters","discourse_group":null,"auto_approve_events":0,"logo":null}]',
':show-tags' => 'false',
],
]);
-
- $groups = json_decode($props[1][':all-groups'], true);
- $this->assertEquals($group->idgroups, $groups[0]['idgroups']);
- $this->assertEquals(0, $groups[0]['location']['distance']);
}
- public function tabProvider(): array {
+ public static function tabProvider(): array {
return [
['', 'mine'],
- ['/all', 'all'],
+ ['/all', 'other'],
['/mine', 'mine'],
- ['/nearby','nearby'],
+ ['/nearby','other'],
+ ['/other', 'other'],
];
}
}
diff --git a/tests/Feature/Groups/GroupSummaryApiTest.php b/tests/Feature/Groups/GroupSummaryApiTest.php
new file mode 100644
index 0000000000..3cc8b06b26
--- /dev/null
+++ b/tests/Feature/Groups/GroupSummaryApiTest.php
@@ -0,0 +1,146 @@
+create(['name' => 'Active Summary Group']);
+ $archived = Group::factory()->create([
+ 'name' => 'Archived Summary Group',
+ 'archived_at' => Carbon::now(),
+ ]);
+
+ // The endpoint validates an `archived` parameter; it must also honour it.
+ $response = $this->get('/api/v2/groups/summary');
+ $response->assertSuccessful();
+ $ids = collect($response->json('data'))->pluck('id');
+ $this->assertTrue($ids->contains($active->idgroups));
+ $this->assertFalse($ids->contains($archived->idgroups));
+
+ $response = $this->get('/api/v2/groups/summary?archived=true');
+ $response->assertSuccessful();
+ $ids = collect($response->json('data'))->pluck('id');
+ $this->assertTrue($ids->contains($active->idgroups));
+ $this->assertTrue($ids->contains($archived->idgroups));
+ }
+
+ public function testIncludesGroupTagsForBadgesAndFiltering(): void
+ {
+ $group = Group::factory()->create();
+ $tag = GroupTags::factory()->create();
+ $group->addTag($tag);
+
+ $response = $this->get('/api/v2/groups/summary');
+ $response->assertSuccessful();
+
+ $summary = collect($response->json('data'))->firstWhere('id', $group->idgroups);
+ $this->assertNotNull($summary);
+ $this->assertArrayHasKey('group_tags_full', $summary);
+ $this->assertEquals($tag->id, $summary['group_tags_full'][0]['id']);
+ $this->assertEquals($tag->tag_name, $summary['group_tags_full'][0]['name']);
+ }
+
+ public function testQueryCountDoesNotScaleWithGroupCount(): void
+ {
+ Group::factory()->count(3)->create();
+
+ // Warm up (first request may have extra overhead).
+ $this->get('/api/v2/groups/summary?includeNextEvent=true&includeCounts=true');
+
+ DB::enableQueryLog();
+ $this->get('/api/v2/groups/summary?includeNextEvent=true&includeCounts=true')->assertSuccessful();
+ $queriesForFew = count(DB::getQueryLog());
+ DB::disableQueryLog();
+ DB::flushQueryLog();
+
+ Group::factory()->count(6)->create();
+
+ DB::enableQueryLog();
+ $this->get('/api/v2/groups/summary?includeNextEvent=true&includeCounts=true')->assertSuccessful();
+ $queriesForMany = count(DB::getQueryLog());
+ DB::disableQueryLog();
+ DB::flushQueryLog();
+
+ // Tripling the groups must not grow the queries: relations should be
+ // eager-loaded, not fetched per group.
+ $this->assertLessThan(
+ $queriesForFew * 1.5,
+ $queriesForMany,
+ "Summary endpoint queries scale with group count: $queriesForFew -> $queriesForMany"
+ );
+ }
+
+ public function testIdsParamHydratesOnlyThoseGroups(): void
+ {
+ $network = Network::factory()->create();
+ $tag = GroupTags::factory()->create();
+ $a = Group::factory()->create(['name' => 'Hydrate A']);
+ $b = Group::factory()->create(['name' => 'Hydrate B']);
+ $other = Group::factory()->create(['name' => 'Not Asked For']);
+ $a->addTag($tag);
+ $network->addGroup($a);
+ Party::factory()->create([
+ 'group' => $a->idgroups,
+ 'event_start_utc' => Carbon::now()->addDays(3)->toIso8601String(),
+ 'event_end_utc' => Carbon::now()->addDays(3)->addHours(2)->toIso8601String(),
+ 'approved' => true,
+ ]);
+ \Cache::forget('future_approved_events');
+
+ $response = $this->get('/api/v2/groups/summary?ids=' . $a->idgroups . ',' . $b->idgroups
+ . '&includeNextEvent=true&includeCounts=true&archived=true');
+ $response->assertSuccessful();
+ $data = collect($response->json('data'));
+
+ // Only the requested groups, with the full row shape the list needs.
+ $this->assertEqualsCanonicalizing([$a->idgroups, $b->idgroups], $data->pluck('id')->all());
+ $ga = $data->firstWhere('id', $a->idgroups);
+ $this->assertEquals($network->id, $ga['networks'][0]['id']);
+ $this->assertNotNull($ga['next_event']);
+ $this->assertArrayHasKey('hosts', $ga);
+ $this->assertEquals($tag->id, $ga['group_tags_full'][0]['id']);
+ }
+
+ public function testIdsParamRejectsMoreThanTwoHundred(): void
+ {
+ $this->expectException(\Illuminate\Validation\ValidationException::class);
+ $this->get('/api/v2/groups/summary?ids=' . implode(',', range(1, 201)));
+ }
+
+ public function testNamesIndexCarriesFilterFields(): void
+ {
+ $network = Network::factory()->create();
+ $tag = GroupTags::factory()->create();
+ $group = Group::factory()->create([
+ 'name' => 'Index Group',
+ 'country_code' => 'GB',
+ ]);
+ $group->addTag($tag);
+ $network->addGroup($group);
+
+ $response = $this->get('/api/v2/groups/names?includeArchived=true');
+ $response->assertSuccessful();
+ $g = collect($response->json('data'))->firstWhere('id', $group->idgroups);
+ $this->assertNotNull($g);
+
+ // The map/list index: identity + position + everything the client-side
+ // filters need, and nothing heavier.
+ $this->assertEqualsCanonicalizing(
+ ['id', 'name', 'lat', 'lng', 'archived_at', 'country', 'network_ids', 'tag_ids'],
+ array_keys($g)
+ );
+ $this->assertEquals('United Kingdom', $g['country']);
+ $this->assertEquals([$network->id], $g['network_ids']);
+ $this->assertEquals([$tag->id], $g['tag_ids']);
+ }
+}
diff --git a/tests/Feature/Groups/GroupViewTest.php b/tests/Feature/Groups/GroupViewTest.php
index 6d6bbd2254..3df7417db8 100644
--- a/tests/Feature/Groups/GroupViewTest.php
+++ b/tests/Feature/Groups/GroupViewTest.php
@@ -7,6 +7,7 @@
use App\Party;
use App\Role;
use Carbon\Carbon;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
@@ -182,26 +183,205 @@ public function testGroupIndexNextEventIsEagerLoaded(): void
'event_end_utc' => $nextWeek->addHours(2)->toIso8601String(),
]);
- // Load the group index and check both groups appear with a next_event.
- $response = $this->get('/group');
+ // Groups are fetched via API (not server-rendered) — test that next_event is returned.
+ $response = $this->get('/api/v2/groups/summary?includeNextEvent=true');
$response->assertSuccessful();
- $props = $this->getVueProperties($response);
- $allGroupsJson = null;
- foreach ($props as $prop) {
- if (isset($prop[':all-groups'])) {
- $allGroupsJson = $prop[':all-groups'];
- break;
- }
+ $groups = $response->json('data');
+ $group1 = collect($groups)->firstWhere('id', $id1);
+ $group2 = collect($groups)->firstWhere('id', $id2);
+
+ $this->assertNotNull($group1, "Group Alpha (id=$id1) not found in API response");
+ $this->assertNotNull($group2, "Group Beta (id=$id2) not found in API response");
+ $this->assertNotNull($group1['next_event'], 'Group Alpha should have a next_event');
+ $this->assertNotNull($group2['next_event'], 'Group Beta should have a next_event');
+ }
+
+ public function testGroupsPageFramesTheCountryForAUserWithNoTown(): void
+ {
+ // A user who has only set their country has no coordinates, so
+ // groupsNearby() can't help - the map used to open on the whole world.
+ // Frame the groups in their country instead.
+ $this->loginAsTestUser(Role::ADMINISTRATOR);
+ $id = $this->createGroup('Group In Country', 'https://therestartproject.org', 'London');
+
+ $group = Group::find($id);
+ $group->country_code = 'GB';
+ $group->latitude = 54.19;
+ $group->longitude = -3.09;
+ $group->approved = true;
+ $group->save();
+
+ $user = \App\User::factory()->restarter()->create([
+ 'country_code' => 'GB',
+ 'location' => null,
+ 'latitude' => null,
+ 'longitude' => null,
+ ]);
+ $this->actingAs($user);
+
+ $props = $this->getVueProperties($this->get('/group/other'));
+ $bounds = json_decode($props[1][':nearby-groups'], true);
+
+ // [[min_lat, min_lng], [max_lat, max_lng]] - a real box, not the inverted
+ // world box [[90,180],[-90,-180]] that means "no location".
+ $this->assertLessThanOrEqual($bounds[1][0], $bounds[0][0], 'Should be a real bounding box, not the inverted world box');
+ $this->assertEqualsWithDelta(54.19, $bounds[0][0], 0.001);
+ $this->assertEqualsWithDelta(-3.09, $bounds[0][1], 0.001);
+ }
+
+ public function testGroupsPageFallsBackToTheWorldWhenTheCountryHasNoGroups(): void
+ {
+ $this->loginAsTestUser(Role::ADMINISTRATOR);
+ $id = $this->createGroup('Group Somewhere Else');
+
+ $group = Group::find($id);
+ $group->country_code = 'GB';
+ $group->latitude = 54.19;
+ $group->longitude = -3.09;
+ $group->save();
+
+ // Country with no groups in it: nothing to frame, so we fall back to the
+ // inverted world box, which the map reads as "show me everything".
+ $user = \App\User::factory()->restarter()->create([
+ 'country_code' => 'NZ',
+ 'location' => null,
+ 'latitude' => null,
+ 'longitude' => null,
+ ]);
+ $this->actingAs($user);
+
+ $props = $this->getVueProperties($this->get('/group/other'));
+ $bounds = json_decode($props[1][':nearby-groups'], true);
+
+ $this->assertEquals([[90, 180], [-90, -180]], $bounds);
+ }
+
+ public function testNextEventIsTheSoonestNotTheFurthestAway(): void
+ {
+ $this->loginAsTestUser(Role::ADMINISTRATOR);
+
+ // A group with more than one upcoming event. The existing coverage only
+ // ever gave a group a single future event, which can't tell "soonest" from
+ // "furthest away" - and the summary API was returning the latter.
+ $id = $this->createGroup('Group With Two Events');
+
+ $soon = Carbon::parse('1pm tomorrow');
+ $later = Carbon::parse('1pm +5 months');
+
+ // Created furthest-first, so a test that happens to pass on insertion order
+ // rather than on the ordering we asked for would still fail here.
+ Party::factory()->create([
+ 'group' => $id,
+ 'approved' => true,
+ 'event_start_utc' => $later->toIso8601String(),
+ 'event_end_utc' => $later->copy()->addHours(2)->toIso8601String(),
+ ]);
+ $soonest = Party::factory()->create([
+ 'group' => $id,
+ 'approved' => true,
+ 'event_start_utc' => $soon->toIso8601String(),
+ 'event_end_utc' => $soon->copy()->addHours(2)->toIso8601String(),
+ ]);
+
+ // The upcoming events are cached globally, not per group.
+ Cache::forget('future_approved_events');
+
+ $response = $this->get('/api/v2/groups/summary?includeNextEvent=true');
+ $response->assertSuccessful();
+
+ $group = collect($response->json('data'))->firstWhere('id', $id);
+ $this->assertNotNull($group, "Group (id=$id) not found in API response");
+ $this->assertNotNull($group['next_event'], 'Group should have a next_event');
+ $this->assertEquals($soonest->idevents, $group['next_event']['id'], 'next_event should be the soonest upcoming event, not the furthest away');
+ }
+
+ public function testNextEventIgnoresUnapprovedEvents(): void
+ {
+ $this->loginAsTestUser(Role::ADMINISTRATOR);
+
+ // The group page only ever counts approved events as the "next" one
+ // (Group::getNextUpcomingEvent), but the summary API used to take any
+ // future event - so an unapproved event showed on the public map as the
+ // group's next event while the group's own page ignored it.
+ $id = $this->createGroup('Group With An Unapproved Event');
+
+ $soon = Carbon::parse('1pm tomorrow');
+ $later = Carbon::parse('1pm +3 months');
+
+ Party::factory()->create([
+ 'group' => $id,
+ 'approved' => false,
+ 'event_start_utc' => $soon->toIso8601String(),
+ 'event_end_utc' => $soon->copy()->addHours(2)->toIso8601String(),
+ ]);
+ $approved = Party::factory()->create([
+ 'group' => $id,
+ 'approved' => true,
+ 'event_start_utc' => $later->toIso8601String(),
+ 'event_end_utc' => $later->copy()->addHours(2)->toIso8601String(),
+ ]);
+
+ Cache::forget('future_approved_events');
+
+ $response = $this->get('/api/v2/groups/summary?includeNextEvent=true');
+ $response->assertSuccessful();
+
+ $group = collect($response->json('data'))->firstWhere('id', $id);
+ $this->assertNotNull($group, "Group (id=$id) not found in API response");
+ $this->assertNotNull($group['next_event'], 'Group should have a next_event');
+ $this->assertEquals($approved->idevents, $group['next_event']['id'], 'next_event should skip the unapproved event and use the approved one');
+ }
+
+ public function testNextEventIsNullWhenTheOnlyUpcomingEventIsUnapproved(): void
+ {
+ $this->loginAsTestUser(Role::ADMINISTRATOR);
+
+ $id = $this->createGroup('Group With Only An Unapproved Event');
+
+ $soon = Carbon::parse('1pm tomorrow');
+ Party::factory()->create([
+ 'group' => $id,
+ 'approved' => false,
+ 'event_start_utc' => $soon->toIso8601String(),
+ 'event_end_utc' => $soon->copy()->addHours(2)->toIso8601String(),
+ ]);
+
+ Cache::forget('future_approved_events');
+
+ $response = $this->get('/api/v2/groups/summary?includeNextEvent=true');
+ $response->assertSuccessful();
+
+ $group = collect($response->json('data'))->firstWhere('id', $id);
+ $this->assertNotNull($group, "Group (id=$id) not found in API response");
+ $this->assertNull($group['next_event'], 'An unapproved event must not be advertised as the next event');
+ }
+
+ public function testFutureScopeReturnsSoonestFirst(): void
+ {
+ // The summary API relies on Party::future() coming back in ascending date
+ // order. scopeUndeleted() applies a DESC order and orderBy() appends, so
+ // this needs an explicit reorder() to hold.
+ $this->loginAsTestUser(Role::ADMINISTRATOR);
+ $id = $this->createGroup('Group For Scope Ordering');
+
+ foreach (['1pm +3 months', '1pm tomorrow', '1pm +5 months'] as $when) {
+ $at = Carbon::parse($when);
+ Party::factory()->create([
+ 'group' => $id,
+ 'approved' => true,
+ 'event_start_utc' => $at->toIso8601String(),
+ 'event_end_utc' => $at->copy()->addHours(2)->toIso8601String(),
+ ]);
}
- $this->assertNotNull($allGroupsJson, 'Could not find :all-groups prop. Props found: ' . json_encode(array_map('array_keys', $props)));
- $groups = json_decode($allGroupsJson, true);
- $group1 = collect($groups)->firstWhere('idgroups', $id1);
- $group2 = collect($groups)->firstWhere('idgroups', $id2);
+ $starts = Party::future()->forGroup($id)->get()->pluck('event_start_utc')->map(function ($d) {
+ return Carbon::parse($d)->timestamp;
+ })->all();
- $this->assertNotNull($group1['next_event'], 'Group 1 should have a next_event');
- $this->assertNotNull($group2['next_event'], 'Group 2 should have a next_event');
+ $sorted = $starts;
+ sort($sorted);
+ $this->assertEquals($sorted, $starts, 'Party::future() should return the soonest event first');
}
public function testGroupIndexQueryCountScalesWithO1NotN(): void
diff --git a/tests/Feature/Networks/NetworkTest.php b/tests/Feature/Networks/NetworkTest.php
index 416d96117d..b256b88299 100644
--- a/tests/Feature/Networks/NetworkTest.php
+++ b/tests/Feature/Networks/NetworkTest.php
@@ -264,10 +264,6 @@ public function network_page(): void
$response = $this->get('/networks/' . $network->id);
$response->assertSee($coordinator->name);
- // Group should not show on network page yet.
- $response = $this->get('/group/network/' . $network->id);
- $response->assertDontSee('"networks":[' . $network->id . ']');
-
// Add the group.
$response = $this->get('/networks/' . $network->id);
$crawler = new Crawler($response->getContent());
@@ -285,10 +281,21 @@ public function network_page(): void
]);
$response->assertRedirect();
- // Group should now show on network page and in encoded list of networks for a group.
+ // /group/network/{id} is retired: coordinators see their groups on the
+ // network page itself (map + list). Old links redirect there.
$response = $this->get('/group/network/' . $network->id);
- $response->assertSee($group->name);
- $response->assertSee('"networks":[' . $network->id . ']', false);
+ $response->assertRedirect('/networks/' . $network->id);
+
+ // The network page embeds the map/list scoped to this network.
+ $response = $this->get('/networks/' . $network->id);
+ $response->assertSuccessful();
+ $response->assertSee(':network="{"id":' . $network->id, false);
+
+ // And the group must be in the API data the page will fetch.
+ $response = $this->get('/api/v2/groups/summary');
+ $summary = collect($response->json('data'))->firstWhere('id', $group->idgroups);
+ $this->assertNotNull($summary);
+ $this->assertEquals($network->id, collect($summary['networks'])->first()['id']);
// All networks list visible to admin.
$this->loginAsTestUser(Role::ADMINISTRATOR);
diff --git a/tests/Unit/GroupFilterTranslationsTest.php b/tests/Unit/GroupFilterTranslationsTest.php
new file mode 100644
index 0000000000..88cac8937a
--- /dev/null
+++ b/tests/Unit/GroupFilterTranslationsTest.php
@@ -0,0 +1,48 @@
+assertFileExists($path, "Missing lang file for locale '$locale'");
+
+ // Read via the filesystem helper rather than require(): the lang
+ // file may already have been included elsewhere, in which case
+ // require_once would return true instead of the translations array.
+ $translations = (new Filesystem())->getRequire($path);
+
+ foreach ($keys as $key) {
+ $this->assertArrayHasKey(
+ $key,
+ $translations,
+ "Missing translation key 'groups.$key' for locale '$locale'"
+ );
+ $this->assertNotSame('', trim((string) $translations[$key]), "Empty 'groups.$key' for locale '$locale'");
+ }
+ }
+ }
+}
diff --git a/tests/__mocks__/leaflet-control-geocoder.js b/tests/__mocks__/leaflet-control-geocoder.js
new file mode 100644
index 0000000000..cc8882da7c
--- /dev/null
+++ b/tests/__mocks__/leaflet-control-geocoder.js
@@ -0,0 +1,10 @@
+// Stub for the leaflet-control-geocoder CommonJS subpath imports used by
+// GroupMap.vue. The real package is a runtime-only dependency; we never
+// exercise its behaviour from Jest, so any plain class is fine.
+class Stub {
+ constructor() {}
+ on() { return this }
+ addTo() { return this }
+ setQuery() {}
+}
+module.exports = { Geocoder: Stub, Photon: Stub, default: Stub }
diff --git a/tests/__mocks__/vue-awesome.js b/tests/__mocks__/vue-awesome.js
new file mode 100644
index 0000000000..8926fb88fb
--- /dev/null
+++ b/tests/__mocks__/vue-awesome.js
@@ -0,0 +1,4 @@
+// Stub for vue-awesome's Icon component (ES module export trips up babel-jest's
+// CJS transformer when imported transitively from a Vue component under test).
+module.exports = { name: 'fa-icon', template: '
' }
+module.exports.default = module.exports