Skip to content

Commit 926dd87

Browse files
authored
Add "Download GeoJSON" menu option (#2138)
1 parent fc4a4d1 commit 926dd87

9 files changed

+378
-11
lines changed

firebase.json

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"source": "/exportCsv",
3232
"function": "exportCsv"
3333
},
34+
{
35+
"source": "/exportGeojson",
36+
"function": "exportGeojson"
37+
},
3438
{
3539
"source": "/sessionLogin",
3640
"function": "sessionLogin"

firebase.local.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"type": 301
2222
}
2323
],
24-
"rewrites": [
24+
"rewrites": [
2525
{
2626
"source": "/importGeoJson",
2727
"function": "importGeoJson"
@@ -30,6 +30,10 @@
3030
"source": "/exportCsv",
3131
"function": "exportCsv"
3232
},
33+
{
34+
"source": "/exportGeojson",
35+
"function": "exportGeojson"
36+
},
3337
{
3438
"source": "**/!(*.*)",
3539
"destination": "/index.html"

functions/src/export-csv.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,6 @@ export async function exportCsvHandler(
5353
}
5454
const ownerId = canImport(user, surveyDoc) ? undefined : userId;
5555

56-
console.log(
57-
`Exporting survey '${surveyId}', job '${jobId}', owner '${
58-
ownerId || 'survey organizer'
59-
}'`
60-
);
61-
6256
const jobDoc = await db.fetchJob(surveyId, jobId);
6357
if (!jobDoc.exists || !jobDoc.data()) {
6458
res.status(HttpStatus.NOT_FOUND).send('Job not found');
@@ -82,6 +76,7 @@ export async function exportCsvHandler(
8276
'Content-Disposition',
8377
'attachment; filename=' + getFileName(jobName)
8478
);
79+
8580
const csvStream = csv.format({
8681
delimiter: ',',
8782
headers,
@@ -98,8 +93,7 @@ export async function exportCsvHandler(
9893
const [loiDoc, submissionDoc] = row;
9994
const loi = toMessage(loiDoc.data(), Pb.LocationOfInterest);
10095
if (loi instanceof Error) throw loi;
101-
if (!isAccessibleLoi(loi, ownerId)) return;
102-
if (submissionDoc) {
96+
if (isAccessibleLoi(loi, ownerId) && submissionDoc) {
10397
const submission = toMessage(submissionDoc.data(), Pb.Submission);
10498
if (submission instanceof Error) throw submission;
10599
writeRow(csvStream, loiProperties, tasks, loi, submission);

functions/src/export-geojson.spec.ts

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* Copyright 2024 The Ground Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
createMockFirestore,
19+
stubAdminApi,
20+
} from '@ground/lib/dist/testing/firestore';
21+
import {
22+
createGetRequestSpy,
23+
createResponseSpy,
24+
} from './testing/http-test-helpers';
25+
import {DecodedIdToken} from 'firebase-admin/auth';
26+
import HttpStatus from 'http-status-codes';
27+
import {SURVEY_ORGANIZER_ROLE} from './common/auth';
28+
import {resetDatastore} from './common/context';
29+
import {Firestore} from 'firebase-admin/firestore';
30+
import {exportGeojsonHandler} from './export-geojson';
31+
import {registry} from '@ground/lib';
32+
import {GroundProtos} from '@ground/proto';
33+
34+
import Pb = GroundProtos.ground.v1beta1;
35+
const sv = registry.getFieldIds(Pb.Survey);
36+
const j = registry.getFieldIds(Pb.Job);
37+
const t = registry.getFieldIds(Pb.Task);
38+
const l = registry.getFieldIds(Pb.LocationOfInterest);
39+
const pr = registry.getFieldIds(Pb.LocationOfInterest.Property);
40+
const p = registry.getFieldIds(Pb.Point);
41+
const c = registry.getFieldIds(Pb.Coordinates);
42+
const g = registry.getFieldIds(Pb.Geometry);
43+
const mq = registry.getFieldIds(Pb.Task.MultipleChoiceQuestion);
44+
const op = registry.getFieldIds(Pb.Task.MultipleChoiceQuestion.Option);
45+
46+
describe('export()', () => {
47+
let mockFirestore: Firestore;
48+
const email = '[email protected]';
49+
const survey = {
50+
[sv.name]: 'Test survey',
51+
[sv.acl]: {
52+
[email]: SURVEY_ORGANIZER_ROLE,
53+
},
54+
};
55+
const job1 = {
56+
id: 'job123',
57+
[j.name]: 'Test job',
58+
[j.tasks]: [
59+
{
60+
[t.id]: 'task001',
61+
[t.prompt]: 'What is the meaning of life?',
62+
[t.textQuestion]: {
63+
['1' /* type */]: Pb.Task.TextQuestion.Type.SHORT_TEXT,
64+
},
65+
},
66+
{
67+
[t.id]: 'task002',
68+
[t.prompt]: 'How much?',
69+
[t.numberQuestion]: {
70+
['1' /* type */]: Pb.Task.NumberQuestion.Type.FLOAT,
71+
},
72+
},
73+
{
74+
[t.id]: 'task003',
75+
[t.prompt]: 'When?',
76+
[t.dateTimeQuestion]: {
77+
['1' /* type */]: Pb.Task.DateTimeQuestion.Type.BOTH_DATE_AND_TIME,
78+
},
79+
},
80+
{
81+
[t.id]: 'task004',
82+
[t.prompt]: 'Which ones?',
83+
[t.multipleChoiceQuestion]: {
84+
[mq.type]: Pb.Task.MultipleChoiceQuestion.Type.SELECT_MULTIPLE,
85+
[mq.options]: [
86+
{
87+
[op.id]: 'aaa',
88+
[op.index]: 1,
89+
[op.label]: 'AAA',
90+
},
91+
{
92+
[op.id]: 'bbb',
93+
[op.index]: 2,
94+
[op.label]: 'BBB',
95+
},
96+
],
97+
[mq.hasOtherOption]: true,
98+
},
99+
},
100+
{
101+
[t.id]: 'task005',
102+
[t.prompt]: 'Where are you now?',
103+
[t.captureLocation]: {
104+
['1' /* min_accuracy_meters */]: 999999,
105+
},
106+
},
107+
{
108+
[t.id]: 'task006',
109+
[t.prompt]: 'Take a photo',
110+
[t.takePhoto]: {
111+
['1' /* min_heading_degrees */]: 0,
112+
['2' /* max_heading_degrees */]: 360,
113+
},
114+
},
115+
],
116+
};
117+
const pointLoi1 = {
118+
id: 'loi100',
119+
[l.id]: 'loi100',
120+
[l.jobId]: job1.id,
121+
[l.customTag]: 'POINT_001',
122+
[l.geometry]: {
123+
[g.point]: {[p.coordinates]: {[c.latitude]: 10.1, [c.longitude]: 125.6}},
124+
},
125+
[l.submissionCount]: 0,
126+
[l.source]: Pb.LocationOfInterest.Source.IMPORTED,
127+
[l.properties]: {
128+
name: {[pr.stringValue]: 'Dinagat Islands'},
129+
area: {[pr.numericValue]: 3.08},
130+
},
131+
};
132+
const testCases = [
133+
{
134+
desc: 'export point',
135+
jobId: job1.id,
136+
survey: survey,
137+
jobs: [job1],
138+
lois: [pointLoi1],
139+
expectedFilename: 'test-job.geojson',
140+
expectedGeojson: {
141+
type: 'FeatureCollection',
142+
features: [
143+
{
144+
type: 'Feature',
145+
properties: {name: 'Dinagat Islands', area: 3.08},
146+
geometry: {type: 'Point', coordinates: [125.6, 10.1]},
147+
},
148+
{
149+
type: 'Feature',
150+
properties: null,
151+
geometry: {type: 'Point', coordinates: [8.3, 47.05]},
152+
},
153+
],
154+
},
155+
},
156+
];
157+
158+
beforeEach(() => {
159+
mockFirestore = createMockFirestore();
160+
stubAdminApi(mockFirestore);
161+
});
162+
163+
afterEach(() => {
164+
resetDatastore();
165+
});
166+
167+
testCases.forEach(({desc, jobId, survey, jobs, lois, expectedFilename}) =>
168+
it(desc, async () => {
169+
// Populate database.
170+
mockFirestore.doc(`surveys/${survey.id}`).set(survey);
171+
jobs?.forEach(({id, ...job}) =>
172+
mockFirestore.doc(`surveys/${survey.id}/jobs/${id}`).set(job)
173+
);
174+
lois?.forEach(({id, ...loi}) =>
175+
mockFirestore.doc(`surveys/${survey.id}/lois/${id}`).set(loi)
176+
);
177+
178+
// Build mock request and response.
179+
const req = await createGetRequestSpy({
180+
url: '/exportGeojson',
181+
query: {
182+
survey: survey.id,
183+
job: jobId,
184+
},
185+
});
186+
const chunks: string[] = [];
187+
const res = createResponseSpy(chunks);
188+
189+
// Run export handler.
190+
await exportGeojsonHandler(req, res, {email} as DecodedIdToken);
191+
192+
// Check post-conditions.
193+
expect(res.status).toHaveBeenCalledOnceWith(HttpStatus.OK);
194+
expect(res.type).toHaveBeenCalledOnceWith('application/json');
195+
expect(res.setHeader).toHaveBeenCalledOnceWith(
196+
'Content-Disposition',
197+
`attachment; filename=${expectedFilename}`
198+
);
199+
})
200+
);
201+
});

0 commit comments

Comments
 (0)