Skip to content

ContentWizardActions: migrate to nanostores #9994#10199

Closed
ashklianko wants to merge 2 commits into
masterfrom
issue-9994
Closed

ContentWizardActions: migrate to nanostores #9994#10199
ashklianko wants to merge 2 commits into
masterfrom
issue-9994

Conversation

@ashklianko
Copy link
Copy Markdown
Member

No description provided.

@ashklianko ashklianko marked this pull request as draft April 1, 2026 19:22
Copy link
Copy Markdown
Member

@edloidas edloidas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 function

and 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>;
};

@ashklianko
Copy link
Copy Markdown
Member Author

No need to check this pull, it is just me investigating potential effort and possible approaches!

@edloidas
Copy link
Copy Markdown
Member

edloidas commented Apr 7, 2026

Well, I gave one on those approaches as well. We should get rid of the all non v6 element-related code eventually anyway.

@ashklianko
Copy link
Copy Markdown
Member Author

Closing the pull for now, the task to be done on the next stage

@ashklianko ashklianko closed this May 29, 2026
@ashklianko ashklianko deleted the issue-9994 branch May 29, 2026 07:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants