diff --git a/.mcp.json b/.mcp.json index db4d84c454c..f1dadefa213 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,6 +3,15 @@ "webiny": { "command": "npx", "args": ["webiny-mcp", "serve", "--skills=./skills"] + }, + "stdlib": { + "command": "npx", + "args": ["-y", "@webiny/stdlib", "serve"] + }, + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp"] } } } diff --git a/extensions/folderDropConfirmation/index.tsx b/extensions/folderDropConfirmation/index.tsx new file mode 100644 index 00000000000..dbb1f9480f3 --- /dev/null +++ b/extensions/folderDropConfirmation/index.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { ContentEntryListConfig } from "webiny/admin/cms/entry/list"; + +const { Browser } = ContentEntryListConfig; + +const FolderDropConfirmationExtension = () => { + return ( + + + + ); +}; + +export default FolderDropConfirmationExtension; diff --git a/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx b/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx index 601e9cce03b..34554c4d31e 100644 --- a/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx +++ b/packages/admin-ui/src/Sidebar/components/SidebarContent.tsx @@ -14,7 +14,10 @@ const SidebarContent = ({ className, children, ...props }: React.ComponentProps< return ( div]:!block", + className + )} {...restProps} > {children} diff --git a/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx b/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx index 764061491ab..1dfb72c4c10 100644 --- a/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx +++ b/packages/admin-ui/src/Sidebar/components/SidebarRoot.tsx @@ -85,7 +85,10 @@ const SidebarRoot = ({ side = "left", className, children, ...props }: SidebarRo )} {...props} > -
+
{children}
diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubButton.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubButton.tsx index b4d780db652..5b5db4bed53 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubButton.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubButton.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { cva } from "~/utils.js"; +import { cn, cva } from "~/utils.js"; import type { SidebarMenuItemProps } from "./SidebarMenuRootItem.js"; import { DivButton } from "./DivButton.js"; import { SidebarMenuItemBadge } from "./SidebarMenuItemBadge.js"; @@ -58,14 +58,16 @@ const SidebarMenuSubButton = ({ const sharedProps = { "data-sidebar": "menu-sub-button", "data-active": active, - className: variants({ variant, disabled, className }), + className: variants({ variant, disabled, className: cn(className, action && "pr-xl") }), onClick }; + const textContent = {text}; + const content = to ? ( {icon} - {text} + {textContent} {badge && (typeof badge === "string" ? : badge)} ) : ( @@ -77,13 +79,13 @@ const SidebarMenuSubButton = ({ tabIndex={variant === "group-label" ? -1 : undefined} > {icon} - {text} + {textContent} {badge && (typeof badge === "string" ? : badge)} ); return ( -
+
{content} {action && ( diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx index 51ef38c6e07..e476b357489 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx @@ -161,7 +161,7 @@ const SidebarMenuSubItem = ({ if (!collapsible) { return ( -
+
-
+
  • {sidebarMenuSubButton}
  • diff --git a/packages/admin-ui/src/Tree/useTree.ts b/packages/admin-ui/src/Tree/useTree.ts index 2de951186da..aa3edcf0246 100644 --- a/packages/admin-ui/src/Tree/useTree.ts +++ b/packages/admin-ui/src/Tree/useTree.ts @@ -56,8 +56,6 @@ export const useTree = >(props: TreeProps) => }); }); - await presenter.handleDrop(newNodeTree); - if (props.onDrop) { const { dragSourceId, dropTargetId } = options; @@ -70,8 +68,14 @@ export const useTree = >(props: TreeProps) => dropTarget: newTreeDto.find(node => node.id === String(options.dropTargetId)) }; - await props.onDrop(newTreeDto, dropOptions); + try { + await props.onDrop(newTreeDto, dropOptions); + } catch { + return; + } } + + await presenter.handleDrop(newNodeTree); }; const changeOpen = (newOpenIds: NodeModel["id"][]) => { diff --git a/packages/api-scheduler/__tests__/SchedulerService.test.ts b/packages/api-scheduler/__tests__/SchedulerService.test.ts index 32070cd07f9..24aee76215f 100644 --- a/packages/api-scheduler/__tests__/SchedulerService.test.ts +++ b/packages/api-scheduler/__tests__/SchedulerService.test.ts @@ -5,6 +5,7 @@ import { CreateScheduleCommand, DeleteScheduleCommand, GetScheduleCommand, + ResourceNotFoundException, SchedulerClient, UpdateScheduleCommand } from "@webiny/aws-sdk/client-scheduler/index.js"; @@ -46,6 +47,15 @@ describe("SchedulerService", () => { it("throws if creating a schedule in the past", async () => { const client = mockClient(SchedulerClient); + client.on(GetScheduleCommand).rejects( + new ResourceNotFoundException({ + Message: "Resource not found.", + message: "Resource not found.", + $metadata: { + httpStatusCode: 404 + } + }) + ); const service = new EventBridgeSchedulerService(() => client, config); const input: SchedulerServiceCreateInput = { diff --git a/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts index 7b73a4ef57b..20307143170 100644 --- a/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts +++ b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts @@ -9,7 +9,8 @@ import { DeleteScheduleCommand, GetScheduleCommand, type SchedulerClient, - UpdateScheduleCommand + UpdateScheduleCommand, + ResourceNotFoundException } from "@webiny/aws-sdk/client-scheduler/index.js"; import { SCHEDULED_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; import type { IScheduledActionEventPayload } from "~/createEventHandler.js"; @@ -34,6 +35,66 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { ) {} public async create(params: SchedulerService.CreateParams): Promise { + // Check if schedule already exists (for auto-update logic) + const exists = await this.exists(params); + if (exists) { + return this.updateInternal(params); + } + + return await this.createInternal(params); + } + + public async update(params: SchedulerService.UpdateParams): Promise { + const exists = await this.exists(params); + if (exists) { + return await this.updateInternal(params); + } + return await this.create(params); + } + + public async delete(params: SchedulerService.DeleteParams): Promise { + const client = this.getClient(); + + const exists = await this.exists(params); + if (!exists) { + throw new WebinyError( + `Cannot delete schedule "${params.id}", tenant "${params.tenant}", because it does not exist.` + ); + } + + const name = this.createScheduleName(params); + + try { + await client.send(new DeleteScheduleCommand({ Name: name })); + } catch (ex) { + if (ex instanceof ResourceNotFoundException) { + return; + } else if (ex.name === "ResourceNotFoundException") { + return; + } + throw WebinyError.from(ex); + } + } + + public async exists(params: SchedulerService.ExistsParams): Promise { + const client = this.getClient(); + + const name = this.createScheduleName(params); + + try { + await client.send(new GetScheduleCommand({ Name: name })); + return true; + } catch (ex) { + if (ex instanceof ResourceNotFoundException) { + return false; + } else if (ex.name === "ResourceNotFoundException") { + return false; + } + throw ex; + } + } + + private async createInternal(params: SchedulerService.CreateParams): Promise { const { id, scheduleFor, tenant } = params; // Validate date is in future @@ -51,12 +112,6 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { const client = this.getClient(); - // Check if schedule already exists (for auto-update logic) - const exists = await this.exists(params); - if (exists) { - return this.update(params); - } - await client.send( new CreateScheduleCommand({ Name: this.createScheduleName(params), @@ -74,7 +129,7 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { ); } - public async update(params: SchedulerService.UpdateParams): Promise { + private async updateInternal(params: SchedulerService.UpdateParams): Promise { const { id, scheduleFor } = params; // Validate date is in future @@ -108,44 +163,6 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { ); } - public async delete(params: SchedulerService.DeleteParams): Promise { - const client = this.getClient(); - - const exists = await this.exists(params); - if (!exists) { - throw new WebinyError( - `Cannot delete schedule "${params.id}", tenant "${params.tenant}", because it does not exist.` - ); - } - - const name = this.createScheduleName(params); - - try { - await client.send(new DeleteScheduleCommand({ Name: name })); - } catch (ex) { - if (ex.name === "ResourceNotFoundException") { - return; - } - throw WebinyError.from(ex); - } - } - - public async exists(params: SchedulerService.ExistsParams): Promise { - const client = this.getClient(); - - const name = this.createScheduleName(params); - - try { - await client.send(new GetScheduleCommand({ Name: name })); - return true; - } catch (ex) { - if (ex.name === "ResourceNotFoundException") { - return false; - } - throw ex; - } - } - private createScheduleExpression(scheduleFor: Date): string { // Format: at(YYYY-MM-DDTHH:mm:ss) - EventBridge expects this format return `at(${scheduleFor.toISOString().replace(/\.\d{3}Z$/, "")})`; diff --git a/packages/app-aco/src/components/FolderTree/List/List.tsx b/packages/app-aco/src/components/FolderTree/List/List.tsx index c95973b7dc5..fe6c2d0a6e5 100644 --- a/packages/app-aco/src/components/FolderTree/List/List.tsx +++ b/packages/app-aco/src/components/FolderTree/List/List.tsx @@ -98,17 +98,25 @@ export const List = ({ // Abort if either folder is not found if (!folder || !targetFolder) { - return; + throw new Error("Folder not found"); } - showConfirmMoveFolderDialog({ - folder, - targetFolder, - onAccept: runDrop + await new Promise((resolve, reject) => { + showConfirmMoveFolderDialog({ + folder, + targetFolder, + onAccept: async () => { + await runDrop(); + resolve(); + }, + onClose: () => { + reject(new Error("cancelled")); + } + }); }); } else { - // Otherwise, perform the drop immediately - await runDrop(); + // Fire API call without awaiting — optimistic UI + runDrop(); } }, [folders, dropConfirmation, showConfirmMoveFolderDialog] diff --git a/packages/app-aco/src/dialogs/useConfirmMoveFolderDialog.tsx b/packages/app-aco/src/dialogs/useConfirmMoveFolderDialog.tsx index fbfca8be4d5..5a8217aedb0 100644 --- a/packages/app-aco/src/dialogs/useConfirmMoveFolderDialog.tsx +++ b/packages/app-aco/src/dialogs/useConfirmMoveFolderDialog.tsx @@ -5,6 +5,7 @@ interface ShowDialogParams { folder: FolderDto; targetFolder: FolderDto; onAccept: (folder: FolderDto, targetFolder: FolderDto) => Promise; + onClose?: () => void; } interface UseConfirmMoveFolderDialogResponse { @@ -14,14 +15,15 @@ interface UseConfirmMoveFolderDialogResponse { export const useConfirmMoveFolderDialog = (): UseConfirmMoveFolderDialogResponse => { const dialogs = useDialogs(); - const showDialog = ({ folder, targetFolder, onAccept }: ShowDialogParams) => { + const showDialog = ({ folder, targetFolder, onAccept, onClose }: ShowDialogParams) => { dialogs.showDialog({ title: "Move folder", content: `You are about to move the folder "${folder.title}" into "${targetFolder.title}"! Are you sure you want to continue?`, acceptLabel: "Move folder", cancelLabel: "Cancel", loadingLabel: "Moving folder...", - onAccept: () => onAccept(folder, targetFolder) + onAccept: () => onAccept(folder, targetFolder), + onClose }); }; diff --git a/packages/app-headless-cms/src/admin/components/ContentEntries/SidebarContent/SidebarContent.tsx b/packages/app-headless-cms/src/admin/components/ContentEntries/SidebarContent/SidebarContent.tsx index b969c9c8eee..f535c070d5c 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntries/SidebarContent/SidebarContent.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntries/SidebarContent/SidebarContent.tsx @@ -10,6 +10,7 @@ export const SidebarContent = () => {
    navigateToFolder(data.id)} enableActions={true} diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateGridItem.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateGridItem.tsx index 741ce635e25..4a7795856b9 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateGridItem.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateGridItem.tsx @@ -36,7 +36,7 @@ export const TemplateGridItem = makeDecoratable( {template.name} - + {template.description}
    diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateListItem.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateListItem.tsx index 6ef788ce101..6294cc18818 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateListItem.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/TemplateListItem.tsx @@ -28,7 +28,7 @@ export const TemplateListItem = ({ template, onTemplate }: TemplateListItemProps {template.name} {template.description && ( - + {template.description} )} diff --git a/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZone.tsx index d566f5c8d7d..5d7e08bb4e2 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZone.tsx @@ -1,8 +1,9 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState, useCallback } from "react"; import { Accordion } from "@webiny/admin-ui"; import type { CmsDynamicZoneTemplate } from "~/types.js"; import { AddTemplateIcon, AddTemplateButton } from "./AddTemplate.js"; import { DynamicZoneTemplate } from "./DynamicZoneTemplate.js"; +import { TemplateDialog } from "./TemplateDialog.js"; import { useModelField, useModelFieldEditor } from "~/admin/hooks/index.js"; function updateOrCreateTemplate( @@ -26,6 +27,9 @@ export const DynamicZone = () => { const { field } = useModelField(); const { updateField } = useModelFieldEditor(); const newTemplateId = useRef(undefined); + const [templateToEdit, setTemplateToEdit] = useState( + undefined + ); const templates: CmsDynamicZoneTemplate[] = field.settings?.templates || []; @@ -43,13 +47,23 @@ export const DynamicZone = () => { }); }; + const onDialogClose = useCallback(() => { + setTemplateToEdit(undefined); + }, []); + useEffect(() => { - // We only want to open the accordion item on first mount of a new template. newTemplateId.current = undefined; }, []); return ( <> + {templateToEdit ? ( + + ) : null} {templates.length ? ( {templates.map((template, index) => ( @@ -60,6 +74,7 @@ export const DynamicZone = () => { field={field} template={template} onChange={updateField} + onEditTemplate={setTemplateToEdit} /> ))} diff --git a/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZoneTemplate.tsx b/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZoneTemplate.tsx index 3614de6dc37..78308d5d4b9 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZoneTemplate.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/dynamicZone/DynamicZoneTemplate.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback } from "react"; import { set } from "dot-prop-immutable"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ReactComponent as EditIcon } from "@webiny/icons/edit.svg"; @@ -13,7 +13,6 @@ import { removeValueAtIndex } from "~/admin/plugins/arrayUtils.js"; import type { CmsDynamicZoneTemplate, CmsEditorFieldsLayout, CmsModelField } from "~/types.js"; -import { TemplateDialog } from "./TemplateDialog.js"; import { FieldEditor } from "~/admin/components/FieldEditor/index.js"; import { normalizeIcon } from "~/utils/normalizeIcon.js"; @@ -23,6 +22,7 @@ interface DynamicZoneTemplateProps { template: CmsDynamicZoneTemplate; onChange: (field: CmsModelField) => void; open: boolean; + onEditTemplate: (template: CmsDynamicZoneTemplate) => void; } interface UpdateTemplate { @@ -40,7 +40,8 @@ export const DynamicZoneTemplate = ({ field, template, onChange, - open + open, + onEditTemplate }: DynamicZoneTemplateProps) => { const { showConfirmation } = useConfirmationDialog({ title: "Delete content template", @@ -48,23 +49,15 @@ export const DynamicZoneTemplate = ({ acceptLabel: "Yes, I'm sure!" }); - const [templateToEdit, setTemplateToEdit] = useState( - undefined - ); - const templates = field.settings?.templates || []; const isFirst = index === 0; const isLast = index === templates.length - 1; const callbackDeps = [onChange, field, index, template.id]; - const onDialogClose = useCallback(() => { - setTemplateToEdit(undefined); - }, []); - const editTemplate = useCallback(() => { - setTemplateToEdit(template); - }, [template]); + onEditTemplate(template); + }, [template, onEditTemplate]); const updateTemplate = useCallback(params => { onChange( @@ -102,7 +95,7 @@ export const DynamicZoneTemplate = ({ title={template.name} description={template.description} icon={icon ? : undefined} - open={open} + defaultOpen={open} actions={ <> } > - {templateToEdit ? ( - - ) : null} - { + {/**/} {/**/} {/**/}