Skip to content

Commit d28b209

Browse files
committed
feat(graph): enforce title validation and trimming for node creation and updates
1 parent ca64d79 commit d28b209

File tree

13 files changed

+542
-59
lines changed

13 files changed

+542
-59
lines changed

e2e/tests/first_node_zoom.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test.describe('first node zoom', () => {
2424
const createResponse = await page.request.post(`/api/projects/${projectId}/nodes`, {
2525
data: {
2626
node_type_id: '01JNODETYPE00000000TASK000',
27-
title: 'New Node',
27+
title: 'Untitled',
2828
description: '',
2929
},
3030
headers: { 'Content-Type': 'application/json' },

src/app/features/graph/create_node.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use super::types::ApiNode;
2323
#[derive(Debug, Deserialize, Validate)]
2424
pub struct CreateNodeRequest {
2525
pub node_type_id: String,
26-
#[validate(length(min = 1, max = 255))]
26+
#[validate(length(max = 255))]
2727
pub title: String,
2828
#[validate(length(max = 2000))]
2929
pub description: Option<String>,
@@ -156,14 +156,18 @@ pub async fn create_node(
156156
"Team short name is not set; cannot allocate task reference".to_string(),
157157
)
158158
})?;
159+
let title = request.title.trim().to_string();
160+
if title.is_empty() {
161+
return Err(AppError::Validation("Title is required".to_string()));
162+
}
159163
let new_node = db::nodes::NewNode {
160164
id: node_id.clone(),
161165
project_id: project.id.clone(),
162166
organization_id: project.organization_id.clone(),
163167
public_ref,
164168
node_type_id,
165169
status_id,
166-
title: request.title,
170+
title,
167171
description: request.description,
168172
estimated_minutes: request.estimated_minutes,
169173
slot_id,

src/app/features/graph/insert_between.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub struct InsertBetweenRequest {
2626
/// Node type for the new node.
2727
pub node_type_id: String,
2828
/// Title for the new node.
29-
#[validate(length(min = 1, max = 255))]
29+
#[validate(length(max = 255))]
3030
pub title: String,
3131
/// Optional description for the new node.
3232
#[validate(length(max = 2000))]
@@ -148,14 +148,18 @@ pub async fn insert_between(
148148
})?;
149149

150150
// Build new node payload.
151+
let title = request.title.trim().to_string();
152+
if title.is_empty() {
153+
return Err(AppError::Validation("Title is required".to_string()));
154+
}
151155
let new_node = db::nodes::NewNode {
152156
id: node_id.clone(),
153157
project_id: project.id.clone(),
154158
organization_id: project.organization_id.clone(),
155159
public_ref,
156160
node_type_id: request.node_type_id.clone(),
157161
status_id,
158-
title: request.title.clone(),
162+
title,
159163
description: request.description.clone(),
160164
estimated_minutes: None,
161165
slot_id,

src/app/features/graph/update_node.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use super::types::ApiNode;
2121
/// Request body for updating a node (partial update).
2222
#[derive(Debug, Deserialize, Validate)]
2323
pub struct UpdateNodeRequest {
24-
#[validate(length(min = 1, max = 255))]
24+
#[validate(length(max = 255))]
2525
pub title: Option<String>,
2626
#[validate(length(max = 2000))]
2727
pub description: Option<String>,
@@ -143,7 +143,14 @@ pub async fn update_node(
143143
)
144144
.await?;
145145

146-
let title = request.title.as_deref().unwrap_or(&node.title);
146+
let resolved_title = match &request.title {
147+
Some(t) => t.trim().to_string(),
148+
None => node.title.trim().to_string(),
149+
};
150+
if resolved_title.is_empty() {
151+
return Err(AppError::Validation("Title is required".to_string()));
152+
}
153+
147154
let description = request.description.or(node.description);
148155
let estimated_minutes = request.estimated_minutes.unwrap_or(node.estimated_minutes);
149156

@@ -163,7 +170,7 @@ pub async fn update_node(
163170
&state.db,
164171
&db::nodes::UpdateNode {
165172
id: node.id.as_str(),
166-
title,
173+
title: resolved_title.as_str(),
167174
description: description.as_deref(),
168175
node_type_id,
169176
status_id,

src/app/features/projects/project_graph_nav.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ <h1 class="graph-nav__brand">
2525
</div>
2626
{% endif %}
2727
<div class="graph-nav__actions">
28-
<button type="button" @click="openSettings()"
28+
<button type="button" @click.stop="openSettings()"
2929
class="graph-nav__icon-btn"
3030
aria-label="Project settings">
3131
<span class="material-symbols-outlined">settings</span>

src/app/features/projects/projects_kanban.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
class="project-kanban-view bg-gray-50 border border-gray-200 rounded-lg overflow-hidden shadow-sm flex-1 flex flex-col min-h-[70vh]">
1414
{% include "project_graph_nav.html" %}
1515

16-
<!-- Settings Drawer -->
17-
<div class="side-drawer" :class="{ 'side-drawer--open': settingsOpen }">
16+
<!-- Settings: x-show forces display:none while task drawer is open. -->
17+
<div class="side-drawer" x-show="settingsOpen && !editingNode" x-cloak
18+
:class="{ 'side-drawer--open': settingsOpen && !editingNode }">
1819
<div class="side-drawer__header">
1920
<h3>Project settings</h3>
2021
<button @click="closeSettings()" class="side-drawer__close" aria-label="Close settings">
@@ -246,7 +247,8 @@ <h3>Edit Task</h3>
246247

247248
<div class="side-drawer__footer">
248249
<button type="button" @click="saveNode()"
249-
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md transition duration-200 flex items-center justify-center gap-2">
250+
:disabled="saving || !nodeTitleIsSaveable((editingNode && editingNode.title) || '')"
251+
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md transition duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:pointer-events-none">
250252
<span x-show="!saving">Save Changes</span>
251253
<span x-show="saving">Saving...</span>
252254
</button>

src/app/features/projects/projects_show.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ <h3 x-text="editingNode && editingNode.is_read_only ? 'External task' : 'Node De
220220
</div>
221221
<div>
222222
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
223-
<input type="text" x-model="editingNode.title"
223+
<input type="text" x-ref="nodeDetailsTitleInput" x-model="editingNode.title"
224224
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500"
225225
@keydown.enter="saveNode()">
226226
</div>
@@ -368,7 +368,8 @@ <h4 class="text-sm font-medium text-gray-800">Other projects</h4>
368368

369369
<div class="side-drawer__footer">
370370
<button type="button" @click="saveNode()" x-show="editingNode && !editingNode.is_read_only"
371-
class="w-full bg-primary hover:opacity-90 text-white font-bold py-2 px-4 rounded-lg border-0 shadow transition duration-200 flex items-center justify-center gap-2 text-xs">
371+
:disabled="saving || !nodeTitleIsSaveable((editingNode && editingNode.title) || '')"
372+
class="w-full bg-primary hover:opacity-90 text-white font-bold py-2 px-4 rounded-lg border-0 shadow transition duration-200 flex items-center justify-center gap-2 text-xs disabled:opacity-50 disabled:pointer-events-none">
372373
<span x-show="!saving">Commit Changes</span>
373374
<span x-show="saving">Saving...</span>
374375
</button>
@@ -380,8 +381,9 @@ <h4 class="text-sm font-medium text-gray-800">Other projects</h4>
380381
</div>
381382
</div>
382383

383-
<!-- Settings Drawer -->
384-
<div class="side-drawer" :class="{ 'side-drawer--open': settingsOpen }">
384+
<!-- Settings: x-show forces display:none when node drawer is open (transform-only hide was still visible). -->
385+
<div class="side-drawer" x-show="settingsOpen && !editingNode" x-cloak
386+
:class="{ 'side-drawer--open': settingsOpen && !editingNode }">
385387
<div class="side-drawer__header">
386388
<h3>Project settings</h3>
387389
<button @click="closeSettings()" class="side-drawer__close" aria-label="Close settings">

0 commit comments

Comments
 (0)