Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 92 additions & 87 deletions plugins/example-plugin/src/nemo_example_plugin/web/dist/index.js

Large diffs are not rendered by default.

27 changes: 24 additions & 3 deletions plugins/example-plugin/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ Runtime contract: `../../../web/packages/studio/src/plugins/types.ts`.

Studio injects everything a plugin needs through a **single `host` prop**
(`host.workspaceId`, `host.auth`, `host.sdk`, `host.navigation`,
`host.notifications`, `host.telemetry`) — grouped so new capabilities extend the
`host.notifications`, `host.telemetry`, `host.breadcrumbs`) — grouped so new capabilities extend the
handle without changing `Root`'s signature. Destructure what you use. All are
backed by Studio's own singletons: `notifications` fires into Studio's shared
toaster, `telemetry` logs to Studio's OTEL pipeline (auto-scoped to the plugin),
`navigation` drives Studio's shared router.
`navigation` drives Studio's shared router, and `breadcrumbs` writes Studio's
breadcrumb bar — which lives in GlobalNav, *outside* the plugin's subtree, so a
plugin cannot render it itself. Studio clears the trail when the plugin
unmounts, but **not between pages within the plugin**: return a cleanup that
clears it, or the trail follows you to the next page. See `src/SharedUiPage.tsx`.

`@tanstack/react-query` **is** shared — call `useQuery`/`useMutation` and it reads
Studio's `QueryClientProvider` (one cache across Studio and every plugin). Put the
Expand Down Expand Up @@ -81,6 +85,16 @@ import { StudioDataView, useStudioDataViewState } from '@nemo/common';
Studio bundles the same files through its own graph. A plugin adds no CSS.
- **`useStudioDataViewState` syncs to URL search params** on Studio's shared
router — two DataViews on one route will fight over them.
- **Toasts need `onNotify`.** `ToastProvider` is *not* shared: Studio mounts it
by deep import, so this bundle carries its own `ToastContext` with nothing in
it. `ConfirmationModal`, `DeleteConfirmationModal` and `LogViewer` therefore
take an `onNotify` prop — pass `host.notifications.notify` and the message
lands in Studio's toaster. Omit it and the message is dropped with a
`logger.warn`; nothing throws, so the miss is silent in the UI.

```tsx
<DeleteConfirmationModal onNotify={host.notifications.notify} ... />
```

## Contract

