Skip to content

Commit f4de880

Browse files
committed
enforce role-based project access and persist Rapid changesets
- add project role resolution composable and project-role lookup support - gate project creation, contributor management, tabs, and task actions by user role - capture Rapid upload changeset IDs and submit them with completed mapping - switch workspace/project flows to the new tasking API env configuration - add frontend guidelines doc and minor project detail UI/map cleanup
1 parent 6431885 commit f4de880

10 files changed

Lines changed: 313 additions & 108 deletions

File tree

components/workspace-project-details/ContributorsTab.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
</div>
88

99
<button
10+
v-if="props.canManage"
1011
class="btn btn-primary project-detail-contributors-add"
1112
type="button"
1213
:disabled="props.addingContributor"
@@ -68,13 +69,14 @@
6869
<app-select
6970
:model-value="localRoles[contributor.id] ?? contributor.role"
7071
:aria-label="`Change role for ${contributor.name}`"
71-
:disabled="props.updatingContributorId === contributor.id"
72+
:disabled="!props.canManage || props.updatingContributorId === contributor.id"
7273
:id="`project-detail-contributor-role-${contributor.id}`"
7374
:options="rowRoleOptions"
7475
@update:model-value="handleRoleSelectUpdate(contributor, $event)"
7576
/>
7677

