Skip to content
Merged
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
87 changes: 69 additions & 18 deletions src/components/nodes/MeshCoreChannelEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ChevronDown, Plus, X } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Alert, AlertDescription } from '@/components/ui/alert';
Expand All @@ -17,9 +17,11 @@
import { filterChannelsForProtocol } from '@/lib/message-channels';
import {
assignedFromFeeder,
assignedIdentityKeys,
assignedMcChannelRowDisplay,
assignedOrderKey,
assignedToApplyEntries,
messageChannelIdentityKey,
messageChannelRowDisplay,
newDraftChannel,
reorderAssigned,
Expand Down Expand Up @@ -60,7 +62,7 @@
const queryClient = useQueryClient();
const internalId = node.internal_id;
const constellationId = node.constellation.id;
const feederSnapshots = node.mc_channels ?? [];

Check warning on line 65 in src/components/nodes/MeshCoreChannelEditor.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

The 'feederSnapshots' logical expression could make the dependencies of useMemo Hook (at line 107) change on every render. To fix this, wrap the initialization of 'feederSnapshots' in its own useMemo() Hook

const { data: constellationChannels = [] } = useConstellationChannels(constellationId);
const catalog = useMemo(() => filterChannelsForProtocol(constellationChannels, 'meshcore'), [constellationChannels]);
Expand All @@ -70,24 +72,44 @@
const [showNewForm, setShowNewForm] = useState(false);
const [newType, setNewType] = useState<'PUBLIC' | 'HASHTAG'>('PUBLIC');
const [newNameInput, setNewNameInput] = useState('');
const [newRegionScope, setNewRegionScope] = useState('');
const [newChannelError, setNewChannelError] = useState<string | null>(null);
const [confirmApplyOpen, setConfirmApplyOpen] = useState(false);

const feederMirrorKey = useMemo(
() => (node.mc_channels ?? []).map((c) => `${c.mc_channel_idx}:${c.id}:${c.region_scope ?? ''}`).join('|'),
[node.mc_channels]
);

const editorResetKey = `${node.internal_id ?? ''}:${feederMirrorKey}`;
const lastEditorResetKey = useRef<string | null>(null);

useEffect(() => {
if (!open) {
lastEditorResetKey.current = null;
return;
}
if (lastEditorResetKey.current === editorResetKey) {
return;
}
lastEditorResetKey.current = editorResetKey;
const next = assignedFromFeeder(node);
setAssigned(next);
setInitialOrderKey(assignedOrderKey(next));
setShowNewForm(false);
setNewNameInput('');
}, [node]);
setNewRegionScope('');
setNewChannelError(null);
}, [open, editorResetKey, node]);

const assignedCatalogIds = useMemo(
() => new Set(assigned.map((a) => a.catalogId).filter((id): id is number => id != null)),
[assigned]
const assignedKeys = useMemo(
() => assignedIdentityKeys(assigned, catalog, feederSnapshots),
[assigned, catalog, feederSnapshots]
);

const available = useMemo(
() => catalog.filter((ch) => !assignedCatalogIds.has(ch.id)),
[catalog, assignedCatalogIds]
() => catalog.filter((ch) => !assignedKeys.has(messageChannelIdentityKey(ch))),
[catalog, assignedKeys]
);

const orderChanged = assigned.length > 0 && assignedOrderKey(assigned) !== initialOrderKey;
Expand All @@ -107,26 +129,43 @@
});

const assignFromCatalog = (channelId: number) => {
setAssigned((prev) => [...prev, { clientId: newClientId('catalog'), catalogId: channelId }]);
const ch = catalog.find((c) => c.id === channelId);
if (!ch) {
return;
}
const identity = messageChannelIdentityKey(ch);
setAssigned((prev) => {
if (assignedIdentityKeys(prev, catalog, feederSnapshots).has(identity)) {
return prev;
}
return [...prev, { clientId: newClientId('catalog'), catalogId: channelId }];
});
};

