Skip to content

Commit df3e847

Browse files
committed
WIP
1 parent 98a0f9e commit df3e847

5 files changed

Lines changed: 139 additions & 69 deletions

File tree

packages/zenflux-react-commander/src/commands-manager.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,24 @@ class CommandsManager {
279279
return listeners.length > 0;
280280
}
281281

282+
public resolveId( commandName: string, componentName: string, index = 0 ): DCommandIdArgs {
283+
const contexts = core.__devGetContextValues?.() || [];
284+
const matches = contexts.filter( c => c.componentName === componentName && !!c.commands[ commandName ] );
285+
const ctx = matches[ index ];
286+
if ( ! ctx ) throw new Error( `Command '${ commandName }' for component '${ componentName }' not found` );
287+
return { commandName, componentName, componentNameUnique: ctx.componentNameUnique };
288+
}
289+
290+
public runByName( commandName: string, componentName: string, args: DCommandArgs ) {
291+
const id = this.resolveId( commandName, componentName );
292+
return this.run( id, args );
293+
}
294+
295+
public hookByNameScoped( params: { commandName: string; componentName: string; ownerId: string }, callback: ( result?: any, args?: DCommandArgs ) => any ) {
296+
const id = this.resolveId( params.commandName, params.componentName );
297+
return this.hookScoped( id, params.ownerId, callback );
298+
}
299+
282300
public isContextRegistered( componentNameUnique: string ) {
283301
return !! core[ GET_INTERNAL_SYMBOL ]( componentNameUnique, true );
284302
}

packages/zenflux-react-commander/src/commands-provider.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ export function ComponentIdProvider(props: {
3030
}) {
3131
const { children, context } = props;
3232
return (
33-
<ComponentIdContext.Provider value={context}>
34-
{children}
35-
</ComponentIdContext.Provider>
33+
<ComponentIdContext.Provider value={context}>
34+
{children}
35+
</ComponentIdContext.Provider>
3636
);
3737
}

packages/zenflux-react-commander/src/use-commands.tsx

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import core from "./_internal/core";
99
import { ComponentIdContext } from "@zenflux/react-commander/commands-context";
1010
import commandsManager from "@zenflux/react-commander/commands-manager";
1111

12-
import type { DCommandArgs, DCommandComponentContextProps, DCommandIdArgs } from "@zenflux/react-commander/definitions";
12+
import type { DCommandArgs, DCommandComponentContextProps, DCommandIdArgs, DCommandSingleComponentContext } from "@zenflux/react-commander/definitions";
1313

1414
function getSafeContext( componentName: string, context?: DCommandComponentContextProps ) {
1515
function maybeWrongContext( componentName: string, componentNameUnique: string ) {
@@ -195,7 +195,7 @@ export function useCommandId( commandName: string, opts?: { match?: string; inde
195195
componentNameUnique: ctx.componentNameUnique,
196196
} );
197197
}
198-
} catch ( _e ) {
198+
} catch {
199199
setId( null );
200200
}
201201
}, [ match, index, commandName ] );
@@ -276,3 +276,84 @@ export function useCommands( input: string[] | Record<string, string> ) {
276276
return result;
277277
}
278278

