Skip to content

Commit d1e69c0

Browse files
authored
Feature-3984: Edit Project Implementation (#90)
# DevBoard Task https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3984/ ## Changes Implemented ### Project Editing - Added a project edit page at: ```text /workspace/:workspaceId/projects/:projectId/edit ``` - Restricted project editing to project leads. - Added editable sections for: - Project description - Rich-text instructions - Custom imagery - Team members and roles - Project configuration - Project actions - Added dirty-state detection and confirmation before leaving with unsaved changes. - Disabled configuration changes after tasks have been generated or the project has left draft status. - Added project actions: - Reset all tasks - Close project - Permanently delete project - Added API methods for updating, resetting, closing, and deleting projects. - Added an edit button to the project details page for project leads. - Standardized task mutation errors through the shared HTTP error resolver. ### Shared API Error Handling Expanded `resolveHttpErrorMessage()` to support: - `detail` strings - Nested `detail.message` objects - FastAPI validation arrays using `msg` - Validation arrays using `message` - Top-level `message` - Top-level `error` - Plain-text responses - Existing-lock metadata containing `task_number` - Caller-provided fallback messages The shared resolver is used by: - Project creation and editing - Contributor mutations - Task generation and saving - Task locking and unlocking - Task submission ### Custom Imagery - Added custom-imagery input to project creation and editing. - Added JSON parsing and schema validation. - Added validation debouncing and stale-request protection. - Added typed custom-imagery models using `ImagerySource`. - Added conversion from project imagery configuration to Rapid imagery configuration. - Added project imagery during initial Rapid startup. - Added imagery when switching an already-loaded Rapid editor between workspaces. - Added custom imagery to the initial Rapid map hash. - Selected the configured imagery as the active Rapid background. ### Rapid Authentication and Integration - Fixed an initialization race that allowed Rapid to fall back to its built-in OSM OAuth login. - Rapid authentication is now applied immediately after initialization begins. - Editor and uploader event registration happens after initialization finishes. - Prevented redirects to the OSM `/login` endpoint during changeset upload. ### Confirmation Dialog Accessibility Improved the confirmation dialog with: - `role="dialog"` - `aria-modal="true"` - Unique title IDs through `useId()` - Initial focus placement - Tab and Shift+Tab focus trapping - Escape-key handling - Focus restoration to the opening element - Disabled-state handling while actions are running - Event-listener cleanup during unmount ### Impacted Areas Of Testing #### Project Editing 1. Sign in as a project lead. 2. Open a project details page. 3. Confirm the edit button is visible. 4. Open the edit page. 5. Verify all sections appear in the sidebar. 6. Change the project description. 7. Change the instructions. 8. Save the project. 9. Return to the project details page. 10. Confirm the updated description and instructions are displayed. 11. Sign in as a non-lead. 12. Confirm the edit button is hidden. 13. Navigate directly to the edit URL. 14. Confirm access is rejected with a `403` response. **Contributor Management** 1. Open the **Team Members** section. 2. Search for a workspace user by name or email. 3. Assign a role and add the user. 4. Confirm the user appears in the project member list. 5. Change the user’s role. 6. Reload the page and confirm the new role persists. 7. Remove the user. **Project Actions** Reset Tasks 1. Open the **Actions** section. 2. Select **Reset All Tasks**. 3. Confirm the confirmation dialog appears. 4. Cancel and verify no action occurs. 5. Reopen and confirm the action. 6. Confirm navigation returns to the Tasks tab. 7. Confirm task assignments and progress were reset. Close Project 1. Select **Close Project**. 2. Confirm the action. 3. Confirm the project status becomes completed. Delete Project 1. Create a disposable project. 2. Select **Delete Project**. 3. Confirm the destructive action. 4. Confirm navigation returns to the project list. 5. Confirm the project no longer appears. Custom Imagery Validation 1. Open project creation or editing. 2. Enter invalid JSON. 3. Confirm a validation error appears. 4. Enter a JSON array instead of one imagery object. 5. Confirm it is rejected. 6. Enter an object that does not match the schema. 7. Confirm schema validation errors appear. 8. Enter a valid imagery object. 9. Confirm the error clears. 10. Save the project. 11. Reload the page. 12. Confirm the imagery JSON persists. Example: ```json { "id": "example-imagery", "name": "Example Imagery", "type": "xyz", "url": "https://tiles.example.com/{z}/{x}/{y}.png", "icon": "https://example.com/icon.svg", "description": "Example imagery", "extent": { "polygon": [], "max_zoom": 19 }, "attribution": { "required": true, "text": "Example attribution", "url": "https://example.com/attribution" } } ``` ## Rapid Custom Imagery 1. Open a task belonging to a project with custom imagery. 2. Confirm Rapid loads successfully. 3. Confirm the project imagery appears in the imagery list. 4. Confirm it is selected as the active background. 5. Return to the project page. 6. Open another project with different imagery without fully refreshing. 7. Confirm the second project’s imagery is added and selected. 8. Open a project without custom imagery. 9. Confirm Rapid still loads normally. ### Screenshots: <img width="1470" height="956" alt="Screenshot 2026-07-14 at 6 03 21 PM" src="https://github.com/user-attachments/assets/4027362e-d10d-4ed6-bf89-63d30075e81a" /> <img width="1470" height="956" alt="Screenshot 2026-07-14 at 6 03 34 PM" src="https://github.com/user-attachments/assets/bc695f12-8da6-48b7-8298-33c05eac74d8" /> <img width="1470" height="956" alt="Screenshot 2026-07-14 at 6 04 02 PM" src="https://github.com/user-attachments/assets/436fabe8-891b-44de-b4e3-3bc28d429909" /> <img width="1465" height="880" alt="Screenshot 2026-07-14 at 7 33 25 PM" src="https://github.com/user-attachments/assets/515ab52b-14d9-4b43-ae16-0c1f77a7d960" /> <img width="1470" height="871" alt="Screenshot 2026-07-14 at 7 33 37 PM" src="https://github.com/user-attachments/assets/c4b8be3c-36b1-4539-baf7-9b2fe77f6fc0" /> <img width="1470" height="878" alt="Screenshot 2026-07-14 at 7 33 47 PM" src="https://github.com/user-attachments/assets/992e917b-4315-4cbf-b0ed-ff89ae17a4de" /> <img width="1470" height="872" alt="Screenshot 2026-07-14 at 7 33 59 PM" src="https://github.com/user-attachments/assets/d8bcfb71-7b0f-4ba8-a443-e34567aeaef1" /> <img width="1470" height="828" alt="Screenshot 2026-07-14 at 7 34 05 PM" src="https://github.com/user-attachments/assets/30e5926f-05a4-4eb8-9fdf-845697884a5f" /> <img width="1470" height="779" alt="Screenshot 2026-07-14 at 7 34 13 PM" src="https://github.com/user-attachments/assets/1ee93c47-cd0e-4626-bb83-52fe5bfe0839" /> <img width="1470" height="956" alt="Screenshot 2026-07-16 at 1 46 16 PM" src="https://github.com/user-attachments/assets/d9dd111b-f916-4a9c-8fa2-c5dd192ea771" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary - Added project editing at `/workspace/:workspaceId/projects/:projectId/edit`, restricted to project leads, including editable fields for project description, rich-text instructions, custom imagery (single JSON object + schema validation), team members and roles, configuration (lock timeout + review-required), and project actions (reset tasks, close project, permanently delete). - Implemented the edit-page flow with per-section dirty-state detection (including rich-text normalization and trimmed comparisons), conditional Save enablement, and unsaved-change protection via both `onBeforeRouteLeave` and `beforeunload` using an accessible confirmation dialog. - Added project-edit support in the data/API layer and UI navigation: - New workspace project API methods: `updateWorkspaceProject`, `closeWorkspaceProject`, `resetWorkspaceProject`, and `deleteWorkspaceProject`, plus improved normalization (e.g., `description`/`summary`, nullable `instructions`, `custom_imagery`) and tighter user-role 404 handling. - Updated project details page to show an “Edit project” button for leads, and improved HTTP-aware error messaging throughout. - Introduced shared infrastructure and UX/accessibility improvements: - New `components/AppConfirmationDialog.vue` with focus restoration, Escape-to-close, Tab focus trapping, busy/disabled states, and spinner primary action. - Enhanced dialog/toast text wrapping (`overflow-wrap: anywhere`, including Toastify toast body layout) to prevent long unbroken messages from overflowing. - Improved rich-text editor interactions (prevent toolbar mousedown default, safer model update emitting, avoid re-setting content while focused). - Added imagery field validation UI/accessibility on wizard/edit steps (invalid/aria-describedby, role="alert" error messaging, and `aria-live` validating help). - Improved contributor UX with new error+retry state for user search, plus shared pagination via new `composables/usePagination` (applied to contributors and tasks tabs). - Hardened project creation/wizard behavior around custom imagery and persistence: - Wizard now validates custom imagery in the “details” step via `validateProjectCustomImagery` (debounced async + request-id to ignore stale results) and blocks progression/creation on validation failure. - Added strict parsing for custom imagery payloads (trims, ignores empty, requires a single non-array JSON object) and sanitizes review instructions HTML before persisting. - Improved wizard stored-state hydration/validation (`isValidProjectWizardStoredState`) and made localStorage persistence safer (debounced writes, try/catch for quota/access failures). - Improved Rapid integration for imagery + unsaved edits: - `RapidManager` init/switch now accepts an optional `customImagerySource` and wires it into Rapid after init/reset; Rapid auth patching was refactored. - Task editor navigation is blocked when Rapid has unsaved edits, using the confirmation dialog + route-leave/beforeunload interception. - Hash/background handling now injects background parameters based on `project.customImagery`. - Added supporting utilities/types and robustness changes: - `services/http.ts` now provides consistent HTTP error message resolution (JSON/non-JSON parsing, nested `detail` handling, lock/task-specific messaging). - `services/index.ts` now requires `VITE_NEW_API_URL` to be set. - `services/project-wizard.ts` switches name availability checks to a dedicated validate-name endpoint (`exists` boolean). - Added `util/rapid-imagery.ts` conversion from `ImagerySource` to Rapid imagery source. - Added `useProjectEditActions` (reset/close/delete flows with dialog + toasts + navigation) and `useProjectEditMembers` (role editing with lead constraint enforcement, optimistic updates, rollback on failure, and confirmation-based removals). <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents bc945e3 + f7e9406 commit d1e69c0

33 files changed

Lines changed: 3468 additions & 306 deletions

assets/scss/main.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,8 @@ label > .form-select:first-child {
313313
.tdei-select-option-selected {
314314
font-weight: 600;
315315
}
316+
317+
.Toastify__toast-body {
318+
min-width: 0;
319+
overflow-wrap: anywhere;
320+
}
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
<template>
2+
<div
3+
v-if="visible"
4+
class="app-confirmation-dialog-backdrop"
5+
role="presentation"
6+
>
7+
<section
8+
ref="dialogRef"
9+
class="app-confirmation-dialog"
10+
:class="`app-confirmation-dialog-theme-${primaryVariant}`"
11+
role="dialog"
12+
aria-modal="true"
13+
:aria-labelledby="titleId"
14+
tabindex="-1"
15+
>
16+
<button
17+
class="btn btn-link app-confirmation-dialog-close"
18+
type="button"
19+
:disabled="busy"
20+
aria-label="Close dialog"
21+
@click="emit('close')"
22+
>
23+
<app-icon
24+
variant="close"
25+
size="24"
26+
no-margin
27+
/>
28+
</button>
29+
30+
<div class="app-confirmation-dialog-icon-shell">
31+
<span class="app-confirmation-dialog-icon">
32+
<app-icon
33+
variant="info"
34+
size="22"
35+
no-margin
36+
/>
37+
</span>
38+
</div>
39+
40+
<h2
41+
:id="titleId"
42+
class="app-confirmation-dialog-title"
43+
>
44+
{{ title }}
45+
</h2>
46+
47+
<p class="app-confirmation-dialog-message">
48+
{{ message }}
49+
</p>
50+
51+
<div class="app-confirmation-dialog-actions">
52+
<button
53+
ref="secondaryActionRef"
54+
class="btn btn-outline-secondary app-confirmation-dialog-secondary"
55+
type="button"
56+
:disabled="busy"
57+
@click="emit('secondary-action')"
58+
>
59+
{{ secondaryActionLabel }}
60+
</button>
61+
62+
<button
63+
ref="primaryActionRef"
64+
class="btn app-confirmation-dialog-primary"
65+
:class="{
66+
'app-confirmation-dialog-primary-danger': primaryVariant === 'danger',
67+
'app-confirmation-dialog-primary-default': primaryVariant === 'primary'
68+
}"
69+
type="button"
70+
:disabled="busy"
71+
@click="emit('primary-action')"
72+
>
73+
<app-spinner
74+
v-if="busy"
75+
size="sm"
76+
/>
77+
<template v-else>
78+
{{ primaryActionLabel }}
79+
</template>
80+
</button>
81+
</div>
82+
</section>
83+
</div>
84+
</template>
85+
86+
<script setup lang="ts">
87+
interface Props {
88+
busy?: boolean;
89+
message: string;
90+
primaryActionLabel: string;
91+
primaryVariant?: 'danger' | 'primary';
92+
secondaryActionLabel?: string;
93+
title: string;
94+
visible: boolean;
95+
}
96+
97+
const props = withDefaults(defineProps<Props>(), {
98+
busy: false,
99+
primaryVariant: 'primary',
100+
secondaryActionLabel: 'No, Cancel',
101+
});
102+
103+
const emit = defineEmits<{
104+
'close': [];
105+
'primary-action': [];
106+
'secondary-action': [];
107+
}>();
108+
109+
const dialogRef = useTemplateRef<HTMLElement>('dialogRef');
110+
const primaryActionRef = useTemplateRef<HTMLButtonElement>('primaryActionRef');
111+
const secondaryActionRef = useTemplateRef<HTMLButtonElement>('secondaryActionRef');
112+
const titleId = useId();
113+
let previouslyFocusedElement: HTMLElement | null = null;
114+
115+
watch(
116+
() => props.visible,
117+
async (isVisible) => {
118+
if (!isVisible) {
119+
previouslyFocusedElement?.focus();
120+
previouslyFocusedElement = null;
121+
return;
122+
}
123+
124+
previouslyFocusedElement = document.activeElement instanceof HTMLElement
125+
? document.activeElement
126+
: null;
127+
await nextTick();
128+
(secondaryActionRef.value ?? primaryActionRef.value ?? dialogRef.value)?.focus();
129+
},
130+
);
131+
132+
watch(
133+
() => props.visible,
134+
(isVisible) => {
135+
if (!import.meta.client) {
136+
return;
137+
}
138+
139+
if (isVisible) {
140+
window.addEventListener('keydown', handleWindowKeydown);
141+
return;
142+
}
143+
144+
window.removeEventListener('keydown', handleWindowKeydown);
145+
},
146+
);
147+
148+
onBeforeUnmount(() => {
149+
if (import.meta.client) {
150+
window.removeEventListener('keydown', handleWindowKeydown);
151+
previouslyFocusedElement?.focus();
152+
}
153+
});
154+
155+
function handleWindowKeydown(event: KeyboardEvent) {
156+
if (event.key === 'Escape' && !props.busy) {
157+
emit('close');
158+
return;
159+
}
160+
161+
if (event.key !== 'Tab' || !dialogRef.value) {
162+
return;
163+
}
164+
165+
const focusableElements = Array.from(dialogRef.value.querySelectorAll<HTMLElement>(
166+
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
167+
));
168+
169+
if (focusableElements.length === 0) {
170+
event.preventDefault();
171+
dialogRef.value.focus();
172+
return;
173+
}
174+
175+
const firstElement = focusableElements[0];
176+
const lastElement = focusableElements.at(-1);
177+
178+
if (event.shiftKey && document.activeElement === firstElement) {
179+
event.preventDefault();
180+
lastElement?.focus();
181+
}
182+
else if (!event.shiftKey && document.activeElement === lastElement) {
183+
event.preventDefault();
184+
firstElement?.focus();
185+
}
186+
}
187+
</script>
188+
189+
<style lang="scss" scoped>
190+
@import "~/assets/scss/theme.scss";
191+
192+
.app-confirmation-dialog-backdrop {
193+
position: fixed;
194+
inset: 0;
195+
z-index: 1100;
196+
display: grid;
197+
place-items: center;
198+
padding: 1.5rem;
199+
background-color: rgba($text-navy, 0.42);
200+
backdrop-filter: blur(2px);
201+
}
202+
203+
.app-confirmation-dialog {
204+
position: relative;
205+
width: min(44rem, 100%);
206+
display: grid;
207+
gap: 1rem;
208+
padding: 2rem 2.15rem 2.05rem;
209+
background: #ffffff;
210+
border-radius: 1.15rem;
211+
box-shadow: 0 1.25rem 3rem rgba($text-navy, 0.18);
212+
}
213+
214+
.app-confirmation-dialog-close {
215+
position: absolute;
216+
top: 1rem;
217+
right: 1rem;
218+
color: rgba($text-navy, 0.72);
219+
text-decoration: none;
220+
}
221+
222+
.app-confirmation-dialog-icon-shell {
223+
display: flex;
224+
}
225+
226+
.app-confirmation-dialog-icon {
227+
width: 4rem;
228+
height: 4rem;
229+
display: inline-flex;
230+
align-items: center;
231+
justify-content: center;
232+
border-radius: 999px;
233+
}
234+
235+
.app-confirmation-dialog-theme-primary .app-confirmation-dialog-icon {
236+
color: #4b5f95;
237+
background: rgba(111, 133, 196, 0.1);
238+
}
239+
240+
.app-confirmation-dialog-theme-danger .app-confirmation-dialog-icon {
241+
color: $danger-red;
242+
background: rgba(199, 57, 58, 0.08);
243+
}
244+
245+
.app-confirmation-dialog-title {
246+
margin: 0;
247+
color: $text-navy;
248+
font-family: var(--secondary-font-family);
249+
font-size: clamp(1.9rem, 2.4vw, 2.3rem);
250+
font-weight: 700;
251+
line-height: 1.12;
252+
letter-spacing: -0.03em;
253+
}
254+
255+
.app-confirmation-dialog-message {
256+
margin: 0;
257+
color: rgba($secondary, 0.98);
258+
font-size: 1rem;
259+
line-height: 1.55;
260+
}
261+
262+
.app-confirmation-dialog-actions {
263+
display: flex;
264+
justify-content: flex-end;
265+
gap: 1rem;
266+
padding-top: 0.4rem;
267+
}
268+
269+
.app-confirmation-dialog-secondary,
270+
.app-confirmation-dialog-primary {
271+
min-width: 10.5rem;
272+
min-height: 3.25rem;
273+
font-size: 1rem;
274+
font-weight: 700;
275+
border-radius: 0.5rem;
276+
}
277+
278+
.app-confirmation-dialog-primary-default {
279+
color: #ffffff;
280+
background: #4d158d;
281+
border: 1px solid #4d158d;
282+
}
283+
284+
.app-confirmation-dialog-primary-default:hover,
285+
.app-confirmation-dialog-primary-default:focus-visible {
286+
color: #ffffff;
287+
background: #421178;
288+
border-color: #421178;
289+
}
290+
291+
.app-confirmation-dialog-primary-danger {
292+
color: #ffffff;
293+
background: #d64545;
294+
border: 1px solid #d64545;
295+
}
296+
297+
.app-confirmation-dialog-primary-danger:hover,
298+
.app-confirmation-dialog-primary-danger:focus-visible {
299+
color: #ffffff;
300+
background: #bf3636;
301+
border-color: #bf3636;
302+
}
303+
304+
@include media-breakpoint-down(sm) {
305+
.app-confirmation-dialog {
306+
padding: 1.6rem 1.25rem 1.35rem;
307+
}
308+
309+
.app-confirmation-dialog-title {
310+
font-size: 1.75rem;
311+
}
312+
313+
.app-confirmation-dialog-actions {
314+
flex-direction: column;
315+
}
316+
317+
.app-confirmation-dialog-secondary,
318+
.app-confirmation-dialog-primary {
319+
width: 100%;
320+
}
321+
}
322+
</style>

components/project-wizard/AoiGeometryMap.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ onMounted(async () => {
141141
ensureLayers();
142142
bindInteractionHandlers();
143143
isMapStyleLoaded.value = true;
144+
syncSources();
145+
syncInteractionState();
146+
applyViewport();
144147
});
145148
});
146149
@@ -196,7 +199,7 @@ function ensureLayers() {
196199
197200
wizardMap.addSource(AOI_SOURCE_ID, {
198201
type: 'geojson',
199-
data: emptyCollection,
202+
data: createAoiFeatureCollection(),
200203
});
201204
202205
wizardMap.addSource(DRAFT_SOURCE_ID, {

components/project-wizard/AssignUsersField.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
</div>
2222

2323
<div
24-
v-if="loading || shouldShowSearchResults"
24+
v-if="loading || shouldShowSearchResults || error"
2525
class="project-wizard-assign-users-results"
2626
>
2727
<div
@@ -32,6 +32,20 @@
3232
<span>Loading users...</span>
3333
</div>
3434

35+
<div
36+
v-else-if="error"
37+
class="project-wizard-assign-users-status"
38+
>
39+
<span>{{ error }}</span>
40+
<button
41+
class="btn btn-link"
42+
type="button"
43+
@click="emit('retry')"
44+
>
45+
Retry
46+
</button>
47+
</div>
48+
3549
<template v-else>
3650
<button
3751
v-for="user in visibleSearchResults"
@@ -119,6 +133,7 @@ import type { ProjectWizardWorkspaceUser } from '~/types/project-wizard';
119133
import type { WorkspaceRole } from '~/types/workspaces';
120134
121135
interface Props {
136+
error?: string | null;
122137
loading: boolean;
123138
searchQuery: string;
124139
searchResults: ProjectWizardWorkspaceUser[];
@@ -129,6 +144,7 @@ const props = defineProps<Props>();
129144
const emit = defineEmits<{
130145
'add:user': [user: ProjectWizardWorkspaceUser];
131146
'remove:user': [userId: string];
147+
'retry': [];
132148
'update:search': [value: string];
133149
}>();
134150

0 commit comments

Comments
 (0)