|
47 | 47 | let timeProjectCategories = $state([]); |
48 | 48 | let selectedTimeProjectCategories = $state([]); |
49 | 49 |
|
50 | | - let formData = $state({ |
51 | | - name: '', |
52 | | - key: '', |
53 | | - description: '', |
54 | | - active: true, |
55 | | - time_project_id: null, |
56 | | - default_view: 'board', |
57 | | - internal_comments_enabled: false |
58 | | - }); |
| 50 | + function blankFormData() { |
| 51 | + return { |
| 52 | + name: '', |
| 53 | + key: '', |
| 54 | + description: '', |
| 55 | + active: true, |
| 56 | + time_project_id: null, |
| 57 | + default_view: 'board', |
| 58 | + internal_comments_enabled: false |
| 59 | + }; |
| 60 | + } |
| 61 | +
|
| 62 | + let formData = $state(blankFormData()); |
59 | 63 |
|
60 | 64 | // The active admin module (registry-driven), used to render the page header. |
61 | 65 | const currentModule = $derived( |
|
78 | 82 | // Permission check for workspace admin |
79 | 83 | const canAdmin = $derived(workspacePermissions.canAdminWorkspace(workspaceId)); |
80 | 84 |
|
| 85 | + // MainApp renders one WorkspaceSettings instance for every |
| 86 | + // /workspaces/:id/settings/* view, so `workspaceId` changes under a mounted |
| 87 | + // component whenever the user moves between two workspaces' settings. The |
| 88 | + // load therefore has to follow the prop, not the mount: otherwise the form |
| 89 | + // keeps showing the previously opened workspace while saveWorkspace() and |
| 90 | + // deleteWorkspace() already act on the new id. |
| 91 | + let moduleSettingsReady = $state(false); |
| 92 | + let lastLoadedWorkspaceId = null; |
| 93 | + let workspaceLoadVersion = 0; |
| 94 | +
|
81 | 95 | onMount(async () => { |
82 | 96 | await moduleSettings.load(); |
83 | 97 |
|
|
87 | 101 | // Don't return — still load data so the component isn't stuck in loading state |
88 | 102 | } |
89 | 103 |
|
90 | | - const loadPromises = [loadWorkspace(), loadTimeProjectCategories()]; |
| 104 | + moduleSettingsReady = true; |
| 105 | + }); |
| 106 | +
|
| 107 | + $effect(() => { |
| 108 | + if (!moduleSettingsReady) return; |
| 109 | + const id = workspaceId ? String(workspaceId) : null; |
| 110 | + if (!id || id === lastLoadedWorkspaceId) return; |
| 111 | + lastLoadedWorkspaceId = id; |
| 112 | + void loadWorkspaceData(); |
| 113 | + }); |
| 114 | +
|
| 115 | + async function loadWorkspaceData() { |
| 116 | + const version = ++workspaceLoadVersion; |
| 117 | + loading = true; |
| 118 | + // Drop the previous workspace's state before rendering anything for the |
| 119 | + // new one — a populated form or a primed delete confirmation must never |
| 120 | + // outlive the workspace it was filled in for. |
| 121 | + workspace = null; |
| 122 | + formData = blankFormData(); |
| 123 | + selectedTimeProjectCategories = []; |
| 124 | + showDeleteConfirm = false; |
| 125 | + deleteConfirmText = ''; |
| 126 | +
|
| 127 | + const loadPromises = [loadWorkspace(version), loadTimeProjectCategories()]; |
91 | 128 | if ($moduleSettings.time_tracking_enabled) { |
92 | 129 | loadPromises.push(loadTimeProjects()); |
93 | 130 | } |
94 | 131 |
|
95 | 132 | await Promise.all(loadPromises); |
96 | | - loading = false; |
97 | | - }); |
| 133 | + if (version === workspaceLoadVersion) { |
| 134 | + loading = false; |
| 135 | + } |
| 136 | + } |
| 137 | +
|
| 138 | + // "Reset" discards local edits by re-reading the workspace currently shown. |
| 139 | + // It must pass the live load version, never be wired up as a bare handler — |
| 140 | + // loadWorkspace() would then receive the click event as its version. |
| 141 | + function resetWorkspaceForm() { |
| 142 | + void loadWorkspace(workspaceLoadVersion); |
| 143 | + } |
98 | 144 |
|
99 | | - async function loadWorkspace() { |
| 145 | + async function loadWorkspace(version) { |
100 | 146 | try { |
101 | | - workspace = await api.workspaces.get(workspaceId); |
102 | | - if (workspace) { |
| 147 | + const loaded = await api.workspaces.get(workspaceId); |
| 148 | + // A newer workspace is already loading — its response owns the form. |
| 149 | + if (version !== workspaceLoadVersion) return; |
| 150 | + workspace = loaded; |
| 151 | + if (loaded) { |
103 | 152 | formData = { |
104 | | - name: workspace.name, |
105 | | - key: workspace.key || '', |
106 | | - description: workspace.description || '', |
107 | | - active: workspace.active, |
108 | | - time_project_id: workspace.time_project_id || null, |
109 | | - default_view: workspace.default_view || 'board', |
110 | | - internal_comments_enabled: workspace.internal_comments_enabled || false |
| 153 | + name: loaded.name, |
| 154 | + key: loaded.key || '', |
| 155 | + description: loaded.description || '', |
| 156 | + active: loaded.active, |
| 157 | + time_project_id: loaded.time_project_id || null, |
| 158 | + default_view: loaded.default_view || 'board', |
| 159 | + internal_comments_enabled: loaded.internal_comments_enabled || false |
111 | 160 | }; |
112 | | - selectedTimeProjectCategories = workspace.time_project_categories || []; |
| 161 | + selectedTimeProjectCategories = loaded.time_project_categories || []; |
113 | 162 | } |
114 | 163 | } catch (error) { |
| 164 | + if (version !== workspaceLoadVersion) return; |
115 | 165 | console.error('Failed to load workspace:', error); |
116 | 166 | } |
117 | 167 | } |
|
145 | 195 | return; |
146 | 196 | } |
147 | 197 |
|
| 198 | + // The workspace can change under the component while the request is in |
| 199 | + // flight, so pin what this save is about before awaiting. Effects then |
| 200 | + // split: what the server actually changed is applied unconditionally, |
| 201 | + // what describes the view is applied only if we are still on that target. |
| 202 | + const targetId = workspaceId; |
| 203 | + const payload = { |
| 204 | + ...formData, |
| 205 | + time_project_id: formData.time_project_id ? parseInt(formData.time_project_id, 10) : null, |
| 206 | + time_project_categories: selectedTimeProjectCategories |
| 207 | + }; |
| 208 | +
|
148 | 209 | try { |
149 | 210 | saving = true; |
150 | | - await api.workspaces.update(workspaceId, { |
151 | | - ...formData, |
152 | | - time_project_id: formData.time_project_id ? parseInt(formData.time_project_id, 10) : null, |
153 | | - time_project_categories: selectedTimeProjectCategories |
| 211 | + await api.workspaces.update(targetId, payload); |
| 212 | +
|
| 213 | + // Update stores so sidebar dropdown reflects name/description changes immediately |
| 214 | + workspacesStore.updateWorkspace(targetId, { |
| 215 | + name: payload.name, |
| 216 | + description: payload.description |
154 | 217 | }); |
155 | 218 |
|
156 | | - // Update local workspace object |
157 | | - workspace = { ...workspace, ...formData }; |
| 219 | + if (targetId !== workspaceId) return; |
158 | 220 |
|
159 | | - // Update stores so sidebar dropdown reflects name/description changes immediately |
160 | | - workspacesStore.updateWorkspace(workspaceId, { name: formData.name, description: formData.description }); |
161 | | - currentWorkspace.patch({ name: formData.name, description: formData.description }); |
| 221 | + // Update local workspace object |
| 222 | + workspace = { ...workspace, ...payload }; |
| 223 | + currentWorkspace.patch({ name: payload.name, description: payload.description }); |
162 | 224 |
|
163 | 225 | successToast(t('workspaceSettings.savedSuccessfully')); |
164 | 226 | } catch (error) { |
|
175 | 237 | } |
176 | 238 |
|
177 | 239 | async function deleteWorkspace() { |
178 | | - if (deleteConfirmText !== workspace.name) { |
| 240 | + // `workspace` is null while a workspace switch is loading. |
| 241 | + if (!workspace || deleteConfirmText !== workspace.name) { |
179 | 242 | errorToast(t('workspaceSettings.pleaseConfirmDeletion')); |
180 | 243 | return; |
181 | 244 | } |
182 | 245 |
|
| 246 | + // Same fencing as saveWorkspace: the deletion is a fact about `targetId` |
| 247 | + // whatever the user is looking at afterwards, but leaving the page is only |
| 248 | + // right while that workspace is still the one on screen. |
| 249 | + const targetId = workspaceId; |
| 250 | + const targetName = workspace.name; |
| 251 | +
|
183 | 252 | try { |
184 | | - await api.workspaces.delete(workspaceId); |
185 | | - workspacesStore.remove(workspaceId); |
| 253 | + await api.workspaces.delete(targetId); |
| 254 | + workspacesStore.remove(targetId); |
| 255 | + successToast(t('workspaceSettings.deletedSuccessfully', { name: targetName })); |
| 256 | +
|
| 257 | + if (targetId !== workspaceId) return; |
| 258 | +
|
186 | 259 | currentWorkspace.clear(); |
187 | | - successToast(t('workspaceSettings.deletedSuccessfully', { name: workspace.name })); |
188 | 260 | setTimeout(() => { |
189 | 261 | navigate('/workspaces'); |
190 | 262 | }, 1000); |
|
336 | 408 | <Button |
337 | 409 | variant="secondary" |
338 | 410 | size="medium" |
339 | | - onclick={loadWorkspace} |
| 411 | + onclick={resetWorkspaceForm} |
340 | 412 | > |
341 | 413 | {t('workspaceSettings.reset')} |
342 | 414 | </Button> |
|
371 | 443 | <Button |
372 | 444 | variant="secondary" |
373 | 445 | size="medium" |
374 | | - onclick={loadWorkspace} |
| 446 | + onclick={resetWorkspaceForm} |
375 | 447 | > |
376 | 448 | {t('workspaceSettings.reset')} |
377 | 449 | </Button> |
|
0 commit comments