diff --git a/Classes/Controller/PreferencesController.php b/Classes/Controller/PreferencesController.php index ca85104..c3beb2e 100644 --- a/Classes/Controller/PreferencesController.php +++ b/Classes/Controller/PreferencesController.php @@ -13,22 +13,32 @@ */ use Doctrine\ORM\EntityManagerInterface; +use Neos\ContentRepository\Domain\Model\NodeInterface; use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; use Neos\Flow\Mvc\View\JsonView; +use Neos\Neos\Controller\CreateContentContextTrait; use Neos\Neos\Domain\Model\UserPreferences; +use Neos\Neos\Service\LinkingService; use Neos\Neos\Service\UserService; +use Neos\Neos\Ui\ContentRepository\Service\NodeService; class PreferencesController extends ActionController { + use CreateContentContextTrait; + protected const FAVOURITES_PREFERENCE = 'commandBar.favourites'; protected const RECENT_COMMANDS_PREFERENCE = 'commandBar.recentCommands'; protected const RECENT_DOCUMENTS_PREFERENCE = 'commandBar.recentDocuments'; protected $defaultViewObjectName = JsonView::class; protected $supportedMediaTypes = ['application/json']; - public function __construct(protected UserService $userService, protected EntityManagerInterface $entityManager) - { + public function __construct( + protected UserService $userService, + protected EntityManagerInterface $entityManager, + protected NodeService $nodeService, + protected LinkingService $linkingService, + ) { } public function getPreferencesAction(): void @@ -37,7 +47,7 @@ public function getPreferencesAction(): void $this->view->assign('value', [ 'favouriteCommands' => $preferences->get(self::FAVOURITES_PREFERENCE) ?? [], 'recentCommands' => $preferences->get(self::RECENT_COMMANDS_PREFERENCE) ?? [], - 'recentDocuments' => $preferences->get(self::RECENT_DOCUMENTS_PREFERENCE)?? [], + 'recentDocuments' => $this->mapContextPathsToNodes($preferences->get(self::RECENT_DOCUMENTS_PREFERENCE) ?? []), 'showBranding' => $this->settings['features']['showBranding'], ]); } @@ -85,14 +95,29 @@ public function addRecentCommandAction(string $commandId): void * Updates the list of recently used documents in the user preferences * * @Flow\SkipCsrfProtection - * @param string[] $nodeContextPaths a list of context paths to uniquely define nodes + * @param string $nodeContextPath a context path to add to the recently visited documents */ - public function setRecentDocumentsAction(array $nodeContextPaths): void + public function addRecentDocumentAction(string $nodeContextPath): void { $preferences = $this->getUserPreferences(); - $preferences->set(self::RECENT_DOCUMENTS_PREFERENCE, $nodeContextPaths); + + $recentDocuments = $preferences->get(self::RECENT_DOCUMENTS_PREFERENCE); + if ($recentDocuments === null) { + $recentDocuments = []; + } + + // Remove the command from the list if it is already in there (to move it to the top) + $recentDocuments = array_filter($recentDocuments, + static fn($existingContextPath) => $existingContextPath !== $nodeContextPath); + // Add the path to the top of the list + array_unshift($recentDocuments, $nodeContextPath); + // Limit the list to 5 items + $recentDocuments = array_slice($recentDocuments, 0, 5); + + // Save the list + $preferences->set(self::RECENT_DOCUMENTS_PREFERENCE, $recentDocuments); $this->entityManager->persist($preferences); - $this->view->assign('value', $nodeContextPaths); + $this->view->assign('value', $this->mapContextPathsToNodes($recentDocuments)); } protected function getUserPreferences(): UserPreferences @@ -104,4 +129,35 @@ protected function getUserPreferences(): UserPreferences return $user->getPreferences(); } + /** + * @var string[] $contextPaths + */ + protected function mapContextPathsToNodes(array $contextPaths): array + { + return array_reduce($contextPaths, function (array $carry, string $contextPath) { + $node = $this->nodeService->getNodeFromContextPath($contextPath); + if ($node instanceof NodeInterface) { + $uri = $this->getNodeUri($node); + if ($uri) { + $carry[]= [ + 'name' => $node->getLabel(), + 'icon' => $node->getNodeType()->getConfiguration('ui.icon') ?? 'question', + 'uri' => $this->getNodeUri($node), + 'contextPath' => $contextPath, + ]; + } + } + return $carry; + }, []); + } + + protected function getNodeUri(NodeInterface $node): string + { + try { + return $this->linkingService->createNodeUri($this->controllerContext, $node, null, 'html', true); + } catch (\Exception $e) { + return ''; + } + } + } diff --git a/packages/commandbar/src/components/SearchBox/SearchBox.tsx b/packages/commandbar/src/components/SearchBox/SearchBox.tsx index 49d581a..18bba9c 100644 --- a/packages/commandbar/src/components/SearchBox/SearchBox.tsx +++ b/packages/commandbar/src/components/SearchBox/SearchBox.tsx @@ -16,6 +16,7 @@ const SearchBox: React.FC = () => { const { executeCommand } = useCommandExecutor(); const { translate } = useIntl(); const inputRef = useRef(); + const activeCommand = state.commands.value[state.activeCommandId.value || state.resultCommandId.value]; const handleChange = useCallback((e) => { if (state.status.value === STATUS.DISPLAYING_RESULT) { @@ -67,6 +68,7 @@ const SearchBox: React.FC = () => { ? translate('SearchBox.commandQuery.placeholder', 'Enter the query for the command') : translate('SearchBox.placeholder', 'What do you want to do today?') } + disabled={state.status.value === STATUS.DISPLAYING_RESULT && !activeCommand?.canHandleQueries} autoFocus onChange={handleChange} onKeyUp={handleKeyPress} diff --git a/packages/commandbar/src/state/CommandBarExecutor.tsx b/packages/commandbar/src/state/CommandBarExecutor.tsx index b2bd2f9..196b6a0 100644 --- a/packages/commandbar/src/state/CommandBarExecutor.tsx +++ b/packages/commandbar/src/state/CommandBarExecutor.tsx @@ -38,7 +38,12 @@ export const CommandBarExecutor: React.FC = ({ childre // Cancel search, or selection, or close command bar e.stopPropagation(); e.preventDefault(); - if (state.selectedCommandGroup.value || state.searchWord.value || state.commandQuery.value) { + if ( + state.selectedCommandGroup.value || + state.searchWord.value || + state.commandQuery.value || + state.result.value + ) { actions.CANCEL(); } else { // Close command bar if cancel is noop diff --git a/packages/commandbar/src/state/CommandBarStateProvider.tsx b/packages/commandbar/src/state/CommandBarStateProvider.tsx index 63d3c9c..edbda5e 100644 --- a/packages/commandbar/src/state/CommandBarStateProvider.tsx +++ b/packages/commandbar/src/state/CommandBarStateProvider.tsx @@ -120,7 +120,7 @@ export const CommandBarStateProvider: React.FC = ({ }, []); // Provide all actions as shorthand functions - const actions: Record void | Promise> = useMemo(() => { + const actions: Record void | Promise> = useMemo(() => { return { [TRANSITION.RESET_SEARCH]: () => dispatch({ type: TRANSITION.RESET_SEARCH }), [TRANSITION.HIGHLIGHT_NEXT_ITEM]: () => dispatch({ type: TRANSITION.HIGHLIGHT_NEXT_ITEM }), @@ -148,13 +148,13 @@ export const CommandBarStateProvider: React.FC = ({ [TRANSITION.EXPAND]: () => dispatch({ type: TRANSITION.EXPAND }), [TRANSITION.ADD_FAVOURITE]: (commandId: CommandId) => { dispatch({ type: TRANSITION.ADD_FAVOURITE, commandId }); - userPreferences + return userPreferences .setFavouriteCommands(state.favouriteCommands.value) .catch((e) => logger.error('Could not update favourite commands', e)); }, [TRANSITION.REMOVE_FAVOURITE]: (commandId: CommandId) => { dispatch({ type: TRANSITION.REMOVE_FAVOURITE, commandId }); - userPreferences + return userPreferences .setFavouriteCommands(state.favouriteCommands.value) .catch((e) => logger.error('Could not update favourite commands', e)); }, diff --git a/packages/commandbar/src/typings/global.d.ts b/packages/commandbar/src/typings/global.d.ts index aae0390..a62916d 100644 --- a/packages/commandbar/src/typings/global.d.ts +++ b/packages/commandbar/src/typings/global.d.ts @@ -126,14 +126,22 @@ type EditPreviewModes = Record; type NodeContextPath = string; +interface RecentDocument { + name: string; + uri: string; + icon: string; + contextPath: NodeContextPath; +} + interface UserPreferences { favouriteCommands: CommandId[]; recentCommands: CommandId[]; - recentDocuments: NodeContextPath[]; + recentDocuments: RecentDocument[]; showBranding: boolean; } interface UserPreferencesService extends UserPreferences { - setFavouriteCommands: (commandIds: CommandId[]) => Promise; - addRecentCommand: (commandId: CommandId) => Promise; + setFavouriteCommands: (commandIds: CommandId[]) => Promise; + addRecentCommand: (commandId: CommandId) => Promise; + addRecentDocument: (nodeContextPath: CommandId) => Promise; } diff --git a/packages/dev-server/src/index.tsx b/packages/dev-server/src/index.tsx index e048bb2..c48db3b 100644 --- a/packages/dev-server/src/index.tsx +++ b/packages/dev-server/src/index.tsx @@ -49,6 +49,7 @@ if (module.hot) module.hot.accept(); let favourites: CommandId[] = []; let recentCommands: CommandId[] = []; + let recentDocuments: CommandId[] = []; const userPreferencesService: UserPreferencesService = { favouriteCommands: [...favourites], @@ -56,6 +57,11 @@ if (module.hot) module.hot.accept(); recentCommands: [...recentCommands], addRecentCommand: async (commandId: CommandId) => void (recentCommands = [commandId, ...recentCommands.filter((id) => id !== commandId).slice(0, 4)]), + addRecentDocument: async (nodeContextPath: CommandId) => + void (recentDocuments = [ + nodeContextPath, + ...recentDocuments.filter((id) => id !== nodeContextPath).slice(0, 4), + ]), recentDocuments: [], showBranding: true, }; diff --git a/packages/module-plugin/src/App.tsx b/packages/module-plugin/src/App.tsx index 27e8fd6..746426a 100644 --- a/packages/module-plugin/src/App.tsx +++ b/packages/module-plugin/src/App.tsx @@ -24,7 +24,7 @@ export default class App extends Component< preferences: { favouriteCommands: CommandId[]; recentCommands: CommandId[]; - recentDocuments: NodeContextPath[]; + recentDocuments: RecentDocument[]; showBranding: boolean; }; } @@ -201,6 +201,7 @@ export default class App extends Component< ...preferences, setFavouriteCommands: PreferencesApi.setFavouriteCommands, addRecentCommand: PreferencesApi.addRecentCommand, + addRecentDocument: PreferencesApi.addRecentDocument, }} translate={App.translate} /> diff --git a/packages/neos-api/src/preferences.ts b/packages/neos-api/src/preferences.ts index 5e7e455..ac81694 100644 --- a/packages/neos-api/src/preferences.ts +++ b/packages/neos-api/src/preferences.ts @@ -3,6 +3,7 @@ import { fetchData } from './fetch'; const ENDPOINT_GET_PREFERENCES = '/neos/shel-neos-commandbar/preferences/getpreferences'; const ENDPOINT_SET_FAVOURITE_COMMANDS = '/neos/shel-neos-commandbar/preferences/setfavourites'; const ENDPOINT_ADD_RECENT_COMMAND = '/neos/shel-neos-commandbar/preferences/addrecentcommand'; +const ENDPOINT_ADD_RECENT_DOCUMENT = '/neos/shel-neos-commandbar/preferences/addrecentdocument'; async function setPreference(endpoint: string, data: any): Promise { return fetchData(endpoint, data, 'POST'); @@ -13,10 +14,15 @@ export async function getPreferences() { } export async function setFavouriteCommands(commandIds: CommandId[]) { - return setPreference(ENDPOINT_SET_FAVOURITE_COMMANDS, { commandIds: commandIds }); + return setPreference(ENDPOINT_SET_FAVOURITE_COMMANDS, { commandIds }); } export async function addRecentCommand(commandId: CommandId) { // TODO: Check if sendBeacon is a better option here to reduce the impact on the user - return setPreference(ENDPOINT_ADD_RECENT_COMMAND, { commandId: commandId }); + return setPreference(ENDPOINT_ADD_RECENT_COMMAND, { commandId }); +} + +export async function addRecentDocument(nodeContextPath: NodeContextPath) { + // TODO: Check if sendBeacon is a better option here to reduce the impact on the user + return setPreference(ENDPOINT_ADD_RECENT_DOCUMENT, { nodeContextPath }); } diff --git a/packages/ui-plugin/src/CommandBarUiPlugin.tsx b/packages/ui-plugin/src/CommandBarUiPlugin.tsx index 42604d9..8f036af 100644 --- a/packages/ui-plugin/src/CommandBarUiPlugin.tsx +++ b/packages/ui-plugin/src/CommandBarUiPlugin.tsx @@ -16,6 +16,7 @@ import { actions as commandBarActions, NeosRootState, selectors as commandBarSel import * as styles from './CommandBarUiPlugin.module.css'; import * as theme from '@neos-commandbar/commandbar/src/Theme.module.css'; +import { addRecentDocument } from '@neos-commandbar/neos-api/src/preferences'; type CommandBarUiPluginProps = { addNode: ( @@ -42,6 +43,7 @@ type CommandBarUiPluginProps = { publishableNodesInDocument: CRNode[]; previewUrl: string | null; setActiveContentCanvasSrc: (uri: string) => void; + setActiveContentCanvasContextPath: (contextPath: string) => void; setEditPreviewMode: (mode: string) => void; siteNode: CRNode; toggleCommandBar: () => void; @@ -52,7 +54,7 @@ type CommandBarUiPluginState = { dragging: boolean; favouriteCommands: CommandId[]; recentCommands: CommandId[]; - recentDocuments: NodeContextPath[]; + recentDocuments: RecentDocument[]; showBranding: boolean; commands: HierarchicalCommandList; }; @@ -79,6 +81,7 @@ class CommandBarUiPlugin extends React.PureComponent { + this.setState({ recentDocuments }); + }); + } + } + buildCommandsFromHotkeys = (): HierarchicalCommandList => { const { hotkeyRegistry, handleHotkeyAction, config } = this.props; const hotkeys: NeosHotKey[] = hotkeyRegistry.getAllAsList(); @@ -330,6 +350,36 @@ class CommandBarUiPlugin extends React.PureComponent { + carry[contextPath] = { + id: contextPath, + name, + icon, + action: async () => { + setActiveContentCanvasSrc(uri); + setActiveContentCanvasContextPath(contextPath); + }, + closeOnExecute: true, + }; + return carry; + }, {} as FlatCommandList), + }; + } + }; + handleSearchNode = async function* (this: CommandBarUiPlugin, query: string): CommandGeneratorResult { const { siteNode, setActiveContentCanvasSrc } = this.props as CommandBarUiPluginProps; yield { @@ -543,6 +593,7 @@ class CommandBarUiPlugin extends React.PureComponent ({}), { publishAction: actions.CR.Workspaces.publish, discardAction: actions.CR.Workspaces.commenceDiscard, setActiveContentCanvasSrc: actions.UI.ContentCanvas.setSrc, + setActiveContentCanvasContextPath: actions.CR.Nodes.setDocumentNode, })(connect(mapStateToProps, mapDispatchToProps)(mapGlobalRegistryToProps(CommandBarUiPlugin)));