Folders: Update folder using app platform APIs (#110449)

This commit is contained in:
Tom Ratcliffe 2025-09-03 11:29:26 +01:00 committed by GitHub
parent 6c517f82ed
commit 95080d9d56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 234 additions and 171 deletions

View File

@ -11,6 +11,7 @@ const collator = new Intl.Collator();
const mockAccessControl = {
'dashboards.permissions:write': true,
'dashboards:create': true,
'folders:write': true,
};
const additionalProperties = {
canAdmin: true,
@ -108,6 +109,19 @@ const createFolderHandler = () =>
});
});
const handlers = [listFoldersHandler(), getFolderHandler(), createFolderHandler()];
const saveFolderHandler = () =>
http.put<{ uid: string }, { title: string; version: number }>('/api/folders/:uid', async ({ params, request }) => {
const { uid } = params;
const body = await request.json();
const folder = mockTree.find((v) => v.item.uid === uid);
if (!folder) {
return HttpResponse.json({ message: 'folder not found' }, { status: 404 });
}
return HttpResponse.json({ ...folder.item, title: body.title });
});
const handlers = [listFoldersHandler(), getFolderHandler(), createFolderHandler(), saveFolderHandler()];
export default handlers;

View File

@ -0,0 +1,17 @@
/** Expected constant response from `/api/search/sorting` */
export const SORT_OPTIONS = {
sortOptions: [
{
description: 'Sort results in an alphabetically ascending order',
displayName: 'Alphabetically (AZ)',
meta: '',
name: 'alpha-asc',
},
{
description: 'Sort results in an alphabetically descending order',
displayName: 'Alphabetically (ZA)',
meta: '',
name: 'alpha-desc',
},
],
};

View File

@ -3,6 +3,8 @@ import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../fixtures/folders';
import { SORT_OPTIONS } from './constants';
const [mockTree] = wellFormedTree();
type FilterArray = Array<(v: (typeof mockTree)[number]) => boolean>;
@ -70,4 +72,6 @@ const getLegacySearchHandler = () =>
return HttpResponse.json(response);
});
export default [getLegacySearchHandler()];
const getSearchSortingHandler = () => http.get('/api/search/sorting', () => HttpResponse.json(SORT_OPTIONS));
export default [getLegacySearchHandler(), getSearchSortingHandler()];

View File

