Folders: Migrate getFolder API to app platform (#107617)
* Add /children endpoint * Update folder client * Add comment * Add feature toggle * Add new version of useFoldersQuery * Error handling * Format * Rename feature toggle * Remove options and move root folder constant * Fix feature toggle merge * Add feature toggle again * Rename useFoldersQuery files * Update API spec * Fix test * Add test * Migrate delete folder button * useGetFolderQueryFacade * Use getFolder facade hook * Recreate legacy getFolder from the APIs * Fix imports * Add comment * Rename function * Simulate virtual folders in the API client * Translations * Update test * Move the hook out of the index file * Fix undefined in test * Better status combining * Use real access api for virtual folders * Add basic test for the hook * Remove commented import * Remove the access control api and use legacy api for it * Update tests * Moved delete folder into facade hook * Remove namespace attribute from virtual folders * go lint --------- Co-authored-by: Clarity-89 <homes89@ukr.net>
This commit is contained in:
co-authored by
Clarity-89
parent
102d230321
commit
85e9bcaa2e
@@ -863,6 +863,9 @@ exports[`better eslint`] = {
|
|||||||
"packages/grafana-ui/src/utils/useAsyncDependency.ts:5381": [
|
"packages/grafana-ui/src/utils/useAsyncDependency.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
],
|
],
|
||||||
|
"public/app/api/clients/folder/v1beta1/hooks.ts:5381": [
|
||||||
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
|
],
|
||||||
"public/app/core/TableModel.ts:5381": [
|
"public/app/core/TableModel.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||||
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
|
|
||||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type subAccessREST struct {
|
type subAccessREST struct {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
|
"k8s.io/apiserver/pkg/storage"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apiserver/pkg/registry/rest"
|
"k8s.io/apiserver/pkg/registry/rest"
|
||||||
@@ -44,17 +47,28 @@ func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
|
||||||
obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
folder, ok := obj.(*folders.Folder)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("expecting folder, found: %T", folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
info := r.parents(ctx, folder)
|
if name == folder.GeneralFolderUID || name == folder.SharedWithMeFolderUID {
|
||||||
|
responder.Object(http.StatusOK, &folders.FolderInfoList{
|
||||||
|
Items: []folders.FolderInfo{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{})
|
||||||
|
if storage.IsNotFound(err) {
|
||||||
|
responder.Object(http.StatusNotFound, nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
responder.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderObj, ok := obj.(*folders.Folder)
|
||||||
|
if !ok {
|
||||||
|
responder.Error(fmt.Errorf("expecting folder, found: %T", folderObj))
|
||||||
|
}
|
||||||
|
|
||||||
|
info := r.parents(ctx, folderObj)
|
||||||
// Start from the root
|
// Start from the root
|
||||||
slices.Reverse(info.Items)
|
slices.Reverse(info.Items)
|
||||||
responder.Object(http.StatusOK, info)
|
responder.Object(http.StatusOK, info)
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { QueryStatus } from '@reduxjs/toolkit/query';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { useGetFolderQuery as useGetFolderQueryLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnnoKeyCreatedBy,
|
||||||
|
AnnoKeyFolder,
|
||||||
|
AnnoKeyManagerKind,
|
||||||
|
AnnoKeyUpdatedBy,
|
||||||
|
AnnoKeyUpdatedTimestamp,
|
||||||
|
DeprecatedInternalId,
|
||||||
|
} from '../../../../features/apiserver/types';
|
||||||
|
import { useGetDisplayMappingQuery } from '../../iam/v0alpha1';
|
||||||
|
|
||||||
|
import { useGetFolderQueryFacade } from './hooks';
|
||||||
|
|
||||||
|
import { useGetFolderQuery, useGetFolderParentsQuery } from './index';
|
||||||
|
|
||||||
|
// Mocks for the hooks used inside useGetFolderQueryFacade
|
||||||
|
jest.mock('./index', () => ({
|
||||||
|
useGetFolderQuery: jest.fn(),
|
||||||
|
useGetFolderParentsQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({
|
||||||
|
useGetFolderQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../iam/v0alpha1', () => ({
|
||||||
|
useGetDisplayMappingQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock config and constants
|
||||||
|
jest.mock('@grafana/runtime', () => {
|
||||||
|
const runtime = jest.requireActual('@grafana/runtime');
|
||||||
|
return {
|
||||||
|
...runtime,
|
||||||
|
config: {
|
||||||
|
...runtime.config,
|
||||||
|
featureToggles: {
|
||||||
|
...runtime.config.featureToggles,
|
||||||
|
foldersAppPlatformAPI: true,
|
||||||
|
},
|
||||||
|
appSubUrl: '/grafana',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFolder = {
|
||||||
|
data: {
|
||||||
|
metadata: {
|
||||||
|
name: 'folder-uid',
|
||||||
|
labels: { [DeprecatedInternalId]: '123' },
|
||||||
|
annotations: {
|
||||||
|
[AnnoKeyUpdatedBy]: 'user-1',
|
||||||
|
[AnnoKeyCreatedBy]: 'user-2',
|
||||||
|
[AnnoKeyFolder]: 'parent-uid',
|
||||||
|
[AnnoKeyManagerKind]: 'user',
|
||||||
|
[AnnoKeyUpdatedTimestamp]: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
creationTimestamp: '2023-01-01T00:00:00Z',
|
||||||
|
generation: 2,
|
||||||
|
},
|
||||||
|
spec: { title: 'Test Folder' },
|
||||||
|
},
|
||||||
|
...getResponseAttributes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockParents = {
|
||||||
|
data: { items: [{ name: 'parent-uid', title: 'Parent Folder' }] },
|
||||||
|
...getResponseAttributes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLegacyResponse = {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
uid: 'uiduiduid',
|
||||||
|
orgId: 1,
|
||||||
|
title: 'bar',
|
||||||
|
url: '/dashboards/f/uiduiduid/bar',
|
||||||
|
hasAcl: false,
|
||||||
|
canSave: true,
|
||||||
|
canEdit: true,
|
||||||
|
canAdmin: true,
|
||||||
|
canDelete: true,
|
||||||
|
createdBy: 'Anonymous',
|
||||||
|
created: '2025-07-14T12:07:36+02:00',
|
||||||
|
updatedBy: 'Anonymous',
|
||||||
|
updated: '2025-07-15T18:01:36+02:00',
|
||||||
|
version: 1,
|
||||||
|
accessControl: {
|
||||||
|
'dashboards.permissions:write': true,
|
||||||
|
'dashboards:create': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...getResponseAttributes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserDisplay = {
|
||||||
|
data: {
|
||||||
|
keys: ['user-1', 'user-2'],
|
||||||
|
display: [{ displayName: 'User One' }, { displayName: 'User Two' }],
|
||||||
|
},
|
||||||
|
...getResponseAttributes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useGetFolderQueryFacade', () => {
|
||||||
|
const oldToggleValue = config.featureToggles.foldersAppPlatformAPI;
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
config.featureToggles.foldersAppPlatformAPI = oldToggleValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(useGetFolderQuery as jest.Mock).mockReturnValue(mockFolder);
|
||||||
|
(useGetFolderParentsQuery as jest.Mock).mockReturnValue(mockParents);
|
||||||
|
(useGetDisplayMappingQuery as jest.Mock).mockReturnValue(mockUserDisplay);
|
||||||
|
(useGetFolderQueryLegacy as jest.Mock).mockReturnValue(mockLegacyResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges multiple responses into a single FolderDTO-like object if flag is true', () => {
|
||||||
|
config.featureToggles.foldersAppPlatformAPI = true;
|
||||||
|
const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid'));
|
||||||
|
expect(result.current.data).toMatchObject({
|
||||||
|
canAdmin: true,
|
||||||
|
canDelete: true,
|
||||||
|
canEdit: true,
|
||||||
|
canSave: true,
|
||||||
|
created: '2023-01-01T00:00:00Z',
|
||||||
|
createdBy: 'User Two',
|
||||||
|
hasAcl: false,
|
||||||
|
id: 123,
|
||||||
|
parentUid: 'parent-uid',
|
||||||
|
managedBy: 'user',
|
||||||
|
title: 'Test Folder',
|
||||||
|
uid: 'folder-uid',
|
||||||
|
updated: '2024-01-01T00:00:00Z',
|
||||||
|
updatedBy: 'User One',
|
||||||
|
url: '/grafana/dashboards/f/folder-uid/test-folder',
|
||||||
|
version: 2,
|
||||||
|
accessControl: {
|
||||||
|
'dashboards.permissions:write': true,
|
||||||
|
'dashboards:create': true,
|
||||||
|
},
|
||||||
|
parents: [
|
||||||
|
{
|
||||||
|
title: 'Parent Folder',
|
||||||
|
uid: 'parent-uid',
|
||||||
|
url: '/grafana/dashboards/f/parent-uid/parent-folder',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns legacy folder response if flag is false', () => {
|
||||||
|
config.featureToggles.foldersAppPlatformAPI = false;
|
||||||
|
const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid'));
|
||||||
|
expect(result.current.data).toMatchObject(mockLegacyResponse.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getResponseAttributes() {
|
||||||
|
return {
|
||||||
|
status: QueryStatus.fulfilled,
|
||||||
|
isUninitialized: false,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
isSuccess: true,
|
||||||
|
isError: false,
|
||||||
|
error: undefined,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { QueryStatus, skipToken } from '@reduxjs/toolkit/query';
|
||||||
|
|
||||||
|
import { AppEvents } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { config, getAppEvents } from '@grafana/runtime';
|
||||||
|
import {
|
||||||
|
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||||
|
useGetFolderQuery as useGetFolderQueryLegacy,
|
||||||
|
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
|
import { FolderDTO } from 'app/types/folders';
|
||||||
|
|
||||||
|
import kbn from '../../../../core/utils/kbn';
|
||||||
|
import {
|
||||||
|
AnnoKeyCreatedBy,
|
||||||
|
AnnoKeyFolder,
|
||||||
|
AnnoKeyManagerKind,
|
||||||
|
AnnoKeyUpdatedBy,
|
||||||
|
AnnoKeyUpdatedTimestamp,
|
||||||
|
DeprecatedInternalId,
|
||||||
|
ManagerKind,
|
||||||
|
} from '../../../../features/apiserver/types';
|
||||||
|
import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services';
|
||||||
|
import { refetchChildren } from '../../../../features/browse-dashboards/state/actions';
|
||||||
|
import { GENERAL_FOLDER_UID } from '../../../../features/search/constants';
|
||||||
|
import { useDispatch } from '../../../../types/store';
|
||||||
|
import { useGetDisplayMappingQuery } from '../../iam/v0alpha1';
|
||||||
|
|
||||||
|
import { rootFolder, sharedWithMeFolder } from './virtualFolders';
|
||||||
|
|
||||||
|
import { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } from './index';
|
||||||
|
|
||||||
|
function getFolderUrl(uid: string, title: string): string {
|
||||||
|
// mimics https://github.com/grafana/grafana/blob/79fe8a9902335c7a28af30e467b904a4ccfac503/pkg/services/dashboards/models.go#L188
|
||||||
|
// Not the same slugify as on the backend https://github.com/grafana/grafana/blob/aac66e91198004bc044754105e18bfff8fbfd383/pkg/infra/slugify/slugify.go#L86
|
||||||
|
// Probably does not matter as it seems to be only for better human readability.
|
||||||
|
const slug = kbn.slugifyForUrl(title);
|
||||||
|
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A proxy function that uses either legacy folder client or the new app platform APIs to get the data in the same
|
||||||
|
* format of a FolderDTO object. As the schema isn't the same, using the app platform needs multiple different calls
|
||||||
|
* which are then stitched together.
|
||||||
|
* @param uid
|
||||||
|
*/
|
||||||
|
export function useGetFolderQueryFacade(uid?: string) {
|
||||||
|
// This may look weird that we call the legacy folder anyway all the time, but the issue is we don't have good API
|
||||||
|
// for the access control metadata yet, and so we still take it from the old api.
|
||||||
|
// see https://github.com/grafana/identity-access-team/issues/1103
|
||||||
|
const legacyFolderResult = useGetFolderQueryLegacy(uid || skipToken);
|
||||||
|
|
||||||
|
if (!config.featureToggles.foldersAppPlatformAPI) {
|
||||||
|
return legacyFolderResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
|
||||||
|
const params = !uid ? skipToken : { name: uid };
|
||||||
|
|
||||||
|
let resultFolder = useGetFolderQuery(isVirtualFolder ? skipToken : params);
|
||||||
|
|
||||||
|
// For virtual folders we simulate the response with hardcoded data.
|
||||||
|
if (isVirtualFolder) {
|
||||||
|
resultFolder = {
|
||||||
|
...resultFolder,
|
||||||
|
status: QueryStatus.fulfilled,
|
||||||
|
fulfilledTimeStamp: Date.now(),
|
||||||
|
isUninitialized: false,
|
||||||
|
error: undefined,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: true,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
data: GENERAL_FOLDER_UID === uid ? rootFolder : sharedWithMeFolder,
|
||||||
|
currentData: GENERAL_FOLDER_UID === uid ? rootFolder : sharedWithMeFolder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We get parents and folders for virtual folders too. Parents should just return empty array but it's easier to
|
||||||
|
// stitch the responses this way and access can actually return different response based on the grafana setup.
|
||||||
|
const resultParents = useGetFolderParentsQuery(params);
|
||||||
|
|
||||||
|
// Load users info if needed.
|
||||||
|
const userKeys = getUserKeys(resultFolder);
|
||||||
|
const needsUserData = !isVirtualFolder && Boolean(userKeys.length);
|
||||||
|
const resultUserDisplay = useGetDisplayMappingQuery(needsUserData ? { key: userKeys } : skipToken);
|
||||||
|
|
||||||
|
// Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy
|
||||||
|
// api client.
|
||||||
|
let newData: FolderDTO | undefined = undefined;
|
||||||
|
if (
|
||||||
|
resultFolder.data &&
|
||||||
|
resultParents.data &&
|
||||||
|
legacyFolderResult.data &&
|
||||||
|
(needsUserData ? resultUserDisplay.data : true)
|
||||||
|
) {
|
||||||
|
const updatedBy = resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||||
|
const createdBy = resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||||
|
|
||||||
|
newData = {
|
||||||
|
canAdmin: legacyFolderResult.data.canAdmin,
|
||||||
|
canDelete: legacyFolderResult.data.canDelete,
|
||||||
|
canEdit: legacyFolderResult.data.canEdit,
|
||||||
|
canSave: legacyFolderResult.data.canSave,
|
||||||
|
accessControl: legacyFolderResult.data.accessControl,
|
||||||
|
created: resultFolder.data.metadata.creationTimestamp || '0001-01-01T00:00:00Z',
|
||||||
|
createdBy:
|
||||||
|
(createdBy && resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(createdBy)]?.displayName) ||
|
||||||
|
'Anonymous',
|
||||||
|
// Does not seem like this is set to true in the legacy API
|
||||||
|
hasAcl: false,
|
||||||
|
id: parseInt(resultFolder.data.metadata.labels?.[DeprecatedInternalId] || '0', 10) || 0,
|
||||||
|
parentUid: resultFolder.data.metadata.annotations?.[AnnoKeyFolder],
|
||||||
|
managedBy: resultFolder.data.metadata.annotations?.[AnnoKeyManagerKind] as ManagerKind,
|
||||||
|
|
||||||
|
title: resultFolder.data.spec.title,
|
||||||
|
uid: resultFolder.data.metadata.name!,
|
||||||
|
updated: resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedTimestamp] || '0001-01-01T00:00:00Z',
|
||||||
|
updatedBy:
|
||||||
|
(updatedBy && resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(updatedBy)]?.displayName) ||
|
||||||
|
'Anonymous',
|
||||||
|
// Seems like this annotation is not populated
|
||||||
|
// url: result.data.metadata.annotations?.[AnnoKeyFolderUrl] || '',
|
||||||
|
// general folder does not come with url
|
||||||
|
// see https://github.com/grafana/grafana/blob/8a05378ef3ae5545c6f7429eae5c174d3c0edbfe/pkg/services/folder/folderimpl/folder_unifiedstorage.go#L88
|
||||||
|
url:
|
||||||
|
uid === GENERAL_FOLDER_UID ? '' : getFolderUrl(resultFolder.data.metadata.name!, resultFolder.data.spec.title!),
|
||||||
|
version: resultFolder.data.metadata.generation || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resultParents.data.items?.length) {
|
||||||
|
newData.parents = resultParents.data.items
|
||||||
|
.filter((i) => i.name !== resultFolder.data!.metadata.name)
|
||||||
|
.map((i) => ({
|
||||||
|
title: i.title,
|
||||||
|
uid: i.name,
|
||||||
|
// No idea how to make slug, on the server it uses a go lib: https://github.com/grafana/grafana/blob/aac66e91198004bc044754105e18bfff8fbfd383/pkg/infra/slugify/slugify.go#L56
|
||||||
|
// Don't think slug is needed for the URL to work though
|
||||||
|
url: getFolderUrl(i.name, i.title),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the stitched data into single RTK query response type object so this looks like a single API call
|
||||||
|
return {
|
||||||
|
...resultFolder,
|
||||||
|
...combinedState(resultFolder, resultParents, legacyFolderResult, resultUserDisplay, needsUserData),
|
||||||
|
refetch: async () => {
|
||||||
|
return Promise.all([
|
||||||
|
resultFolder.refetch(),
|
||||||
|
resultParents.refetch(),
|
||||||
|
legacyFolderResult.refetch(),
|
||||||
|
// TODO: Not sure about this, if we refetch this but the response from result change and this is dependant on
|
||||||
|
// that result what are we refetching here? Maybe this is redundant.
|
||||||
|
resultUserDisplay.refetch(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
data: newData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFolderMutationFacade() {
|
||||||
|
const [deleteFolder] = useDeleteFolderMutation();
|
||||||
|
const [deleteFolderLegacy] = useDeleteFolderMutationLegacy();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
return async (folder: FolderDTO) => {
|
||||||
|
if (config.featureToggles.foldersAppPlatformAPI) {
|
||||||
|
const result = await deleteFolder({ name: folder.uid });
|
||||||
|
if (!result.error) {
|
||||||
|
// We need to update a legacy version of the folder storage for now until all is in the new API.
|
||||||
|
// we could do it in the enhanceEndpoint method but we would also need to change the args as we need parentUID
|
||||||
|
// here and so it seemed easier to do it here.
|
||||||
|
dispatch(
|
||||||
|
refetchChildren({
|
||||||
|
parentUID: folder.parentUid || GENERAL_FOLDER_UID,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see
|
||||||
|
// public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here.
|
||||||
|
getAppEvents().publish({
|
||||||
|
type: AppEvents.alertSuccess.name,
|
||||||
|
payload: [t('folders.api.folder-deleted-success', 'Folder deleted')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return deleteFolderLegacy(folder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function combinedState(
|
||||||
|
result: ReturnType<typeof useGetFolderQuery>,
|
||||||
|
resultParents: ReturnType<typeof useGetFolderParentsQuery>,
|
||||||
|
resultLegacyFolder: ReturnType<typeof useGetFolderQueryLegacy>,
|
||||||
|
resultUserDisplay: ReturnType<typeof useGetDisplayMappingQuery>,
|
||||||
|
needsUserData: boolean
|
||||||
|
) {
|
||||||
|
const results = needsUserData
|
||||||
|
? [result, resultParents, resultLegacyFolder, resultUserDisplay]
|
||||||
|
: [result, resultParents, resultLegacyFolder];
|
||||||
|
return {
|
||||||
|
isLoading: results.some((r) => r.isLoading),
|
||||||
|
isFetching: results.some((r) => r.isFetching),
|
||||||
|
isSuccess: results.every((r) => r.isSuccess),
|
||||||
|
isError: results.some((r) => r.isError),
|
||||||
|
// Only one error will be shown. TODO: somehow create a single error out of them?
|
||||||
|
error: results.find((r) => r.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserKeys(resultFolder: ReturnType<typeof useGetFolderQuery>): string[] {
|
||||||
|
return resultFolder.data
|
||||||
|
? [
|
||||||
|
resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy],
|
||||||
|
resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy],
|
||||||
|
].filter((v) => v !== undefined)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { generatedAPI } from './endpoints.gen';
|
|||||||
|
|
||||||
export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({});
|
export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({});
|
||||||
|
|
||||||
export const { useGetFolderQuery } = folderAPIv1beta1;
|
export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } = folderAPIv1beta1;
|
||||||
|
|
||||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||||
export { type Folder, type FolderList } from './endpoints.gen';
|
export { type Folder, type FolderList } from './endpoints.gen';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const rootFolder = {
|
||||||
|
kind: 'Folder',
|
||||||
|
apiVersion: 'folder.grafana.app/v1beta1',
|
||||||
|
metadata: {
|
||||||
|
name: 'general',
|
||||||
|
uid: 'DvhY6m059FraHn96xsOKsb8GRtHy2ftVDUPkqZTzP4kX',
|
||||||
|
resourceVersion: '-62135596800000',
|
||||||
|
creationTimestamp: undefined,
|
||||||
|
annotations: {
|
||||||
|
'grafana.app/updatedTimestamp': '0001-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
title: 'Dashboards',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
status: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sharedWithMeFolder = {
|
||||||
|
kind: 'Folder',
|
||||||
|
apiVersion: 'folder.grafana.app/v1beta1',
|
||||||
|
metadata: {
|
||||||
|
name: 'sharedwithme',
|
||||||
|
uid: 'DlDSzXw31VwXu6LHMw0JMoFvfVtYzyf3NEPzsOXHtxQX',
|
||||||
|
resourceVersion: '-62135596800000',
|
||||||
|
creationTimestamp: undefined,
|
||||||
|
annotations: {
|
||||||
|
'grafana.app/updatedTimestamp': '0001-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
title: 'Shared with me',
|
||||||
|
description: 'Dashboards and folders shared with me',
|
||||||
|
},
|
||||||
|
status: {},
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react';
|
import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react';
|
||||||
import { skipToken } from '@reduxjs/toolkit/query';
|
|
||||||
import debounce from 'debounce-promise';
|
import debounce from 'debounce-promise';
|
||||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
@@ -8,8 +7,8 @@ import * as React from 'react';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { Alert, Icon, Input, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui';
|
import { Alert, Icon, Input, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||||
|
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
import { getStatusFromError } from 'app/core/utils/errors';
|
import { getStatusFromError } from 'app/core/utils/errors';
|
||||||
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
|
||||||
import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types';
|
import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types';
|
||||||
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
|
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
|
||||||
import { QueryResponse } from 'app/features/search/service/types';
|
import { QueryResponse } from 'app/features/search/service/types';
|
||||||
@@ -71,7 +70,7 @@ export function NestedFolderPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
}: NestedFolderPickerProps) {
|
}: NestedFolderPickerProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const selectedFolder = useGetFolderQuery(value || skipToken);
|
const selectedFolder = useGetFolderQueryFacade(value);
|
||||||
// user might not have access to the folder, but they have access to the dashboard
|
// user might not have access to the folder, but they have access to the dashboard
|
||||||
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
|
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
|
||||||
// but doesn't have access to the folder
|
// but doesn't have access to the folder
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { skipToken } from '@reduxjs/toolkit/query/react';
|
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
|
|
||||||
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
|
||||||
import { FolderDTO } from 'app/types/folders';
|
import { FolderDTO } from 'app/types/folders';
|
||||||
|
|
||||||
interface ReturnBag {
|
interface ReturnBag {
|
||||||
@@ -13,7 +11,7 @@ interface ReturnBag {
|
|||||||
* @TODO propagate error state
|
* @TODO propagate error state
|
||||||
*/
|
*/
|
||||||
export function useFolder(uid?: string): ReturnBag {
|
export function useFolder(uid?: string): ReturnBag {
|
||||||
const fetchFolderState = useGetFolderQuery(uid || skipToken);
|
const fetchFolderState = useGetFolderQueryFacade(uid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading: fetchFolderState.isLoading,
|
loading: fetchFolderState.isLoading,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { skipToken } from '@reduxjs/toolkit/query';
|
|
||||||
import { memo, useEffect, useMemo } from 'react';
|
import { memo, useEffect, useMemo } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router-dom-v5-compat';
|
import { useLocation, useParams } from 'react-router-dom-v5-compat';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
@@ -8,6 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
|
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
|
||||||
|
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { useDispatch } from 'app/types/store';
|
import { useDispatch } from 'app/types/store';
|
||||||
@@ -19,7 +19,7 @@ import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
|
|||||||
import { useSearchStateManager } from '../search/state/SearchStateManager';
|
import { useSearchStateManager } from '../search/state/SearchStateManager';
|
||||||
import { getSearchPlaceholder } from '../search/tempI18nPhrases';
|
import { getSearchPlaceholder } from '../search/tempI18nPhrases';
|
||||||
|
|
||||||
import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI';
|
import { useSaveFolderMutation } from './api/browseDashboardsAPI';
|
||||||
import { BrowseActions } from './components/BrowseActions/BrowseActions';
|
import { BrowseActions } from './components/BrowseActions/BrowseActions';
|
||||||
import { BrowseFilters } from './components/BrowseFilters';
|
import { BrowseFilters } from './components/BrowseFilters';
|
||||||
import { BrowseView } from './components/BrowseView';
|
import { BrowseView } from './components/BrowseView';
|
||||||
@@ -70,7 +70,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
}
|
}
|
||||||
}, [isSearching, searchState.result, stateManager]);
|
}, [isSearching, searchState.result, stateManager]);
|
||||||
|
|
||||||
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
|
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
|
||||||
const [saveFolder] = useSaveFolderMutation();
|
const [saveFolder] = useSaveFolderMutation();
|
||||||
const navModel = useMemo(() => {
|
const navModel = useMemo(() => {
|
||||||
if (!folderDTO) {
|
if (!folderDTO) {
|
||||||
@@ -90,7 +90,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
const hasSelection = useHasSelection();
|
const hasSelection = useHasSelection();
|
||||||
|
|
||||||
// Fetch the root (aka general) folder if we're not in a specific folder
|
// Fetch the root (aka general) folder if we're not in a specific folder
|
||||||
const { data: rootFolderDTO } = useGetFolderQuery(folderDTO ? skipToken : 'general');
|
const { data: rootFolderDTO } = useGetFolderQueryFacade(folderDTO ? undefined : 'general');
|
||||||
const folder = folderDTO ?? rootFolderDTO;
|
const folder = folderDTO ?? rootFolderDTO;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom-v5-compat';
|
|||||||
|
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { Alert } from '@grafana/ui';
|
import { Alert } from '@grafana/ui';
|
||||||
|
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel';
|
import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel';
|
||||||
|
|
||||||
@@ -12,14 +13,14 @@ import { GRAFANA_RULER_CONFIG } from '../alerting/unified/api/featureDiscoveryAp
|
|||||||
import { stringifyErrorLike } from '../alerting/unified/utils/misc';
|
import { stringifyErrorLike } from '../alerting/unified/utils/misc';
|
||||||
import { rulerRuleType } from '../alerting/unified/utils/rules';
|
import { rulerRuleType } from '../alerting/unified/utils/rules';
|
||||||
|
|
||||||
import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI';
|
import { useSaveFolderMutation } from './api/browseDashboardsAPI';
|
||||||
import { FolderActionsButton } from './components/FolderActionsButton';
|
import { FolderActionsButton } from './components/FolderActionsButton';
|
||||||
|
|
||||||
const { useRulerNamespaceQuery } = alertRuleApi;
|
const { useRulerNamespaceQuery } = alertRuleApi;
|
||||||
|
|
||||||
export function BrowseFolderAlertingPage() {
|
export function BrowseFolderAlertingPage() {
|
||||||
const { uid: folderUID = '' } = useParams();
|
const { uid: folderUID = '' } = useParams();
|
||||||
const { data: folderDTO, isLoading: isFolderLoading } = useGetFolderQuery(folderUID);
|
const { data: folderDTO, isLoading: isFolderLoading } = useGetFolderQueryFacade(folderUID);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: rulerNamespace = {},
|
data: rulerNamespace = {},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { AppEvents } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
|
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
|
||||||
@@ -8,8 +9,9 @@ import { appEvents } from 'app/core/core';
|
|||||||
import { ShowModalReactEvent } from 'app/types/events';
|
import { ShowModalReactEvent } from 'app/types/events';
|
||||||
import { FolderDTO } from 'app/types/folders';
|
import { FolderDTO } from 'app/types/folders';
|
||||||
|
|
||||||
|
import { useDeleteFolderMutationFacade } from '../../../api/clients/folder/v1beta1/hooks';
|
||||||
import { ManagerKind } from '../../apiserver/types';
|
import { ManagerKind } from '../../apiserver/types';
|
||||||
import { useDeleteFolderMutation, useMoveFolderMutation } from '../api/browseDashboardsAPI';
|
import { useMoveFolderMutation } from '../api/browseDashboardsAPI';
|
||||||
import { getFolderPermissions } from '../permissions';
|
import { getFolderPermissions } from '../permissions';
|
||||||
|
|
||||||
import { DeleteModal } from './BrowseActions/DeleteModal';
|
import { DeleteModal } from './BrowseActions/DeleteModal';
|
||||||
@@ -25,7 +27,8 @@ export function FolderActionsButton({ folder }: Props) {
|
|||||||
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
||||||
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
||||||
const [moveFolder] = useMoveFolderMutation();
|
const [moveFolder] = useMoveFolderMutation();
|
||||||
const [deleteFolder] = useDeleteFolderMutation();
|
|
||||||
|
const deleteFolder = useDeleteFolderMutationFacade();
|
||||||
|
|
||||||
const { canEditFolders, canDeleteFolders, canViewPermissions, canSetPermissions } = getFolderPermissions(folder);
|
const { canEditFolders, canDeleteFolders, canViewPermissions, canSetPermissions } = getFolderPermissions(folder);
|
||||||
const isProvisionedFolder = folder.managedBy === ManagerKind.Repo;
|
const isProvisionedFolder = folder.managedBy === ManagerKind.Repo;
|
||||||
@@ -44,7 +47,21 @@ export function FolderActionsButton({ folder }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
await deleteFolder(folder);
|
const result = await deleteFolder(folder);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
appEvents.publish({
|
||||||
|
type: AppEvents.alertError.name,
|
||||||
|
payload: [
|
||||||
|
t(
|
||||||
|
'browse-dashboards.folder-actions-button.delete-folder-error',
|
||||||
|
'Error deleting folder. Please try again later.'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
reportInteraction('grafana_manage_dashboards_item_deleted', {
|
reportInteraction('grafana_manage_dashboards_item_deleted', {
|
||||||
item_counts: {
|
item_counts: {
|
||||||
folder: 1,
|
folder: 1,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit';
|
|||||||
|
|
||||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
|
|
||||||
|
import { GENERAL_FOLDER_UID } from '../../search/constants';
|
||||||
import { isSharedWithMe } from '../components/utils';
|
import { isSharedWithMe } from '../components/utils';
|
||||||
import { BrowseDashboardsState } from '../types';
|
import { BrowseDashboardsState } from '../types';
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export function refetchChildrenFulfilled(state: BrowseDashboardsState, action: R
|
|||||||
isFullyLoaded: kind === 'dashboard' && lastPageOfKind,
|
isFullyLoaded: kind === 'dashboard' && lastPageOfKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parentUID) {
|
if (parentUID && parentUID !== GENERAL_FOLDER_UID) {
|
||||||
state.childrenByParentUID[parentUID] = newCollection;
|
state.childrenByParentUID[parentUID] = newCollection;
|
||||||
} else {
|
} else {
|
||||||
state.rootItems = newCollection;
|
state.rootItems = newCollection;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Trans } from '@grafana/i18n';
|
|||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { CellProps, Stack, Text, Icon, useStyles2 } from '@grafana/ui';
|
import { CellProps, Stack, Text, Icon, useStyles2 } from '@grafana/ui';
|
||||||
import { getSvgSize } from '@grafana/ui/internal';
|
import { getSvgSize } from '@grafana/ui/internal';
|
||||||
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
|
|
||||||
import { LocalPlugin } from '../../plugins/admin/types';
|
import { LocalPlugin } from '../../plugins/admin/types';
|
||||||
import { useGetDashboardByUidQuery, useGetLibraryElementByUidQuery } from '../api';
|
import { useGetDashboardByUidQuery, useGetLibraryElementByUidQuery } from '../api';
|
||||||
@@ -121,7 +121,7 @@ function FolderInfo({ data }: { data: ResourceTableItem }) {
|
|||||||
const folderUID = data.refId;
|
const folderUID = data.refId;
|
||||||
const skipApiCall = !!data.name && !!data.parentName;
|
const skipApiCall = !!data.name && !!data.parentName;
|
||||||
|
|
||||||
const { data: folderData, isLoading, isError } = useGetFolderQuery(folderUID, { skip: skipApiCall });
|
const { data: folderData, isLoading, isError } = useGetFolderQueryFacade(skipApiCall ? undefined : folderUID);
|
||||||
|
|
||||||
const folderName = data.name || folderData?.title;
|
const folderName = data.name || folderData?.title;
|
||||||
const folderParentName = data.parentName || folderData?.parents?.[folderData.parents.length - 1]?.title;
|
const folderParentName = data.parentName || folderData?.parents?.[folderData.parents.length - 1]?.title;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from '../../../api/clients/provisioning/v0alpha1';
|
import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from 'app/api/clients/provisioning/v0alpha1';
|
||||||
|
|
||||||
const emptyRepos: Repository[] = [];
|
const emptyRepos: Repository[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -3588,6 +3588,7 @@
|
|||||||
},
|
},
|
||||||
"folder-actions-button": {
|
"folder-actions-button": {
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"delete-folder-error": "Error deleting folder. Please try again later.",
|
||||||
"folder-actions": "Folder actions",
|
"folder-actions": "Folder actions",
|
||||||
"manage-permissions": "Manage permissions",
|
"manage-permissions": "Manage permissions",
|
||||||
"move": "Move"
|
"move": "Move"
|
||||||
@@ -7496,6 +7497,9 @@
|
|||||||
"badge-tooltip": "Provisioned"
|
"badge-tooltip": "Provisioned"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
|
"api": {
|
||||||
|
"folder-deleted-success": "Folder deleted"
|
||||||
|
},
|
||||||
"get-loading-nav": {
|
"get-loading-nav": {
|
||||||
"main": {
|
"main": {
|
||||||
"title": {
|
"title": {
|
||||||
|
|||||||
Reference in New Issue
Block a user