Expand All @@ -92,8 +106,15 @@ import { StudioDataView, useStudioDataViewState } from '@nemo/common';
auth: { accessToken: string; getAccessToken: () => string };
sdk: { platform: /* @nemo/sdk platform hooks */ };
navigation: { navigate: (to: string) => void; back: () => void };
notifications: { notify: (message: string, type?: 'success'|'error'|'info'|'warning') => void };
notifications: {
notify: (
message: string,
type?: 'success'|'error'|'info'|'warning',
options?: { durationMs?: number | false },
) => void;
};
telemetry: { info; warn; error: (m, cause?) => void; event: (name, attrs?) => void };
breadcrumbs: { set: (trail: { label: string; href?: string }[]) => void };
};
}
```
Expand Down
15 changes: 14 additions & 1 deletion plugins/example-plugin/web/src/SharedUiPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
useStudioDataViewState,
} from "@nemo/common";
import { Stack, Text } from "@nvidia/foundations-react-core";
import { useCallback, type ComponentProps } from "react";
import { useCallback, useEffect, type ComponentProps } from "react";
import { pluginPath } from "./paths";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import type { PluginHost, Workspace } from "./types";

// `@nemo/common` is external like react — this is Studio's own StudioDataView,
Expand All @@ -22,6 +23,18 @@ export function SharedUiPage({ host }: { host: PluginHost }) {
});
const workspaces = data?.data ?? [];

// Renders in Studio's chrome, outside this subtree. Studio clears the trail
// when the plugin unmounts, but not between pages — so clear it here too.
const { set: setBreadcrumbs } = host.breadcrumbs;
const { workspaceId } = host;
useEffect(() => {
setBreadcrumbs([
{ label: "Example Plugin", href: pluginPath(workspaceId, "overview") },
{ label: "Shared UI" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, workspaceId]);

// Syncs to URL search params — one DataView per route or they fight.
const dataViewState = useStudioDataViewState();

Expand Down
16 changes: 15 additions & 1 deletion plugins/example-plugin/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,21 @@ export interface PluginNavigation {

export type NotificationType = 'success' | 'error' | 'info' | 'warning';

export interface NotificationOptions {
durationMs?: number | false;
}

export interface PluginNotifications {
notify: (message: string, type?: NotificationType) => void;
notify: (message: string, type?: NotificationType, options?: NotificationOptions) => void;
}

export interface PluginBreadcrumb {
label: string;
href?: string;
}

export interface PluginBreadcrumbs {
set: (trail: PluginBreadcrumb[]) => void;
}

export interface PluginTelemetry {
Expand All @@ -56,6 +69,7 @@ export interface PluginHost {
navigation: PluginNavigation;
notifications: PluginNotifications;
telemetry: PluginTelemetry;
breadcrumbs: PluginBreadcrumbs;
}

export interface PluginRootProps {
Expand Down
2 changes: 2 additions & 0 deletions web/packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"happy-dom": "catalog:",
"msw": "catalog:",
"p-limit": "catalog:",
"react-dropzone": "catalog:",
"react-hook-form": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
Expand All @@ -77,6 +78,7 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@opentelemetry/api-logs": "^0.219.0",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "8.20.5",
"@tanstack/react-virtual": "3.13.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { getErrorMessage, isValidationErrorArray } from '@studio/api/common/utils';
import { getErrorMessage, isValidationErrorArray } from '@nemo/common/src/api/common/utils';
import { AxiosError, AxiosHeaders, InternalAxiosRequestConfig } from 'axios';

describe('isValidationErrorArray', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { AccessibleTitle } from '@studio/components/AccessibleTitle';
import { AccessibleTitle } from '@nemo/common/src/components/AccessibleTitle';
import { render, waitFor } from '@testing-library/react';

describe('AccessibleTitle', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { getErrorMessage } from '@nemo/common/src/api/common/utils';
import { FormModal } from '@nemo/common/src/components/FormModal';
import { CJobCancellableStatuses } from '@nemo/common/src/constants/query';
import { useToast } from '@nemo/common/src/providers/toast/useToast';
Expand All @@ -11,23 +12,26 @@ import {
} from '@nemo/sdk/generated/platform/api';
import { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema';
import { Button, Flex, Text } from '@nvidia/foundations-react-core';
import { getErrorMessage } from '@studio/api/common/utils';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useQueryClient } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { FC, MouseEvent, useState } from 'react';

interface CancelJobButtonProps {
workspace: string;
jobName: string;
jobStatus?: PlatformJobStatus;
compact?: boolean;
}

export const CancelJobButton: FC<CancelJobButtonProps> = ({ jobName, jobStatus, compact }) => {
export const CancelJobButton: FC<CancelJobButtonProps> = ({
workspace,
jobName,
jobStatus,
compact,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const toast = useToast();
const queryClient = useQueryClient();
const workspace = useWorkspaceFromPath();

const { mutateAsync, isPending } = useJobsCancelJob({
mutation: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput';
import { FormModal, FormModalProps } from '@nemo/common/src/components/FormModal';
import { LoadingButton } from '@nemo/common/src/components/LoadingButton';
import { useToast } from '@nemo/common/src/providers/toast/useToast';
import type { NotifyFn } from '@nemo/common/src/providers/toast/types';
import { useNotify } from '@nemo/common/src/providers/toast/useNotify';
import { getErrorMessage } from '@nemo/common/src/utils/error';
import { handleFormErrorsGeneric } from '@nemo/common/src/utils/forms/error';
import { Stack, Text } from '@nvidia/foundations-react-core';
import { getErrorMessage } from '@studio/components/NewDataDesignerJobForm/utils';
import { handleFormErrorsGeneric } from '@studio/util/forms/error';
import { type ComponentProps, type FC, useState } from 'react';
import { useForm } from 'react-hook-form';

Expand All @@ -34,6 +35,8 @@ export interface ConfirmationModalProps extends Pick<FormModalProps, 'open' | 'o
submitButtonColor?: SubmitButtonColor;
/** When true, success and error toasts from this modal are skipped (caller handles feedback). */
suppressResultToasts?: boolean;
/** Where result messages go. Defaults to the surrounding ToastProvider; plugins pass `host.notifications.notify`. */
onNotify?: NotifyFn;
}

export const ConfirmationModal: FC<ConfirmationModalProps> = ({
Expand All @@ -49,6 +52,7 @@ export const ConfirmationModal: FC<ConfirmationModalProps> = ({
submitButtonText = 'Confirm',
submitButtonColor,
suppressResultToasts = false,
onNotify,
}) => {
const {
reset,
Expand All @@ -60,7 +64,7 @@ export const ConfirmationModal: FC<ConfirmationModalProps> = ({
defaultValues: { confirmText: '' },
});
const [isPending, setIsPending] = useState(false);
const toast = useToast();
const notify = useNotify(onNotify);

const resetAndClose = () => {
reset();
Expand All @@ -73,13 +77,13 @@ export const ConfirmationModal: FC<ConfirmationModalProps> = ({
const ok = await onConfirm();
if (!suppressResultToasts) {
if (ok) {
toast.success(successText);
notify(successText, 'success');
} else {
toast.error(errorText);
notify(errorText, 'error');
}
}
} catch (error: unknown) {
toast.error(getErrorMessage(error));
notify(getErrorMessage(error), 'error');
} finally {
resetAndClose();
setIsPending(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ConfirmationModal } from '@nemo/common/src/components/ConfirmationModal';
import { FormModalProps } from '@nemo/common/src/components/FormModal';
import { ConfirmationModal } from '@studio/components/modals/ConfirmationModal';
import type { NotifyFn } from '@nemo/common/src/providers/toast/types';
import { FC } from 'react';

interface DeleteModalProps extends Pick<FormModalProps, 'open' | 'onClose'> {
Expand All @@ -14,6 +15,8 @@ interface DeleteModalProps extends Pick<FormModalProps, 'open' | 'onClose'> {
successText?: string;
errorText?: string;
suppressResultToasts?: boolean;
/** Where result messages go. Defaults to the surrounding ToastProvider; plugins pass `host.notifications.notify`. */
onNotify?: NotifyFn;
}

export const DeleteConfirmationModal: FC<DeleteModalProps> = ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { getErrorMessage } from '@nemo/common/src/api/common/utils';
import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage';
import { logger } from '@nemo/common/src/utils/logger';
import { Flex, PageHeader, Panel, Stack, Text } from '@nvidia/foundations-react-core';
import { getErrorMessage } from '@studio/api/common/utils';
import { logger } from '@studio/util/logger';
import { FileX } from 'lucide-react';
import { ComponentProps, FC, ReactNode, useEffect } from 'react';
import { useRouteError, isRouteErrorResponse } from 'react-router';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { ExpandableMessage } from '@studio/components/ExpandableMessage';
import { ExpandableMessage } from '@nemo/common/src/components/ExpandableMessage';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Block, Flex, Label, Stack, Text } from '@nvidia/foundations-react-core';
import { FileTag, FileTagStatus } from '@studio/components/FileTag';
import { FileTag, FileTagStatus } from '@nemo/common/src/components/FileTag';
import {
getAcceptedFileExtensions,
hadInvalidFileTypeError,
hadTooManyFilesError,
} from '@studio/components/FileUpload/util';
import { InputErrorText } from '@studio/components/InputErrorText';
} from '@nemo/common/src/components/FileUpload/util';
import { InputErrorText } from '@nemo/common/src/components/InputErrorText';
import { Block, Flex, Label, Stack, Text } from '@nvidia/foundations-react-core';
import { FilePlus, FileText } from 'lucide-react';
import { FC, MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import { DropEvent, DropzoneOptions, FileRejection, useDropzone } from 'react-dropzone';
Expand Down
10 changes: 7 additions & 3 deletions web/packages/common/src/components/LogViewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

import { useStickToBottom } from '@nemo/common/src/hooks/useStickToBottom';
import { useToast } from '@nemo/common/src/providers/toast/useToast';
import type { NotifyFn } from '@nemo/common/src/providers/toast/types';
import { useNotify } from '@nemo/common/src/providers/toast/useNotify';
import { triggerDownload } from '@nemo/common/src/utils/file';
import { formatLogs } from '@nemo/common/src/utils/logs';
import type { PlatformJobLog } from '@nemo/sdk/generated/platform/schema';
Expand All @@ -27,6 +28,8 @@ interface LogViewerProps {
downloadFilename?: string;
rows?: number;
emptyMessage?: string;
/** Where the copy confirmation goes. Defaults to the surrounding ToastProvider; plugins pass `host.notifications.notify`. */
onNotify?: NotifyFn;
}

export const LogViewer: FC<LogViewerProps> = ({
Expand All @@ -35,6 +38,7 @@ export const LogViewer: FC<LogViewerProps> = ({
downloadFilename,
rows = DEFAULT_ROW_COUNT,
emptyMessage = 'No logs available yet',
onNotify,
}) => {
const [showAllLogs, setShowAllLogs] = useState(false);
const [wrapLines, setWrapLines] = useState(false);
Expand All @@ -45,7 +49,7 @@ export const LogViewer: FC<LogViewerProps> = ({

const isShowingLogs = useMemo(() => logs.length > 0 && !isLoading, [logs.length, isLoading]);

const { success } = useToast();
const notify = useNotify(onNotify);

const { ref: codeScrollRef, scrollToBottom } = useStickToBottom<HTMLDivElement>({
enabled: isShowingLogs,
Expand Down Expand Up @@ -87,7 +91,7 @@ export const LogViewer: FC<LogViewerProps> = ({
kind="block"
collapsible={false}
rows={rows}
onCopySuccess={() => success('Copied to clipboard!', { durationMs: 3000 })}
onCopySuccess={() => notify('Copied to clipboard!', 'success', { durationMs: 3000 })}
className="min-h-auto h-full"
attributes={{
CodeSnippetCode: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { QuickActionsMenuRoot } from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot';
import { QuickActionsMenuRoot } from '@nemo/common/src/components/QuickActionsMenu/QuickActionsMenuRoot';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Expand Down
21 changes: 21 additions & 0 deletions web/packages/common/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@
// Plugin API: the surface Studio serves to plugin bundles as `@nemo/common`.
// Removals are breaking. Explicit exports, not `export *`.

export { AccessibleTitle } from '@nemo/common/src/components/AccessibleTitle';
export { AccordionSection } from '@nemo/common/src/components/AccordionSection';
export { ConfirmationModal } from '@nemo/common/src/components/ConfirmationModal';
export { DeleteConfirmationModal } from '@nemo/common/src/components/DeleteConfirmationModal';
export type { AccordionSectionProps } from '@nemo/common/src/components/AccordionSection';
export { ExpandableMessage } from '@nemo/common/src/components/ExpandableMessage';
export { FileTag } from '@nemo/common/src/components/FileTag';
export type { FileTagProps, FileTagStatus } from '@nemo/common/src/components/FileTag';
export { FileUpload } from '@nemo/common/src/components/FileUpload';
export type { FileUploadProps, RenderFileTagFn } from '@nemo/common/src/components/FileUpload';
export { InputErrorText } from '@nemo/common/src/components/InputErrorText';
export { QuickActionsMenuRoot } from '@nemo/common/src/components/QuickActionsMenu/QuickActionsMenuRoot';
export type { QuickActionItem } from '@nemo/common/src/components/QuickActionsMenu/QuickActionsMenuRoot';

export {
StudioDataView,
StudioDataViewToolbar,
Expand Down Expand Up @@ -46,6 +60,13 @@ export {
} from '@nemo/common/src/utils/query';
export { triggerDownload } from '@nemo/common/src/utils/file';

export { getErrorMessage } from '@nemo/common/src/utils/error';
export { handleFormErrorsGeneric } from '@nemo/common/src/utils/forms/error';
export { logger, toError } from '@nemo/common/src/utils/logger';

export type { NotifyFn, NotifyType } from '@nemo/common/src/providers/toast/types';

export { JOB_POLLING_INTERVAL_MS } from '@nemo/common/src/constants';
export {
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
Expand Down
Loading
Loading