@ -7,32 +7,42 @@ const [mockTree] = wellFormedTree();
type FilterArray = Array<(v: (typeof mockTree)[number]) => boolean>;
const typeMap: Record<string, string> = {
folder: 'folders',
dashboard: 'dashboards',
};
const getSearchHandler = () =>
http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/search', ({ request }) => {
http.get('/apis/dashboard.grafana.app/v0alpha1/namespaces/:namespace/search', ({ request }) => {
const folderFilter = new URL(request.url).searchParams.get('folder') || null;
const typeFilter = new URL(request.url).searchParams.get('type') || null;
const response = mockTree
.filter((filterItem) => {
const filters: FilterArray = [];
if (folderFilter && folderFilter !== 'general') {
filters.push(({ item }) => item.kind === 'folder' && item.parentUID === folderFilter);
}
if (folderFilter === 'general') {
filters.push(({ item }) => item.kind === 'folder' && item.parentUID === undefined);
}
if (typeFilter) {
filters.push(({ item }) => item.kind === typeFilter);
}
if (folderFilter && folderFilter !== 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter
);
}
if (folderFilter === 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === undefined
);
}
return filters.every((filterPredicate) => filterPredicate(filterItem));
})
.map(({ item }) => {
const random = Chance(item.uid);
return {
resource: 'folders',
resource: typeMap[item.kind],
name: item.uid,
title: item.title,
field: {

View File

@ -11,6 +11,32 @@ const baseResponse = {
apiVersion: 'folder.grafana.app/v1beta1',
};
const folderToAppPlatform = (folder: (typeof mockTree)[number]['item'], id?: number, namespace?: string) => {
return {
...baseResponse,
metadata: {
name: folder.uid,
namespace: namespace ?? 'default',
uid: folder.uid,
creationTimestamp: '2023-01-01T00:00:00Z',
annotations: {
// TODO: Generalise annotations in fixture data
'grafana.app/createdBy': 'user:1',
'grafana.app/updatedBy': 'user:2',
'grafana.app/managedBy': 'user',
'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z',
'grafana.app/folder': folder.kind === 'folder' ? folder.parentUID : undefined,
},
labels: {
'grafana.app/deprecatedInternalID': id ?? '123',
},
},
spec: { title: folder.title, description: '' },
status: {},
};
};
const folderNotFoundError = getErrorResponse('folder not found', 404);
const getFolderHandler = () =>
@ -26,28 +52,9 @@ const getFolderHandler = () =>
return HttpResponse.json(folderNotFoundError, { status: 404 });
}
return HttpResponse.json({
...baseResponse,
metadata: {
name: response.item.uid,
namespace,
uid: response.item.uid,
creationTimestamp: '2023-01-01T00:00:00Z',
annotations: {
// TODO: Generalise annotations in fixture data
'grafana.app/createdBy': 'user:1',
'grafana.app/updatedBy': 'user:2',
'grafana.app/managedBy': 'user',
'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z',
'grafana.app/folder': response.item.kind === 'folder' ? response.item.parentUID : undefined,
},
labels: {
'grafana.app/deprecatedInternalID': '123',
},
},
spec: { title: response.item.title, description: '' },
status: {},
});
const appPlatformFolder = folderToAppPlatform(response.item, undefined, namespace);
return HttpResponse.json(appPlatformFolder);
}
);
@ -121,35 +128,40 @@ const createFolderHandler = () =>
const parentUid = body?.metadata?.annotations?.['grafana.app/folder'];
const random = Chance(title);
const name = random.string({ length: 10 });
const uid = random.string({ length: 45 });
const id = random.integer({ min: 1, max: 1000 });
return HttpResponse.json({
...baseResponse,
metadata: {
name,
namespace,
uid,
resourceVersion: '1756207979831',
generation: 1,
creationTimestamp: '2025-08-26T11:32:59Z',
labels: {
'grafana.app/deprecatedInternalID': id,
},
annotations: {
'grafana.app/createdBy': 'user:1',
'grafana.app/folder': parentUid,
'grafana.app/updatedBy': 'user:1',
'grafana.app/updatedTimestamp': '2025-08-26T11:32:59Z',
},
},
spec: {
title,
description: '',
},
status: {},
});
const appPlatformFolder = folderToAppPlatform(
{ uid, title, parentUID: parentUid, kind: 'folder' },
id,
namespace
);
return HttpResponse.json(appPlatformFolder);
}
);
export default [getFolderHandler(), getFolderParentsHandler(), createFolderHandler()];
const replaceFolderHandler = () =>
http.put<{ folderUid: string; namespace: string }, PartialFolderPayload>(
'/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid',
async ({ params, request }) => {
const body = await request.json();
const { folderUid } = params;
const response = mockTree.find(({ item }) => {
return item.uid === folderUid;
});
if (!response) {
return HttpResponse.json(folderNotFoundError, { status: 404 });
}
const modifiedFolder = {
...response.item,
title: body.spec.title,
};
const appPlatformFolder = folderToAppPlatform(modifiedFolder);
return HttpResponse.json(appPlatformFolder);
}
);
export default [getFolderHandler(), getFolderParentsHandler(), createFolderHandler(), replaceFolderHandler()];

View File

