Compare commits

...

2 Commits

Author SHA1 Message Date
Clarity-89
470ac231bf BrowseDashboards: Split utils 2026-01-13 16:25:42 +02:00
Ashley Harrison
dffae66fdc Storybook: Add workflow to deploy canary storybook (#116138)
* add first attempt at storybook deploy action for canary

* don't run on push to main yet!

* add CODEOWNER
2026-01-13 13:10:04 +00:00
15 changed files with 201 additions and 120 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1275,6 +1275,7 @@ embed.go @grafana/grafana-as-code
/.github/workflows/i18n-crowdin-download.yml @grafana/grafana-frontend-platform
/.github/workflows/i18n-crowdin-create-tasks.yml @grafana/grafana-frontend-platform
/.github/workflows/i18n-verify.yml @grafana/grafana-frontend-platform
/.github/workflows/deploy-storybook.yml @grafana/grafana-frontend-platform
/.github/workflows/deploy-storybook-preview.yml @grafana/grafana-frontend-platform
/.github/workflows/scripts/crowdin/create-tasks.ts @grafana/grafana-frontend-platform
/.github/workflows/scripts/publish-frontend-metrics.mts @grafana/grafana-frontend-platform

79
.github/workflows/deploy-storybook.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Deploy Storybook
on:
workflow_dispatch:
# push:
# branches:
# - main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
detect-changes:
# Only run in grafana/grafana
if: github.repository == 'grafana/grafana'
name: Detect whether code changed
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: true # required to get more history in the changed-files action
fetch-depth: 2
- name: Detect changes
id: detect-changes
uses: ./.github/actions/change-detection
with:
self: .github/workflows/deploy-storybook.yml
deploy-storybook:
name: Deploy Storybook
runs-on: ubuntu-latest
needs: detect-changes
# Only run in grafana/grafana
if: github.repository == 'grafana/grafana' && needs.detect-changes.outputs.changed-frontend-packages == 'true'
permissions:
contents: read
id-token: write
env:
BUCKET_NAME: grafana-storybook
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Install dependencies
run: yarn install --immutable
- name: Build storybook
run: yarn storybook:build
# Create the GCS folder name
# Right now, this just returns "canary"
# But we'll expand this to work for "latest" as well in the future
- name: Create deploy name
id: create-deploy-name
run: |
echo "deploy-name=canary" >> "$GITHUB_OUTPUT"
- name: Upload Storybook
uses: grafana/shared-workflows/actions/push-to-gcs@main
with:
environment: prod
bucket: ${{ env.BUCKET_NAME }}
bucket_path: ${{ steps.create-deploy-name.outputs.deploy-name }}
path: packages/grafana-ui/dist/storybook
service_account: github-gf-storybook-deploy@grafanalabs-workload-identity.iam.gserviceaccount.com
parent: false

View File

@@ -0,0 +1,36 @@
import impressionSrv from 'app/core/services/impression_srv';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult } from 'app/features/search/service/types';
/**
* Returns dashboard search results ordered the same way the user opened them.
*/
export async function getRecentlyViewedDashboards(maxItems = 5): Promise<DashboardQueryResult[]> {
try {
const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems);
if (!recentlyOpened.length) {
return [];
}
const searchResults = await getGrafanaSearcher().search({
kind: ['dashboard'],
limit: recentlyOpened.length,
uid: recentlyOpened,
});
const dashboards = searchResults.view.toArray();
// Keep dashboards in the same order the user opened them.
// When a UID is missing from the search response
// push it to the end instead of letting indexOf return -1
const order = (uid: string) => {
const idx = recentlyOpened.indexOf(uid);
return idx === -1 ? recentlyOpened.length : idx;
};
dashboards.sort((a, b) => order(a.uid) - order(b.uid));
return dashboards;
} catch (error) {
console.error('Failed to load recently viewed dashboards', error);
return [];
}
}

View File

@@ -7,7 +7,7 @@ import { queryResultToViewItem } from 'app/features/search/service/utils';
import { DashboardViewItem } from 'app/features/search/types';
import { AccessControlAction } from 'app/types/accessControl';
import { getFolderURL, isSharedWithMe } from '../components/utils';
import { getFolderURL, isSharedWithMe } from '../utils/dashboards';
export const PAGE_SIZE = 50;

View File

