mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 09:52:06 +02:00
Tweak fanout display and update docs
This commit is contained in:
@@ -190,7 +190,11 @@ function MyTypeConfigEditor({
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`. The `ScopeSelector` component is defined within the same file — it provides all/none/only/except radio buttons with channel and contact checklists.
|
If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`.
|
||||||
|
|
||||||
|
The `ScopeSelector` component is defined within the same file. It accepts an optional `showRawPackets` prop:
|
||||||
|
- **Without `showRawPackets`** (webhook, apprise): shows message scope only (all/only/except — no "none" option since that would make the integration a no-op). A warning appears when the effective selection matches nothing.
|
||||||
|
- **With `showRawPackets`** (private MQTT): adds a "Forward raw packets" toggle and includes the "No messages" option (valid when raw packets are enabled). The warning appears only when both raw packets and messages are effectively disabled.
|
||||||
|
|
||||||
**c)** Add default config and scope in `handleAddCreate`:
|
**c)** Add default config and scope in `handleAddCreate`:
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
@@ -189,31 +189,7 @@ function MqttPrivateConfigEditor({
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<ScopeSelector scope={scope} onChange={onScopeChange} showRawPackets />
|
||||||
<Label>Scope</Label>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={scope.messages === 'all'}
|
|
||||||
onChange={(e) =>
|
|
||||||
onScopeChange({ ...scope, messages: e.target.checked ? 'all' : 'none' })
|
|
||||||
}
|
|
||||||
className="h-4 w-4 rounded border-border"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Forward decoded messages</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={scope.raw_packets === 'all'}
|
|
||||||
onChange={(e) =>
|
|
||||||
onScopeChange({ ...scope, raw_packets: e.target.checked ? 'all' : 'none' })
|
|
||||||
}
|
|
||||||
className="h-4 w-4 rounded border-border"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Forward raw packets</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -398,9 +374,11 @@ function getFilterKeys(filter: unknown): string[] {
|
|||||||
function ScopeSelector({
|
function ScopeSelector({
|
||||||
scope,
|
scope,
|
||||||
onChange,
|
onChange,
|
||||||
|
showRawPackets = false,
|
||||||
}: {
|
}: {
|
||||||
scope: Record<string, unknown>;
|
scope: Record<string, unknown>;
|
||||||
onChange: (scope: Record<string, unknown>) => void;
|
onChange: (scope: Record<string, unknown>) => void;
|
||||||
|
showRawPackets?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
@@ -425,7 +403,9 @@ function ScopeSelector({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const messages = scope.messages ?? 'all';
|
const messages = scope.messages ?? 'all';
|
||||||
const mode = getScopeMode(messages);
|
const rawMode = getScopeMode(messages);
|
||||||
|
// When raw packets aren't offered, "none" is not a valid choice — treat as "all"
|
||||||
|
const mode = !showRawPackets && rawMode === 'none' ? 'all' : rawMode;
|
||||||
const isListMode = mode === 'only' || mode === 'except';
|
const isListMode = mode === 'only' || mode === 'except';
|
||||||
|
|
||||||
const selectedChannels: string[] =
|
const selectedChannels: string[] =
|
||||||
@@ -487,6 +467,19 @@ function ScopeSelector({
|
|||||||
except: 'All except listed channels/contacts',
|
except: 'All except listed channels/contacts',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rawEnabled = showRawPackets && scope.raw_packets === 'all';
|
||||||
|
|
||||||
|
// Warn when the effective scope matches nothing
|
||||||
|
const messagesEffectivelyNone =
|
||||||
|
mode === 'none' ||
|
||||||
|
(mode === 'only' && selectedChannels.length === 0 && selectedContacts.length === 0) ||
|
||||||
|
(mode === 'except' &&
|
||||||
|
channels.length > 0 &&
|
||||||
|
filteredContacts.length > 0 &&
|
||||||
|
selectedChannels.length >= channels.length &&
|
||||||
|
selectedContacts.length >= filteredContacts.length);
|
||||||
|
const showEmptyScopeWarning = messagesEffectivelyNone && !rawEnabled;
|
||||||
|
|
||||||
// For "except" mode, checked means the item is in the exclusion list (will be excluded)
|
// For "except" mode, checked means the item is in the exclusion list (will be excluded)
|
||||||
const isChannelChecked = (key: string) =>
|
const isChannelChecked = (key: string) =>
|
||||||
mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
|
mode === 'except' ? selectedChannels.includes(key) : selectedChannels.includes(key);
|
||||||
@@ -500,11 +493,28 @@ function ScopeSelector({
|
|||||||
|
|
||||||
const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
|
const checkboxLabel = mode === 'except' ? 'exclude' : 'include';
|
||||||
|
|
||||||
|
const messageModes: ScopeMode[] = showRawPackets
|
||||||
|
? ['all', 'none', 'only', 'except']
|
||||||
|
: ['all', 'only', 'except'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Message Scope</Label>
|
<Label>Message Scope</Label>
|
||||||
|
|
||||||
|
{showRawPackets && (
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rawEnabled}
|
||||||
|
onChange={(e) => onChange({ ...scope, raw_packets: e.target.checked ? 'all' : 'none' })}
|
||||||
|
className="h-4 w-4 rounded border-border"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Forward raw packets</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{(['all', 'none', 'only', 'except'] as const).map((m) => (
|
{messageModes.map((m) => (
|
||||||
<label key={m} className="flex items-center gap-2 cursor-pointer">
|
<label key={m} className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -518,6 +528,12 @@ function ScopeSelector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showEmptyScopeWarning && (
|
||||||
|
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
Nothing is selected — this integration will not forward any data.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isListMode && (
|
{isListMode && (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground">{listHint}</p>
|
<p className="text-xs text-muted-foreground">{listHint}</p>
|
||||||
|
|||||||
@@ -125,6 +125,118 @@ describe('SettingsFanoutSection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('webhook with persisted "none" scope renders "All messages" selected', async () => {
|
||||||
|
const wh: FanoutConfig = {
|
||||||
|
...webhookConfig,
|
||||||
|
scope: { messages: 'none', raw_packets: 'none' },
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// "none" is not a valid mode without raw packets — should fall back to "all"
|
||||||
|
const allRadio = screen.getByLabelText('All messages');
|
||||||
|
expect(allRadio).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show "No messages" scope option for webhook', async () => {
|
||||||
|
const wh: FanoutConfig = {
|
||||||
|
...webhookConfig,
|
||||||
|
scope: { messages: 'all', raw_packets: 'none' },
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText('All messages')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('No messages')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty scope warning when "only" mode has nothing selected', async () => {
|
||||||
|
const wh: FanoutConfig = {
|
||||||
|
...webhookConfig,
|
||||||
|
scope: { messages: { channels: [], contacts: [] }, raw_packets: 'none' },
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([wh]);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText(/will not forward any data/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows warning for private MQTT when both scope axes are off', async () => {
|
||||||
|
const mqtt: FanoutConfig = {
|
||||||
|
id: 'mqtt-1',
|
||||||
|
type: 'mqtt_private',
|
||||||
|
name: 'My MQTT',
|
||||||
|
enabled: true,
|
||||||
|
config: { broker_host: 'localhost', broker_port: 1883 },
|
||||||
|
scope: { messages: 'none', raw_packets: 'none' },
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText(/will not forward any data/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private MQTT shows raw packets toggle and No messages option', async () => {
|
||||||
|
const mqtt: FanoutConfig = {
|
||||||
|
id: 'mqtt-1',
|
||||||
|
type: 'mqtt_private',
|
||||||
|
name: 'My MQTT',
|
||||||
|
enabled: true,
|
||||||
|
config: { broker_host: 'localhost', broker_port: 1883 },
|
||||||
|
scope: { messages: 'all', raw_packets: 'all' },
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText('Forward raw packets')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('No messages')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private MQTT hides warning when raw packets enabled but messages off', async () => {
|
||||||
|
const mqtt: FanoutConfig = {
|
||||||
|
id: 'mqtt-1',
|
||||||
|
type: 'mqtt_private',
|
||||||
|
name: 'My MQTT',
|
||||||
|
enabled: true,
|
||||||
|
config: { broker_host: 'localhost', broker_port: 1883 },
|
||||||
|
scope: { messages: 'none', raw_packets: 'all' },
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
};
|
||||||
|
mockedApi.getFanoutConfigs.mockResolvedValue([mqtt]);
|
||||||
|
renderSection();
|
||||||
|
await waitFor(() => expect(screen.getByText('My MQTT')).toBeInTheDocument());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||||
|
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.queryByText(/will not forward any data/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('navigates to create view when clicking add button', async () => {
|
it('navigates to create view when clicking add button', async () => {
|
||||||
const createdWebhook: FanoutConfig = {
|
const createdWebhook: FanoutConfig = {
|
||||||
id: 'wh-new',
|
id: 'wh-new',
|
||||||
|
|||||||
Reference in New Issue
Block a user