feat: implement copy and link
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DSP408 } from '../../../../types';
|
||||
import { Channel, InputChannel, OutputChannel } from '../../types';
|
||||
import Button from '../../../../components/Button/Button';
|
||||
import Card from '../../../../components/Card/Card';
|
||||
|
||||
import './LinkChannelsModal.css';
|
||||
|
||||
const INPUT_CHANNELS: readonly InputChannel[] = Object.values(InputChannel);
|
||||
const OUTPUT_CHANNELS: readonly OutputChannel[] = Object.values(OutputChannel);
|
||||
|
||||
type LinkMode = 'input' | 'output';
|
||||
|
||||
type LinkGroup = {
|
||||
source: Channel;
|
||||
destinations: Channel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps every channel (by its raw enum string) to the channel it currently
|
||||
* follows. A channel that follows itself is unlinked. Several channels can
|
||||
* follow the same leader (many-to-one), but a channel that is itself
|
||||
* following someone can't also be followed by others - no chains.
|
||||
*/
|
||||
type LeaderMap = Record<string, string>;
|
||||
|
||||
const wrapInputChannel = (raw: string): Channel => ({ Input: raw as InputChannel });
|
||||
const wrapOutputChannel = (raw: string): Channel => ({ Output: raw as OutputChannel });
|
||||
|
||||
/**
|
||||
* Gets the raw enum string (InA, Out1, ...) out of a channel entry.
|
||||
* The device state has been observed to store these as plain strings
|
||||
* (e.g. "InA") rather than the wrapped { Input: ... } / { Output: ... }
|
||||
* shape used elsewhere, so this accepts either form defensively.
|
||||
*/
|
||||
function channelRawId(channel: Channel | string): string {
|
||||
if (typeof channel === 'string') {
|
||||
return channel;
|
||||
}
|
||||
|
||||
return 'Input' in channel ? channel.Input : channel.Output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks any chains that might be present in a leader map (a channel that
|
||||
* both follows someone and is followed by others) by releasing whoever
|
||||
* follows a follower back to themselves. Keeps the invariant - a channel
|
||||
* can lead or follow, never both - true regardless of what the device
|
||||
* reported.
|
||||
*/
|
||||
function sanitizeLeaderMap(leaderOf: LeaderMap): LeaderMap {
|
||||
const next = { ...leaderOf };
|
||||
|
||||
Object.keys(next).forEach((channel) => {
|
||||
const leader = next[channel];
|
||||
|
||||
if (leader !== channel && next[leader] !== leader) {
|
||||
// `channel` follows a leader that itself follows someone else -
|
||||
// release `channel` back to itself to break the chain.
|
||||
next[channel] = channel;
|
||||
}
|
||||
});
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the DSP's real link state (per-channel `linked_channels: Channel[]`)
|
||||
* and turns it into a leader map, so the modal opens already reflecting the
|
||||
* device. Every channel defaults to following itself (unlinked); channels
|
||||
* reported as linked follow whichever channel the device reported first.
|
||||
*/
|
||||
function extractLeaderMap<T extends string>(
|
||||
states: Partial<Record<T, { linked_channels: (Channel | string)[] }>> | undefined,
|
||||
orderedChannels: readonly T[]
|
||||
): LeaderMap {
|
||||
const leaderOf: LeaderMap = {};
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
leaderOf[channel] = channel;
|
||||
});
|
||||
|
||||
if (!states) {
|
||||
return leaderOf;
|
||||
}
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
const state = states[channel];
|
||||
|
||||
if (!state || state.linked_channels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaderId = channelRawId(state.linked_channels[0]);
|
||||
|
||||
if (leaderId !== channel) {
|
||||
leaderOf[channel] = leaderId;
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizeLeaderMap(leaderOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a leader map into one linkChannels call per leader that has
|
||||
* followers (source = leader, destinations = its followers), plus one call
|
||||
* per still-unlinked, follower-less channel with no destinations. Channels
|
||||
* that are themselves following someone don't get their own call - they're
|
||||
* already covered by their leader's call.
|
||||
*/
|
||||
function buildLinkCalls(
|
||||
leaderOf: LeaderMap,
|
||||
orderedChannels: readonly string[],
|
||||
wrap: (raw: string) => Channel
|
||||
): LinkGroup[] {
|
||||
const followersByLeader = new Map<string, string[]>();
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
const leader = leaderOf[channel];
|
||||
|
||||
if (leader === channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = followersByLeader.get(leader) ?? [];
|
||||
existing.push(channel);
|
||||
followersByLeader.set(leader, existing);
|
||||
});
|
||||
|
||||
const calls: LinkGroup[] = [];
|
||||
|
||||
orderedChannels.forEach((channel) => {
|
||||
const followers = followersByLeader.get(channel);
|
||||
|
||||
if (followers) {
|
||||
calls.push({ source: wrap(channel), destinations: followers.map(wrap) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderOf[channel] === channel) {
|
||||
calls.push({ source: wrap(channel), destinations: [] });
|
||||
}
|
||||
});
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
function LinkChannelsModal({
|
||||
dsp,
|
||||
onClose,
|
||||
linkChannels,
|
||||
}: {
|
||||
dsp: DSP408;
|
||||
onClose: () => void;
|
||||
linkChannels: (source: Channel, destinations: Channel[]) => Promise<boolean>;
|
||||
}) {
|
||||
const [mode, setMode] = useState<LinkMode>('input');
|
||||
|
||||
const [inputLeaders, setInputLeaders] = useState<LeaderMap>(() =>
|
||||
extractLeaderMap(dsp.state?.current_config?.input_states, INPUT_CHANNELS)
|
||||
);
|
||||
|
||||
const [outputLeaders, setOutputLeaders] = useState<LeaderMap>(() =>
|
||||
extractLeaderMap(dsp.state?.current_config?.output_states, OUTPUT_CHANNELS)
|
||||
);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const channels: readonly string[] = mode === 'input' ? INPUT_CHANNELS : OUTPUT_CHANNELS;
|
||||
const leaderOf = mode === 'input' ? inputLeaders : outputLeaders;
|
||||
const setLeaderOf = mode === 'input' ? setInputLeaders : setOutputLeaders;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const followChannel = (follower: string, leader: string) => {
|
||||
setLeaderOf((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
// `follower` can no longer lead anyone once it follows `leader` -
|
||||
// release whoever currently follows `follower` back to themselves.
|
||||
Object.keys(next).forEach((channel) => {
|
||||
if (next[channel] === follower) {
|
||||
next[channel] = channel;
|
||||
}
|
||||
});
|
||||
|
||||
next[follower] = leader;
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const unlinkChannel = (channel: string) => {
|
||||
setLeaderOf((prev) => ({ ...prev, [channel]: channel }));
|
||||
};
|
||||
|
||||
const handleCellClick = (row: string, col: string) => {
|
||||
const isSelf = row === col;
|
||||
|
||||
if (isSelf) {
|
||||
if (leaderOf[row] !== row) {
|
||||
unlinkChannel(row);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderOf[row] !== row || leaderOf[col] === row) {
|
||||
// Row is locked (it's itself following someone) or this pair is
|
||||
// already the current state - nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
followChannel(col, row);
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const groups = [
|
||||
...buildLinkCalls(inputLeaders, INPUT_CHANNELS, wrapInputChannel),
|
||||
...buildLinkCalls(outputLeaders, OUTPUT_CHANNELS, wrapOutputChannel),
|
||||
];
|
||||
|
||||
for (const group of groups) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await linkChannels(group.source, group.destinations);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-channels-overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="link-channels-modal">
|
||||
<div className="link-channels-modal__header">
|
||||
<h2>Link Channels</h2>
|
||||
<p>
|
||||
Link one or more channels to a single channel. A channel not linked to another is linked
|
||||
to itself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="link-channels-modal__mode-toggle" role="tablist" aria-label="Channel type">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'input'}
|
||||
className={`link-channels-modal__mode-btn ${mode === 'input' ? 'is-active' : ''}`}
|
||||
onClick={() => setMode('input')}
|
||||
>
|
||||
Input
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'output'}
|
||||
className={`link-channels-modal__mode-btn ${mode === 'output' ? 'is-active' : ''}`}
|
||||
onClick={() => setMode('output')}
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-matrix"
|
||||
style={{ gridTemplateColumns: `auto repeat(${channels.length}, 1fr)` }}
|
||||
>
|
||||
<div className="link-matrix__corner" />
|
||||
|
||||
{channels.map((col) => (
|
||||
<div className="link-matrix__col-label" key={`col-${col}`}>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{channels.map((row, rowIndex) => {
|
||||
const isRowLocked = leaderOf[row] !== row;
|
||||
|
||||
return (
|
||||
<div className="link-matrix__row" key={`row-${row}`}>
|
||||
<div className="link-matrix__row-label">{row}</div>
|
||||
|
||||
{channels.map((col, colIndex) => {
|
||||
if (colIndex < rowIndex) {
|
||||
return (
|
||||
<div
|
||||
className="link-matrix__cell link-matrix__cell--empty"
|
||||
key={`cell-${row}-${col}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelf = row === col;
|
||||
const isActive = isSelf ? leaderOf[row] === row : leaderOf[col] === row;
|
||||
const isUnavailable = !isSelf && !isActive && isRowLocked;
|
||||
|
||||
const label = isSelf
|
||||
? isActive
|
||||
? `${row} not linked`
|
||||
: `Unlink ${row}`
|
||||
: isUnavailable
|
||||
? `${row} is already linked to another channel`
|
||||
: isActive
|
||||
? `${col} linked to ${row}`
|
||||
: `Link ${col} to ${row}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`cell-${row}-${col}`}
|
||||
className={[
|
||||
'link-matrix__cell',
|
||||
'link-matrix__cell--toggle',
|
||||
isUnavailable && 'link-matrix__cell--unavailable',
|
||||
isActive && 'is-active',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={isActive}
|
||||
disabled={isActive || isUnavailable}
|
||||
aria-label={label}
|
||||
onClick={() => handleCellClick(row, col)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="link-channels-modal__actions">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleValidate} disabled={isSaving}>
|
||||
{isSaving ? 'Linking...' : 'Validate'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkChannelsModal;
|
||||
Reference in New Issue
Block a user