From 8a9e3ffd318dc304b6c603b6a43a5153b4fc486e Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Mon, 18 Aug 2025 14:48:18 +0100 Subject: [PATCH] Folders: Remove OldFolderPicker component (#109791) --- .betterer.results | 3 +- .../Select/OldFolderPicker.test.tsx | 190 --------- .../components/Select/OldFolderPicker.tsx | 395 ------------------ .../forms/SaveDashboardAsForm.test.tsx | 5 - .../manage-dashboards/state/actions.ts | 26 -- public/locales/en-US/grafana.json | 5 - 6 files changed, 1 insertion(+), 623 deletions(-) delete mode 100644 public/app/core/components/Select/OldFolderPicker.test.tsx delete mode 100644 public/app/core/components/Select/OldFolderPicker.tsx diff --git a/.betterer.results b/.betterer.results index a217b42a0a5..695051344fb 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2489,8 +2489,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], - [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + [0, 0, 0, "Unexpected any. Specify a different type.", "6"] ], "public/app/features/manage-dashboards/state/reducers.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/app/core/components/Select/OldFolderPicker.test.tsx b/public/app/core/components/Select/OldFolderPicker.test.tsx deleted file mode 100644 index 69fb67c5c9a..00000000000 --- a/public/app/core/components/Select/OldFolderPicker.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import selectEvent from 'react-select-event'; - -import { selectors } from '@grafana/e2e-selectors'; -import { contextSrv } from 'app/core/core'; -import * as api from 'app/features/manage-dashboards/state/actions'; - -import { DashboardSearchHit } from '../../../features/search/types'; - -import { OldFolderPicker, getInitialValues } from './OldFolderPicker'; - -describe('OldFolderPicker', () => { - it('should render', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - }); - - it('Should apply filter to the folders search results', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - { title: 'Dash 3', uid: '7MeksYbmk' } as DashboardSearchHit, - ]); - - render( hits.filter((h) => h.uid !== 'wfTJJL5Wz')} />); - - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions).toHaveLength(2); - expect(pickerOptions[0]).toHaveTextContent('Dash 1'); - expect(pickerOptions[1]).toHaveTextContent('Dash 3'); - }); - - it('should allow creating a new option', async () => { - const newFolder = { title: 'New Folder', uid: '7MeksYbmk' } as DashboardSearchHit; - - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - const onChangeFn = jest.fn(); - - const create = jest.spyOn(api, 'createFolder').mockResolvedValue(newFolder); - - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - - await userEvent.type(screen.getByTestId(selectors.components.FolderPicker.input), newFolder.title); - const enter = await screen.findByText('Hit enter to add'); - - await userEvent.click(enter); - await waitFor(() => { - expect(create).toHaveBeenCalledWith({ title: newFolder.title }); - }); - - expect(onChangeFn).toHaveBeenCalledWith({ title: newFolder.title, uid: newFolder.uid }); - await waitFor(() => { - expect(screen.getByText(newFolder.title)).toBeInTheDocument(); - }); - }); - - it('should show the Dashboards root by default for editors', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); - - const onChangeFn = jest.fn(); - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions[0]).toHaveTextContent('Dashboards'); - }); - - it('should not show the Dashboards root by default if showRoot is false', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true); - - const onChangeFn = jest.fn(); - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions[0]).not.toHaveTextContent('Dashboards'); - }); - - it('should not show the Dashboards root by default for not editors', async () => { - jest - .spyOn(api, 'searchFolders') - .mockResolvedValue([ - { title: 'Dash 1', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash 2', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ]); - - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); - - const onChangeFn = jest.fn(); - render(); - expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument(); - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - selectEvent.openMenu(pickerContainer); - - const pickerOptions = await screen.findAllByTestId(selectors.components.Select.option); - - expect(pickerOptions[0]).not.toHaveTextContent('Dashboards'); - }); - - it('should return the correct search results when typing in the select', async () => { - jest.spyOn(api, 'searchFolders').mockImplementation((query: string) => { - return Promise.resolve( - [ - { title: 'Dash Test', uid: 'xMsQdBfWz' } as DashboardSearchHit, - { title: 'Dash Two', uid: 'wfTJJL5Wz' } as DashboardSearchHit, - ].filter((dash) => dash.title.indexOf(query) > -1) - ); - }); - jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); - const onChangeFn = jest.fn(); - render(); - - const pickerContainer = screen.getByTestId(selectors.components.FolderPicker.input); - await userEvent.type(pickerContainer, 'Test'); - - expect(await screen.findByText('Dash Test')).toBeInTheDocument(); - expect(screen.queryByText('Dash Two')).not.toBeInTheDocument(); - }); -}); - -describe('getInitialValues', () => { - describe('when called with folderUid and title', () => { - it('then it should return folderUid and title', async () => { - const getFolder = jest.fn().mockResolvedValue({}); - const folder = await getInitialValues({ folderUid: '', folderName: 'Some title', getFolder }); - - expect(folder).toEqual({ label: 'Some title', value: '' }); - expect(getFolder).not.toHaveBeenCalled(); - }); - }); - - describe('when called with just a folderUid', () => { - it('then it should call api to retrieve title', async () => { - const getFolder = jest.fn().mockResolvedValue({ uid: '', title: 'Title from api' }); - const folder = await getInitialValues({ folderUid: '', getFolder }); - - expect(folder).toEqual({ label: 'Title from api', value: '' }); - expect(getFolder).toHaveBeenCalledTimes(1); - expect(getFolder).toHaveBeenCalledWith(''); - }); - }); - - describe('when called without folderUid', () => { - it('then it should throw an error', async () => { - const getFolder = jest.fn().mockResolvedValue({}); - await expect(getInitialValues({ getFolder })).rejects.toThrow(); - }); - }); -}); diff --git a/public/app/core/components/Select/OldFolderPicker.tsx b/public/app/core/components/Select/OldFolderPicker.tsx deleted file mode 100644 index 13ddca85986..00000000000 --- a/public/app/core/components/Select/OldFolderPicker.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { css } from '@emotion/css'; -import debounce from 'debounce-promise'; -import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; -import * as React from 'react'; -import { useAsync } from 'react-use'; - -import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { Trans, t } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; -import { ActionMeta, AsyncVirtualizedSelect, Input, InputActionMeta, useStyles2 } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; -import { contextSrv } from 'app/core/services/context_srv'; -import { createFolder, getFolderByUid, searchFolders } from 'app/features/manage-dashboards/state/actions'; -import { DashboardSearchHit } from 'app/features/search/types'; -import { AccessControlAction } from 'app/types/accessControl'; -import { PermissionLevelString, SearchQueryType } from 'app/types/acl'; - -export type FolderPickerFilter = (hits: DashboardSearchHit[]) => DashboardSearchHit[]; - -export const ADD_NEW_FOLER_OPTION = '+ Add new'; - -export interface FolderWarning { - warningCondition: (value: string) => boolean; - warningComponent: () => JSX.Element; -} - -export interface CustomAdd { - disallowValues: boolean; - isAllowedValue?: (value: string) => boolean; -} - -export interface Props { - onChange: ($folder: { title: string; uid: string }) => void; - enableCreateNew?: boolean; - rootName?: string; - enableReset?: boolean; - dashboardId?: number | string; - initialTitle?: string; - initialFolderUid?: string; - permissionLevel?: Exclude; - filter?: FolderPickerFilter; - allowEmpty?: boolean; - showRoot?: boolean; - onClear?: () => void; - searchQueryType?: SearchQueryType; - customAdd?: CustomAdd; - folderWarning?: FolderWarning; - - /** - * Skips loading all folders in order to find the folder matching - * the folder where the dashboard is stored. - * Instead initialFolderUid and initialTitle will be used to display the correct folder. - * initialFolderUid needs to be a string or an error will be thrown. - */ - skipInitialLoad?: boolean; - /** The id of the search input. Use this to set a matching label with htmlFor */ - inputId?: string; - invalid?: boolean; -} - -export type SelectedFolder = SelectableValue; -const VALUE_FOR_ADD = '-10'; - -export function OldFolderPicker(props: Props) { - const { - dashboardId, - allowEmpty, - onChange, - filter, - enableCreateNew, - inputId, - onClear, - enableReset, - initialFolderUid, - initialTitle = '', - permissionLevel = PermissionLevelString.Edit, - rootName: rootNameProp, - showRoot = true, - skipInitialLoad, - searchQueryType, - customAdd, - folderWarning, - invalid, - } = props; - - const rootName = rootNameProp ?? 'Dashboards'; - - const [folder, setFolder] = useState(null); - const [isCreatingNew, setIsCreatingNew] = useState(false); - const [inputValue, setInputValue] = useState(''); - const [newFolderValue, setNewFolderValue] = useState(folder?.title ?? ''); - - const styles = useStyles2(getStyles); - - const isClearable = typeof onClear === 'function'; - - const getOptions = useCallback( - async (query: string) => { - const searchHits = await searchFolders(query, permissionLevel, searchQueryType); - const resultsAfterMapAndFilter = mapSearchHitsToOptions(searchHits, filter); - const options: Array> = resultsAfterMapAndFilter; - - reportInteraction('grafana_folder_picker_results_loaded', { - results: options.length, - searchTermLength: query.length, - enableCreateNew: Boolean(enableCreateNew), - }); - - const hasAccess = - contextSrv.hasPermission(AccessControlAction.DashboardsWrite) || - contextSrv.hasPermission(AccessControlAction.DashboardsCreate); - - if (hasAccess && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { - options.unshift({ label: rootName, value: '' }); - } - - if ( - enableReset && - query === '' && - initialTitle !== '' && - !options.find((option) => option.label === initialTitle) - ) { - options.unshift({ label: initialTitle, value: initialFolderUid }); - } - if (enableCreateNew && Boolean(customAdd)) { - return [...options, { value: VALUE_FOR_ADD, label: ADD_NEW_FOLER_OPTION, title: query }]; - } else { - return options; - } - }, - [ - enableReset, - initialFolderUid, - initialTitle, - permissionLevel, - rootName, - showRoot, - searchQueryType, - filter, - enableCreateNew, - customAdd, - ] - ); - - const debouncedSearch = useMemo(() => { - return debounce(getOptions, 300, { leading: true }); - }, [getOptions]); - - const loadInitialValue = async () => { - const resetFolder: SelectableValue = { label: initialTitle, value: undefined }; - const rootFolder: SelectableValue = { label: rootName, value: '' }; - - const options = await getOptions(''); - - let folder: SelectableValue | null = null; - - if (initialFolderUid !== undefined && initialFolderUid !== null) { - folder = options.find((option) => option.value === initialFolderUid) || null; - } else if (enableReset && initialTitle) { - folder = resetFolder; - } else if (initialFolderUid) { - folder = options.find((option) => option.id === initialFolderUid) || null; - } - - if (!folder && !allowEmpty) { - if (contextSrv.isEditor) { - folder = rootFolder; - } else { - // We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard - const isPersistedDashBoard = !!dashboardId; - if (isPersistedDashBoard) { - folder = resetFolder; - } else { - folder = options.length > 0 ? options[0] : resetFolder; - } - } - } - !isCreatingNew && setFolder(folder); - }; - - useEffect(() => { - // if this is not the same as our initial value notify parent - if (folder && folder.value !== initialFolderUid) { - !isCreatingNew && folder.value && folder.label && onChange({ uid: folder.value, title: folder.label }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [folder, initialFolderUid]); - - // initial values for dropdown - useAsync(async () => { - if (skipInitialLoad) { - const folder = await getInitialValues({ - getFolder: getFolderByUid, - folderUid: initialFolderUid, - folderName: initialTitle, - }); - setFolder(folder); - } - - await loadInitialValue(); - }, [skipInitialLoad, initialFolderUid, initialTitle]); - - useEffect(() => { - if (folder && folder.id === VALUE_FOR_ADD) { - setIsCreatingNew(true); - } - }, [folder]); - - const onFolderChange = useCallback( - (newFolder: SelectableValue | null | undefined, actionMeta: ActionMeta) => { - if (newFolder?.value === VALUE_FOR_ADD) { - setFolder({ - id: VALUE_FOR_ADD, - title: inputValue, - }); - setNewFolderValue(inputValue); - } else { - if (!newFolder) { - newFolder = { value: '', label: rootName }; - } - - if (actionMeta.action === 'clear' && onClear) { - onClear(); - return; - } - - setFolder(newFolder); - onChange({ uid: newFolder.value!, title: newFolder.label! }); - } - }, - [onChange, onClear, rootName, inputValue] - ); - - const createNewFolder = useCallback( - async (folderName: string) => { - if (folderWarning?.warningCondition(folderName)) { - reportInteraction('grafana_folder_picker_folder_created', { status: 'failed_condition' }); - return false; - } - - const newFolder = await createFolder({ title: folderName }); - let folder: SelectableValue = { value: '', label: 'Not created' }; - - if (newFolder.uid) { - reportInteraction('grafana_folder_picker_folder_created', { status: 'success' }); - appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); - folder = { value: newFolder.uid, label: newFolder.title }; - - setFolder(newFolder); - onFolderChange(folder, { action: 'create-option', option: folder }); - } else { - reportInteraction('grafana_folder_picker_folder_created', { status: 'failed' }); - appEvents.emit(AppEvents.alertError, ['Folder could not be created']); - } - - return folder; - }, - [folderWarning, onFolderChange] - ); - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - const dissalowValues = Boolean(customAdd?.disallowValues); - if (event.key === 'Enter' && dissalowValues && !customAdd?.isAllowedValue!(newFolderValue)) { - event.preventDefault(); - return; - } - - switch (event.key) { - case 'Enter': { - createNewFolder(folder?.title!); - setIsCreatingNew(false); - break; - } - case 'Escape': { - setFolder({ value: '', label: rootName }); - setIsCreatingNew(false); - } - } - }, - [customAdd?.disallowValues, customAdd?.isAllowedValue, newFolderValue, createNewFolder, folder?.title, rootName] - ); - - const onNewFolderChange = (e: FormEvent) => { - const value = e.currentTarget.value; - setNewFolderValue(value); - setFolder({ id: undefined, title: value }); - }; - - const onBlur = () => { - setFolder({ value: '', label: rootName }); - setIsCreatingNew(false); - }; - - const onInputChange = (value: string, { action }: InputActionMeta) => { - if (action === 'input-change') { - setInputValue((ant) => value); - } - - if (action === 'menu-close') { - setInputValue((_) => value); - } - return; - }; - - const FolderWarningWhenCreating = () => { - if (folderWarning?.warningCondition(newFolderValue)) { - return ; - } else { - return null; - } - }; - - const FolderWarningWhenSearching = () => { - if (folderWarning?.warningCondition(inputValue)) { - return ; - } else { - return null; - } - }; - - if (isCreatingNew) { - return ( - <> - -
- Press enter to create the new folder. -
- - - ); - } else { - return ( -
- - -
- ); - } -} - -function mapSearchHitsToOptions(hits: DashboardSearchHit[], filter?: FolderPickerFilter) { - const filteredHits = filter ? filter(hits) : hits; - return filteredHits.map((hit) => ({ label: hit.title, value: hit.uid })); -} -interface Args { - getFolder: typeof getFolderByUid; - folderUid?: string; - folderName?: string; -} - -export async function getInitialValues({ folderName, folderUid, getFolder }: Args): Promise> { - if (folderUid === null || folderUid === undefined) { - throw new Error('folderUid is not found.'); - } - - if (folderName) { - return { label: folderName, value: folderUid }; - } - - const folderDto = await getFolder(folderUid); - return { label: folderDto.title, value: folderUid }; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - newFolder: css({ - color: theme.colors.warning.main, - fontSize: theme.typography.bodySmall.fontSize, - paddingBottom: theme.spacing(1), - }), -}); diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx index de5b14e257c..232e692fc9e 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx @@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event'; import { render, screen } from 'test/test-utils'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; -import * as api from 'app/features/manage-dashboards/state/actions'; import { SaveDashboardResponseDTO } from 'app/types/dashboard'; import { SaveDashboardAsForm, SaveDashboardAsFormProps } from './SaveDashboardAsForm'; @@ -15,8 +14,6 @@ jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => ({ }, })); -jest.spyOn(api, 'searchFolders').mockResolvedValue([]); - const prepareDashboardMock = (panel: object) => { const json = { title: 'name', @@ -56,7 +53,6 @@ const renderAndSubmitForm = async ( describe('SaveDashboardAsForm', () => { describe('default values', () => { it('applies default dashboard properties', async () => { - jest.spyOn(api, 'searchFolders').mockResolvedValue([]); const spy = jest.fn(); await renderAndSubmitForm(prepareDashboardMock({}), spy, { @@ -72,7 +68,6 @@ describe('SaveDashboardAsForm', () => { }); it("appends 'Copy' to the name when the dashboard isnt new", async () => { - jest.spyOn(api, 'searchFolders').mockResolvedValue([]); const spy = jest.fn(); await renderAndSubmitForm(prepareDashboardMock({}), spy, { diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index 039c5b5999a..2f3e278ff7a 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -10,7 +10,6 @@ import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; -import { PermissionLevelString, SearchQueryType } from 'app/types/acl'; import { ThunkResult } from 'app/types/store'; import { @@ -21,7 +20,6 @@ import { } from '../../dashboard/components/DashExportModal/DashboardExporter'; import { getLibraryPanel } from '../../library-panels/state/api'; import { LibraryElementDTO, LibraryElementKind } from '../../library-panels/types'; -import { DashboardSearchHit } from '../../search/types'; import { DashboardJson } from '../types'; import { @@ -308,30 +306,6 @@ const getDataSourceDescription = (input: { usage?: InputUsage }): string | undef return undefined; }; -/** @deprecated Use RTK Query methods from features/browse-dashboards/api/browseDashboardsAPI.ts instead */ -export function createFolder(payload: any) { - return getBackendSrv().post('/api/folders', payload); -} - -export const SLICE_FOLDER_RESULTS_TO = 1000; - -export async function searchFolders( - query: string, - permission?: PermissionLevelString, - type: SearchQueryType = SearchQueryType.Folder -): Promise { - return getBackendSrv().get('/api/search', { - query, - type: type, - permission, - limit: SLICE_FOLDER_RESULTS_TO, - }); -} - -export function getFolderByUid(uid: string): Promise<{ uid: string; title: string }> { - return getBackendSrv().get(`/api/folders/${uid}`); -} - export async function processV2DatasourceInput( spec: PanelQueryKind['spec'] | QueryVariableKind['spec'] | AnnotationQueryKind['spec'], inputs: Record = {} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d39902baac5..d6a38f55e6e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7526,11 +7526,6 @@ "select-aria-label": "Folder filter", "select-placeholder": "Filter by folder" }, - "folder-picker": { - "create-instructions": "Press enter to create the new folder.", - "input-placeholder": "Press enter to confirm new folder", - "loading": "Loading folders..." - }, "folder-repo": { "provisioned-badge": "Provisioned", "read-only-badge": "Read only"