@@ -13,20 +13,20 @@ import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types/store';
import { PAGE_SIZE } from '../api/services';
import { canSelectItems } from '../permissions';
import { fetchNextChildrenPage } from '../state/actions';
import {
useFlatTreeState,
rootItemsSelector,
useBrowseLoadingStatus,
useCheckboxSelectionState,
useChildrenByParentUIDState,
useBrowseLoadingStatus,
useFlatTreeState,
useLoadNextChildrenPage,
rootItemsSelector,
} from '../state/hooks';
import { setFolderOpenState, setItemSelectionState, setAllSelection } from '../state/slice';
import { BrowseDashboardsState, DashboardTreeSelection, SelectionState, BrowseDashboardsPermissions } from '../types';
import { setAllSelection, setFolderOpenState, setItemSelectionState } from '../state/slice';
import { BrowseDashboardsPermissions, BrowseDashboardsState, DashboardTreeSelection, SelectionState } from '../types';
import { DashboardsTree } from './DashboardsTree';
import { canSelectItems } from './utils';
interface BrowseViewProps {
height: number;

View File

@@ -10,9 +10,9 @@ import { useSelectionRepoValidation } from 'app/features/provisioning/hooks/useS
import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository';
import { useSelector } from 'app/types/store';
import { canEditItemType } from '../permissions';
import { DashboardsTreeCellProps, SelectionState } from '../types';
import { isSharedWithMe, canEditItemType } from './utils';
import { isSharedWithMe } from '../utils/dashboards';
export default function CheckboxCell({
row: { original: row },

View File

@@ -1,6 +1,6 @@
import { css, cx } from '@emotion/css';
import { useCallback, useEffect, useId, useMemo, useRef } from 'react';
import * as React from 'react';
import { useCallback, useEffect, useId, useMemo, useRef } from 'react';
import { TableInstance, useTable } from 'react-table';
import { VariableSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
@@ -11,20 +11,21 @@ import { Trans, t } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { DashboardViewItem } from 'app/features/search/types';
import { canSelectItems } from '../permissions';
import {
BrowseDashboardsPermissions,
DashboardsTreeCellProps,
DashboardsTreeColumn,
DashboardsTreeItem,
SelectionState,
BrowseDashboardsPermissions,
} from '../types';
import { makeRowID } from '../utils/dashboards';
import CheckboxCell from './CheckboxCell';
import CheckboxHeaderCell from './CheckboxHeaderCell';
import { NameCell } from './NameCell';
import { TagsCell } from './TagsCell';
import { useCustomFlexLayout } from './customFlexTableLayout';
import { makeRowID, canSelectItems } from './utils';
interface DashboardsTreeProps {
items: DashboardsTreeItem[];

View File

@@ -4,7 +4,7 @@ import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Icon, IconButton, Link, Spinner, useStyles2, Text } from '@grafana/ui';
import { Icon, IconButton, Link, Spinner, Text, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/internal';
import { getIconForItem } from 'app/features/search/service/utils';
@@ -12,8 +12,7 @@ import { Indent } from '../../../core/components/Indent/Indent';
import { FolderRepo } from '../../../core/components/NestedFolderPicker/FolderRepo';
import { useChildrenByParentUIDState } from '../state/hooks';
import { DashboardsTreeCellProps } from '../types';
import { makeRowID } from './utils';
import { makeRowID } from '../utils/dashboards';
const CHEVRON_SIZE = 'md';
const ICON_SIZE = 'sm';

View File

@@ -6,12 +6,12 @@ import { GrafanaTheme2, store } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui';
import { Button, CollapsableSection, Grid, Spinner, Stack, Text, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { useDashboardLocationInfo } from 'app/features/search/hooks/useDashboardLocationInfo';
import { DashListItem } from 'app/plugins/panel/dashlist/DashListItem';
import { getRecentlyViewedDashboards } from './utils';
import { getRecentlyViewedDashboards } from '../api/recentlyViewed';
const MAX_RECENT = 5;

View File

@@ -9,12 +9,11 @@ import { SearchStateManager } from 'app/features/search/state/SearchStateManager
import { DashboardViewItemKind, SearchState } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types/store';
import { canEditItemType, canSelectItems } from '../permissions';
import { useHasSelection } from '../state/hooks';
import { setAllSelection, setItemSelectionState } from '../state/slice';
import { BrowseDashboardsPermissions } from '../types';
import { canEditItemType, canSelectItems } from './utils';
interface SearchViewProps {
height: number;
width: number;

View File

@@ -1,98 +0,0 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import impressionSrv from 'app/core/services/impression_srv';
import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult } from 'app/features/search/service/types';
import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types';
export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) {
return baseId + item.uid;
}
export function isSharedWithMe(uid: string) {
return uid === config.sharedWithMeFolderUID;
}
// Construct folder URL and append orgId to it
export function getFolderURL(uid: string) {
const { orgId } = contextSrv.user;
const subUrlPrefix = config.appSubUrl ?? '';
const url = `${subUrlPrefix}/dashboards/f/${uid}/`;
if (orgId) {
return `${url}?orgId=${orgId}`;
}
return url;
}
// Collect selected dashboard and folder from the DashboardTreeSelection
// This is used to prepare the items for bulk delete operation.
export function collectSelectedItems(selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
const resources: ResourceRef[] = [];
// folders
for (const [uid, selected] of Object.entries(selectedItems.folder)) {
if (selected) {
resources.push({ name: uid, group: 'folder.grafana.app', kind: 'Folder' });
}
}
// dashboards
for (const [uid, selected] of Object.entries(selectedItems.dashboard)) {
if (selected) {
resources.push({ name: uid, group: 'dashboard.grafana.app', kind: 'Dashboard' });
}
}
return resources;
}
export function canEditItemType(itemKind: string, permissions: BrowseDashboardsPermissions) {
const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions;
return itemKind === 'folder'
? Boolean(canEditFolders || canDeleteFolders)
: Boolean(canEditDashboards || canDeleteDashboards);
}
export function canSelectItems(permissions: BrowseDashboardsPermissions) {
const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions;
// Users can select items only if they have both edit and delete permissions for at least one item type
const canSelectFolders = canEditFolders || canDeleteFolders;
const canSelectDashboards = canEditDashboards || canDeleteDashboards;
return Boolean(canSelectFolders || canSelectDashboards);
}
/**
* Returns dashboard search results ordered the same way the user opened them.
*/
export async function getRecentlyViewedDashboards(maxItems = 5): Promise<DashboardQueryResult[]> {
try {
const recentlyOpened = (await impressionSrv.getDashboardOpened()).slice(0, maxItems);
if (!recentlyOpened.length) {
return [];
}
const searchResults = await getGrafanaSearcher().search({
kind: ['dashboard'],
limit: recentlyOpened.length,
uid: recentlyOpened,
});
const dashboards = searchResults.view.toArray();
// Keep dashboards in the same order the user opened them.
// When a UID is missing from the search response
// push it to the end instead of letting indexOf return -1
const order = (uid: string) => {
const idx = recentlyOpened.indexOf(uid);
return idx === -1 ? recentlyOpened.length : idx;
};
dashboards.sort((a, b) => order(a.uid) - order(b.uid));
return dashboards;
} catch (error) {
console.error('Failed to load recently viewed dashboards', error);
return [];
}
}

View File

@@ -2,6 +2,8 @@ import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { FolderDTO } from 'app/types/folders';
import { BrowseDashboardsPermissions } from './types';
function checkFolderPermission(action: AccessControlAction, folderDTO?: FolderDTO) {
return folderDTO ? contextSrv.hasPermissionInMetadata(action, folderDTO) : contextSrv.hasPermission(action);
}
@@ -31,3 +33,18 @@ export function getFolderPermissions(folderDTO?: FolderDTO) {
canDeleteDashboards,
};
}
export function canEditItemType(itemKind: string, permissions: BrowseDashboardsPermissions) {
const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions;
return itemKind === 'folder'
? Boolean(canEditFolders || canDeleteFolders)
: Boolean(canEditDashboards || canDeleteDashboards);
}
export function canSelectItems(permissions: BrowseDashboardsPermissions) {
const { canEditFolders, canDeleteFolders, canEditDashboards, canDeleteDashboards } = permissions;
// Users can select items only if they have both edit and delete permissions for at least one item type
const canSelectFolders = canEditFolders || canDeleteFolders;
const canSelectDashboards = canEditDashboards || canDeleteDashboards;
return Boolean(canSelectFolders || canSelectDashboards);
}

View File

@@ -2,10 +2,9 @@ import { useCallback, useRef } from 'react';
import { createSelector } from 'reselect';
import { DashboardViewItem } from 'app/features/search/types';
import { useSelector, StoreState, useDispatch } from 'app/types/store';
import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { PAGE_SIZE } from '../api/services';
import { isSharedWithMe } from '../components/utils';
import {
BrowseDashboardsState,
DashboardsTreeItem,
@@ -13,6 +12,7 @@ import {
DashboardViewItemWithUIItems,
UIDashboardViewItem,
} from '../types';
import { isSharedWithMe } from '../utils/dashboards';
import { fetchNextChildrenPage } from './actions';
import { getPaginationPlaceholders } from './utils';

View File

@@ -3,8 +3,8 @@ import { PayloadAction } from '@reduxjs/toolkit';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { GENERAL_FOLDER_UID } from '../../search/constants';
import { isSharedWithMe } from '../components/utils';
import { BrowseDashboardsState } from '../types';
import { isSharedWithMe } from '../utils/dashboards';
import { fetchNextChildrenPage, refetchChildren } from './actions';
import { findItem } from './utils';

View File

@@ -0,0 +1,47 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob';
import { DashboardTreeSelection, DashboardViewItemWithUIItems } from '../types';
export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) {
return baseId + item.uid;
}
export function isSharedWithMe(uid: string) {
return uid === config.sharedWithMeFolderUID;
}
// Construct folder URL and append orgId to it
export function getFolderURL(uid: string) {
const { orgId } = contextSrv.user;
const subUrlPrefix = config.appSubUrl ?? '';
const url = `${subUrlPrefix}/dashboards/f/${uid}/`;
if (orgId) {
return `${url}?orgId=${orgId}`;
}
return url;
}
// Collect selected dashboard and folder from the DashboardTreeSelection
// This is used to prepare the items for bulk delete operation.
export function collectSelectedItems(selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>) {
const resources: ResourceRef[] = [];
// folders
for (const [uid, selected] of Object.entries(selectedItems.folder)) {
if (selected) {
resources.push({ name: uid, group: 'folder.grafana.app', kind: 'Folder' });
}
}
// dashboards
for (const [uid, selected] of Object.entries(selectedItems.dashboard)) {
if (selected) {
resources.push({ name: uid, group: 'dashboard.grafana.app', kind: 'Dashboard' });
}
}
return resources;
}