279+
export function useCommandRunner( commandName: string, opts?: { match?: string; index?: number } ) {
280+
const id = useCommandId( commandName, opts );
281+
282+
return React.useCallback( ( args: DCommandArgs, callback?: ( result: unknown ) => void ) => {
283+
if ( ! id ) return;
284+
return commandsManager.run( id, args, callback as any );
285+
}, [ id ] );
286+
}
287+
288+
export function useCommandHook(
289+
commandName: string,
290+
handler: ( result?: unknown, args?: DCommandArgs ) => void,
291+
opts?: { match?: string; index?: number; ignoreDuplicate?: boolean }
292+
) {
293+
const componentContext = React.useContext( ComponentIdContext );
294+
const fallbackId = React.useId();
295+
const ownerId = componentContext?.isSet ? componentContext.getNameUnique() : ( "GLOBAL-" + fallbackId );
296+
297+
const id = useCommandId( commandName, opts );
298+
299+
React.useEffect( () => {
300+
if ( ! id ) return;
301+
302+
const handle = commandsManager.hookScoped( id, ownerId, handler, {
303+
__ignoreDuplicatedHookError: !! opts?.ignoreDuplicate,
304+
} );
305+
306+
return () => {
307+
commandsManager.unhookHandle( handle );
308+
};
309+
}, [ id?.componentNameUnique, ownerId, handler ] );
310+
}
311+
312+
export function useChildCommandHook(
313+
childComponentName: string,
314+
commandName: string,
315+
handler: ( result?: unknown, args?: DCommandArgs ) => void,
316+
opts?: { filter?: (ctx: ReturnType<typeof useCommanderComponent>) => boolean; ignoreDuplicate?: boolean }
317+
) {
318+
const children = useCommanderChildrenComponents(childComponentName);
319+
320+
React.useEffect(() => {
321+
const disposers: Array<() => void> = [];
322+
323+
children.forEach((cmd) => {
324+
if (opts?.filter && !opts.filter(cmd)) return;
325+
cmd.hook(commandName, handler);
326+
disposers.push(() => cmd.unhook(commandName));
327+
});
328+
329+
return () => {
330+
disposers.forEach(d => d());
331+
};
332+
}, [children.map(c => c.getId()).join("|"), commandName, handler]);
333+
}
334+
335+
export function useChildCommandRunner(
336+
childComponentName: string,
337+
selector: (ctx: DCommandSingleComponentContext) => string // returns key to match (e.g., itemKey)
338+
) {
339+
const children = useCommanderChildrenComponents(childComponentName);
340+
341+
const getByKey = React.useCallback((key: string) => {
342+
for (const cmd of children) {
343+
const ctx = cmd.getInternalContext();
344+
const k = selector(ctx);
345+
if (k === key) return cmd;
346+
}
347+
return null;
348+
}, [children, selector]);
349+
350+
const run = React.useCallback((key: string, commandName: string, args: DCommandArgs) => {
351+
const cmd = getByKey(key);
352+
if (!cmd) return false;
353+
cmd.run(commandName, args);
354+
return true;
355+
}, [getByKey]);
356+
357+
return run;
358+
}
359+

zenflux-react-app-examples/budget-allocation/src/app.tsx

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import React, { useEffect } from "react";
22

33
import { API } from "@zenflux/react-api/src";
4-
import commandsManager from "@zenflux/react-commander/commands-manager";
54

6-
import { useScopedCommand } from "@zenflux/react-commander/use-commands";
5+
import { useCommandHook, useCommandRunner } from "@zenflux/react-commander/use-commands";
76

87
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@zenflux/app-budget-allocation/src/components/ui/tabs";
98

@@ -45,35 +44,24 @@ function App() {
4544

4645
const [ selectedTab, setSelectedTab ] = React.useState( location.hash.replace( "#", "" ) );
4746

48-
const addChannel = useScopedCommand( "App/AddChannel" );
47+
const runAddChannel = useCommandRunner( "App/AddChannel" );
4948

5049
useEffect( () => {
51-
if ( ! addChannel.id ) return;
52-
5350
if ( location.hash === "#allocation/add-channel" ) {
5451
location.hash = "#allocation";
5552
setSelectedTab( "allocation" );
5653

5754
setTimeout( () => {
58-
addChannel.run( {} );
55+
runAddChannel( {} );
5956
}, 1000 );
6057
}
61-
}, [ location.hash, addChannel.id?.componentNameUnique ] );
62-
63-
useEffect( () => {
64-
if ( ! addChannel.id ) return;
65-
66-
const handle = addChannel.hookScoped( () => {
67-
location.hash = "#allocation/add-channel";
68-
69-
setSelectedTab( "allocation" );
70-
} );
58+
}, [ location.hash ] );
7159

72-
return () => {
73-
addChannel.unhookHandle( handle );
74-
};
60+
useCommandHook( "App/AddChannel", () => {
61+
location.hash = "#allocation/add-channel";
7562

76-
}, [ addChannel.id?.componentNameUnique ] );
63+
setSelectedTab( "allocation" );
64+
} );
7765

7866
const items = [
7967
{ id: "allocation", title: "Budget Allocation", content: <LazyLoader ContentComponent={ BudgetAllocation }/> },

zenflux-react-app-examples/budget-allocation/src/components/channels/channels-list-accordion-interactions.tsx

Lines changed: 27 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
useCommanderComponent,
99
useAnyComponentCommands,
1010
useCommanderState,
11-
useCommanderChildrenComponents
11+
useChildCommandHook,
12+
useChildCommandRunner
1213
} from "@zenflux/react-commander/use-commands";
1314

1415
import ChannelItemAccordion from "@zenflux/app-budget-allocation/src/components//channel/channel-item-accordion.tsx";
@@ -20,32 +21,23 @@ import type { ChannelItemAccordionComponent } from "@zenflux/app-budget-allocati
2021
const scheduler = new EventEmitter();
2122