@ -17,7 +17,7 @@ import {
useDeleteMultipleFoldersMutationFacade,
useMoveMultipleFoldersMutationFacade,
} from './hooks';
import { setupCreateFolder } from './test-utils';
import { setupCreateFolder, setupUpdateFolder } from './test-utils';
import { useDeleteFolderMutation, useUpdateFolderMutation } from './index';
@ -68,7 +68,7 @@ const renderFolderHook = async () => {
wrapper: getWrapper({}),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeDefined();
});
return result;
};
@ -246,20 +246,20 @@ describe('useMoveMultipleFoldersMutationFacade', () => {
});
});
describe('useCreateFolder', () => {
describe.each([
// app platform
true,
// legacy
false,
])('folderAppPlatformAPI toggle set to: %s', (toggle) => {
beforeEach(() => {
config.featureToggles.foldersAppPlatformAPI = toggle;
});
afterEach(() => {
config.featureToggles = originalToggles;
});
describe.each([
// app platform
true,
// legacy
false,
])('folderAppPlatformAPI toggle set to: %s', (toggle) => {
beforeEach(() => {
config.featureToggles.foldersAppPlatformAPI = toggle;
});
afterEach(() => {
config.featureToggles = originalToggles;
});
describe('useCreateFolder', () => {
it('creates a folder', async () => {
const { user } = setupCreateFolder();
@ -268,4 +268,15 @@ describe('useCreateFolder', () => {
expect(await screen.findByText('Folder created')).toBeInTheDocument();
});
});
describe('useUpdateFolder', () => {
it('updates a folder', async () => {
const { user } = await setupUpdateFolder(folderA_folderA.item.uid);
await user.type(screen.getByLabelText('Folder Title'), 'Updated Folder');
await user.click(screen.getByText('Update Folder'));
expect(await screen.findByText('Folder updated')).toBeInTheDocument();
});
});
});

View File

@ -11,6 +11,7 @@ import {
useDeleteFoldersMutation as useDeleteFoldersMutationLegacy,
useNewFolderMutation as useLegacyNewFolderMutation,
useMoveFoldersMutation as useMoveFoldersMutationLegacy,
useSaveFolderMutation as useLegacySaveFolderMutation,
MoveFoldersArgs,
DeleteFoldersArgs,
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
@ -44,6 +45,8 @@ import {
useUpdateFolderMutation,
Folder,
CreateFolderApiArg,
useReplaceFolderMutation,
ReplaceFolderApiArg,
} from './index';
/** Trigger necessary actions to ensure legacy folder stores are updated */
@ -320,6 +323,38 @@ export function useCreateFolder() {
return [createFolderAppPlatform, result] as const;
}
export function useUpdateFolder() {
const [updateFolder, result] = useReplaceFolderMutation();
const legacyHook = useLegacySaveFolderMutation();
if (!config.featureToggles.foldersAppPlatformAPI) {
return legacyHook;
}
const updateFolderAppPlatform = async (folder: Pick<FolderDTO, 'uid' | 'title' | 'version' | 'parentUid'>) => {
const payload: ReplaceFolderApiArg = {
name: folder.uid,
folder: {
spec: { title: folder.title },
metadata: {
name: folder.uid,
},
status: {},
},
};
const result = await updateFolder(payload);
dispatchRefetchChildren(folder.parentUid);
return {
...result,
data: result.data ? appPlatformFolderToLegacyFolder(result.data) : undefined,
};
};
return [updateFolderAppPlatform, result] as const;
}
function combinedState(
result: ReturnType<typeof useGetFolderQuery>,
resultParents: ReturnType<typeof useGetFolderParentsQuery>,

View File

@ -45,7 +45,8 @@ export const {
useDeleteFolderMutation,
useCreateFolderMutation,
useUpdateFolderMutation,
useReplaceFolderMutation,
} = folderAPIv1beta1;
// eslint-disable-next-line no-barrel-files/no-barrel-files
export { type Folder, type FolderList, type CreateFolderApiArg } from './endpoints.gen';
export { type Folder, type FolderList, type CreateFolderApiArg, type ReplaceFolderApiArg } from './endpoints.gen';

View File

@ -1,9 +1,10 @@
import { render } from 'test/test-utils';
import { useState } from 'react';
import { render, screen } from 'test/test-utils';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
import { useCreateFolder } from './hooks';
import { useCreateFolder, useUpdateFolder } from './hooks';
const [_, { folderA }] = getFolderFixtures();
@ -19,5 +20,27 @@ const TestCreationComponent = () => {
);
};
const TestUpdateComponent = ({ folderUID }: { folderUID: string }) => {
const [updateFolder, result] = useUpdateFolder();
const [title, setTitle] = useState('');
return (
<>
<AppNotificationList />
<label htmlFor="title">Folder Title</label>
<input id="title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
<button onClick={() => updateFolder({ title, uid: folderUID })}>Update Folder</button>
<div>{result.isSuccess ? 'Folder updated' : 'Error updating folder'}</div>
</>
);
};
/** Renders test component with a button that will create a new folder */
export const setupCreateFolder = () => render(<TestCreationComponent />);
/** Renders test component with a button that allows updating a folder */
export const setupUpdateFolder = async (folderUID: string) => {
const view = render(<TestUpdateComponent folderUID={folderUID} />);
await screen.findByText('Update Folder');
return view;
};

View File

@ -1,14 +1,11 @@
import { render as rtlRender, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { ComponentProps } from 'react';
import * as React from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import AutoSizer from 'react-virtualized-auto-sizer';
import { TestProvider } from 'test/helpers/TestProvider';
import { render as testRender, screen, waitFor } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
import server, { setupMockServer } from '@grafana/test-utils/server';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { contextSrv } from 'app/core/core';
import { backendSrv } from 'app/core/services/backend_srv';
@ -16,17 +13,10 @@ import { backendSrv } from 'app/core/services/backend_srv';
import BrowseDashboardsPage from './BrowseDashboardsPage';
import * as permissions from './permissions';
setBackendSrv(backendSrv);
setupMockServer();
const [_, { dashbdD, folderA, folderA_folderA }] = getFolderFixtures();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
config: {
...jest.requireActual('@grafana/runtime').config,
unifiedAlertingEnabled: true,
},
}));
const [_, { dashbdD, folderA, folderA_folderA }] = getFolderFixtures();
jest.mock('react-virtualized-auto-sizer', () => {
return {
@ -51,42 +41,10 @@ jest.mock('react-router-dom-v5-compat', () => ({
useParams: jest.fn().mockReturnValue({}),
}));
function render(...[ui, options]: Parameters<typeof rtlRender>) {
const { rerender } = rtlRender(
<TestProvider
storeState={{
navIndex: {
'dashboards/browse': {
text: 'Dashboards',
id: 'dashboards/browse',
},
},
}}
>
{ui}
</TestProvider>,
options
);
const wrappedRerender = (ui: React.ReactElement) => {
rerender(
<TestProvider
storeState={{
navIndex: {
'dashboards/browse': {
text: 'Dashboards',
id: 'dashboards/browse',
},
},
}}
>
{ui}
</TestProvider>
);
};
return {
rerender: wrappedRerender,
};
function render(ui: Parameters<typeof testRender>[0]) {
return testRender(ui, {
preloadedState: { navIndex: { 'dashboards/browse': { text: 'Dashboards', id: 'dashboards/browse' } } },
});
}
describe('browse-dashboards BrowseDashboardsPage', () => {
@ -102,16 +60,7 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
};
beforeEach(() => {
server.use(
http.get('/api/search/sorting', () => {
return HttpResponse.json({
sortOptions: [],
});
})
);
});
beforeEach(() => {
config.unifiedAlertingEnabled = true;
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => mockPermissions);
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
});
@ -188,10 +137,10 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('selecting an item hides the filters and shows the actions instead', async () => {
render(<BrowseDashboardsPage queryParams={{}} />);
const { user } = render(<BrowseDashboardsPage queryParams={{}} />);
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashboards.table.checkbox(dashbdD.item.uid));
await userEvent.click(checkbox);
await user.click(checkbox);
// Check the filters are now hidden
expect(screen.queryByText('Filter by tag')).not.toBeInTheDocument();
@ -203,10 +152,10 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('navigating into a child item resets the selected state', async () => {
const { rerender } = render(<BrowseDashboardsPage queryParams={{}} />);
const { rerender, user } = render(<BrowseDashboardsPage queryParams={{}} />);
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashboards.table.checkbox(folderA.item.uid));
await userEvent.click(checkbox);
await user.click(checkbox);
// Check the actions are now visible
expect(screen.getByRole('button', { name: 'Move' })).toBeInTheDocument();
@ -304,12 +253,12 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
});
it('selecting an item hides the filters and shows the actions instead', async () => {
render(<BrowseDashboardsPage queryParams={{}} />);
const { user } = render(<BrowseDashboardsPage queryParams={{}} />);
const checkbox = await screen.findByTestId(
selectors.pages.BrowseDashboards.table.checkbox(folderA_folderA.item.uid)
);
await userEvent.click(checkbox);
await user.click(checkbox);
// Check the filters are now hidden
expect(screen.queryByText('Filter by tag')).not.toBeInTheDocument();

View File

@ -7,7 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
import { getConfig } from 'app/core/config';
import { useDispatch } from 'app/types/store';
@ -21,7 +21,6 @@ import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResour
import { useSearchStateManager } from '../search/state/SearchStateManager';
import { getSearchPlaceholder } from '../search/tempI18nPhrases';
import { useSaveFolderMutation } from './api/browseDashboardsAPI';
import { BrowseActions } from './components/BrowseActions/BrowseActions';
import { BrowseFilters } from './components/BrowseFilters';
import { BrowseView } from './components/BrowseView';
@ -73,7 +72,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
}, [isSearching, searchState.result, stateManager]);
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
const [saveFolder] = useSaveFolderMutation();
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
if (!folderDTO) {
return undefined;

View File

@ -1,5 +1,4 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { render, screen } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';

View File

@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel';
@ -13,7 +13,6 @@ import { GRAFANA_RULER_CONFIG } from '../alerting/unified/api/featureDiscoveryAp
import { stringifyErrorLike } from '../alerting/unified/utils/misc';
import { rulerRuleType } from '../alerting/unified/utils/rules';
import { useSaveFolderMutation } from './api/browseDashboardsAPI';
import { FolderActionsButton } from './components/FolderActionsButton';
const { useRulerNamespaceQuery } = alertRuleApi;
@ -31,7 +30,7 @@ export function BrowseFolderAlertingPage() {
namespace: folderUID,
});
const [saveFolder] = useSaveFolderMutation();
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
if (!folderDTO) {

View File

@ -2,6 +2,7 @@ import { http, HttpResponse } from 'msw';
import { useParams } from 'react-router-dom-v5-compat';
import { render, screen } from 'test/test-utils';
import { config, setBackendSrv } from '@grafana/runtime';
import server, { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { contextSrv } from 'app/core/core';
@ -11,15 +12,9 @@ import BrowseFolderLibraryPanelsPage from './BrowseFolderLibraryPanelsPage';
import { getLibraryElementsResponse } from './fixtures/libraryElements.fixture';
import * as permissions from './permissions';
setBackendSrv(backendSrv);
setupMockServer();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
config: {
...jest.requireActual('@grafana/runtime').config,
unifiedAlertingEnabled: true,
},
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useParams: jest.fn(),
@ -46,19 +41,15 @@ describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => {
};
beforeEach(() => {
config.unifiedAlertingEnabled = true;
server.use(
http.get('/api/library-elements', () => {
return HttpResponse.json({
result: mockLibraryElementsResponse,
});
}),
http.get('/api/search/sorting', () => {
return HttpResponse.json({});
})
);
});
beforeEach(() => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => mockPermissions);
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
});

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
@ -10,15 +11,13 @@ import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsS
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
import { LibraryElementDTO } from '../library-panels/types';
import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI';
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
export function BrowseFolderLibraryPanelsPage() {
const { uid: folderUID = '' } = useParams();
const { data: folderDTO } = useGetFolderQuery(folderUID);
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
const [selected, setSelected] = useState<LibraryElementDTO | undefined>(undefined);
const [saveFolder] = useSaveFolderMutation();
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
if (!folderDTO) {

View File

@ -115,7 +115,7 @@ export const browseDashboardsAPI = createApi({
}),
// save an existing folder (e.g. rename)
saveFolder: builder.mutation<FolderDTO, FolderDTO>({
saveFolder: builder.mutation<FolderDTO, Pick<FolderDTO, 'uid' | 'title' | 'version' | 'parentUid'>>({
// because the getFolder calls contain the parents, renaming a parent/grandparent/etc needs to invalidate all child folders
// we could do something smart and recursively invalidate these child folders but it doesn't seem worth it
// instead let's just invalidate all the getFolder calls