const removeAssigned = (index: number) => {
setAssigned((prev) => prev.filter((_, i) => i !== index));
const removeAssigned = (clientId: string) => {
setAssigned((prev) => prev.filter((row) => row.clientId !== clientId));
};

const moveAssigned = (index: number, direction: -1 | 1) => {
setAssigned((prev) => reorderAssigned(prev, index, index + direction));
};

const addDraftToAssigned = () => {
const draft = newDraftChannel(newType, newNameInput);
if (draft.mc_channel_type === 'HASHTAG' && !draft.mc_hashtag) {
return;
setNewChannelError(null);
try {
const draft = newDraftChannel(newType, newNameInput, newRegionScope);
if (draft.mc_channel_type === 'HASHTAG' && !draft.name) {
setNewChannelError('Hashtag channels require a non-empty tag.');
return;
}
setAssigned((prev) => [...prev, { clientId: newClientId('draft'), draft }]);
setShowNewForm(false);
setNewNameInput('');
setNewRegionScope('');
setNewType('PUBLIC');
} catch (err) {
setNewChannelError(err instanceof Error ? err.message : 'Invalid region scope.');
}
setAssigned((prev) => [...prev, { clientId: newClientId('draft'), draft }]);
setShowNewForm(false);
setNewNameInput('');
setNewType('PUBLIC');
};

const syncedLabel = node.mc_channels_synced_at
Expand Down Expand Up @@ -228,6 +267,18 @@
/>
)}
</div>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={newRegionScope}
onChange={(e) => setNewRegionScope(e.target.value)}
placeholder="Region scope (optional)"
aria-label="Region scope"
/>
{newChannelError ? (
<p className="text-xs text-destructive" role="alert">
{newChannelError}
</p>
) : null}
<div className="flex gap-2">
<Button type="button" size="sm" className="flex-1" onClick={addDraftToAssigned}>
Add to radio
Expand Down Expand Up @@ -302,7 +353,7 @@
size="icon"
className="h-8 w-8 text-destructive"
aria-label="Remove from radio"
onClick={() => removeAssigned(index)}
onClick={() => removeAssigned(row.clientId)}
>
<X className="h-4 w-4" />
</Button>
Expand Down
63 changes: 51 additions & 12 deletions src/lib/mc-channel-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest';
import {
assignedFromFeeder,
assignedIdentityKeys,
assignedMcChannelRowDisplay,
assignedOrderKey,
assignedToApplyEntries,
formatAssignedMcChannelLabel,
formatMcChannelDraftLabel,
messageChannelIdentityKey,
messageChannelRowDisplay,
newDraftChannel,
reorderAssigned,
Expand All @@ -27,8 +29,8 @@ describe('mc-channel-editor', () => {
constellation: 1,
protocol: 'meshcore',
mc_channel_type: 'HASHTAG',
mc_hashtag: 'test',
display_label: '#test',
region_scope: 'sample-west',
display_label: '#test · sample-west',
},
];

Expand All @@ -38,27 +40,64 @@ describe('mc-channel-editor', () => {
mc_channel_idx: 0,
name: 'test',
mc_channel_type: 'HASHTAG',
mc_hashtag: 'test',
region_scope: 'sample-west',
},
];

it('builds assigned rows from feeder mirror', () => {
const node = {
mc_channels: feederSnapshots,
} as OwnedManagedNode;
expect(assignedFromFeeder(node)).toEqual([{ clientId: 'catalog-11', catalogId: 11 }]);
expect(assignedFromFeeder(node)).toEqual([{ clientId: 'slot-0-11', catalogId: 11 }]);
});

it('treats scoped and unscoped catalog rows as distinct identities', () => {
const scoped = {
id: 11,
name: 'test',
constellation: 1,
protocol: 'meshcore' as const,
mc_channel_type: 'HASHTAG' as const,
region_scope: 'sample-west',
};
const unscoped = {
id: 12,
name: 'test',
constellation: 1,
protocol: 'meshcore' as const,
mc_channel_type: 'HASHTAG' as const,
region_scope: null,
};
expect(messageChannelIdentityKey(scoped)).not.toBe(messageChannelIdentityKey(unscoped));
const keys = assignedIdentityKeys([{ clientId: 'a', catalogId: 11 }], [scoped, unscoped], []);
expect(keys.has(messageChannelIdentityKey(scoped))).toBe(true);
expect(keys.has(messageChannelIdentityKey(unscoped))).toBe(false);
});

it('uses unique client ids per feeder slot', () => {
const node = {
mc_channels: [
{ id: 11, mc_channel_idx: 0, name: 'a', mc_channel_type: 'PUBLIC' as const, region_scope: null },
{ id: 12, mc_channel_idx: 1, name: 'b', mc_channel_type: 'PUBLIC' as const, region_scope: null },
],
} as OwnedManagedNode;
const rows = assignedFromFeeder(node);
expect(rows[0].clientId).not.toBe(rows[1].clientId);
});

it('formats hashtag labels for catalog and draft rows', () => {
expect(
formatAssignedMcChannelLabel({ clientId: 'a', catalogId: 11 }, catalog, feederSnapshots)
).toBe('#test');
expect(formatMcChannelDraftLabel(newDraftChannel('HASHTAG', 'mesh'))).toBe('#mesh');
).toBe('#test · sample-west');
expect(formatMcChannelDraftLabel(newDraftChannel('HASHTAG', 'mesh', 'uk-wide'))).toBe('#mesh · uk-wide');
expect(formatMcChannelDraftLabel(newDraftChannel('PUBLIC', 'Scotland'))).toBe('Scotland');
});

it('includes type label in row display for catalog and draft', () => {
expect(messageChannelRowDisplay(catalog[1])).toEqual({ label: '#test', typeLabel: 'HASHTAG' });
expect(messageChannelRowDisplay(catalog[1])).toEqual({
label: '#test · sample-west',
typeLabel: 'HASHTAG',
});
expect(
assignedMcChannelRowDisplay({ clientId: 'd', draft: newDraftChannel('PUBLIC', 'Scotland') }, catalog, [])
).toEqual({ label: 'Scotland', typeLabel: 'PUBLIC' });
Expand All @@ -72,31 +111,31 @@ describe('mc-channel-editor', () => {
constellation: 1,
protocol: 'meshcore',
mc_channel_type: 2,
mc_hashtag: 'test',
region_scope: 'uk-wide',
},
];
expect(
formatAssignedMcChannelLabel({ clientId: 'a', catalogId: 11 }, intCatalog, feederSnapshots)
).toBe('#test');
).toBe('#test · uk-wide');
});

it('maps assigned order to apply entries with slot indices', () => {
const assigned = [
{ clientId: 'd1', draft: newDraftChannel('HASHTAG', 'newtag') },
{ clientId: 'd1', draft: newDraftChannel('HASHTAG', 'newtag', 'west') },
{ clientId: 'c1', catalogId: 10 },
];
expect(assignedToApplyEntries(assigned, catalog, feederSnapshots)).toEqual([
{
mc_channel_idx: 0,
mc_channel_type: 'HASHTAG',
name: 'newtag',
mc_hashtag: 'newtag',
region_scope: 'west',
},
{
mc_channel_idx: 1,
mc_channel_type: 'PUBLIC',
name: 'Scotland',
mc_hashtag: null,
region_scope: null,
},
]);
});
Expand Down
Loading
Loading