ContentWizardActions: migrate to nanostores #9994#10199
Conversation
edloidas
left a comment
There was a problem hiding this comment.
I don't really like, how this is look like half-measures. Adjusting legacy code and making it work via bridges.
If this is fixing some real problems and gaps, or simplifying things, I assume these changes must be minimal.
But if we actually need to rework actions as such, we should be more radical.
More modular structure of the stores according to good architecture:
actions/
types.ts # ActionId, ActionViewState, ActionDefinition, ActionLocks
evaluate.ts # evaluateActions() — shared engine
locks.store.ts # $actionLocks atom
browse/
browse-actions.registry.ts # ActionDefinition[] — static config
browse-actions.store.ts # $browseContext, $browseActions computed stores
browse-actions.commands.ts # dispatchBrowseAction(id)
wizard/
wizard-actions.registry.ts # ActionDefinition[] — static config
wizard-actions.store.ts # $wizardContext, $wizardActions computed stores
wizard-actions.commands.ts # dispatchWizardAction(id)
commands
hooks/
useBrowseAction.ts # (id) → ActionViewState + execute
useBrowseActionGroup.ts # (group) → sorted ActionViewState[] + execute map
useWizardAction.ts # same, reads from wizard context
Some may argue that commands stores is not needed and we should store action directly in component if we have one, which is also an option.
But having them in one place and calculated seems reasonable:
browse-actions.commands.ts
type CommandHandler = () => void | Promise<void>;
const BROWSE_COMMANDS: Record<string, CommandHandler> = {
'content.new': () => openNewContentDialog(),
// ...
};
export function dispatchBrowseAction(id: ActionId): void {
const state = $browseActions.get()[id];
if (!state?.enabled) return;
const handler = BROWSE_COMMANDS[id];
handler?.();
// Register hotkeys (should probably be done on demand in scope of a shell/view
// register hotkeys functionand the browse-actions.registry.ts:
import {i18n} from '@enonic/lib-admin-ui/util/Messages';
import {type ActionDefinition} from '../types';
import {type BrowseActionContext} from './browse-actions.store';
export const BROWSE_REGISTRY: ActionDefinition<BrowseActionContext>[] = [
{
id: 'content.new',
group: 'browse.primary',
order: 10,
label: i18n('action.new'),
enabled: (ctx) => ctx.canCreate && (ctx.isEmpty || ctx.childrenAllowed),
},
};It works simply as this, evaluating from bottom to top:
useBrowseAction(id)
$browseActions
evaluateActions()
$actionLocks
$browseContext
$currentItems
$permissions
$childrenAllowed
And the chain would like this:
User clicks a tree row
→ setSelection([id])
→ $selection updates
→ $currentItems recomputes (computed from $selection + $contentCache)
→ $permissions re-fetches (subscription on $currentIds, async)
→ $browseContext recomputes (computed from $currentItems + $permissions + ...)
→ $browseActions recomputes (evaluateActions runs here)
→ useStore($browseActions) triggers re-render
→ Button reads state.enabled, state.label, etc.
Code:
Actions types and groups (for grouping, in toolbar and menus), as string literal union, follows our conventions, preferring it over enums:
type ActionId = 'content.new' | 'content.edit' | 'content.publish' | /* ... */;
type ActionGroup = 'browse.primary' | 'browse.publish' | 'browse.context' | 'wizard.primary' | 'wizard.publish';Shared actions components, not *Action classes.
PublishAction.tsx
const PublishButton = () => {
const { state, execute } = useBrowseAction('content.publish');
if (!state.visible) return null;
return (
<Button
label={state.label}
disabled={!state.enabled}
pending={state.pending}
title={state.disabledReason}
onClick={execute}
/>
);
};While useBrowseAction.ts is something like this (or execute can also be located in the PublishButton itself. But we can have common ActionButton.tsx component that will accept just the ActionId.
function useBrowseAction(id: ActionId) {
const actions = useStore($browseActions);
const state = actions[id];
const execute = useCallback(() => dispatchBrowseAction(id), [id]);
return { state, execute };
}Here the browseActions store is the computed one and present plain view data in a record:
const $browseActions = computed(
[$browseContext, $actionLocks],
(ctx, locks) => evaluateActions(BROWSE_REGISTRY, ctx, locks),
);$actionLocks is a store with common flags that may affect actions, e.g. contentPublished.
const $browseContext = computed(
[$currentItems, $permissions, $childrenAllowed, $hasUnpublishedChildren],
(items, permissions, childrenAllowed, hasUnpubChildren): BrowseActionContext => ({
isEmpty: items.length === 0,
isSingle: items.length === 1,
canPublish: permissions.includes(Permission.PUBLISH),
canModify: permissions.includes(Permission.MODIFY),
hasAllOnline: items.every((i) => i.isOnline()),
childrenAllowed,
hasUnpublishedChildren: hasUnpubChildren,
// ...
}),
);evaluateActions() — pure function, called reactively. Basically evaluated all the data needed to render the button correctly.
function evaluateActions<Ctx>(
registry: ActionDefinition<Ctx>[],
ctx: Ctx,
locks: ActionLocks,
): Record<ActionId, ActionViewState> {
const result: Record<string, ActionViewState> = {};
for (const def of registry) {
const lockedOut = def.lockedBy?.some((key) => locks[key]) ?? false;
result[def.id] = {
label: typeof def.label === 'function' ? def.label(ctx) : def.label,
enabled: !lockedOut && def.enabled(ctx),
visible: def.visible?.(ctx) ?? true,
pending: def.pending?.(ctx),
pressed: def.pressed?.(ctx),
// ...
};
}
return result;
}The action definition is also quite simple and defines how the action will be calculated:
type ActionDefinition<Ctx> = {
id: ActionId;
group: ActionGroup;
order: number;
hotkey?: string;
label: string | ((ctx: Ctx) => string);
enabled: (ctx: Ctx) => boolean;
visible?: (ctx: Ctx) => boolean;
pending?: (ctx: Ctx) => boolean;
execute: (ctx: Ctx) => void | Promise<void>;
};|
No need to check this pull, it is just me investigating potential effort and possible approaches! |
|
Well, I gave one on those approaches as well. We should get rid of the all non v6 element-related code eventually anyway. |
|
Closing the pull for now, the task to be done on the next stage |
No description provided.