Skip to content
Merged

Beta #118

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/hooks/useCollaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps

// Note: onRemoteUpdate is intentionally not in deps to prevent re-subscription on callback changes
}, [documentId, documentType, userId, userEmail, userName, enabled]);

Check warning on line 174 in apps/web/src/hooks/useCollaboration.ts

View workflow job for this annotation

GitHub Actions / typescript-checks

React Hook useEffect has missing dependencies: 'channels' and 'onRemoteUpdate'. Either include them or remove the dependency array. If 'onRemoteUpdate' changes too often, find the parent component that defines it and wrap that definition in useCallback

/**
* Broadcast a message to other users.
Expand Down
99 changes: 61 additions & 38 deletions apps/web/src/pages/CommsPlannerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@
const effectiveUserId = user?.id || (isSharedEdit ? anonymousUserId : "");
const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : "");
const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : "");

// Local state for plan name input to prevent reversion during typing
const [localPlanName, setLocalPlanName] = useState("");
const [localPlanNameInitialized, setLocalPlanNameInitialized] = useState(false);

const {
planName,
elements,
Expand Down Expand Up @@ -211,20 +216,69 @@
userName: effectiveUserName,
enabled: collaborationEnabled,
onRemoteUpdate: (payload) => {
// GUARD: Don't process remote updates if collaboration is disabled
// This prevents input reversion on NEW documents where collaboration is off
if (!collaborationEnabled) {
console.log("[CommsPlannerEditor] Ignoring remote update - collaboration disabled");
return;
}

if (payload.type === "field_update" && payload.field) {
// Handle field updates from remote users
console.log("[CommsPlannerEditor] Remote field update:", payload.field, payload.value);
// Update will be handled by database subscription
if (payload.field === "name") {
setPlanName(payload.value);
// Update local plan name state when remote changes arrive
setLocalPlanName(payload.value);
}
// Other field updates will be handled by database subscription
}
},
});

// Presence hook
const { setEditingField } = usePresence({

Check failure on line 240 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

'setEditingField' is assigned a value but never used
channel: null, // Will be set up when collaboration channels are ready
userId: effectiveUserId,
});

// Initialize local plan name from planName when document loads
useEffect(() => {
if (planName && !localPlanNameInitialized) {
setLocalPlanName(planName);
setLocalPlanNameInitialized(true);
}
}, [planName, localPlanNameInitialized]);

// Debounced sync: Update planName after user stops typing (500ms delay)
useEffect(() => {
if (!localPlanNameInitialized) return;

const handler = setTimeout(() => {
if (localPlanName !== planName) {
setPlanName(localPlanName);

// Broadcast plan name change to other collaborators
if (collaborationEnabled && broadcast) {
broadcast({
type: "field_update",
field: "name",
value: localPlanName,
});
}
}
}, 500);

return () => clearTimeout(handler);
}, [
localPlanName,
localPlanNameInitialized,
planName,
setPlanName,
collaborationEnabled,
broadcast,
]);

// Real-time database subscription for syncing changes across users
useEffect(() => {
if (!collaborationEnabled || !documentId) {
Expand All @@ -245,41 +299,10 @@
},
(payload) => {
console.log("[CommsPlannerEditor] Received database UPDATE event:", payload);
// Update local state with the new data
// IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent
// triggering auto-save, which would create an infinite loop
if (payload.new) {
const { version, last_edited, metadata, ...userEditableFields } = payload.new as any;

// Update store with new data
if (userEditableFields.name) {
setPlanName(userEditableFields.name);
}
if (userEditableFields.venue_geometry) {
setVenueWidth(userEditableFields.venue_geometry.width);
setVenueHeight(userEditableFields.venue_geometry.height);
}
if (userEditableFields.zones) {
setZones(userEditableFields.zones);
}
if (userEditableFields.elements) {
const loadedElements = userEditableFields.elements.map((el: any) => ({
...el,
systemType: el.system_type,
channels: el.channel_set,
}));
setElements(loadedElements);
}
if (userEditableFields.beltpacks) {
setBeltpacks(userEditableFields.beltpacks);
}
if (typeof userEditableFields.dfs_enabled !== "undefined") {
setDfsEnabled(userEditableFields.dfs_enabled);
}
if (typeof userEditableFields.poe_budget_total !== "undefined") {
setPoeBudget(userEditableFields.poe_budget_total);
}
}
// NOTE: We intentionally DO NOT update local state from database UPDATE events
// because they include our own saves echoing back, which would overwrite user typing
// Real-time collaboration updates happen via the broadcast channel in useCollaboration
console.log("[CommsPlannerEditor] Ignoring database UPDATE to prevent input reversion");
},
)
.subscribe();
Expand Down Expand Up @@ -315,7 +338,7 @@
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [collaborationEnabled, forceSave]);

Check warning on line 341 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

React Hook useEffect has a missing dependency: 'handleSave'. Either include it or remove the dependency array

const getDefaultModel = (systemType: SystemType): SystemModel => {
switch (systemType) {
Expand Down Expand Up @@ -741,7 +764,7 @@
setDfsEnabled(resource.dfs_enabled || false);
setPoeBudget(resource.poe_budget_total || 1000);

const loadedElements = (resource.elements || []).map((el: any) => ({

Check failure on line 767 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
...el,
systemType: el.system_type,
channels: el.channel_set,
Expand All @@ -749,7 +772,7 @@
setElements(loadedElements);

const loadedBeltpacks = resource.beltpacks || [];
const hasExistingAssignments = loadedBeltpacks.some((bp: any) => bp.transceiverRef);

Check failure on line 775 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
if (hasExistingAssignments) {
setBeltpacks(loadedBeltpacks);
} else {
Expand All @@ -761,7 +784,7 @@
console.log(
"[CommsPlannerEditor] SHARED comms_planner resource loaded successfully for editing.",
);
} catch (error: any) {

Check failure on line 787 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
console.error(
"[CommsPlannerEditor] Error fetching SHARED comms plan:",
error.message,
Expand Down Expand Up @@ -973,7 +996,7 @@
// Transform the data to match VersionHistory interface
// We need to join with users table to get email
const versionsWithUsers = await Promise.all(
(data || []).map(async (version: any) => {

Check failure on line 999 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
let userEmail = "Unknown User";

if (version.created_by) {
Expand Down Expand Up @@ -1004,7 +1027,7 @@
);

setVersionHistory(versionsWithUsers);
} catch (error: any) {

Check failure on line 1030 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
console.error("Error fetching version history:", error);
setHistoryError(error.message || "Failed to load version history");
} finally {
Expand All @@ -1030,7 +1053,7 @@
if (!historyData) throw new Error("Version not found");

// Update the current document with the historical content
const restoredContent = historyData.content as any;

Check failure on line 1056 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type

// Update in the database
const { error: updateError } = await supabase
Expand All @@ -1051,7 +1074,7 @@
}
if (restoredContent.zones) setZones(restoredContent.zones);
if (restoredContent.elements) {
const loadedElements = restoredContent.elements.map((el: any) => ({

Check failure on line 1077 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
...el,
systemType: el.system_type,
channels: el.channel_set,
Expand All @@ -1072,7 +1095,7 @@

// Refresh history to show the restoration
await fetchVersionHistory();
} catch (error: any) {

Check failure on line 1098 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

Unexpected any. Specify a different type
console.error("Error restoring version:", error);
alert(`Failed to restore version: ${error.message}`);
}
Expand All @@ -1083,7 +1106,7 @@
if (showHistory && documentId) {
fetchVersionHistory();
}
}, [showHistory, documentId]);

Check warning on line 1109 in apps/web/src/pages/CommsPlannerEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

React Hook useEffect has a missing dependency: 'fetchVersionHistory'. Either include it or remove the dependency array

// Auto-save logic (only for new documents without collaboration)
useEffect(() => {
Expand Down Expand Up @@ -1488,8 +1511,8 @@
</button>
<input
type="text"
value={planName}
onChange={(e) => setPlanName(e.target.value)}
value={localPlanName || planName}
onChange={(e) => setLocalPlanName(e.target.value)}
className="text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0"
/>
</div>
Expand Down
108 changes: 73 additions & 35 deletions apps/web/src/pages/CorporateMicPlotEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { supabase } from "../lib/supabase";
import { useAuth } from "../lib/AuthContext";

Check failure on line 4 in apps/web/src/pages/CorporateMicPlotEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

'useAuth' is defined but never used
import { v4 as uuidv4 } from "uuid";
import Header from "../components/Header";
import Footer from "../components/Footer";
Expand Down Expand Up @@ -59,6 +59,10 @@
const effectiveUserEmail = user?.email || (isSharedEdit ? `${anonymousUserId}@shared` : "");
const effectiveUserName = user?.user_metadata?.name || (isSharedEdit ? "Anonymous User" : "");

// Local state for name input to prevent reversion during typing
const [localName, setLocalName] = useState("");
const [localNameInitialized, setLocalNameInitialized] = useState(false);

// Enable collaboration for existing documents (including edit-mode shared links)
// For shared links, routeId will be undefined, so we check micPlot?.id instead
// For edit-mode shared links, allow collaboration even without authentication
Expand Down Expand Up @@ -189,11 +193,27 @@
userName: effectiveUserName,
enabled: collaborationEnabled,
onRemoteUpdate: (payload) => {
// GUARD: Don't process remote updates if collaboration is disabled
// This prevents input reversion on NEW documents where collaboration is off
if (!collaborationEnabled) {
console.log("[CorporateMicPlotEditor] Ignoring remote update - collaboration disabled");
return;
}

if (payload.type === "field_update" && payload.field) {
setMicPlot((prev: any) => ({
...prev,
[payload.field!]: payload.value,
}));
if (payload.field === "name") {
setMicPlot((prev: any) => ({
...prev,
name: payload.value,
}));
// Update local name state when remote changes arrive
setLocalName(payload.value);
} else {
setMicPlot((prev: any) => ({
...prev,
[payload.field!]: payload.value,
}));
}
}
},
});
Expand All @@ -204,6 +224,47 @@
userId: effectiveUserId,
});

// Initialize local name from micPlot.name when document loads
useEffect(() => {
if (micPlot?.name && !localNameInitialized) {
setLocalName(micPlot.name);
setLocalNameInitialized(true);
}
}, [micPlot?.name, localNameInitialized]);

// Debounced sync: Update micPlot.name after user stops typing (500ms delay)
useEffect(() => {
if (!localNameInitialized) return;

const handler = setTimeout(() => {
if (localName !== micPlot?.name) {
setMicPlot((prev: any) => (prev ? { ...prev, name: localName } : prev));

// Broadcast name change to other collaborators
if (collaborationEnabled && broadcast) {
broadcast({
type: "field_update",
field: "name",
value: localName,
userId: effectiveUserId,
}).catch((err) =>
console.error("[CorporateMicPlotEditor] Failed to broadcast name change:", err),
);
}
}
}, 500);

return () => clearTimeout(handler);
}, [
localName,
localNameInitialized,
micPlot?.name,
setMicPlot,
collaborationEnabled,
broadcast,
effectiveUserId,
]);

// Keyboard shortcut for manual save (Cmd/Ctrl+S)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -239,20 +300,12 @@
},
(payload) => {
console.log("[CorporateMicPlotEditor] Received database UPDATE event:", payload);
// Update local state with the new data
// IMPORTANT: Exclude metadata fields (version, last_edited, metadata) to prevent
// triggering auto-save, which would create an infinite loop
if (payload.new) {
const { version, last_edited, metadata, ...userEditableFields } = payload.new as any;
setMicPlot((prev: any) => ({
...prev,
...userEditableFields,
// Keep existing metadata to avoid triggering auto-save
version: prev?.version,
last_edited: prev?.last_edited,
metadata: prev?.metadata,
}));
}
// NOTE: We intentionally DO NOT update local state from database UPDATE events
// because they include our own saves echoing back, which would overwrite user typing
// Real-time collaboration updates happen via the broadcast channel in useCollaboration
console.log(
"[CorporateMicPlotEditor] Ignoring database UPDATE to prevent input reversion",
);
},
)
.subscribe();
Expand All @@ -264,22 +317,7 @@
}, [collaborationEnabled, micPlot?.id]);

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (micPlot) {
const newName = e.target.value;
setMicPlot({ ...micPlot, name: newName });

// Broadcast name change to other collaborators
if (collaborationEnabled && broadcast) {
broadcast({
type: "field_update",
field: "name",
value: newName,
userId: effectiveUserId,
}).catch((err) =>
console.error("[CorporateMicPlotEditor] Failed to broadcast name change:", err),
);
}
}
setLocalName(e.target.value);
};

const handleAddPresenter = () => {
Expand Down Expand Up @@ -565,7 +603,7 @@
if (showHistory && micPlot?.id) {
fetchVersionHistory();
}
}, [showHistory, micPlot?.id]);

Check warning on line 606 in apps/web/src/pages/CorporateMicPlotEditor.tsx

View workflow job for this annotation

GitHub Actions / typescript-checks

React Hook useEffect has a missing dependency: 'fetchVersionHistory'. Either include it or remove the dependency array

const handleBackNavigation = () => {
if (isSharedEdit) {
Expand Down Expand Up @@ -633,7 +671,7 @@
<div className="flex-grow min-w-0">
<input
type="text"
value={micPlot.name}
value={localName || micPlot?.name || ""}
onChange={handleNameChange}
className="text-xl md:text-2xl font-bold text-white bg-transparent border-none focus:outline-none focus:ring-0 w-full"
placeholder="Enter Mic Plot Name"
Expand Down
Loading
Loading