Merge branch 'master' into sb9

This commit is contained in:
Charles de Dreuille
2025-09-15 11:27:15 +01:00
42 changed files with 1135 additions and 74 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
When possible, requests a more limited set of results from the Gitlab projects API, which can reduce the amount of network traffic required to sync with Gitlab.
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/cli': patch
---
Modify the `backstage.json` also for custom patterns if it extends the default pattern.
Examples:
- `@backstage/*` (default pattern)
- `@{backstage,backstage-community}/*`
- `@{extra1,backstage,extra2}/*`
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-catalog': patch
---
Catalog table columns support i18n
+1 -1
View File
@@ -1,5 +1,5 @@
{
"mode": "pre",
"mode": "exit",
"tag": "next",
"initialVersions": {
"example-app": "0.2.112",
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Add block creations field in github branch protection scaffolder actions
+35
View File
@@ -0,0 +1,35 @@
---
'@backstage/plugin-scaffolder-backend-module-github': minor
---
Adding a new scaffolder action `github:issues:create` following the reference of `github:issues:label` with `dryRun` testing possibility
It can be used like this
```
steps:
- id: create-simple-issue
name: Create Simple Issue
action: github:issues:create
input:
repoUrl: ${{ parameters.repoUrl }}
title: "[${{ parameters.projectName }}] Simple Bug Report"
body: |
## Bug Description
This is a simple bug report created by the scaffolder template.
### Steps to Reproduce
1. Run the application
2. Navigate to the main page
3. Click on the problematic button
### Expected Behavior
The button should work correctly.
### Actual Behavior
The button does not respond to clicks.
output:
links:
- title: Simple Issue
url: ${{ steps['create-simple-issue'].output.issueUrl }}
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
feat: support no en languages
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Prevent the MultiEntityPicker from removing options present in form state when new options are selected
+16 -22
View File
@@ -217,18 +217,18 @@ export const cellPropDefs: Record<string, PropDef> = {
};
export const tablePaginationPropDefs: Record<string, PropDef> = {
pageIndex: {
offset: {
type: 'number',
description: 'The current page index.',
description: 'The current offset (starting index) for pagination.',
},
pageSize: {
type: 'number',
description: 'The number of items per page.',
},
setPageIndex: {
setOffset: {
type: 'enum',
values: ['(pageIndex: number) => void'],
description: 'Handler that is called when the page index changes.',
values: ['(offset: number) => void'],
description: 'Handler that is called when the offset changes.',
},
setPageSize: {
type: 'enum',
@@ -277,10 +277,7 @@ export const tableUsageSnippet = `import { Cell, ..., TableHeader, TablePaginati
</Table>
<TablePagination />`;
export const tableBasicSnippet = `import { Table, TablePagination } from '@backstage/ui';
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
export const tableBasicSnippet = `import { Table, TableHeader, Column, TableBody, Row, Cell, CellProfile, TablePagination, useTable } from '@backstage/ui';
const data = [
{
@@ -293,10 +290,13 @@ const data = [
// ... more data
];
const newData = data4.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
// Uncontrolled pagination (easiest)
const { data: paginatedData, paginationProps } = useTable({
data,
pagination: {
defaultPageSize: 5,
},
});
<Table>
<TableHeader>
@@ -306,9 +306,9 @@ const newData = data4.slice(
<Column>Albums</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
{paginatedData?.map(item => (
<Row key={item.name}>
<CellProfileBUI
<CellProfile
name={item.name}
src={item.image}
href={item.website}
@@ -320,10 +320,4 @@ const newData = data4.slice(
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data4.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>`;
<TablePagination {...paginationProps} />`;
@@ -185,7 +185,7 @@ export const examplePlugin = createFrontendPlugin({
## Plugin specific extensions
There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](https://backstage.io/docs/features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page.
There are many different plugins that you can extend with additional functionality through extensions. One such plugin is [the catalog plugin](../../features/software-catalog/), one of the core features of Backstage. It lets you catalog the software in your organization, where each item in the catalog has its own page that can be populated with tools and information relating to that catalog entity. In this example we will explore how our plugin can provide such a tool to display on an entity page.
```tsx title="in src/plugin.ts - An example entity content extension"
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
@@ -772,6 +772,7 @@ describe('bump', () => {
res(
ctx.status(200),
ctx.json({
releaseVersion: '1.0.0',
packages: [],
}),
),
@@ -797,7 +798,7 @@ describe('bump', () => {
'bumping @backstage-extra/custom in b to ^1.1.0',
'bumping @backstage-extra/custom-two in b to ^2.0.0',
'bumping @backstage/theme in b to ^2.0.0',
'Skipping backstage.json update as custom pattern is used',
'Your project is now at version 1.0.0, which has been written to backstage.json',
'Running yarn install to install new versions',
'Checking for moved packages to the @backstage-community namespace...',
'⚠️ The following packages may have breaking changes:',
@@ -18,6 +18,7 @@ maybeBootstrapProxy();
import fs from 'fs-extra';
import chalk from 'chalk';
import { minimatch } from 'minimatch';
import semver from 'semver';
import { OptionValues } from 'commander';
import yaml from 'yaml';
@@ -78,6 +79,14 @@ type PkgVersionInfo = {
location: string;
};
function extendsDefaultPattern(pattern: string): boolean {
if (!pattern.endsWith('/*')) {
return false;
}
return minimatch('@backstage/', pattern.slice(0, -1));
}
export default async (opts: OptionValues) => {
const lockfilePath = paths.resolveTargetRoot('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
@@ -245,8 +254,8 @@ export default async (opts: OptionValues) => {
console.log();
// Do not update backstage.json when upgrade patterns are used.
if (pattern === DEFAULT_PATTERN_GLOB) {
// Do not update backstage.json when default pattern is not covered
if (extendsDefaultPattern(pattern)) {
await bumpBackstageJsonVersion(
releaseManifest.releaseVersion,
hasYarnPlugin,
@@ -144,11 +144,26 @@ describe('AppLanguageSelector', () => {
AppLanguageSelector.createWithStorage({
availableLanguages: ['de'],
}),
).toThrow("Supported languages must include 'en'");
).toThrow(
"Initial language must be one of the supported languages, got 'en'",
);
const selector = AppLanguageSelector.createWithStorage(baseOptions);
expect(() => selector.setLanguage('sv')).toThrow(
"Failed to change language to 'sv', available languages are 'en', 'de'",
);
});
it('should support no en languages', () => {
const selector = AppLanguageSelector.createWithStorage({
availableLanguages: ['de'],
defaultLanguage: 'de',
});
expect(selector.getLanguage()).toEqual({ language: 'de' });
expect(() => selector.setLanguage('en')).toThrow(
"Failed to change language to 'en', available languages are 'de'",
);
});
});
@@ -44,11 +44,9 @@ export class AppLanguageSelector implements AppLanguageApi {
)}'`,
);
}
if (!languages.includes(DEFAULT_LANGUAGE)) {
throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`);
}
const initialLanguage = options?.defaultLanguage ?? DEFAULT_LANGUAGE;
if (!languages.includes(initialLanguage)) {
throw new Error(
`Initial language must be one of the supported languages, got '${initialLanguage}'`,
@@ -59,6 +59,7 @@ function setupFakeServer(
page: number;
include_subgroups: boolean;
archived: boolean;
simple?: boolean;
}) => {
data: GitLabProject[];
nextPage?: number;
@@ -78,10 +79,12 @@ function setupFakeServer(
const page = req.url.searchParams.get('page');
const include_subgroups = req.url.searchParams.get('include_subgroups');
const archived = req.url.searchParams.get('archived');
const simple = req.url.searchParams.get('simple');
const response = listProjectsCallback({
page: parseInt(page!, 10),
include_subgroups: include_subgroups === 'true',
archived: archived === 'true',
simple: simple === 'true',
});
// Filter the fake results based on the `last_activity_after` parameter
@@ -471,6 +474,110 @@ describe('GitlabDiscoveryProcessor', () => {
});
expect(result2).toHaveLength(1);
});
it('sets simple=true when skipForkedRepos is false', async () => {
const processor = getProcessor({
options: { skipForkedRepos: false },
});
setupFakeServer(
PROJECTS_URL,
_ => {
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
path_with_namespace: '1',
},
],
};
},
request => {
// Verify that simple=true is set in the request
expect(request.url.searchParams.get('simple')).toBe('true');
},
);
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
expect(result).toHaveLength(1);
});
it('does not set simple when skipForkedRepos is true', async () => {
const processor = getProcessor({
options: { skipForkedRepos: true },
});
setupFakeServer(
PROJECTS_URL,
_ => {
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
path_with_namespace: '1',
// Include forked_from_project to test fork filtering
forked_from_project: {
id: 100,
name: 'original-project',
},
},
],
};
},
request => {
// Verify that simple parameter is not set
expect(request.url.searchParams.get('simple')).toBeNull();
},
);
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
// Should be empty because forked repo is skipped
expect(result).toHaveLength(0);
});
it('sets default parameters correctly (archived=false, simple=true)', async () => {
const processor = getProcessor(); // Uses defaults: skipForkedRepos=false, includeArchivedRepos=false
setupFakeServer(
PROJECTS_URL,
_ => {
return {
data: [
{
id: 1,
archived: false,
default_branch: 'main',
last_activity_at: '2021-08-05T11:03:05.774Z',
web_url: 'https://gitlab.fake/1',
path_with_namespace: '1',
},
],
};
},
request => {
// Verify default parameters: archived=false and simple=true
expect(request.url.searchParams.get('archived')).toBe('false');
expect(request.url.searchParams.get('simple')).toBe('true');
},
);
const result: any[] = [];
await processor.readLocation(PROJECT_LOCATION, false, e => {
result.push(e);
});
expect(result).toHaveLength(1);
});
});
describe('handles failure', () => {
@@ -115,6 +115,11 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor {
// that the options doesn't include the key so that the API doesn't receive an empty query parameter.
...(lastActivity && { last_activity_after: lastActivity }),
...(!this.includeArchivedRepos && { archived: false }),
// Only use simple=true when we don't need to skip forked repos.
// The simple=true parameter reduces response size by returning fewer fields,
// but it excludes the 'forked_from_project' field which is required for fork detection.
// Therefore, we can only optimize with simple=true when skipForkedRepos is false.
...(!this.skipForkedRepos && { simple: true }),
};
const projects = paginated(options => client.listProjects(options), opts);
@@ -187,6 +187,55 @@ describe('GitLabClient', () => {
expect(allProjects).toHaveLength(mock.all_projects_response.length);
});
it('should pass simple parameter to API when provided', async () => {
const client = new GitLabClient({
config: readGitLabIntegrationConfig(
new ConfigReader(mock.config_self_managed),
),
logger: mockServices.logger.mock(),
});
// Mock the pagedRequest method to verify parameters
const mockPagedRequest = jest.fn().mockResolvedValue({
items: [],
nextPage: undefined,
});
(client as any).pagedRequest = mockPagedRequest;
await client.listProjects({ simple: true });
expect(mockPagedRequest).toHaveBeenCalledWith('/projects', {
simple: true,
});
});
it('should pass simple parameter to group projects API when provided', async () => {
const client = new GitLabClient({
config: readGitLabIntegrationConfig(
new ConfigReader(mock.config_self_managed),
),
logger: mockServices.logger.mock(),
});
// Mock the pagedRequest method to verify parameters
const mockPagedRequest = jest.fn().mockResolvedValue({
items: [],
nextPage: undefined,
});
(client as any).pagedRequest = mockPagedRequest;
await client.listProjects({ group: 'test-group', simple: true });
expect(mockPagedRequest).toHaveBeenCalledWith(
'/groups/test-group/projects',
{
group: 'test-group',
simple: true,
include_subgroups: true,
},
);
});
});
describe('listUsers', () => {
@@ -43,6 +43,7 @@ interface ListProjectOptions extends CommonListOptions {
group?: string;
membership?: boolean;
topics?: string;
simple?: boolean;
}
interface UserListOptions extends CommonListOptions {
@@ -147,6 +147,21 @@ describe('GitlabDiscoveryEntityProvider - refresh', () => {
'GitlabDiscoveryEntityProvider:test-id',
);
// Mock the GitLabClient listProjects method to verify default parameters
const originalListProjects = (provider as any).gitLabClient.listProjects;
const mockListProjects = jest.fn().mockImplementation(async options => {
// Verify default parameters: archived=false and simple=true (since skipForkedRepos=false by default)
expect(options).toMatchObject({
group: 'group1',
per_page: 50,
archived: false,
simple: true, // Should be set since skipForkedRepos defaults to false
});
// Call the original method to maintain test behavior
return originalListProjects.call((provider as any).gitLabClient, options);
});
(provider as any).gitLabClient.listProjects = mockListProjects;
await provider.connect(entityProviderConnection);
const taskDef = schedule.getTasks()[0];
@@ -675,3 +690,115 @@ describe('GitlabDiscoveryEntityProvider - events', () => {
expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1);
});
});
describe('GitlabDiscoveryEntityProvider - simple parameter', () => {
it('should pass simple=true when skipForkedRepos is false', async () => {
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'example.com',
apiBaseUrl: 'https://example.com/api/v4',
token: 'test-token',
},
],
},
catalog: {
providers: {
gitlab: {
'test-id': {
host: 'example.com',
group: 'test-group',
skipForkedRepos: false,
},
},
},
},
});
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
})[0];
// Mock the GitLabClient listProjects method to verify parameters
const mockListProjects = jest.fn().mockResolvedValue({
items: [],
nextPage: undefined,
});
(provider as any).gitLabClient.listProjects = mockListProjects;
await provider.connect(entityProviderConnection);
await provider.refresh(logger);
expect(mockListProjects).toHaveBeenCalledWith({
group: 'test-group',
page: undefined,
per_page: 50,
archived: false,
simple: true, // Should be set when skipForkedRepos is false
});
});
it('should not pass simple when skipForkedRepos is true', async () => {
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'example.com',
apiBaseUrl: 'https://example.com/api/v4',
token: 'test-token',
},
],
},
catalog: {
providers: {
gitlab: {
'test-id': {
host: 'example.com',
group: 'test-group',
skipForkedRepos: true,
},
},
},
},
});
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
schedule,
})[0];
// Mock the GitLabClient listProjects method to verify parameters
const mockListProjects = jest.fn().mockResolvedValue({
items: [],
nextPage: undefined,
});
(provider as any).gitLabClient.listProjects = mockListProjects;
await provider.connect(entityProviderConnection);
await provider.refresh(logger);
expect(mockListProjects).toHaveBeenCalledWith({
group: 'test-group',
page: undefined,
per_page: 50,
archived: false,
// simple should not be present when skipForkedRepos is true
});
});
});
@@ -326,6 +326,11 @@ export class GitlabDiscoveryEntityProvider implements EntityProvider {
...(!this.config.includeArchivedRepos && { archived: false }),
...(this.config.membership && { membership: true }),
...(this.config.topics && { topics: this.config.topics }),
// Only use simple=true when we don't need to skip forked repos.
// The simple=true parameter reduces response size by returning fewer fields,
// but it excludes the 'forked_from_project' field which is required for fork detection.
// Therefore, we can only optimize with simple=true when skipForkedRepos is false.
...(!this.config.skipForkedRepos && { simple: true }),
},
);
@@ -6,8 +6,9 @@ info:
The API surface consists of a few distinct groups of functionality. Each has a
dedicated section below.
> **Note:** This page only describes some of the most commonly used parts of the
> API, and is a work in progress.
:::note Note
This page only describes some of the most commonly used parts of the API, and is a work in progress.
:::
All of the URL paths in this article are assumed to be on top of some base URL
pointing at your catalog installation. For example, if the path given in a
+46
View File
@@ -86,6 +86,18 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'userListPicker.personalFilter.title': 'Personal';
readonly 'userListPicker.personalFilter.ownedLabel': 'Owned';
readonly 'userListPicker.personalFilter.starredLabel': 'Starred';
readonly 'entityTableColumnTitle.name': 'Name';
readonly 'entityTableColumnTitle.type': 'Type';
readonly 'entityTableColumnTitle.label': 'Label';
readonly 'entityTableColumnTitle.title': 'Title';
readonly 'entityTableColumnTitle.description': 'Description';
readonly 'entityTableColumnTitle.domain': 'Domain';
readonly 'entityTableColumnTitle.namespace': 'Namespace';
readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle';
readonly 'entityTableColumnTitle.owner': 'Owner';
readonly 'entityTableColumnTitle.system': 'System';
readonly 'entityTableColumnTitle.targets': 'Targets';
readonly 'entityTableColumnTitle.tags': 'Tags';
}
>;
@@ -516,6 +528,40 @@ export type EntityPredicateValue =
$contains: EntityPredicateExpression;
};
// @alpha (undocumented)
export const EntityTableColumnTitle: ({
translationKey,
}: EntityTableColumnTitleProps) =>
| 'Title'
| 'Domain'
| 'System'
| 'Lifecycle'
| 'Namespace'
| 'Owner'
| 'Tags'
| 'Type'
| 'Name'
| 'Description'
| 'Targets'
| 'Label';
// @alpha (undocumented)
export type EntityTableColumnTitleProps = {
translationKey:
| 'name'
| 'system'
| 'owner'
| 'type'
| 'lifecycle'
| 'namespace'
| 'description'
| 'tags'
| 'targets'
| 'title'
| 'label'
| 'domain';
};
// @alpha
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
+2 -2
View File
@@ -171,7 +171,7 @@ export const columnFactories: Readonly<{
defaultKind?: string;
}): TableColumn<T>;
createEntityRelationColumn<T extends Entity>(options: {
title: string;
title: string | JSX.Element;
relation: string;
defaultKind?: string;
filter?: {
@@ -575,7 +575,7 @@ export const EntityTable: {
defaultKind?: string;
}): TableColumn<T>;
createEntityRelationColumn<T extends Entity>(options: {
title: string;
title: string | JSX.Element;
relation: string;
defaultKind?: string;
filter?: {
+1
View File
@@ -20,3 +20,4 @@ export * from './predicates';
export { catalogReactTranslationRef } from '../translation';
export { isOwnerOf } from '../utils/isOwnerOf';
export { useEntityPermission } from '../hooks/useEntityPermission';
export * from '../components/EntityTable/TitleColumn';
@@ -0,0 +1,25 @@
/*
* Copyright 2025 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 { screen } from '@testing-library/react';
import { EntityTableColumnTitle } from './TitleColumn';
import { renderInTestApp } from '@backstage/test-utils';
describe('<EntityTableColumnTitle />', () => {
it('renders the translated title for the given key', async () => {
await renderInTestApp(<EntityTableColumnTitle translationKey="name" />);
expect(screen.getByText('Name')).toBeInTheDocument();
});
});
@@ -0,0 +1,47 @@
/*
* Copyright 2025 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 { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogReactTranslationRef } from '../../translation';
/**
* @alpha
*/
export type EntityTableColumnTitleProps = {
translationKey:
| 'name'
| 'system'
| 'owner'
| 'type'
| 'lifecycle'
| 'namespace'
| 'description'
| 'tags'
| 'targets'
| 'title'
| 'label'
| 'domain';
};
/**
* @alpha
*/
export const EntityTableColumnTitle = ({
translationKey,
}: EntityTableColumnTitleProps) => {
const { t } = useTranslationRef(catalogReactTranslationRef);
return t(`entityTableColumnTitle.${translationKey}`);
};
@@ -27,8 +27,7 @@ import {
EntityRefLinks,
humanizeEntityRef,
} from '../EntityRefLink';
// TODO: column title support i18n
import { EntityTableColumnTitle } from './TitleColumn';
/** @public */
export const columnFactories = Object.freeze({
@@ -46,7 +45,7 @@ export const columnFactories = Object.freeze({
}
return {
title: 'Name',
title: <EntityTableColumnTitle translationKey="name" />,
highlight: true,
customFilterAndSearch(filter, entity) {
// TODO: We could implement this more efficiently, like searching over
@@ -72,7 +71,7 @@ export const columnFactories = Object.freeze({
};
},
createEntityRelationColumn<T extends Entity>(options: {
title: string;
title: string | JSX.Element;
relation: string;
defaultKind?: string;
filter?: { kind: string };
@@ -109,14 +108,14 @@ export const columnFactories = Object.freeze({
},
createOwnerColumn<T extends Entity>(): TableColumn<T> {
return this.createEntityRelationColumn({
title: 'Owner',
title: <EntityTableColumnTitle translationKey="type" />,
relation: RELATION_OWNED_BY,
defaultKind: 'group',
});
},
createDomainColumn<T extends Entity>(): TableColumn<T> {
return this.createEntityRelationColumn({
title: 'Domain',
title: <EntityTableColumnTitle translationKey="domain" />,
relation: RELATION_PART_OF,
defaultKind: 'domain',
filter: {
@@ -126,7 +125,7 @@ export const columnFactories = Object.freeze({
},
createSystemColumn<T extends Entity>(): TableColumn<T> {
return this.createEntityRelationColumn({
title: 'System',
title: <EntityTableColumnTitle translationKey="system" />,
relation: RELATION_PART_OF,
defaultKind: 'system',
filter: {
@@ -136,7 +135,7 @@ export const columnFactories = Object.freeze({
},
createMetadataDescriptionColumn<T extends Entity>(): TableColumn<T> {
return {
title: 'Description',
title: <EntityTableColumnTitle translationKey="description" />,
field: 'metadata.description',
render: entity => (
<OverflowTooltip
@@ -149,13 +148,13 @@ export const columnFactories = Object.freeze({
},
createSpecLifecycleColumn<T extends Entity>(): TableColumn<T> {
return {
title: 'Lifecycle',
title: <EntityTableColumnTitle translationKey="lifecycle" />,
field: 'spec.lifecycle',
};
},
createSpecTypeColumn<T extends Entity>(): TableColumn<T> {
return {
title: 'Type',
title: <EntityTableColumnTitle translationKey="type" />,
field: 'spec.type',
};
},
+14
View File
@@ -124,5 +124,19 @@ export const catalogReactTranslationRef = createTranslationRef({
},
orgFilterAllLabel: 'All',
},
entityTableColumnTitle: {
name: 'Name',
system: 'System',
owner: 'Owner',
type: 'Type',
lifecycle: 'Lifecycle',
namespace: 'Namespace',
description: 'Description',
tags: 'Tags',
targets: 'Targets',
title: 'Title',
label: 'Label',
domain: 'Domain',
},
},
});
@@ -23,6 +23,7 @@ import { CatalogTableRow } from './types';
import { OverflowTooltip, TableColumn } from '@backstage/core-components';
import { Entity } from '@backstage/catalog-model';
import { JsonArray } from '@backstage/types';
import { EntityTableColumnTitle } from '@backstage/plugin-catalog-react/alpha';
// The columnFactories symbol is not directly exported, but through the
// CatalogTable.columns field.
@@ -41,7 +42,7 @@ export const columnFactories = Object.freeze({
}
return {
title: 'Name',
title: <EntityTableColumnTitle translationKey="name" />,
field: 'resolved.entityRef',
highlight: true,
customSort({ entity: entity1 }, { entity: entity2 }) {
@@ -59,7 +60,7 @@ export const columnFactories = Object.freeze({
},
createSystemColumn(): TableColumn<CatalogTableRow> {
return {
title: 'System',
title: <EntityTableColumnTitle translationKey="system" />,
field: 'resolved.partOfSystemRelationTitle',
customFilterAndSearch: (query, row) => {
if (!row.resolved.partOfSystemRelations) {
@@ -83,7 +84,7 @@ export const columnFactories = Object.freeze({
},
createOwnerColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Owner',
title: <EntityTableColumnTitle translationKey="owner" />,
field: 'resolved.ownedByRelationsTitle',
render: ({ resolved }) => (
<EntityRefLinks
@@ -95,7 +96,7 @@ export const columnFactories = Object.freeze({
},
createSpecTargetsColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Targets',
title: <EntityTableColumnTitle translationKey="targets" />,
field: 'entity.spec.targets',
customFilterAndSearch: (query, row) => {
let targets: JsonArray = [];
@@ -132,7 +133,7 @@ export const columnFactories = Object.freeze({
} = { hidden: false },
): TableColumn<CatalogTableRow> {
return {
title: 'Type',
title: <EntityTableColumnTitle translationKey="type" />,
field: 'entity.spec.type',
hidden: options.hidden,
width: 'auto',
@@ -140,13 +141,13 @@ export const columnFactories = Object.freeze({
},
createSpecLifecycleColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Lifecycle',
title: <EntityTableColumnTitle translationKey="lifecycle" />,
field: 'entity.spec.lifecycle',
};
},
createMetadataDescriptionColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Description',
title: <EntityTableColumnTitle translationKey="description" />,
field: 'entity.metadata.description',
render: ({ entity }) => (
<OverflowTooltip
@@ -159,7 +160,7 @@ export const columnFactories = Object.freeze({
},
createTagsColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Tags',
title: <EntityTableColumnTitle translationKey="tags" />,
field: 'entity.metadata.tags',
cellStyle: {
padding: '0px 16px 0px 20px',
@@ -185,7 +186,7 @@ export const columnFactories = Object.freeze({
hidden?: boolean;
}): TableColumn<CatalogTableRow> {
return {
title: 'Title',
title: <EntityTableColumnTitle translationKey="title" />,
field: 'entity.metadata.title',
hidden: options?.hidden,
searchable: true,
@@ -202,7 +203,9 @@ export const columnFactories = Object.freeze({
}
return {
title: options?.title || 'Label',
title: options?.title || (
<EntityTableColumnTitle translationKey="label" />
),
field: 'entity.metadata.labels',
cellStyle: {
padding: '0px 16px 0px 20px',
@@ -235,7 +238,7 @@ export const columnFactories = Object.freeze({
},
createNamespaceColumn(): TableColumn<CatalogTableRow> {
return {
title: 'Namespace',
title: <EntityTableColumnTitle translationKey="namespace" />,
field: 'entity.metadata.namespace',
width: 'auto',
};
@@ -81,6 +81,7 @@ export function createGithubBranchProtectionAction(options: {
requireLastPushApproval?: boolean | undefined;
requiredCommitSigning?: boolean | undefined;
requiredLinearHistory?: boolean | undefined;
blockCreations?: boolean | undefined;
token?: string | undefined;
},
{
@@ -136,6 +137,27 @@ export function createGithubEnvironmentAction(options: {
'v2'
>;
// @public
export function createGithubIssuesCreateAction(options: {
integrations: ScmIntegrationRegistry;
githubCredentialsProvider?: GithubCredentialsProvider;
}): TemplateAction<
{
repoUrl: string;
title: string;
body?: string | undefined;
assignees?: string[] | undefined;
milestone?: string | number | undefined;
labels?: string[] | undefined;
token?: string | undefined;
},
{
issueUrl: string;
issueNumber: number;
},
'v2'
>;
// @public
export function createGithubIssuesLabelAction(options: {
integrations: ScmIntegrationRegistry;
@@ -44,6 +44,7 @@ type BranchProtectionOptions = {
dismissStaleReviews?: boolean;
requiredCommitSigning?: boolean;
requiredLinearHistory?: boolean;
blockCreations?: boolean;
};
export const enableBranchProtectionOnDefaultRepoBranch = async ({
@@ -64,6 +65,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({
dismissStaleReviews = false,
requiredCommitSigning = false,
requiredLinearHistory = false,
blockCreations = false,
}: BranchProtectionOptions): Promise<void> => {
const tryOnce = async () => {
try {
@@ -96,6 +98,7 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({
},
required_conversation_resolution: requiredConversationResolution,
required_linear_history: requiredLinearHistory,
block_creations: blockCreations,
});
if (requiredCommitSigning) {
@@ -99,6 +99,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: false,
required_linear_history: false,
block_creations: false,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -132,6 +133,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: false,
required_linear_history: false,
block_creations: false,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -165,6 +167,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: true,
required_linear_history: false,
block_creations: false,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -202,6 +205,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: true,
required_linear_history: true,
block_creations: false,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -105,6 +105,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: false,
required_linear_history: false,
block_creations: false,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -142,6 +143,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: false,
required_linear_history: false,
block_creations: false,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -178,6 +180,7 @@ describe('github:branch-protection:create', () => {
requireLastPushApproval: true,
requiredCommitSigning: true,
requiredLinearHistory: true,
blockCreations: true,
},
});
@@ -211,6 +214,7 @@ describe('github:branch-protection:create', () => {
},
required_conversation_resolution: true,
required_linear_history: true,
block_creations: true,
});
expect(
mockOctokit.rest.repos.createCommitSignatureProtection,
@@ -57,6 +57,7 @@ export function createGithubBranchProtectionAction(options: {
requireLastPushApproval: inputProps.requireLastPushApproval,
requiredCommitSigning: inputProps.requiredCommitSigning,
requiredLinearHistory: inputProps.requiredLinearHistory,
blockCreations: inputProps.blockCreations,
token: inputProps.token,
},
},
@@ -76,6 +77,7 @@ export function createGithubBranchProtectionAction(options: {
requireLastPushApproval = false,
requiredCommitSigning = false,
requiredLinearHistory = false,
blockCreations,
token: providedToken,
} = ctx.input;
@@ -129,6 +131,7 @@ export function createGithubBranchProtectionAction(options: {
dismissStaleReviews,
requiredCommitSigning,
requiredLinearHistory,
blockCreations,
});
},
});
@@ -0,0 +1,73 @@
/*
* 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 * as yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Create a simple issue',
example: yaml.stringify({
steps: [
{
action: 'github:issues:create',
name: 'Create issue',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
title: 'Bug report',
body: 'Found a bug that needs to be fixed',
},
},
],
}),
},
{
description: 'Create an issue with labels and assignees',
example: yaml.stringify({
steps: [
{
action: 'github:issues:create',
name: 'Create issue with metadata',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
title: 'Feature request',
body: 'This is a new feature request',
labels: ['enhancement', 'needs-review'],
assignees: ['octocat'],
milestone: 1,
},
},
],
}),
},
{
description: 'Create an issue with specific token',
example: yaml.stringify({
steps: [
{
action: 'github:issues:create',
name: 'Create issue with token',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
title: 'Documentation update',
body: 'Update the documentation for the new API',
labels: ['documentation'],
token: 'gph_YourGitHubToken',
},
},
],
}),
},
];
@@ -0,0 +1,160 @@
/*
* Copyright 2021 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 { createGithubIssuesCreateAction } from './githubIssuesCreate';
import {
ScmIntegrations,
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
} from '@backstage/integration';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { ConfigReader } from '@backstage/config';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { getOctokitOptions } from '../util';
jest.mock('../util', () => {
return {
getOctokitOptions: jest.fn(),
};
});
import { Octokit } from 'octokit';
const octokitMock = Octokit as unknown as jest.Mock;
const mockOctokit = {
rest: {
issues: {
create: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: jest.fn(),
}));
describe('github:issues:create', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const getOctokitOptionsMock = getOctokitOptions as jest.Mock;
const integrations = ScmIntegrations.fromConfig(config);
let githubCredentialsProvider: GithubCredentialsProvider;
let action: TemplateAction<any, any, any>;
const mockContext = createMockActionContext({
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
title: 'Test Issue',
body: 'This is a test issue',
labels: ['bug', 'test'],
assignees: ['octocat'],
milestone: 1,
},
});
beforeEach(() => {
jest.resetAllMocks();
octokitMock.mockImplementation(() => mockOctokit);
mockOctokit.rest.issues.create.mockResolvedValue({
data: {
html_url: 'https://github.com/owner/repo/issues/1',
number: 1,
},
});
githubCredentialsProvider =
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
action = createGithubIssuesCreateAction({
integrations,
githubCredentialsProvider,
});
});
it('should pass context logger to Octokit client', async () => {
await action.handler(mockContext);
expect(octokitMock).toHaveBeenCalledWith(
expect.objectContaining({ log: mockContext.logger }),
);
});
it('should call the githubApi for creating issue', async () => {
await action.handler(mockContext);
expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Test Issue',
body: 'This is a test issue',
labels: ['bug', 'test'],
assignees: ['octocat'],
milestone: 1,
});
expect(getOctokitOptionsMock.mock.calls[0][0].token).toBeUndefined();
});
it('should call the githubApi for creating issue with token', async () => {
await action.handler({
...mockContext,
input: { ...mockContext.input, token: 'gph_YourGitHubToken' },
});
expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Test Issue',
body: 'This is a test issue',
labels: ['bug', 'test'],
assignees: ['octocat'],
milestone: 1,
});
expect(getOctokitOptionsMock.mock.calls[0][0].token).toEqual(
'gph_YourGitHubToken',
);
});
it('should output issue URL and number', async () => {
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'issueUrl',
'https://github.com/owner/repo/issues/1',
);
expect(mockContext.output).toHaveBeenCalledWith('issueNumber', 1);
});
it('should create issue with minimal input', async () => {
const minimalContext = createMockActionContext({
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
title: 'Simple Issue',
},
});
await action.handler(minimalContext);
expect(mockOctokit.rest.issues.create).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Simple Issue',
body: undefined,
labels: undefined,
assignees: undefined,
milestone: undefined,
});
});
});
@@ -0,0 +1,182 @@
/*
* Copyright 2021 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 {
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import {
createTemplateAction,
parseRepoUrl,
} from '@backstage/plugin-scaffolder-node';
import { assertError, InputError } from '@backstage/errors';
import { Octokit } from 'octokit';
import { getOctokitOptions } from '../util';
import { examples } from './githubIssuesCreate.examples';
/**
* Creates an issue on GitHub
* @public
*/
export function createGithubIssuesCreateAction(options: {
integrations: ScmIntegrationRegistry;
githubCredentialsProvider?: GithubCredentialsProvider;
}) {
const { integrations, githubCredentialsProvider } = options;
return createTemplateAction({
id: 'github:issues:create',
description: 'Creates an issue on GitHub.',
examples,
supportsDryRun: true,
schema: {
input: {
repoUrl: z =>
z.string({
description:
'Accepts the format `github.com?repo=reponame&owner=owner` where `reponame` is the repository name and `owner` is an organization or username',
}),
title: z =>
z.string({
description: 'The title of the issue',
}),
body: z =>
z
.string({
description: 'The contents of the issue',
})
.optional(),
assignees: z =>
z
.array(z.string(), {
description:
'Logins for Users to assign to this issue. NOTE: Only users with push access can set assignees for new issues. Assignees are silently dropped otherwise.',
})
.optional(),
milestone: z =>
z
.union([z.string(), z.number()], {
description:
'The number of the milestone to associate this issue with. NOTE: Only users with push access can set the milestone for new issues. The milestone is silently dropped otherwise.',
})
.optional(),
labels: z =>
z
.array(z.string(), {
description:
'Labels to associate with this issue. NOTE: Only users with push access can set labels for new issues. Labels are silently dropped otherwise.',
})
.optional(),
token: z =>
z
.string({
description:
'The `GITHUB_TOKEN` to use for authorization to GitHub',
})
.optional(),
},
output: {
issueUrl: z =>
z.string({
description: 'The URL of the created issue',
}),
issueNumber: z =>
z.number({
description: 'The number of the created issue',
}),
},
},
async handler(ctx) {
const {
repoUrl,
title,
body,
assignees,
milestone,
labels,
token: providedToken,
} = ctx.input;
const { host, owner, repo } = parseRepoUrl(repoUrl, integrations);
ctx.logger.info(`Creating issue "${title}" on repo ${repo}`);
if (!owner) {
throw new InputError('Invalid repository owner provided in repoUrl');
}
const octokitOptions = await getOctokitOptions({
integrations,
credentialsProvider: githubCredentialsProvider,
host,
owner,
repo,
token: providedToken,
});
const client = new Octokit({
...octokitOptions,
log: ctx.logger,
});
if (ctx.isDryRun) {
ctx.logger.info(`Performing dry run of creating issue "${title}"`);
ctx.output('issueUrl', `https://github.com/${owner}/${repo}/issues/42`);
ctx.output('issueNumber', 42);
ctx.logger.info(`Dry run complete`);
return;
}
try {
const issue = await ctx.checkpoint({
key: `github.issues.create.${owner}.${repo}.${title}`,
fn: async () => {
const response = await client.rest.issues.create({
owner,
repo,
title,
body,
assignees,
milestone,
labels,
});
return {
html_url: response.data.html_url,
number: response.data.number,
};
},
});
if (!issue) {
throw new Error('Failed to create issue');
}
ctx.output('issueUrl', issue.html_url);
ctx.output('issueNumber', issue.number);
ctx.logger.info(
`Successfully created issue #${issue.number}: ${issue.html_url}`,
);
} catch (e) {
assertError(e);
ctx.logger.warn(
`Failed: creating issue '${title}' on repo: '${repo}', ${e.message}`,
);
throw e;
}
},
});
}
@@ -16,6 +16,7 @@
export { createGithubActionsDispatchAction } from './githubActionsDispatch';
export { createGithubIssuesLabelAction } from './githubIssuesLabel';
export { createGithubIssuesCreateAction } from './githubIssuesCreate';
export { createGithubRepoCreateAction } from './githubRepoCreate';
export { createGithubRepoPushAction } from './githubRepoPush';
export { createGithubWebhookAction } from './githubWebhook';
@@ -348,6 +348,14 @@ const requiredLinearHistory = (z: typeof zod) =>
})
.optional();
const blockCreations = (z: typeof zod) =>
z
.boolean({
description: `Prevents creation of new branches during push, unless the push is initiated by a user, team, or app (defined in restrictions) which has the ability to push.`,
})
.default(false)
.optional();
const repoVariables = (z: typeof zod) =>
z
.record(z.string(), {
@@ -449,4 +457,5 @@ export {
protectEnforceAdmins,
bypassPullRequestAllowances,
branch,
blockCreations,
};
@@ -27,6 +27,7 @@ import {
createGithubDeployKeyAction,
createGithubEnvironmentAction,
createGithubIssuesLabelAction,
createGithubIssuesCreateAction,
createGithubRepoCreateAction,
createGithubRepoPushAction,
createGithubWebhookAction,
@@ -82,6 +83,10 @@ export const githubModule = createBackendModule({
integrations,
githubCredentialsProvider,
}),
createGithubIssuesCreateAction({
integrations,
githubCredentialsProvider,
}),
createGithubRepoCreateAction({
integrations,
githubCredentialsProvider,
@@ -262,6 +262,71 @@ describe('<MultiEntityPicker />', () => {
});
});
describe('with existing form data', () => {
beforeEach(() => {
uiSchema = { 'ui:options': {} };
props = {
onChange,
schema,
required,
uiSchema,
rawErrors,
formData: ['group:default/team-a'],
} as unknown as FieldProps;
});
it('preserves existing data on blur', async () => {
const { getByRole } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'squ' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['group:default/team-a', 'squ']);
});
it('preserves existing data on value create', async () => {
const { getByRole } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'squ' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(onChange).toHaveBeenCalledWith(['group:default/team-a', 'squ']);
});
it('preserves existing data on selecting an existing option', async () => {
catalogApi.getEntities.mockResolvedValue({ items: entities });
const { getByRole } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.mouseDown(input);
const optionA = screen.getByText('squad-b');
await userEvent.click(optionA as HTMLElement);
expect(onChange).toHaveBeenCalledWith([
'group:default/team-a',
'group:default/squad-b',
]);
});
});
describe('uses full entity ref', () => {
beforeEach(() => {
uiSchema = {
@@ -50,6 +50,12 @@ import { scaffolderTranslationRef } from '../../../translation';
export { MultiEntityPickerSchema } from './schema';
// AutocompleteChangeReason events that can be triggered when a user inputs a freeSolo option
const FREE_SOLO_EVENTS: readonly AutocompleteChangeReason[] = [
'blur',
'create-option',
];
/**
* The underlying component that is rendered in the form for the `MultiEntityPicker`
* field extension.
@@ -110,29 +116,34 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
(_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => {
const values = refs
.map(ref => {
// If the ref is not a string, then it was a selected option in the picker
if (typeof ref !== 'string') {
// if ref does not exist: pass 'undefined' to trigger validation for required value
return ref ? stringifyEntityRef(ref as Entity) : undefined;
}
if (reason === 'blur' || reason === 'create-option') {
// Add in default namespace, etc.
let entityRef = ref;
try {
// Attempt to parse the entity ref into it's full form.
entityRef = stringifyEntityRef(
parseEntityRef(ref as string, {
defaultKind,
defaultNamespace,
}),
);
} catch (err) {
// If the passed in value isn't an entity ref, do nothing.
}
// We need to check against formData here as that's the previous value for this field.
if (formData?.includes(ref) || allowArbitraryValues) {
return entityRef;
}
// Add in default namespace, etc.
let entityRef = ref;
try {
// Attempt to parse the entity ref into it's full form.
entityRef = stringifyEntityRef(
parseEntityRef(ref as string, {
defaultKind,
defaultNamespace,
}),
);
} catch (err) {
// If the passed in value isn't an entity ref, do nothing.
}
// We need to check against formData here as that's the previous value for this field.
if (
// If value already matches what exists in form data, allow it
formData?.includes(ref) ||
// If arbitrary values are allowed and the reason is a free solo event, allow it
(allowArbitraryValues && FREE_SOLO_EVENTS.includes(reason))
) {
return entityRef;
}
return undefined;