2223
// On channel list, request edit title name
23-
function onEditRequest(
24+
function _onEditRequest(
2425
channel: ChannelItemAccordionComponent,
2526
setSelected: ( selected: { [ key: string ]: boolean } ) => void,
26-
channelsCommands: ReturnType<typeof useCommanderComponent>,
27-
accordionItemCommands: ReturnType<typeof useCommanderChildrenComponents>,
27+
runAccordionItem: ReturnType<typeof useChildCommandRunner>,
2828
) {
2929
// Select the channel (trigger accordion item selection)
3030
setSelected( { [ channel.props.meta.id ]: true } );
3131

32-
const correspondingCommand = accordionItemCommands.find( ( command ) => {
33-
return command.getInternalContext().props.itemKey === channel.props.meta.id;
34-
} );
35-
36-
const tryToEnableEdit = ( correspondingCommand: any ) => {
37-
// Try tell accordion to enter edit mode
38-
correspondingCommand?.run( "UI/AccordionItem/EditableTitle", { state: true } );
39-
40-
return correspondingCommand;
41-
};
42-
43-
if ( tryToEnableEdit( correspondingCommand ) ) return;
32+
const enabled = runAccordionItem( channel.props.meta.id, "UI/AccordionItem/EditableTitle", { state: true } );
33+
if ( enabled ) return;
4434

45-
scheduler.once( `enable-editable-title-${ channel.props.meta.id }`, tryToEnableEdit );
35+
scheduler.once( `enable-editable-title-${ channel.props.meta.id }`, () =>
36+
runAccordionItem( channel.props.meta.id, "UI/AccordionItem/EditableTitle", { state: true } )
37+
);
4638
}
4739

48-
function onRemoveRequest(
40+
function _onRemoveRequest(
4941
channel: ChannelItemAccordionComponent,
5042
getChannelsListState: ReturnType<typeof useCommanderState<ChannelListState>>[ 0 ],
5143
setChannelsListState: ReturnType<typeof useCommanderState<ChannelListState>>[ 1 ]
@@ -98,42 +90,33 @@ export function channelsListAccordionInteractions() {
9890

9991
const channelsCommands = useCommanderComponent( "App/ChannelsList" );
10092

101-
useCommanderChildrenComponents( "UI/AccordionItem", ( accordionItemCommands ) => {
102-
if ( ! accordionItemCommands.length ) return;
103-
104-
// Hook on title changed, run command within the channel list, to inform about the change
105-
accordionItemCommands.forEach( ( command ) => {
106-
if ( ! command.isAlive() ) return;
107-
108-
command.hook( "UI/AccordionItem/OnTitleChanged", ( result, args ) => {
109-
channelsCommands.run( "App/ChannelsList/SetName", {
110-
id: args!.itemKey,
111-
name: args!.title,
112-
} );
93+
useChildCommandHook(
94+
"UI/AccordionItem",
95+
"UI/AccordionItem/OnTitleChanged",
96+
( _result, args: any ) => {
97+
channelsCommands.run( "App/ChannelsList/SetName", {
98+
id: args.itemKey,
99+
name: args.title,
113100
} );
101+
}
102+
);
114103

115-
// This will ensure that the accordion item will enter edit mode, if the channel list requested it
116-
const key = `enable-editable-title-${ command.getInternalContext().props.itemKey }`;
117-
if ( scheduler.eventNames().includes( key ) ) {
118-
scheduler.emit( key, command );
119-
scheduler.removeAllListeners( key );
120-
}
121-
} );
104+
const runAccordionItem = useChildCommandRunner(
105+
"UI/AccordionItem",
106+
( ctx ) => ctx.props.itemKey
107+
);
122108

109+
React.useEffect( () => {
123110
channelsCommands.hook( "App/ChannelsList/EditRequest", ( r, args: any ) =>
124-
onEditRequest( args.channel, setSelected, channelsCommands, accordionItemCommands ) );
111+
_onEditRequest( args.channel, setSelected, runAccordionItem ) );
125112

126113
channelsCommands.hook( "App/ChannelsList/RemoveRequest", ( r, args: any ) =>
127-
onRemoveRequest( args.channel, getChannelsListState, setChannelsListState ) );
114+
_onRemoveRequest( args.channel, getChannelsListState, setChannelsListState ) );
128115

129116
return () => {
130-
accordionItemCommands.forEach( ( command ) => {
131-
command.unhook( "UI/AccordionItem/OnTitleChanged" );
132-
} );
133-
134117
commandsManager.unhookWithinComponent( channelsCommands.getId() );
135118
};
136-
} );
119+
}, [ isMounted() ] );
137120

138121
React.useEffect( () => {
139122
const addChannelCommands = useAnyComponentCommands( "App/AddChannel" );

0 commit comments

Comments
 (0)