;
- /**
- * Maximum number of rows user can have in the grid.
- * @defaultValue unlimited
- */
- maxRows?: number;
- /**
- * Custom style for grid.
- */
- style?: React.CSSProperties;
- /**
- * Compaction type of widgets in the grid. This controls where widgets are moved in case
- * they are overlapping in the grid.
- */
- compactType?: 'vertical' | 'horizontal' | null;
- /**
- * Controls if widgets can overlap in the grid. If true, grid can be placed one over the other.
- * @defaultValue false
- */
- allowOverlap?: boolean;
- /**
- * Controls if widgets can collide with each other. If true, grid items won't change position when being dragged over.
- * @defaultValue false
- */
- preventCollision?: boolean;
-};
-
/**
* A component that allows customizing components in home grid layout.
*
@@ -318,6 +246,9 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
isDraggable: editMode,
},
settings: {},
+ movable: widget.movable,
+ deletable: widget.deletable,
+ resizable: widget.resizable,
},
]);
setAddWidgetDialogOpen(false);
@@ -348,9 +279,11 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
setEditMode(mode);
setWidgets(
widgets.map(w => {
+ const resizable = w.resizable === false ? false : mode;
+ const movable = w.movable === false ? false : mode;
return {
...w,
- layout: { ...w.layout, isDraggable: mode, isResizable: mode },
+ layout: { ...w.layout, isDraggable: movable, isResizable: resizable },
};
}),
);
@@ -370,7 +303,20 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
};
const handleRestoreDefaultConfig = () => {
- setWidgets(defaultLayout);
+ setWidgets(
+ defaultLayout.map(w => {
+ const resizable = w.resizable === false ? false : editMode;
+ const movable = w.movable === false ? false : editMode;
+ return {
+ ...w,
+ layout: {
+ ...w.layout,
+ isDraggable: movable,
+ isResizable: resizable,
+ },
+ };
+ }),
+ );
};
return (
@@ -404,7 +350,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
style={props.style}
allowOverlap={props.allowOverlap}
preventCollision={props.preventCollision}
- draggableCancel=".overlayGridItem,.widgetSettingsDialog"
+ draggableCancel=".overlayGridItem,.widgetSettingsDialog,.disabled"
containerPadding={props.containerPadding}
margin={props.containerMargin}
breakpoints={
@@ -435,7 +381,9 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
return (
@@ -447,6 +395,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => {
handleRemove={handleRemove}
handleSettingsSave={handleSettingsSave}
settings={w.settings}
+ deletable={w.deletable}
/>
)}
diff --git a/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx b/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx
index 261dabb833..2c3a1d9ab9 100644
--- a/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx
+++ b/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx
@@ -58,10 +58,12 @@ interface WidgetSettingsOverlayProps {
handleRemove: (id: string) => void;
handleSettingsSave: (id: string, settings: Record) => void;
settings?: Record;
+ deletable?: boolean;
}
export const WidgetSettingsOverlay = (props: WidgetSettingsOverlayProps) => {
- const { id, widget, settings, handleRemove, handleSettingsSave } = props;
+ const { id, widget, settings, handleRemove, handleSettingsSave, deletable } =
+ props;
const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false);
const styles = useStyles();
@@ -110,13 +112,15 @@ export const WidgetSettingsOverlay = (props: WidgetSettingsOverlayProps) => {
)}
-
-
- handleRemove(id)}>
-
-
-
-
+ {deletable !== false && (
+
+
+ handleRemove(id)}>
+
+
+
+
+ )}
);
diff --git a/plugins/home/src/components/CustomHomepage/index.ts b/plugins/home/src/components/CustomHomepage/index.ts
index eed597cca3..380fdb4b25 100644
--- a/plugins/home/src/components/CustomHomepage/index.ts
+++ b/plugins/home/src/components/CustomHomepage/index.ts
@@ -14,5 +14,8 @@
* limitations under the License.
*/
export { CustomHomepageGrid } from './CustomHomepageGrid';
-export type { CustomHomepageGridProps, Breakpoint } from './CustomHomepageGrid';
-export type { LayoutConfiguration } from './types';
+export type {
+ CustomHomepageGridProps,
+ Breakpoint,
+ LayoutConfiguration,
+} from './types';
diff --git a/plugins/home/src/components/CustomHomepage/types.ts b/plugins/home/src/components/CustomHomepage/types.ts
index 8e9c6f403e..79e94c5f03 100644
--- a/plugins/home/src/components/CustomHomepage/types.ts
+++ b/plugins/home/src/components/CustomHomepage/types.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { ReactElement } from 'react';
+import React, { ReactElement, ReactNode } from 'react';
import { Layout } from 'react-grid-layout';
import { z } from 'zod';
import { RJSFSchema, UiSchema } from '@rjsf/utils';
@@ -24,12 +24,91 @@ const RSJFTypeUiSchema: z.ZodType = z.any();
const ReactElementSchema: z.ZodType = z.any();
const LayoutSchema: z.ZodType = z.any();
+/**
+ * Breakpoint options for
+ *
+ * @public
+ */
+export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+
+/**
+ * Props customizing the component.
+ *
+ * @public
+ */
+export type CustomHomepageGridProps = {
+ /**
+ * Children contain all widgets user can configure on their own homepage.
+ */
+ children?: ReactNode;
+ /**
+ * Default layout for the homepage before users have modified it.
+ */
+ config?: LayoutConfiguration[];
+ /**
+ * Height of grid row in pixels.
+ * @defaultValue 60
+ */
+ rowHeight?: number;
+ /**
+ * Screen width in pixels for different breakpoints.
+ * @defaultValue theme breakpoints
+ */
+ breakpoints?: Record;
+ /**
+ * Number of grid columns for different breakpoints.
+ * @defaultValue \{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 \}
+ */
+ cols?: Record;
+ /**
+ * Grid container padding (x, y) in pixels for all or specific breakpoints.
+ * @defaultValue [0, 0]
+ * @example [10, 10]
+ * @example \{ lg: [10, 10] \}
+ */
+ containerPadding?: [number, number] | Record;
+ /**
+ * Grid container margin (x, y) in pixels for all or specific breakpoints.
+ * @defaultValue [0, 0]
+ * @example [10, 10]
+ * @example \{ lg: [10, 10] \}
+ */
+ containerMargin?: [number, number] | Record;
+ /**
+ * Maximum number of rows user can have in the grid.
+ * @defaultValue unlimited
+ */
+ maxRows?: number;
+ /**
+ * Custom style for grid.
+ */
+ style?: React.CSSProperties;
+ /**
+ * Compaction type of widgets in the grid. This controls where widgets are moved in case
+ * they are overlapping in the grid.
+ */
+ compactType?: 'vertical' | 'horizontal' | null;
+ /**
+ * Controls if widgets can overlap in the grid. If true, grid can be placed one over the other.
+ * @defaultValue false
+ */
+ allowOverlap?: boolean;
+ /**
+ * Controls if widgets can collide with each other. If true, grid items won't change position when being dragged over.
+ * @defaultValue false
+ */
+ preventCollision?: boolean;
+};
+
export const LayoutConfigurationSchema = z.object({
component: ReactElementSchema,
x: z.number().nonnegative('x must be positive number'),
y: z.number().nonnegative('y must be positive number'),
width: z.number().positive('width must be positive number'),
height: z.number().positive('height must be positive number'),
+ movable: z.boolean().optional(),
+ deletable: z.boolean().optional(),
+ resizable: z.boolean().optional(),
});
/**
@@ -43,6 +122,9 @@ export type LayoutConfiguration = {
y: number;
width: number;
height: number;
+ movable?: boolean;
+ deletable?: boolean;
+ resizable?: boolean;
};
export const WidgetSchema = z.object({
@@ -64,6 +146,9 @@ export const WidgetSchema = z.object({
.optional(),
settingsSchema: RSJFTypeSchema.optional(),
uiSchema: RSJFTypeUiSchema.optional(),
+ movable: z.boolean().optional(),
+ deletable: z.boolean().optional(),
+ resizable: z.boolean().optional(),
});
export type Widget = z.infer;
@@ -72,6 +157,9 @@ const GridWidgetSchema = z.object({
id: z.string(),
layout: LayoutSchema,
settings: z.record(z.string(), z.any()),
+ movable: z.boolean().optional(),
+ deletable: z.boolean().optional(),
+ resizable: z.boolean().optional(),
});
export type GridWidget = z.infer;
diff --git a/plugins/opencost/src/components/SelectWindow.jsx b/plugins/opencost/src/components/SelectWindow.jsx
index e374571c00..470770f35c 100644
--- a/plugins/opencost/src/components/SelectWindow.jsx
+++ b/plugins/opencost/src/components/SelectWindow.jsx
@@ -16,7 +16,6 @@
import React, { useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/styles';
-import { endOfDay, startOfDay } from '@date-io/luxon';
import {
MuiPickersUtilsProvider,
KeyboardDatePicker,
@@ -67,14 +66,14 @@ const SelectWindow = ({ windowOptions, window, setWindow }) => {
};
const handleStartDateChange = date => {
- if (isValid(date)) {
- setStartDate(startOfDay(date));
+ if (isValid(date.toJSDate())) {
+ setStartDate(date.startOf('day').toJSDate());
}
};
const handleEndDateChange = date => {
- if (isValid(date)) {
- setEndDate(endOfDay(date));
+ if (isValid(date.toJSDate())) {
+ setEndDate(date.endOf('day').toJSDate());
}
};
diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md
index 704401e8d4..d85df7125c 100644
--- a/plugins/org/api-report.md
+++ b/plugins/org/api-report.md
@@ -30,10 +30,13 @@ export const EntityOwnershipCard: (props: {
variant?: InfoCardVariants | undefined;
entityFilterKind?: string[] | undefined;
hideRelationsToggle?: boolean | undefined;
- relationsType?: string | undefined;
+ relationsType?: EntityRelationAggregation | undefined;
entityLimit?: number | undefined;
}) => JSX_2.Element;
+// @public (undocumented)
+export type EntityRelationAggregation = 'direct' | 'aggregated';
+
// @public (undocumented)
export const EntityUserProfileCard: (props: {
variant?: InfoCardVariants | undefined;
@@ -77,7 +80,7 @@ export const OwnershipCard: (props: {
variant?: InfoCardVariants;
entityFilterKind?: string[];
hideRelationsToggle?: boolean;
- relationsType?: string;
+ relationsType?: EntityRelationAggregation;
entityLimit?: number;
}) => React_2.JSX.Element;
diff --git a/plugins/org/package.json b/plugins/org/package.json
index 0193a1592b..81e5a2aa6f 100644
--- a/plugins/org/package.json
+++ b/plugins/org/package.json
@@ -60,6 +60,7 @@
"@testing-library/dom": "^8.0.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
+ "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.0.0",
"@types/node": "^16.11.26",
"msw": "^1.0.0"
diff --git a/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx
index 8dab5c4290..2725991d0d 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx
@@ -34,6 +34,7 @@ import React from 'react';
import pluralize from 'pluralize';
import { catalogIndexRouteRef } from '../../../routes';
import { useGetEntities } from './useGetEntities';
+import { EntityRelationAggregation } from './types';
const useStyles = makeStyles((theme: BackstageTheme) =>
createStyles({
@@ -109,13 +110,11 @@ const EntityCountTile = ({
export const ComponentsGrid = ({
entity,
relationsType,
- isGroup,
entityFilterKind,
entityLimit = 6,
}: {
entity: Entity;
- relationsType: string;
- isGroup: boolean;
+ relationsType: EntityRelationAggregation;
entityFilterKind?: string[];
entityLimit?: number;
}) => {
@@ -123,7 +122,6 @@ export const ComponentsGrid = ({
const { componentsWithCounters, loading, error } = useGetEntities(
entity,
relationsType,
- isGroup,
entityFilterKind,
entityLimit,
);
diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
index 30b7dee2de..faba9b5627 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
@@ -263,10 +263,11 @@ describe('OwnershipCard', () => {
},
);
- expect(getByText('OPENAPI').closest('a')).toHaveAttribute(
- 'href',
- '/create/?filters%5Bkind%5D=api&filters%5Btype%5D=openapi&filters%5Bowners%5D=my-team&filters%5Buser%5D=all',
- );
+ const href = getByText('OPENAPI').closest('a')?.href ?? '';
+ // This env does not support URLSearchParams
+ const queryParams = decodeURIComponent(href);
+
+ expect(queryParams).toContain('filters[owners]=my-team');
});
it('links to the catalog with the user and groups filters from an user profile', async () => {
@@ -299,7 +300,7 @@ describe('OwnershipCard', () => {
const { getByText } = await renderInTestApp(
-
+
,
{
@@ -309,9 +310,11 @@ describe('OwnershipCard', () => {
},
);
- expect(getByText('OPENAPI').closest('a')).toHaveAttribute(
- 'href',
- '/create/?filters%5Bkind%5D=api&filters%5Btype%5D=openapi&filters%5Bowners%5D=user%3Athe-user&filters%5Bowners%5D=my-team&filters%5Bowners%5D=custom%2Fsome-team&filters%5Buser%5D=all',
+ const href = getByText('OPENAPI').closest('a')?.href ?? '';
+ // This env does not support URLSearchParams
+ const queryParams = decodeURIComponent(href);
+ expect(queryParams).toMatch(
+ /filters\[owners\]=custom\/some\-team.*filters\[owners\]=user:the-user/,
);
});
diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
index d104eb032d..2fb7826e52 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
@@ -27,6 +27,7 @@ import {
} from '@material-ui/core';
import React, { useState } from 'react';
import { ComponentsGrid } from './ComponentsGrid';
+import { EntityRelationAggregation } from './types';
const useStyles = makeStyles(theme => ({
list: {
@@ -56,7 +57,7 @@ export const OwnershipCard = (props: {
variant?: InfoCardVariants;
entityFilterKind?: string[];
hideRelationsToggle?: boolean;
- relationsType?: string;
+ relationsType?: EntityRelationAggregation;
entityLimit?: number;
}) => {
const {
@@ -70,7 +71,6 @@ export const OwnershipCard = (props: {
hideRelationsToggle === undefined ? false : hideRelationsToggle;
const classes = useStyles();
const { entity } = useEntity();
- const isGroup = entity.kind === 'Group';
const [getRelationsType, setRelationsType] = useState(
relationsType || 'direct',
);
@@ -102,7 +102,6 @@ export const OwnershipCard = (props: {
}
name="pin"
inputProps={{ 'aria-label': 'Ownership Type Switch' }}
- disabled={!isGroup}
/>
Aggregated Relations
@@ -114,7 +113,6 @@ export const OwnershipCard = (props: {
entity={entity}
entityLimit={entityLimit}
relationsType={getRelationsType}
- isGroup={isGroup}
entityFilterKind={entityFilterKind}
/>
diff --git a/plugins/org/src/components/Cards/OwnershipCard/index.ts b/plugins/org/src/components/Cards/OwnershipCard/index.ts
index dc0cef0226..a4798cc4bf 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/index.ts
+++ b/plugins/org/src/components/Cards/OwnershipCard/index.ts
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export * from './OwnershipCard';
+export * from './types';
diff --git a/plugins/catalog-backend/src/service/request/parseFullTextFilterFields.ts b/plugins/org/src/components/Cards/OwnershipCard/types.ts
similarity index 64%
rename from plugins/catalog-backend/src/service/request/parseFullTextFilterFields.ts
rename to plugins/org/src/components/Cards/OwnershipCard/types.ts
index 02ba5864fc..c1e95ee94e 100644
--- a/plugins/catalog-backend/src/service/request/parseFullTextFilterFields.ts
+++ b/plugins/org/src/components/Cards/OwnershipCard/types.ts
@@ -1,5 +1,3 @@
-import { parseStringParam } from './common';
-
/*
* Copyright 2023 The Backstage Authors
*
@@ -15,14 +13,6 @@ import { parseStringParam } from './common';
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-export function parseFullTextFilterFields(params: Record) {
- const fullTextFilterFields = parseStringParam(
- params.fullTextFilterFields,
- 'fullTextFilterFields',
- );
- if (!fullTextFilterFields) {
- return undefined;
- }
- return fullTextFilterFields.split(',');
-}
+/** @public */
+export type EntityRelationAggregation = 'direct' | 'aggregated';
diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts
new file mode 100644
index 0000000000..191bd10284
--- /dev/null
+++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
+import { useGetEntities } from './useGetEntities';
+import { CatalogApi } from '@backstage/catalog-client';
+import { renderHook } from '@testing-library/react-hooks';
+import { getEntityRelations } from '@backstage/plugin-catalog-react';
+
+const givenParentGroup = 'team.squad1';
+const givenLeafGroup = 'team.squad2';
+const givenUser = 'user.john';
+const givenParentGroupEntity = {
+ kind: 'Group',
+ metadata: {
+ name: givenParentGroup,
+ },
+} as Partial as Entity;
+const givenLeafGroupEntity = {
+ kind: 'Group',
+ metadata: {
+ name: givenLeafGroup,
+ },
+} as Partial as Entity;
+const givenUserEntity = {
+ kind: 'User',
+ metadata: {
+ name: givenUser,
+ },
+} as Partial as Entity;
+
+const catalogApiMock: Pick = {
+ getEntities: jest.fn(async () => Promise.resolve({ items: [] })),
+ getEntitiesByRefs: jest.fn(async ({ entityRefs: [ref] }) =>
+ ref.includes(givenParentGroup)
+ ? { items: [givenParentGroupEntity] }
+ : { items: [givenLeafGroupEntity] },
+ ),
+};
+
+jest.mock('@backstage/core-plugin-api', () => ({
+ useApi: jest.fn(() => catalogApiMock),
+}));
+jest.mock('@backstage/plugin-catalog-react', () => ({
+ catalogApiRef: {},
+ getEntityRelations: jest.fn(entity => {
+ if (entity?.metadata.name === givenParentGroup) {
+ return [
+ {
+ kind: 'Group',
+ namespace: 'default',
+ name: givenLeafGroup,
+ } as CompoundEntityRef,
+ ];
+ } else if (entity?.kind === 'User') {
+ return [
+ {
+ kind: 'Group',
+ namespace: 'default',
+ name: givenLeafGroup,
+ } as CompoundEntityRef,
+ ];
+ }
+
+ return [];
+ }) as typeof getEntityRelations,
+}));
+
+describe('useGetEntities', () => {
+ const ownersFilter = (...owners: string[]) =>
+ expect.objectContaining({
+ filter: expect.arrayContaining([
+ expect.objectContaining({
+ 'relations.ownedBy': owners,
+ }),
+ ]),
+ });
+
+ describe('given aggregated relationsType', () => {
+ const whenHookIsCalledWith = async (_entity: Entity) => {
+ const hook = renderHook(
+ ({ entity }) => useGetEntities(entity, 'aggregated'),
+ {
+ initialProps: { entity: _entity },
+ },
+ );
+
+ await hook.waitForNextUpdate();
+ };
+
+ it('given group entity should aggregate child ownership', async () => {
+ await whenHookIsCalledWith(givenParentGroupEntity);
+ expect(catalogApiMock.getEntities).toHaveBeenCalledWith(
+ ownersFilter(
+ `group:default/${givenParentGroup}`,
+ `group:default/${givenLeafGroup}`,
+ ),
+ );
+ });
+
+ it('given user entity should aggregate parent ownership and direct', async () => {
+ await whenHookIsCalledWith(givenUserEntity);
+ expect(catalogApiMock.getEntities).toHaveBeenCalledWith(
+ ownersFilter(
+ `group:default/${givenLeafGroup}`,
+ `user:default/${givenUser}`,
+ ),
+ );
+ });
+ });
+
+ describe('given direct relationsType', () => {
+ const whenHookIsCalledWith = async (_entity: Entity) => {
+ const hook = renderHook(
+ ({ entity }) => useGetEntities(entity, 'direct'),
+ {
+ initialProps: { entity: _entity },
+ },
+ );
+
+ await hook.waitForNextUpdate();
+ };
+
+ it('given group entity should return directly owned entities', async () => {
+ await whenHookIsCalledWith(givenLeafGroupEntity);
+ expect(catalogApiMock.getEntities).toHaveBeenCalledWith(
+ ownersFilter(`group:default/${givenLeafGroup}`),
+ );
+ });
+
+ it('given user entity should return directly owned entities', async () => {
+ await whenHookIsCalledWith(givenUserEntity);
+ expect(catalogApiMock.getEntities).toHaveBeenCalledWith(
+ ownersFilter(`user:default/${givenUser}`),
+ );
+ });
+ });
+});
diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts
index a59f534300..1ff01cd8e1 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts
+++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts
@@ -31,6 +31,7 @@ import limiterFactory from 'p-limit';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import qs from 'qs';
+import { EntityRelationAggregation as EntityRelationsAggregation } from './types';
const limiter = limiterFactory(10);
@@ -57,78 +58,109 @@ const getQueryParams = (
return qs.stringify({ filters }, { arrayFormat: 'repeat' });
};
-const getOwnersEntityRef = (owner: Entity): string[] => {
- let owners = [stringifyEntityRef(owner)];
- if (owner.kind === 'User') {
- const ownerGroups = getEntityRelations(owner, RELATION_MEMBER_OF, {
- kind: 'Group',
+const getMemberOfEntityRefs = (owner: Entity): string[] => {
+ const parentGroups = getEntityRelations(owner, RELATION_MEMBER_OF, {
+ kind: 'Group',
+ });
+
+ const ownerGroupsNames = parentGroups.map(({ kind, namespace, name }) =>
+ stringifyEntityRef({
+ kind,
+ namespace,
+ name,
+ }),
+ );
+
+ return [...ownerGroupsNames, stringifyEntityRef(owner)];
+};
+
+const isEntity = (entity: Entity | undefined): entity is Entity =>
+ entity !== undefined;
+
+const getChildOwnershipEntityRefs = async (
+ entity: Entity,
+ catalogApi: CatalogApi,
+): Promise => {
+ const childGroups = getEntityRelations(entity, RELATION_PARENT_OF, {
+ kind: 'Group',
+ });
+
+ const hasChildGroups = childGroups.length > 0;
+
+ if (hasChildGroups) {
+ const entityRefs = childGroups.map(r => stringifyEntityRef(r));
+ const childGroupResponse = await catalogApi.getEntitiesByRefs({
+ fields: ['kind', 'metadata.namespace', 'metadata.name'],
+ entityRefs,
});
- const ownerGroupsName = ownerGroups.map(ownerGroup =>
- stringifyEntityRef({
- kind: ownerGroup.kind,
- namespace: ownerGroup.namespace,
- name: ownerGroup.name,
- }),
- );
- owners = [...owners, ...ownerGroupsName];
+ const childGroupEntities = childGroupResponse.items.filter(isEntity);
+
+ return (
+ await Promise.all(
+ childGroupEntities.map(childGroupEntity =>
+ limiter(() =>
+ getChildOwnershipEntityRefs(childGroupEntity, catalogApi),
+ ),
+ ),
+ )
+ ).flatMap(aggregated => aggregated);
}
+
+ return [stringifyEntityRef(entity)];
+};
+
+const getOwners = async (
+ entity: Entity,
+ relations: EntityRelationsAggregation,
+ catalogApi: CatalogApi,
+): Promise => {
+ const isGroup = entity.kind === 'Group';
+ const isAggregated = relations === 'aggregated';
+ const isUserEntity = entity.kind === 'User';
+
+ const owners: string[] = [];
+
+ if (isAggregated && isGroup) {
+ const childEntityRefs = await getChildOwnershipEntityRefs(
+ entity,
+ catalogApi,
+ );
+ owners.push(stringifyEntityRef(entity));
+ owners.push.apply(owners, childEntityRefs);
+ } else if (isAggregated && isUserEntity) {
+ const parentEntityRefs = getMemberOfEntityRefs(entity);
+ owners.push.apply(owners, parentEntityRefs);
+ } else {
+ owners.push(stringifyEntityRef(entity));
+ }
+
return owners;
};
-const getAggregatedOwnersEntityRef = async (
- parentGroup: Entity,
+const getOwnedEntitiesByOwners = (
+ owners: string[],
+ kinds: string[],
catalogApi: CatalogApi,
-): Promise => {
- const requestedEntities: Entity[] = [];
- const outstandingEntities = new Map>();
- const processedEntities = new Set();
- requestedEntities.push(parentGroup);
- let currentEntity = parentGroup;
-
- while (requestedEntities.length > 0) {
- const childRelations = getEntityRelations(
- currentEntity,
- RELATION_PARENT_OF,
+) =>
+ catalogApi.getEntities({
+ filter: [
{
- kind: 'Group',
+ kind: kinds,
+ 'relations.ownedBy': owners,
},
- );
-
- await Promise.all(
- childRelations.map(childGroup =>
- limiter(async () => {
- const promise = catalogApi.getEntityByRef(childGroup);
- outstandingEntities.set(childGroup.name, promise);
- try {
- const processedEntity = await promise;
- if (processedEntity) {
- requestedEntities.push(processedEntity);
- }
- } finally {
- outstandingEntities.delete(childGroup.name);
- }
- }),
- ),
- );
- requestedEntities.shift();
- processedEntities.add(
- stringifyEntityRef({
- kind: currentEntity.kind,
- namespace: currentEntity.metadata.namespace,
- name: currentEntity.metadata.name,
- }),
- );
- // always set currentEntity to the first element of array requestedEntities
- currentEntity = requestedEntities[0];
- }
-
- return Array.from(processedEntities);
-};
+ ],
+ fields: [
+ 'kind',
+ 'metadata.name',
+ 'metadata.namespace',
+ 'spec.type',
+ 'relations',
+ ],
+ });
export function useGetEntities(
entity: Entity,
- relationsType: string,
- isGroup: boolean,
+ relations: EntityRelationsAggregation,
entityFilterKind?: string[],
entityLimit = 6,
): {
@@ -151,25 +183,13 @@ export function useGetEntities(
error,
value: componentsWithCounters,
} = useAsync(async () => {
- const owners =
- relationsType === 'aggregated' && isGroup
- ? await getAggregatedOwnersEntityRef(entity, catalogApi)
- : getOwnersEntityRef(entity);
- const ownedEntitiesList = await catalogApi.getEntities({
- filter: [
- {
- kind: kinds,
- 'relations.ownedBy': owners,
- },
- ],
- fields: [
- 'kind',
- 'metadata.name',
- 'metadata.namespace',
- 'spec.type',
- 'relations',
- ],
- });
+ const owners = await getOwners(entity, relations, catalogApi);
+
+ const ownedEntitiesList = await getOwnedEntitiesByOwners(
+ owners,
+ kinds,
+ catalogApi,
+ );
const counts = ownedEntitiesList.items.reduce(
(acc: EntityTypeProps[], ownedEntity) => {
@@ -204,7 +224,7 @@ export function useGetEntities(
kind: string;
queryParams: string;
}>;
- }, [catalogApi, entity, relationsType]);
+ }, [catalogApi, entity, relations]);
return {
componentsWithCounters,
diff --git a/plugins/org/src/setupTests.ts b/plugins/org/src/setupTests.ts
index 28a35d2b06..09cb508b22 100644
--- a/plugins/org/src/setupTests.ts
+++ b/plugins/org/src/setupTests.ts
@@ -14,3 +14,7 @@
* limitations under the License.
*/
import '@testing-library/jest-dom';
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx
index 3d6ffc5f8c..c74859ce20 100644
--- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx
+++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx
@@ -108,7 +108,7 @@ export const PlaylistEntitiesTable = ({
const actions = editAllowed
? [
{
- icon: DeleteIcon,
+ icon: () => ,
tooltip: `Remove from ${singularTitleLowerCase}`,
onClick: removeEntity,
},
diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx
index f892e8de7f..3a31dcaade 100644
--- a/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx
+++ b/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx
@@ -166,7 +166,7 @@ export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => {
},
{
label: `Delete ${singularTitle}`,
- icon: ,
+ icon: ,
disabled: !deleteAllowed,
onClick: () => setOpenDeleteDialog(true),
},
diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.test.ts
new file mode 100644
index 0000000000..fe04142a98
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.test.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ ScmIntegrations,
+ DefaultGithubCredentialsProvider,
+ GithubCredentialsProvider,
+} from '@backstage/integration';
+import { ConfigReader } from '@backstage/config';
+import { getVoidLogger } from '@backstage/backend-common';
+import { TemplateAction } from '@backstage/plugin-scaffolder-node';
+import { PassThrough } from 'stream';
+import { createGithubActionsDispatchAction } from './githubActionsDispatch';
+import yaml from 'yaml';
+import { examples } from './githubActionsDispatch.examples';
+
+const mockOctokit = {
+ rest: {
+ actions: {
+ createWorkflowDispatch: jest.fn(),
+ },
+ },
+};
+jest.mock('octokit', () => ({
+ Octokit: class {
+ constructor() {
+ return mockOctokit;
+ }
+ },
+}));
+
+describe('github:actions:dispatch', () => {
+ const config = new ConfigReader({
+ integrations: {
+ github: [
+ { host: 'github.com', token: 'tokenlols' },
+ { host: 'ghe.github.com' },
+ ],
+ },
+ });
+
+ const integrations = ScmIntegrations.fromConfig(config);
+ let githubCredentialsProvider: GithubCredentialsProvider;
+ let action: TemplateAction;
+
+ const mockContext = {
+ input: {
+ repoUrl: 'github.com?repo=repo&owner=owner',
+ workflowId: 'a-workflow-id',
+ branchOrTagName: 'main',
+ },
+ workspacePath: 'lol',
+ logger: getVoidLogger(),
+ logStream: new PassThrough(),
+ output: jest.fn(),
+ createTemporaryDirectory: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ githubCredentialsProvider =
+ DefaultGithubCredentialsProvider.fromIntegrations(integrations);
+ action = createGithubActionsDispatchAction({
+ integrations,
+ githubCredentialsProvider,
+ });
+ });
+
+ it('should call the githubApis for creating WorkflowDispatch without an input object', async () => {
+ mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({
+ data: {
+ foo: 'bar',
+ },
+ });
+
+ const repoUrl = 'github.com?repo=repo&owner=owner';
+ const workflowId = 'dispatch_workflow';
+ const branchOrTagName = 'main';
+ const ctx = Object.assign({}, mockContext, {
+ input: { repoUrl, workflowId, branchOrTagName },
+ });
+ await action.handler(ctx);
+
+ expect(
+ mockOctokit.rest.actions.createWorkflowDispatch,
+ ).toHaveBeenCalledWith({
+ owner: 'owner',
+ repo: 'repo',
+ workflow_id: workflowId,
+ ref: branchOrTagName,
+ });
+ });
+
+ it('should call the githubApis for creating WorkflowDispatch with an input object', async () => {
+ mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({
+ data: {
+ foo: 'bar',
+ },
+ });
+
+ const repoUrl = 'github.com?repo=repo&owner=owner';
+ const workflowId = 'dispatch_workflow';
+ const branchOrTagName = 'main';
+ const workflowInputs = yaml.parse(examples[1].example).steps[0].input
+ .workflowInputs;
+ const ctx = Object.assign({}, mockContext, {
+ input: { repoUrl, workflowId, branchOrTagName, workflowInputs },
+ });
+ await action.handler(ctx);
+ expect(
+ mockOctokit.rest.actions.createWorkflowDispatch,
+ ).toHaveBeenCalledWith({
+ owner: 'owner',
+ repo: 'repo',
+ workflow_id: workflowId,
+ ref: branchOrTagName,
+ inputs: workflowInputs,
+ });
+ });
+});
diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.ts
new file mode 100644
index 0000000000..33ad86dd89
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TemplateExample } from '@backstage/plugin-scaffolder-node';
+import yaml from 'yaml';
+
+export const examples: TemplateExample[] = [
+ {
+ description: 'GitHub Action Workflow Without Inputs.',
+ example: yaml.stringify({
+ steps: [
+ {
+ action: 'github:actions:dispatch',
+ name: 'Dispatch Github Action Workflow',
+ input: {
+ repoUrl: 'github.com?repo=repo&owner=owner',
+ workflowId: 'WORKFLOW_ID',
+ branchOrTagName: 'main',
+ },
+ },
+ ],
+ }),
+ },
+ {
+ description: 'GitHub Action Workflow With Inputs',
+ example: yaml.stringify({
+ steps: [
+ {
+ action: 'github:actions:dispatch',
+ name: 'Dispatch Github Action Workflow with inputs',
+ input: {
+ repoUrl: 'github.com?repo=repo&owner=owner',
+ workflowId: 'WORKFLOW_ID',
+ branchOrTagName: 'main',
+ workflowInputs: {
+ input1: 'value1',
+ input2: 'value2',
+ },
+ },
+ },
+ ],
+ }),
+ },
+ {
+ description: 'GitHub Action Workflow With Custom Token',
+ example: yaml.stringify({
+ steps: [
+ {
+ action: 'github:actions:dispatch',
+ name: 'Dispatch GitHub Action Workflow (custom token)',
+ input: {
+ repoUrl: 'github.com?repo=reponame&owner=owner',
+ workflowId: 'WORKFLOW_ID',
+ branchOrTagName: 'release-1.0',
+ token: '${{ secrets.MY_CUSTOM_TOKEN }}',
+ },
+ },
+ ],
+ }),
+ },
+];
diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts
index cd1593ee9f..6623ee1f27 100644
--- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts
+++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts
@@ -23,6 +23,7 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { Octokit } from 'octokit';
import { parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
+import { examples } from './githubActionsDispatch.examples';
/**
* Creates a new action that dispatches a GitHub Action workflow for a given branch or tag.
@@ -44,6 +45,7 @@ export function createGithubActionsDispatchAction(options: {
id: 'github:actions:dispatch',
description:
'Dispatches a GitHub Action workflow for a given branch or tag',
+ examples,
schema: {
input: {
type: 'object',
diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx
index add62b39df..f6fe08e866 100644
--- a/plugins/search-react/src/context/SearchContext.tsx
+++ b/plugins/search-react/src/context/SearchContext.tsx
@@ -102,7 +102,7 @@ export const useSearchContextCheck = () => {
* The initial state of `SearchContextProvider`.
*
*/
-const searchInitialState: SearchContextState = {
+const defaultInitialSearchState: SearchContextState = {
term: '',
types: [],
filters: {},
@@ -111,7 +111,7 @@ const searchInitialState: SearchContextState = {
};
const useSearchContextValue = (
- initialValue: SearchContextState = searchInitialState,
+ initialValue: SearchContextState = defaultInitialSearchState,
) => {
const searchApi = useApi(searchApiRef);
@@ -245,12 +245,16 @@ export const SearchContextProvider = (props: SearchContextProviderProps) => {
const configApi = useApi(configApiRef);
+ const propsInitialSearchState = initialState ?? {};
+
+ const configInitialSearchState = configApi.has('search.query.pageLimit')
+ ? { pageLimit: configApi.getNumber('search.query.pageLimit') }
+ : {};
+
const searchContextInitialState = {
- ...searchInitialState,
- ...(initialState || {}),
- pageLimit:
- configApi.getOptionalNumber('search.query.pageLimit') ||
- initialState?.pageLimit,
+ ...defaultInitialSearchState,
+ ...propsInitialSearchState,
+ ...configInitialSearchState,
};
return hasParentContext && inheritParentContextIfAvailable ? (
diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx
index df39c2ce55..7e2dabc3d4 100644
--- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx
+++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx
@@ -45,7 +45,13 @@ const RadarDescription = (props: Props): JSX.Element => {
const { open, onClose, title, description, timeline, url, links } = props;
return (
-