diff --git a/.betterer.results b/.betterer.results index 76cbf6455d5..0c0f8b906bc 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2959,6 +2959,10 @@ exports[`better eslint`] = { "public/app/features/auth-config/utils/data.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/browse-dashboards/BrowseDashboardsPage.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] + ], "public/app/features/browse-dashboards/api/browseDashboardsAPI.ts:5381": [ [0, 0, 0, "Do not re-export imported variable (\`@reduxjs/toolkit/query/react\`)", "0"] ], @@ -2972,6 +2976,26 @@ exports[`better eslint`] = { [0, 0, 0, "\'@grafana/ui/src/components/Icon/utils\' import is restricted from being used by a pattern. Import from the public export instead.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] ], + "public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx:5381": [ + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "6"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "12"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "13"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "14"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "15"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "16"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "17"] + ], "public/app/features/browse-dashboards/state/index.ts:5381": [ [0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"], [0, 0, 0, "Do not use export all (\`export * from ...\`)", "1"], diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index 28c17981efa..83852b6bc4d 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -74,6 +74,9 @@ func TestIntegrationOpenAPIs(t *testing.T) { }, { Group: "investigations.grafana.app", Version: "v0alpha1", + }, { + Group: "folder.grafana.app", + Version: "v0alpha1", }} for _, gv := range groups { VerifyOpenAPISnapshots(t, dir, gv, h) diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index e9f095b5d49..434c618b06e 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -6,30 +6,9 @@ export interface Props { } export function FolderRepo({ repo }: Props) { - if (!repo) { + if (!repo || (typeof repo !== 'string' && repo.target === 'instance')) { return null; } return ; - // const [repoName, icon] = getRepoDetails(repo); - // - // return ( - // - // {' '} - // | {repoName} - // - // ); } - -// function getRepoDetails(spec: RepositoryView): [string, IconName] { -// switch (spec.type) { -// case 'github': -// return [spec.title ?? '', 'github']; -// case 'local': -// return [spec.title ?? '', 'file-blank']; -// case 's3': -// return [spec.title ?? '', 'cloud']; -// default: -// return ['', 'question-circle']; -// } -// } diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index 544a4ff01b9..1609cf7c1a2 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -28,6 +28,7 @@ import usersReducers from 'app/features/users/state/reducers'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; import { alertingApi } from '../../features/alerting/unified/api/alertingApi'; +import { folderAPI } from '../../features/folders/api'; import { iamApi } from '../../features/iam/api/api'; import { userPreferencesAPI } from '../../features/preferences/api'; import { provisioningAPI } from '../../features/provisioning/api'; @@ -63,6 +64,7 @@ const rootReducers = { [iamApi.reducerPath]: iamApi.reducer, [userPreferencesAPI.reducerPath]: userPreferencesAPI.reducer, [provisioningAPI.reducerPath]: provisioningAPI.reducer, + [folderAPI.reducerPath]: folderAPI.reducer, }; const addedReducers = {}; diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index cb222a73867..6b2daa2e25e 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -5,7 +5,7 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui'; +import { LinkButton, FilterInput, useStyles2, Text, Stack, Alert, Icon } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; import { Trans } from 'app/core/internationalization'; @@ -127,6 +127,7 @@ const BrowseDashboardsPage = memo(() => { ); }; + const prLink = search.get('prLink'); return ( { } > + {prLink && ( + + Open pull request in GitHub + + + } + onRemove={() => window.open(prLink)} + /> + )}
{ + const instanceConfig = settings.items.find((repo) => repo.target === 'instance'); + + if (instanceConfig) { + return { ...item, repository: instanceConfig }; + } const repository = settings.items.find((repo) => { if (typeof item.repository === 'string') { return repo.name === item.repository; diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index ed06d0b14a2..d2b6203b768 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -16,6 +16,7 @@ import { FolderDTO } from 'app/types'; import { useNewFolderMutation } from '../api/browseDashboardsAPI'; import { NewFolderForm } from './NewFolderForm'; +import { NewProvisionedFolderForm } from './NewProvisionedFolderForm'; interface Props { parentFolder?: FolderDTO; @@ -102,7 +103,15 @@ export default function CreateNewButton({ parentFolder, canCreateDashboard, canC onClose={() => setShowNewFolderDrawer(false)} size="sm" > - setShowNewFolderDrawer(false)} /> + {parentFolder?.repository ? ( + setShowNewFolderDrawer(false)} + onCancel={() => setShowNewFolderDrawer(false)} + parentFolder={parentFolder} + /> + ) : ( + setShowNewFolderDrawer(false)} /> + )} )} diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx new file mode 100644 index 00000000000..e8531376074 --- /dev/null +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.test.tsx @@ -0,0 +1,434 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AppEvents } from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; +import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; +import { useCreateRepositoryFilesWithPathMutation, useGetRepositoryQuery } from 'app/features/provisioning/api'; +import { usePullRequestParam } from 'app/features/provisioning/hooks'; + +import { FolderDTO } from '../../../types'; +import { useGetFolderQuery } from '../../folders/api'; + +import { NewProvisionedFolderForm } from './NewProvisionedFolderForm'; + +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + return { + ...actual, + getAppEvents: jest.fn(), + locationService: { + partial: jest.fn(), + }, + config: { + ...actual.config, + }, + }; +}); + +jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => { + return { + validationSrv: { + validateNewFolderName: jest.fn(), + }, + }; +}); + +jest.mock('app/features/provisioning/api', () => { + return { + useCreateRepositoryFilesWithPathMutation: jest.fn(), + useGetRepositoryQuery: jest.fn(), + }; +}); + +jest.mock('../../folders/api', () => { + return { + useGetFolderQuery: jest.fn(), + }; +}); + +jest.mock('app/features/provisioning/hooks', () => { + return { + usePullRequestParam: jest.fn(), + }; +}); + +// Mock the defaults +jest.mock('../../dashboard-scene/saving/provisioned/defaults', () => { + return { + getDefaultWorkflow: jest.fn().mockReturnValue('write'), + getWorkflowOptions: jest.fn().mockReturnValue([ + { label: 'Commit directly', value: 'write' }, + { label: 'Create a branch', value: 'branch' }, + ]), + }; +}); + +interface Props { + onSubmit: () => void; + onCancel: () => void; + parentFolder: FolderDTO; +} + +function setup(props: Partial = {}) { + const user = userEvent.setup(); + + const defaultProps: Props = { + onSubmit: jest.fn(), + onCancel: jest.fn(), + parentFolder: { + id: 1, + uid: 'folder-uid', + title: 'Parent Folder', + url: '/dashboards/f/folder-uid', + hasAcl: false, + canSave: true, + canEdit: true, + canAdmin: true, + canDelete: true, + repository: { + name: 'test-repo', + type: 'github', + }, + } as unknown as FolderDTO, + ...props, + }; + + return { + user, + ...render(), + props: defaultProps, + }; +} + +describe('NewProvisionedFolderForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mocks + const mockAppEvents = { + publish: jest.fn(), + }; + (getAppEvents as jest.Mock).mockReturnValue(mockAppEvents); + + // Mock useGetRepositoryQuery + (useGetRepositoryQuery as jest.Mock).mockReturnValue({ + data: { + spec: { + type: 'github', + github: { + url: 'https://github.com/grafana/grafana', + branch: 'main', + }, + workflows: ['write', 'branch'], + }, + }, + isLoading: false, + isError: false, + }); + + // Mock useGetFolderQuery + (useGetFolderQuery as jest.Mock).mockReturnValue({ + data: { + metadata: { + annotations: { + 'source.path': '/dashboards', + }, + }, + }, + isLoading: false, + isError: false, + }); + + // Mock usePullRequestParam + (usePullRequestParam as jest.Mock).mockReturnValue(null); + + // Mock useCreateRepositoryFilesWithPathMutation + const mockCreate = jest.fn(); + const mockRequest = { + isSuccess: false, + isError: false, + isLoading: false, + error: null, + }; + (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([mockCreate, mockRequest]); + + (validationSrv.validateNewFolderName as jest.Mock).mockResolvedValue(true); + }); + + it('should render the form with correct fields', () => { + setup(); + + // Check if form elements are rendered + expect(screen.getByRole('textbox', { name: /folder name/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /comment/i })).toBeInTheDocument(); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('should show loading state when repository data is loading', () => { + (useGetRepositoryQuery as jest.Mock).mockReturnValue({ + data: null, + isLoading: true, + isError: false, + }); + + setup(); + + expect(screen.getByTestId('Spinner')).toBeInTheDocument(); + }); + + it('should show error when repository is not found', () => { + (useGetRepositoryQuery as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + isError: true, + }); + + setup(); + + expect(screen.getByText('Repository not found')).toBeInTheDocument(); + }); + + it('should show branch field when branch workflow is selected', async () => { + const { user } = setup(); + + expect(screen.queryByRole('textbox', { name: /branch/i })).not.toBeInTheDocument(); + + const branchOption = screen.getByRole('radio', { name: /create a branch/i }); + await user.click(branchOption); + + expect(screen.getByRole('textbox', { name: /branch/i })).toBeInTheDocument(); + }); + + it('should validate folder name', async () => { + (validationSrv.validateNewFolderName as jest.Mock).mockRejectedValue(new Error('Folder name already exists')); + + const { user } = setup(); + + const folderNameInput = screen.getByRole('textbox', { name: /folder name/i }); + await user.clear(folderNameInput); + await user.type(folderNameInput, 'Existing Folder'); + + // Submit the form + const submitButton = screen.getByRole('button', { name: /^create$/i }); + await user.click(submitButton); + + // Wait for validation error to appear + await waitFor(() => { + expect(screen.getByText('Folder name already exists')).toBeInTheDocument(); + }); + }); + + it('should validate branch name', async () => { + const { user } = setup(); + + // Select branch workflow + const branchOption = screen.getByRole('radio', { name: /create a branch/i }); + await user.click(branchOption); + + // Enter invalid branch name + const branchInput = screen.getByRole('textbox', { name: /branch/i }); + await user.clear(branchInput); + await user.type(branchInput, 'invalid//branch'); + + // Submit the form + const submitButton = screen.getByRole('button', { name: /^create$/i }); + await user.click(submitButton); + + // Wait for validation error to appear + await waitFor(() => { + expect(screen.getByText('Invalid branch name.')).toBeInTheDocument(); + }); + }); + + it('should create folder successfully', async () => { + const mockCreate = jest.fn(); + (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([ + mockCreate, + { + isSuccess: true, + isError: false, + isLoading: false, + error: null, + }, + ]); + + const { user, props } = setup(); + + const folderNameInput = screen.getByRole('textbox', { name: /folder name/i }); + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + + await user.clear(folderNameInput); + await user.type(folderNameInput, 'New Test Folder'); + + await user.clear(commentInput); + await user.type(commentInput, 'Creating a new test folder'); + + // Submit the form + const submitButton = screen.getByRole('button', { name: /^create$/i }); + await user.click(submitButton); + + // Check if create was called with correct parameters + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + ref: undefined, // write workflow uses undefined ref + name: 'test-repo', + message: 'Creating a new test folder', + body: { + title: 'New Test Folder', + type: 'folder', + }, + }) + ); + }); + + // Check if onSubmit was called + expect(props.onSubmit).toHaveBeenCalled(); + }); + + it('should create folder with branch workflow', async () => { + const mockCreate = jest.fn(); + (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([ + mockCreate, + { + isSuccess: true, + isError: false, + isLoading: false, + error: null, + }, + ]); + + const { user } = setup(); + + // Fill form + const folderNameInput = screen.getByRole('textbox', { name: /folder name/i }); + await user.clear(folderNameInput); + await user.type(folderNameInput, 'Branch Folder'); + + // Select branch workflow + const branchOption = screen.getByRole('radio', { name: /create a branch/i }); + await user.click(branchOption); + + // Enter branch name + const branchInput = screen.getByRole('textbox', { name: /branch/i }); + await user.clear(branchInput); + await user.type(branchInput, 'feature/new-folder'); + + // Submit the form + const submitButton = screen.getByRole('button', { name: /^create$/i }); + await user.click(submitButton); + + // Check if create was called with correct parameters + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + ref: 'feature/new-folder', + name: 'test-repo', + message: 'Create folder: Branch Folder', + body: { + title: 'Branch Folder', + type: 'folder', + }, + }) + ); + }); + }); + + it('should show error when folder creation fails', async () => { + const mockCreate = jest.fn(); + (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([ + mockCreate, + { + isSuccess: false, + isError: true, + isLoading: false, + error: 'Failed to create folder', + }, + ]); + + const { user } = setup(); + + // Fill form + const folderNameInput = screen.getByRole('textbox', { name: /folder name/i }); + await user.clear(folderNameInput); + await user.type(folderNameInput, 'Error Folder'); + + // Submit the form + const submitButton = screen.getByRole('button', { name: /^create$/i }); + await user.click(submitButton); + + // Check if error alert was published + await waitFor(() => { + const appEvents = getAppEvents(); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: ['Error creating folder', 'Failed to create folder'], + }); + }); + }); + + it('should disable create button when form is submitting', async () => { + (useCreateRepositoryFilesWithPathMutation as jest.Mock).mockReturnValue([ + jest.fn(), + { + isSuccess: false, + isError: false, + isLoading: true, + error: null, + }, + ]); + + setup(); + + // Create button should be disabled and show loading text + const createButton = screen.getByRole('button', { name: /creating/i }); + expect(createButton).toBeDisabled(); + expect(createButton).toHaveTextContent('Creating...'); + }); + + it('should show PR link when PR URL is available', () => { + (usePullRequestParam as jest.Mock).mockReturnValue('https://github.com/grafana/grafana/pull/1234'); + + setup(); + + // PR alert should be visible - use text content instead of role + expect(screen.getByText('Pull request created')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveTextContent('https://github.com/grafana/grafana/pull/1234'); + }); + + it('should call onCancel when cancel button is clicked', async () => { + const { user, props } = setup(); + + // Click cancel button + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + // Check if onCancel was called + expect(props.onCancel).toHaveBeenCalled(); + }); + + it('should show read-only alert when repository has no workflows', () => { + (useGetRepositoryQuery as jest.Mock).mockReturnValue({ + data: { + spec: { + type: 'github', + github: { + url: 'https://github.com/grafana/grafana', + branch: 'main', + }, + workflows: [], // Empty workflows array + }, + }, + isLoading: false, + isError: false, + }); + + setup(); + + // Read-only alert should be visible - use text content instead of role + expect(screen.getByText('This repository is read only')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx new file mode 100644 index 00000000000..509c0ac5a87 --- /dev/null +++ b/public/app/features/browse-dashboards/components/NewProvisionedFolderForm.tsx @@ -0,0 +1,239 @@ +import { skipToken } from '@reduxjs/toolkit/query'; +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { AppEvents } from '@grafana/data'; +import { getAppEvents, locationService } from '@grafana/runtime'; +import { Alert, Button, Field, Input, RadioButtonGroup, Spinner, Stack, TextArea } from '@grafana/ui'; +import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; +import { + RepositorySpec, + useCreateRepositoryFilesWithPathMutation, + useGetRepositoryQuery, +} from 'app/features/provisioning/api'; +import { usePullRequestParam } from 'app/features/provisioning/hooks'; +import { WorkflowOption } from 'app/features/provisioning/types'; +import { validateBranchName } from 'app/features/provisioning/utils/git'; + +import { FolderDTO } from '../../../types'; +import { AnnoKeySourcePath } from '../../apiserver/types'; +import { getDefaultWorkflow, getWorkflowOptions } from '../../dashboard-scene/saving/provisioned/defaults'; +import { useGetFolderQuery } from '../../folders/api'; + +type FormData = { + ref?: string; + path: string; + comment?: string; + repo: string; + workflow?: WorkflowOption; + title: string; +}; + +interface Props { + onSubmit: () => void; + onCancel: () => void; + parentFolder: FolderDTO; +} + +const initialFormValues: Partial = { + title: '', + comment: '', + ref: `folder/${Date.now()}`, +}; + +export function NewProvisionedFolderForm({ onSubmit, onCancel, parentFolder }: Props) { + const repositoryName = parentFolder.repository?.name; + const query = useGetRepositoryQuery(repositoryName ? { name: repositoryName } : skipToken); + const prURL = usePullRequestParam(); + const [create, request] = useCreateRepositoryFilesWithPathMutation(); + + // Get k8s folder data, necessary to get parent folder path + const folderQuery = useGetFolderQuery({ name: parentFolder.uid }); + + if (!query.data && !query.isLoading) { + return ; + } + + const repositoryConfig = query.data?.spec; + const isGitHub = Boolean(repositoryConfig?.github); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + control, + setValue, + } = useForm({ defaultValues: { ...initialFormValues, workflow: getDefaultWorkflow(repositoryConfig) } }); + + const [workflow, ref] = watch(['workflow', 'ref']); + const prLink = getPRLink(repositoryConfig, ref); + + useEffect(() => { + setValue('workflow', getDefaultWorkflow(repositoryConfig)); + }, [repositoryConfig, setValue]); + + useEffect(() => { + const appEvents = getAppEvents(); + if (request.isSuccess) { + onSubmit(); + if (workflow === 'branch' && prLink) { + locationService.partial({ prLink }); + } else { + appEvents.publish({ + type: AppEvents.alertSuccess.name, + payload: ['Folder created successfully'], + }); + } + } else if (request.isError) { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: ['Error creating folder', request.error], + }); + } + }, [request.isSuccess, request.isError, request.error, onSubmit, ref, prLink, workflow]); + + if (query.isLoading || folderQuery.isLoading) { + return ; + } + + const validateFolderName = async (folderName: string) => { + try { + await validationSrv.validateNewFolderName(folderName); + return true; + } catch (e) { + if (e instanceof Error) { + return e.message; + } + return 'Invalid folder name'; + } + }; + + const doSave = async ({ ref, title, workflow, comment }: FormData) => { + if (!title) { + return; + } + const basePath = folderQuery.data?.metadata?.annotations?.[AnnoKeySourcePath] ?? ''; + + // Convert folder title to filename format (lowercase, replace spaces with hyphens) + const titleInFilenameFormat = title + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + + const path = `${basePath}/${titleInFilenameFormat}/`; + + const folderModel = { + title, + type: 'folder', + }; + + if (workflow === 'write') { + ref = undefined; + } + + create({ + ref, + name: repositoryName ?? '', + path, + message: comment || `Create folder: ${title}`, + body: folderModel, + }); + }; + + return ( +
+ + {!repositoryConfig?.workflows.length && ( + + If you have direct access to the target, copy the JSON and paste it there. + + )} + + + + + + +