7778
<button
79+
v-if="props.canManage"
7880
class="btn btn-outline-danger project-detail-contributors-delete"
7981
type="button"
8082
:disabled="props.updatingContributorId === contributor.id"
@@ -181,6 +183,8 @@ interface SelectOption {
181183
}
182184
183185
interface Props {
186+
/** Whether the current user may add, update, or remove contributors. */
187+
canManage: boolean;
184188
addingContributor: boolean;
185189
availableUsers: ProjectWizardWorkspaceUser[];
186190
availableUsersLoading: boolean;

components/workspace-project-details/ProjectMap.vue

Lines changed: 48 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
<template>
22
<!--
3-
Interactive MapLibre GL map for the project detail page.
4-
5-
Responsibilities:
63
- Renders the Area of Interest (AOI) as a semi-transparent fill with a dashed outline.
74
- Renders individual project tasks as coloured polygons (colour = task status).
85
- Renders a task-grid preview (used during the task-setup workflow).
@@ -84,30 +81,19 @@ import type {
8481
} from '~/types/project-wizard';
8582
8683
interface Props {
87-
/** The project's area-of-interest polygon. Null when the project has no AOI yet. */
8884
aoi: WorkspaceProjectAoiFeature | null;
89-
/** The task ID that is currently highlighted/selected. */
9085
selectedTaskId?: string | null;
91-
/**
92-
* Optional task grid to show on the map. Used in two scenarios:
93-
* - Preview grid (task-setup workflow, before tasks are saved)
94-
* - Persisted task grid (built from saved task geometries on the detail page)
95-
*/
9686
taskGrid?: ProjectWizardGeneratedTaskFeatureCollection | ProjectWizardTaskPreviewFeatureCollection | null;
97-
/** Saved project tasks. Each task has geometry + status used to colour the polygon. */
9887
tasks: WorkspaceProjectTaskListItem[];
9988
}
10089
10190
const props = defineProps<Props>();
10291
const emit = defineEmits<{
103-
/** Fired when the user clicks on a task polygon. The parent should update `selectedTaskId`. */
10492
'select-task': [taskId: string];
10593
}>();
10694
107-
/** Ref to the div that MapLibre will mount into. */
10895
const mapRef = useTemplateRef<HTMLDivElement>('mapRef');
10996
110-
// ─── MapLibre Source & Layer IDs ─────────────────────────────────────────────
11197
const AOI_SOURCE_ID = 'project-detail-aoi';
11298
const AOI_OUTLINE_SOURCE_ID = 'project-detail-aoi-outline';
11399
const TASK_SOURCE_ID = 'project-detail-tasks';
@@ -118,14 +104,8 @@ const TASK_FILL_ID = 'project-detail-task-fill';
118104
const TASK_LINE_ID = 'project-detail-task-line';
119105
const TASK_GRID_FILL_ID = 'project-detail-task-grid-fill';
120106
const TASK_GRID_LINE_ID = 'project-detail-task-grid-line';
121-
122-
/** Default map center (Seattle) shown when no AOI/tasks are available yet. */
123107
const DEFAULT_CENTER: [number, number] = [-122.3321, 47.6062];
124108
125-
/**
126-
* Minimal MapLibre style that only loads OpenStreetMap raster tiles.
127-
* We don't use a hosted style (e.g. MapTiler) to keep things self-contained and free.
128-
*/
129109
const baseMapStyle: StyleSpecification = {
130110
version: 8,
131111
sources: {
@@ -146,48 +126,31 @@ const baseMapStyle: StyleSpecification = {
146126
],
147127
};
148128
149-
/**
150-
* A reusable empty FeatureCollection used to initialise MapLibre sources.
151-
* Sources must have valid GeoJSON at creation time, so we give them this placeholder
152-
* and update them later via `setData()` once real data is available.
153-
*/
154129
const emptyCollection: FeatureCollection = {
155130
type: 'FeatureCollection',
156131
features: [],
157132
};
158133
159-
/** Colours to display in the legend — must stay in sync with the MapLibre paint expressions below. */
160134
const legendItems = [
161135
{ label: 'Ready for mapping', color: '#fde9aa' },
162136
{ label: 'Ready for validation', color: '#a8d8f8' },
163137
{ label: 'More mapping needed', color: '#f8be90' },
164138
{ label: 'Completed', color: '#aae8cd' },
165139
];
166140
167-
/**
168-
* Module-level references to the MapLibre instances.
169-
* These are intentionally NOT Vue refs because MapLibre manages its own internal state —
170-
* wrapping the map object in Vue's reactivity system would cause unnecessary overhead.
171-
* We only interact with them through regular function calls.
172-
*/
173141
let maplibregl: typeof import('maplibre-gl') | null = null;
174142
let detailMap: import('maplibre-gl').Map | null = null;
175-
/** Holds references to DOM Marker instances for locked tasks so we can remove them on update. */
176143
let lockedTaskMarkers: Marker[] = [];
177144
let mapResizeObserver: ResizeObserver | null = null;
145+
const mapReady = ref(false);
146+
let hasFittedInitialBounds = false;
178147
179-
/** Show the legend only when there's something meaningful to show on the map. */
180148
const showLegend = computed(() =>
181149
Boolean(props.aoi)
182150
|| props.tasks.length > 0
183151
|| Boolean(props.taskGrid?.features.length),
184152
);
185153
186-
/**
187-
* Convert the task list items into a GeoJSON FeatureCollection that MapLibre can render.
188-
* Each feature carries `status`, `selected`, and `locked` properties which are used
189-
* by the paint expressions on the task fill/line layers to drive colours and widths.
190-
*/
191154
const taskFeatureCollection = computed<FeatureCollection<Polygon>>(() => ({
192155
type: 'FeatureCollection',
193156
features: props.tasks
@@ -205,11 +168,6 @@ const taskFeatureCollection = computed<FeatureCollection<Polygon>>(() => ({
205168
})),
206169
}));
207170
208-
/**
209-
* The AOI polygon converted to LineStrings for the dashed-outline layer.
210-
* We use a separate source for the outline because MapLibre doesn't support
211-
* dashed fill-outlines directly — you need a dedicated `line` layer.
212-
*/
213171
const aoiOutlineFeatureCollection = computed<FeatureCollection<LineString | MultiLineString>>(() => ({
214172
type: 'FeatureCollection',
215173
features: props.aoi ? createOutlineFeatures(props.aoi.geometry) : [],
@@ -237,10 +195,8 @@ const lockedTaskMarkersData = computed(() =>
237195
);
238196
239197
/**
240-
* Lazy-load MapLibre GL on mount so it doesn't bloat the initial page bundle.
241-
* The map is only needed when this component is actually rendered, not on every page.
242198
*
243-
* Setup order matters:
199+
* Setup order:
244200
* 1. Create the Map instance.
245201
* 2. Add the navigation control (zoom buttons).
246202
* 3. Wait for the `load` event — sources/layers can only be added after the style is ready.
@@ -268,23 +224,46 @@ onMounted(async () => {
268224
bindInteractionHandlers();
269225
syncSources();
270226
fitMapToData();
227+
mapReady.value = true;
228+
setTimeout(() => {
229+
if (!detailMap) return;
230+
syncSources();
231+
fitMapToData();
232+
}, 300);
271233
});
272234
273235
mapResizeObserver = new ResizeObserver(() => {
274-
detailMap?.resize();
236+
if (!detailMap) return;
237+
detailMap.resize();
238+
const container = detailMap.getContainer();
239+
if (container.clientWidth > 0 && container.clientHeight > 0 && !hasFittedInitialBounds && mapReady.value) {
240+
fitMapToData();
241+
}
275242
});
276243
mapResizeObserver.observe(mapRef.value);
277244
});
278245
279246
/**
280247
* When any of the key data props change (AOI, tasks, task grid), push the updated
281248
* FeatureCollections into the MapLibre sources and re-fit the viewport.
282-
* `deep: true` is required here because the FeatureCollection objects are complex nested structures.
283-
*/
249+
*
250+
**/
284251
watch(
285-
() => [props.aoi, taskFeatureCollection.value, props.taskGrid],
252+
[
253+
() => props.aoi,
254+
() => props.tasks,
255+
() => props.taskGrid,
256+
() => taskFeatureCollection.value,
257+
() => mapReady.value,
258+
],
286259
() => {
287-
detailMap?.resize();
260+
// Whenever props change, reset the fit flag so we attempt to fit the new data
261+
hasFittedInitialBounds = false;
262+
263+
if (!mapReady.value) {
264+
return;
265+
}
266+
288267
syncSources();
289268
fitMapToData();
290269
},
@@ -304,8 +283,6 @@ onBeforeUnmount(() => {
304283
});
305284
306285
/**
307-
* Create all MapLibre sources and layers exactly once, after the style has loaded.
308-
* We guard with `getSource(AOI_SOURCE_ID)` so this is idempotent — calling it twice is safe.
309286
*
310287
* Layer order (painter's algorithm — later layers paint over earlier ones):
311288
* aoiFill → taskGridFill → taskGridLine → taskFill → taskLine → aoiLine
@@ -438,11 +415,6 @@ function ensureLayers() {
438415
detailMap.addLayer(aoiLineLayer);
439416
}
440417
441-
/**
442-
* Wire up mouse-enter/leave (cursor changes) and click (task selection) events.
443-
* We listen on both the fill AND line layers because a click on the border of two
444-
* tasks might only hit the line layer, not the fill.
445-
*/
446418
function bindInteractionHandlers() {
447419
if (!detailMap) {
448420
return;
@@ -455,16 +427,9 @@ function bindInteractionHandlers() {
455427
detailMap.on('click', TASK_FILL_ID, handleTaskClick);
456428
detailMap.on('click', TASK_LINE_ID, handleTaskClick);
457429
}
458-
459-
/**
460-
* Push the latest data from props/computed values into the MapLibre GeoJSON sources.
461-
* This is called after the style loads (initial render) and every time watched props change.
462-
*
463-
* We guard with `isStyleLoaded()` because `setData()` will throw if called before the
464-
* style is ready — for example, if props change before the async `load` event fires.
465-
*/
430+
// sync the computed FeatureCollections into the MapLibre sources so they render on the map.
466431
function syncSources() {
467-
if (!detailMap || !detailMap.isStyleLoaded()) {
432+
if (!detailMap) {
468433
return;
469434
}
470435
@@ -473,10 +438,14 @@ function syncSources() {
473438
const taskSource = detailMap.getSource(TASK_SOURCE_ID) as GeoJSONSource | undefined;
474439
const taskGridSource = detailMap.getSource(TASK_GRID_SOURCE_ID) as GeoJSONSource | undefined;
475440
476-
aoiSource?.setData(props.aoi ?? emptyCollection);
477-
aoiOutlineSource?.setData(aoiOutlineFeatureCollection.value);
478-
taskSource?.setData(taskFeatureCollection.value);
479-
taskGridSource?.setData(props.taskGrid ?? emptyCollection);
441+
if (!aoiSource || !aoiOutlineSource || !taskSource || !taskGridSource) {
442+
return;
443+
}
444+
445+
aoiSource.setData(props.aoi ?? emptyCollection);
446+
aoiOutlineSource.setData(aoiOutlineFeatureCollection.value);
447+
taskSource.setData(taskFeatureCollection.value);
448+
taskGridSource.setData(props.taskGrid ?? emptyCollection);
480449
481450
// Once tasks or a generated grid exist, remove the AOI wash so the task cells stay legible.
482451
const hasTaskOverlay = taskFeatureCollection.value.features.length > 0
@@ -486,18 +455,16 @@ function syncSources() {
486455
syncLockedTaskMarkers();
487456
}
488457
489-
/**
490-
* Zoom and pan the map to frame all available data.
491-
* Priority: task bounds > task-grid bounds > AOI bounds > default center.
492-
* On the tasks tab, real task geometry is the most important context — if we fit to a large AOI
493-
* first, newly created tasks can be technically present but visually imperceptible on initial load.
494-
* If nothing is available yet, we just ease back to the default Seattle center.
495-
*/
496458
function fitMapToData() {
497459
if (!detailMap) {
498460
return;
499461
}
500462
463+
const container = detailMap.getContainer();
464+
if (container.clientWidth === 0 || container.clientHeight === 0) {
465+
return;
466+
}
467+
501468
const aoiBounds = props.aoi ? getFeatureBounds(props.aoi.geometry) : null;
502469
const taskBounds = taskFeatureCollection.value.features.length > 0
503470
? getFeatureCollectionBounds(taskFeatureCollection.value)
@@ -523,13 +490,10 @@ function fitMapToData() {
523490
maxZoom: 14,
524491
essential: true,
525492
});
493+
494+
hasFittedInitialBounds = true;
526495
}
527496
528-
/**
529-
* Returns the padding (in px) to apply when fitting the map to feature bounds.
530-
* Expressed as an object so we can later make per-side adjustments if the UI changes
531-
* (e.g. a side-panel overlapping the left side of the map).
532-
*/
533497
function resolvePadding(): PaddingOptions {
534498
return {
535499
top: 64,
@@ -748,7 +712,6 @@ function handleTaskMouseLeave() {
748712
/**
749713
* Emit the clicked task's ID to the parent page.
750714
* We validate that the feature has a non-empty string `id` property before emitting
751-
* to avoid passing null/undefined IDs when the user clicks on the map background.
752715
*/
753716
function handleTaskClick(event: MapLayerMouseEvent) {
754717
const clickedTask = event.features?.[0]?.properties?.id;

components/workspace-project-details/TasksTab.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,8 +428,8 @@ function formatTaskStatus(status: WorkspaceProjectTaskStatus) {
428428
.project-detail-task-list-header,
429429
.project-detail-task-item {
430430
display: grid;
431-
grid-template-columns: minmax(8.5rem, 1fr) minmax(12rem, 1.55fr) minmax(9rem, 1fr) minmax(7.75rem, 0.95fr);
432-
gap: 0.9rem;
431+
grid-template-columns: minmax(5.5rem, 1fr) minmax(9.5rem, 1.4fr) minmax(7.5rem, 1fr) minmax(6.5rem, 0.9fr);
432+
gap: 0.75rem;
433433
}
434434
435435
.project-detail-task-list-header {

0 commit comments

Comments
 (0)