Merge remote-tracking branch 'origin/master' into package-workspaces
This commit is contained in:
@@ -48,6 +48,8 @@ app:
|
||||
- page:catalog/entity:
|
||||
config:
|
||||
showNavItemIcons: true
|
||||
# default content order for all groups, can be 'title' or 'natural'
|
||||
# contentOrder: title
|
||||
groups:
|
||||
# placing a tab at the beginning
|
||||
- overview:
|
||||
@@ -58,6 +60,9 @@ app:
|
||||
- documentation:
|
||||
title: Docs
|
||||
icon: docs
|
||||
# example aliasing a group
|
||||
# aliases:
|
||||
# - docs
|
||||
- deployment:
|
||||
title: Deployments
|
||||
# example adding a new group
|
||||
@@ -97,22 +102,19 @@ app:
|
||||
# - entity-card:azure-devops/readme
|
||||
|
||||
# Entity page contents
|
||||
- entity-content:catalog/overview:
|
||||
config:
|
||||
group: overview
|
||||
- entity-content:catalog/overview
|
||||
- entity-content:api-docs/definition
|
||||
- entity-content:api-docs/apis:
|
||||
config:
|
||||
# example associating with a default group
|
||||
# example overriding the default group
|
||||
group: documentation
|
||||
icon: kind:api
|
||||
- entity-content:techdocs:
|
||||
config:
|
||||
group: documentation
|
||||
icon: techdocs
|
||||
- entity-content:kubernetes/kubernetes:
|
||||
config:
|
||||
# example disassociating with a default group
|
||||
# example disassociating from the default group
|
||||
group: false
|
||||
# - entity-content:azure-devops/pipelines
|
||||
# - entity-content:azure-devops/pull-requests
|
||||
|
||||
+15
-27
@@ -28,12 +28,7 @@ import {
|
||||
ActionsRegistryActionOptions,
|
||||
ActionsRegistryService,
|
||||
} from '@backstage/backend-plugin-api/alpha';
|
||||
import {
|
||||
ForwardedError,
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
} from '@backstage/errors';
|
||||
import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors';
|
||||
|
||||
export class DefaultActionsRegistryService implements ActionsRegistryService {
|
||||
private actions: Map<string, ActionsRegistryActionOptions<any, any>> =
|
||||
@@ -131,31 +126,24 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await action.action({
|
||||
input: input.data,
|
||||
credentials,
|
||||
logger: this.logger,
|
||||
});
|
||||
const result = await action.action({
|
||||
input: input.data,
|
||||
credentials,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
const output = action.schema?.output
|
||||
? action.schema.output(z).safeParse(result?.output)
|
||||
: ({ success: true, data: result?.output } as const);
|
||||
const output = action.schema?.output
|
||||
? action.schema.output(z).safeParse(result?.output)
|
||||
: ({ success: true, data: result?.output } as const);
|
||||
|
||||
if (!output.success) {
|
||||
throw new InputError(
|
||||
`Invalid output from action "${req.params.actionId}"`,
|
||||
output.error,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ output: output.data });
|
||||
} catch (error) {
|
||||
throw new ForwardedError(
|
||||
`Failed execution of action "${req.params.actionId}"`,
|
||||
error,
|
||||
if (!output.success) {
|
||||
throw new InputError(
|
||||
`Invalid output from action "${req.params.actionId}"`,
|
||||
output.error,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ output: output.data });
|
||||
},
|
||||
);
|
||||
return router;
|
||||
|
||||
+28
-5
@@ -22,7 +22,7 @@ import {
|
||||
import { httpRouterServiceFactory } from '../../../entrypoints/httpRouter';
|
||||
import request from 'supertest';
|
||||
import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
|
||||
|
||||
describe('actionsRegistryServiceFactory', () => {
|
||||
@@ -510,7 +510,7 @@ describe('actionsRegistryServiceFactory', () => {
|
||||
expect(body).toMatchObject({ output: { ok: true } });
|
||||
});
|
||||
|
||||
it('should return the error from the action if it throws', async () => {
|
||||
it('should forward the original error when the action throws a known error', async () => {
|
||||
const { server } = await startTestBackend({
|
||||
features: [pluginSubject, ...defaultServices],
|
||||
});
|
||||
@@ -528,9 +528,32 @@ describe('actionsRegistryServiceFactory', () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toMatchObject({
|
||||
error: {
|
||||
message: expect.stringContaining(
|
||||
'Failed execution of action "my-plugin:test"',
|
||||
),
|
||||
name: 'InputError',
|
||||
message: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward a NotFoundError from the action with 404 status', async () => {
|
||||
const { server } = await startTestBackend({
|
||||
features: [pluginSubject, ...defaultServices],
|
||||
});
|
||||
|
||||
mockAction.mockRejectedValue(new NotFoundError('entity not found'));
|
||||
|
||||
const { body, status } = await request(server)
|
||||
.post(
|
||||
'/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke',
|
||||
)
|
||||
.send({
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(body).toMatchObject({
|
||||
error: {
|
||||
name: 'NotFoundError',
|
||||
message: 'entity not found',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
BackstageCredentials,
|
||||
LoggerService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { ForwardedError, InputError, NotFoundError } from '@backstage/errors';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { z, AnyZodObject } from 'zod';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
@@ -126,31 +126,24 @@ export class MockActionsRegistry
|
||||
throw new InputError(`Invalid input to action "${opts.id}"`, input.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await action.action({
|
||||
input: input.data,
|
||||
credentials: opts.credentials ?? mockCredentials.none(),
|
||||
logger: this.logger,
|
||||
});
|
||||
const result = await action.action({
|
||||
input: input.data,
|
||||
credentials: opts.credentials ?? mockCredentials.none(),
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
const output = action.schema?.output
|
||||
? action.schema.output(z).safeParse(result?.output)
|
||||
: ({ success: true, data: result?.output } as const);
|
||||
const output = action.schema?.output
|
||||
? action.schema.output(z).safeParse(result?.output)
|
||||
: ({ success: true, data: result?.output } as const);
|
||||
|
||||
if (!output.success) {
|
||||
throw new InputError(
|
||||
`Invalid output from action "${opts.id}"`,
|
||||
output.error,
|
||||
);
|
||||
}
|
||||
|
||||
return { output: output.data };
|
||||
} catch (error) {
|
||||
throw new ForwardedError(
|
||||
`Failed execution of action "${opts.id}"`,
|
||||
error,
|
||||
if (!output.success) {
|
||||
throw new InputError(
|
||||
`Invalid output from action "${opts.id}"`,
|
||||
output.error,
|
||||
);
|
||||
}
|
||||
|
||||
return { output: output.data };
|
||||
}
|
||||
|
||||
register<
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common';
|
||||
import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { SerializedError } from '@backstage/errors';
|
||||
import type { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import type { SerializedError } from '@backstage/errors';
|
||||
|
||||
// @public
|
||||
export type AddLocationRequest = {
|
||||
@@ -236,6 +236,7 @@ export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: string[];
|
||||
fields?: EntityFieldsQuery | undefined;
|
||||
filter?: EntityFilterQuery;
|
||||
query?: FilterPredicate;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -280,6 +281,7 @@ export interface GetEntityAncestorsResponse {
|
||||
export interface GetEntityFacetsRequest {
|
||||
facets: string[];
|
||||
filter?: EntityFilterQuery;
|
||||
query?: FilterPredicate;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -320,6 +322,7 @@ export type QueryEntitiesInitialRequest = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: EntityFilterQuery;
|
||||
query?: FilterPredicate;
|
||||
orderFields?: EntityOrderQuery;
|
||||
fullTextFilter?: {
|
||||
term: string;
|
||||
|
||||
@@ -302,6 +302,103 @@ describe('CatalogClient', () => {
|
||||
|
||||
expect(response).toEqual({ items: [entity, undefined] });
|
||||
});
|
||||
|
||||
it('sends only query predicate in the body when query is provided without filter', async () => {
|
||||
expect.assertions(3);
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('');
|
||||
await expect(req.json()).resolves.toEqual({
|
||||
entityRefs: ['k:n/a'],
|
||||
query: { kind: 'Component' },
|
||||
});
|
||||
return res(ctx.json({ items: [entity] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ['k:n/a'],
|
||||
query: { kind: 'Component' },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity] });
|
||||
});
|
||||
|
||||
it('merges filter and query into $all predicate when both are provided', async () => {
|
||||
expect.assertions(4);
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('');
|
||||
const body = await req.json();
|
||||
expect(body.entityRefs).toEqual(['k:n/a']);
|
||||
expect(body.query).toEqual({
|
||||
$all: [{ kind: 'Component' }, { kind: 'API' }],
|
||||
});
|
||||
return res(ctx.json({ items: [entity] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ['k:n/a'],
|
||||
query: { kind: 'Component' },
|
||||
filter: { kind: ['API'] },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity] });
|
||||
});
|
||||
|
||||
it('sends filter as query parameter when only filter is provided (backward compat)', async () => {
|
||||
expect.assertions(4);
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.search).toBe('?filter=kind%3DAPI');
|
||||
const body = await req.json();
|
||||
expect(body).toEqual({ entityRefs: ['k:n/a'] });
|
||||
expect(body.query).toBeUndefined();
|
||||
return res(ctx.json({ items: [entity] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{
|
||||
entityRefs: ['k:n/a'],
|
||||
filter: { kind: ['API'] },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEntities', () => {
|
||||
@@ -540,6 +637,350 @@ describe('CatalogClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEntities with predicate-based queries (POST endpoint)', () => {
|
||||
const defaultResponse = {
|
||||
items: [
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'service-1',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
owner: 'team-a',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'service-2',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
owner: 'team-b',
|
||||
},
|
||||
},
|
||||
],
|
||||
totalItems: 2,
|
||||
pageInfo: {},
|
||||
};
|
||||
|
||||
it('should use POST endpoint when query is provided', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.body).toMatchObject({
|
||||
query: { kind: 'component' },
|
||||
limit: 20,
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
const response = await client.queryEntities({
|
||||
query: { kind: 'component' },
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
expect(response.items).toEqual(defaultResponse.items);
|
||||
expect(response.totalItems).toBe(2);
|
||||
});
|
||||
|
||||
it('should support $all operator', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
|
||||
},
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: {
|
||||
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support $any operator', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
},
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: {
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support $not operator', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
$not: { 'spec.lifecycle': 'experimental' },
|
||||
},
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: {
|
||||
$not: { 'spec.lifecycle': 'experimental' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support $exists operator', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
'spec.owner': { $exists: true },
|
||||
},
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: {
|
||||
'spec.owner': { $exists: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support $in operator', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] },
|
||||
},
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: {
|
||||
'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support complex nested predicates', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
$all: [
|
||||
{ kind: 'component' },
|
||||
{
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
},
|
||||
{
|
||||
$not: {
|
||||
'spec.lifecycle': 'experimental',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: {
|
||||
$all: [
|
||||
{ kind: 'component' },
|
||||
{
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
},
|
||||
{
|
||||
$not: {
|
||||
'spec.lifecycle': 'experimental',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send orderFields with correct format', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body.orderBy).toEqual([
|
||||
{ field: 'metadata.name', order: 'asc' },
|
||||
]);
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: { kind: 'component' },
|
||||
orderFields: { field: 'metadata.name', order: 'asc' },
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send multiple orderFields with correct format', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body.orderBy).toEqual([
|
||||
{ field: 'metadata.name', order: 'asc' },
|
||||
{ field: 'spec.type', order: 'desc' },
|
||||
]);
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: { kind: 'component' },
|
||||
orderFields: [
|
||||
{ field: 'metadata.name', order: 'asc' },
|
||||
{ field: 'spec.type', order: 'desc' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send limit and offset parameters in the body', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.body.limit).toBe(50);
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await client.queryEntities({
|
||||
query: { kind: 'component' },
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should paginate using POST when cursor contains a query', async () => {
|
||||
// Simulate a cursor that contains a query predicate (as the server would encode it)
|
||||
const cursorPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
orderFields: [],
|
||||
orderFieldValues: [],
|
||||
isPrevious: false,
|
||||
query: { kind: 'component' },
|
||||
totalItems: 100,
|
||||
}),
|
||||
).toString('base64');
|
||||
|
||||
const page2Response = {
|
||||
items: [
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'service-3', namespace: 'default' },
|
||||
},
|
||||
],
|
||||
totalItems: 100,
|
||||
pageInfo: {},
|
||||
};
|
||||
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.body).toMatchObject({ cursor: cursorPayload });
|
||||
return res(ctx.json(page2Response));
|
||||
});
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
const response = await client.queryEntities({
|
||||
cursor: cursorPayload,
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
expect(response.items).toEqual(page2Response.items);
|
||||
expect(response.totalItems).toBe(100);
|
||||
});
|
||||
|
||||
it('should use GET endpoint for cursor without query', async () => {
|
||||
// A cursor that does NOT contain a query field should go to GET
|
||||
const cursorPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
orderFields: [],
|
||||
orderFieldValues: [],
|
||||
isPrevious: false,
|
||||
totalItems: 50,
|
||||
}),
|
||||
).toString('base64');
|
||||
|
||||
const mockedGetEndpoint = jest.fn().mockImplementation((_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
items: [],
|
||||
totalItems: 50,
|
||||
pageInfo: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const mockedPostEndpoint = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/entities/by-query`, mockedGetEndpoint),
|
||||
rest.post(`${mockBaseUrl}/entities/by-query`, mockedPostEndpoint),
|
||||
);
|
||||
|
||||
await client.queryEntities({ cursor: cursorPayload });
|
||||
|
||||
expect(mockedGetEndpoint).toHaveBeenCalledTimes(1);
|
||||
expect(mockedPostEndpoint).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors from POST endpoint', async () => {
|
||||
const mockedEndpoint = jest
|
||||
.fn()
|
||||
.mockImplementation((_req, res, ctx) => res(ctx.status(400)));
|
||||
|
||||
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
await expect(() =>
|
||||
client.queryEntities({ query: { kind: 'component' } }),
|
||||
).rejects.toThrow(/Request failed with 400/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamEntities', () => {
|
||||
const defaultResponse: QueryEntitiesResponse = {
|
||||
items: [
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
parseEntityRef,
|
||||
stringifyLocationRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { InputError, ResponseError } from '@backstage/errors';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import {
|
||||
AddLocationRequest,
|
||||
AddLocationResponse,
|
||||
@@ -46,10 +47,17 @@ import {
|
||||
StreamEntitiesRequest,
|
||||
ValidateEntityResponse,
|
||||
} from './types/api';
|
||||
import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils';
|
||||
import {
|
||||
convertFilterToPredicate,
|
||||
isQueryEntitiesInitialRequest,
|
||||
splitRefsIntoChunks,
|
||||
cursorContainsQuery,
|
||||
} from './utils';
|
||||
import {
|
||||
DefaultApiClient,
|
||||
GetEntitiesByQuery,
|
||||
GetLocationsByQueryRequest,
|
||||
QueryEntitiesByPredicateRequest,
|
||||
TypedResponse,
|
||||
} from './schema/openapi';
|
||||
import type {
|
||||
@@ -229,11 +237,36 @@ export class CatalogClient implements CatalogApi {
|
||||
request: GetEntitiesByRefsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesByRefsResponse> {
|
||||
const { filter, query } = request;
|
||||
|
||||
// Only convert and merge if both filter and query are provided, or if
|
||||
// query alone is provided. When only filter is given, preserve the old
|
||||
// query-parameter behavior for backward compatibility.
|
||||
let filterPredicate: FilterPredicate | undefined;
|
||||
if (query !== undefined) {
|
||||
if (typeof query !== 'object' || query === null || Array.isArray(query)) {
|
||||
throw new InputError('Query must be an object');
|
||||
}
|
||||
filterPredicate = query;
|
||||
if (filter !== undefined) {
|
||||
const converted = convertFilterToPredicate(filter);
|
||||
filterPredicate = { $all: [filterPredicate, converted] };
|
||||
}
|
||||
}
|
||||
|
||||
const getOneChunk = async (refs: string[]) => {
|
||||
const response = await this.apiClient.getEntitiesByRefs(
|
||||
{
|
||||
body: { entityRefs: refs, fields: request.fields },
|
||||
query: { filter: this.getFilterValue(request.filter) },
|
||||
body: {
|
||||
entityRefs: refs,
|
||||
fields: request.fields,
|
||||
...(filterPredicate && {
|
||||
query: filterPredicate as unknown as { [key: string]: any },
|
||||
}),
|
||||
},
|
||||
query: filterPredicate
|
||||
? {}
|
||||
: { filter: this.getFilterValue(request.filter) },
|
||||
},
|
||||
options,
|
||||
);
|
||||
@@ -266,11 +299,26 @@ export class CatalogClient implements CatalogApi {
|
||||
request: QueryEntitiesRequest = {},
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<QueryEntitiesResponse> {
|
||||
const params: Partial<
|
||||
Parameters<typeof this.apiClient.getEntitiesByQuery>[0]['query']
|
||||
> = {};
|
||||
const isInitialRequest = isQueryEntitiesInitialRequest(request);
|
||||
|
||||
if (isQueryEntitiesInitialRequest(request)) {
|
||||
// Route to POST endpoint if query predicate is provided (initial request)
|
||||
if (isInitialRequest && request.query) {
|
||||
return this.queryEntitiesByPredicate(request, options);
|
||||
}
|
||||
|
||||
// Route to POST endpoint if cursor contains a query predicate (pagination)
|
||||
// TODO(freben): It's costly and non-opaque to have to introspect the cursor
|
||||
// like this. It should be refactored in the future to not need this.
|
||||
// Suggestion: make the GET and POST endpoints understand the same cursor
|
||||
// format, and pick which one to call ONLY based on whether the cursor size
|
||||
// risks hitting url length limits
|
||||
if (!isInitialRequest && cursorContainsQuery(request.cursor)) {
|
||||
return this.queryEntitiesByPredicate(request, options);
|
||||
}
|
||||
|
||||
const params: Partial<GetEntitiesByQuery['query']> = {};
|
||||
|
||||
if (isInitialRequest) {
|
||||
const {
|
||||
fields = [],
|
||||
filter,
|
||||
@@ -320,6 +368,84 @@ export class CatalogClient implements CatalogApi {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query entities using predicate-based filters (POST endpoint).
|
||||
* @internal
|
||||
*/
|
||||
private async queryEntitiesByPredicate(
|
||||
request: QueryEntitiesRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<QueryEntitiesResponse> {
|
||||
const body: QueryEntitiesByPredicateRequest = {};
|
||||
|
||||
if (isQueryEntitiesInitialRequest(request)) {
|
||||
const {
|
||||
filter,
|
||||
query,
|
||||
limit,
|
||||
offset,
|
||||
orderFields,
|
||||
fullTextFilter,
|
||||
fields,
|
||||
} = request;
|
||||
|
||||
let filterPredicate: FilterPredicate | undefined;
|
||||
if (query !== undefined) {
|
||||
if (
|
||||
typeof query !== 'object' ||
|
||||
query === null ||
|
||||
Array.isArray(query)
|
||||
) {
|
||||
throw new InputError('Query must be an object');
|
||||
}
|
||||
filterPredicate = query;
|
||||
}
|
||||
if (filter !== undefined) {
|
||||
const converted = convertFilterToPredicate(filter);
|
||||
filterPredicate = filterPredicate
|
||||
? { $all: [filterPredicate, converted] }
|
||||
: converted;
|
||||
}
|
||||
if (filterPredicate !== undefined) {
|
||||
body.query = filterPredicate as unknown as { [key: string]: any };
|
||||
}
|
||||
|
||||
if (limit !== undefined) {
|
||||
body.limit = limit;
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
body.offset = offset;
|
||||
}
|
||||
if (orderFields !== undefined) {
|
||||
body.orderBy = [orderFields].flat();
|
||||
}
|
||||
if (fullTextFilter) {
|
||||
body.fullTextFilter = fullTextFilter;
|
||||
}
|
||||
if (fields?.length) {
|
||||
body.fields = fields;
|
||||
}
|
||||
} else {
|
||||
body.cursor = request.cursor;
|
||||
if (request.limit !== undefined) {
|
||||
body.limit = request.limit;
|
||||
}
|
||||
if (request.fields?.length) {
|
||||
body.fields = request.fields;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await this.requestRequired(
|
||||
await this.apiClient.queryEntitiesByPredicate({ body }, options),
|
||||
);
|
||||
|
||||
return {
|
||||
items: res.items,
|
||||
totalItems: res.totalItems,
|
||||
pageInfo: res.pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc CatalogApi.getEntityByRef}
|
||||
*/
|
||||
@@ -378,7 +504,13 @@ export class CatalogClient implements CatalogApi {
|
||||
request: GetEntityFacetsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntityFacetsResponse> {
|
||||
const { filter = [], facets } = request;
|
||||
const { filter = [], query, facets } = request;
|
||||
|
||||
// Route to POST endpoint if query predicate is provided
|
||||
if (query) {
|
||||
return this.getEntityFacetsByPredicate(request, options);
|
||||
}
|
||||
|
||||
return await this.requestOptional(
|
||||
await this.apiClient.getEntityFacets(
|
||||
{
|
||||
@@ -389,6 +521,45 @@ export class CatalogClient implements CatalogApi {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity facets using predicate-based filters (POST endpoint).
|
||||
* @internal
|
||||
*/
|
||||
private async getEntityFacetsByPredicate(
|
||||
request: GetEntityFacetsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntityFacetsResponse> {
|
||||
const { filter, query, facets } = request;
|
||||
|
||||
let filterPredicate: FilterPredicate | undefined;
|
||||
if (query !== undefined) {
|
||||
if (typeof query !== 'object' || query === null || Array.isArray(query)) {
|
||||
throw new InputError('Query must be an object');
|
||||
}
|
||||
filterPredicate = query;
|
||||
}
|
||||
if (filter !== undefined) {
|
||||
const converted = convertFilterToPredicate(filter);
|
||||
filterPredicate = filterPredicate
|
||||
? { $all: [filterPredicate, converted] }
|
||||
: converted;
|
||||
}
|
||||
|
||||
return await this.requestOptional(
|
||||
await this.apiClient.queryEntityFacetsByPredicate(
|
||||
{
|
||||
body: {
|
||||
facets,
|
||||
...(filterPredicate && {
|
||||
query: filterPredicate as unknown as { [key: string]: any },
|
||||
}),
|
||||
},
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc CatalogApi.addLocation}
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,8 @@ import { Entity } from '../models/Entity.model';
|
||||
import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
|
||||
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
|
||||
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
|
||||
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model';
|
||||
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
|
||||
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
|
||||
import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model';
|
||||
@@ -139,6 +141,18 @@ export type GetEntityFacets = {
|
||||
filter?: Array<string>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type QueryEntitiesByPredicate = {
|
||||
body: QueryEntitiesByPredicateRequest;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type QueryEntityFacetsByPredicate = {
|
||||
body: QueryEntityFacetsByPredicateRequest;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -449,6 +463,56 @@ export class DefaultApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query entities using predicate-based filters.
|
||||
* @param queryEntitiesByPredicateRequest -
|
||||
*/
|
||||
public async queryEntitiesByPredicate(
|
||||
// @ts-ignore
|
||||
request: QueryEntitiesByPredicate,
|
||||
options?: RequestOptions,
|
||||
): Promise<TypedResponse<EntitiesQueryResponse>> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
|
||||
|
||||
const uriTemplate = `/entities/by-query`;
|
||||
|
||||
const uri = parser.parse(uriTemplate).expand({});
|
||||
|
||||
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request.body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity facets using predicate-based filters.
|
||||
* @param queryEntityFacetsByPredicateRequest -
|
||||
*/
|
||||
public async queryEntityFacetsByPredicate(
|
||||
// @ts-ignore
|
||||
request: QueryEntityFacetsByPredicate,
|
||||
options?: RequestOptions,
|
||||
): Promise<TypedResponse<EntityFacetsResponse>> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
|
||||
|
||||
const uriTemplate = `/entity-facets`;
|
||||
|
||||
const uri = parser.parse(uriTemplate).expand({});
|
||||
|
||||
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request.body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the entity related to entityRef.
|
||||
* @param refreshEntityRequest -
|
||||
|
||||
+4
@@ -24,4 +24,8 @@
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: Array<string>;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
|
||||
import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicateRequest {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: Array<QueryEntitiesByPredicateRequestOrderByInner>;
|
||||
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicateRequestFullTextFilter {
|
||||
term?: string;
|
||||
fields?: Array<string>;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicateRequestOrderByInner {
|
||||
field: string;
|
||||
order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum =
|
||||
| 'asc'
|
||||
| 'desc';
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntityFacetsByPredicateRequest {
|
||||
facets: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
@@ -45,6 +45,10 @@ export * from '../models/LocationsQueryResponse.model';
|
||||
export * from '../models/LocationsQueryResponsePageInfo.model';
|
||||
export * from '../models/ModelError.model';
|
||||
export * from '../models/NullableEntity.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
|
||||
export * from '../models/QueryEntityFacetsByPredicateRequest.model';
|
||||
export * from '../models/RecursivePartialEntity.model';
|
||||
export * from '../models/RecursivePartialEntityMeta.model';
|
||||
export * from '../models/RecursivePartialEntityMetaAllOf.model';
|
||||
|
||||
@@ -370,6 +370,25 @@ describe('InMemoryCatalogClient', () => {
|
||||
{ kind: 'CustomKind', metadata: { name: 'e1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports query predicate filter', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.getEntitiesByRefs({
|
||||
entityRefs: ['secondcustomkind:default/e2', 'customkind:default/e1'],
|
||||
query: { kind: 'CustomKind' },
|
||||
});
|
||||
expect(result.items).toEqual([undefined, entity1]);
|
||||
});
|
||||
|
||||
it('supports both filter and query predicate together', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.getEntitiesByRefs({
|
||||
entityRefs: ['customkind:default/e1', 'customkind:other/e3'],
|
||||
filter: { kind: 'CustomKind' },
|
||||
query: { 'metadata.namespace': 'other' },
|
||||
});
|
||||
expect(result.items).toEqual([undefined, entity3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryEntities', () => {
|
||||
@@ -683,6 +702,83 @@ describe('InMemoryCatalogClient', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by predicate query', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.queryEntities({
|
||||
query: { kind: 'CustomKind' },
|
||||
});
|
||||
expect(result.items).toEqual([entity1, entity3]);
|
||||
expect(result.totalItems).toBe(2);
|
||||
});
|
||||
|
||||
it('filters by predicate query with $all', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.queryEntities({
|
||||
query: {
|
||||
$all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }],
|
||||
},
|
||||
});
|
||||
expect(result.items).toEqual([entity1, entity3]);
|
||||
});
|
||||
|
||||
it('filters by predicate query with $any', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.queryEntities({
|
||||
query: {
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
},
|
||||
});
|
||||
expect(result.items).toEqual([entity1, entity3, entity4]);
|
||||
});
|
||||
|
||||
it('filters by predicate query with $not', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.queryEntities({
|
||||
query: {
|
||||
$all: [
|
||||
{ kind: 'CustomKind' },
|
||||
{ $not: { 'spec.lifecycle': 'production' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters by predicate query with $in', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.queryEntities({
|
||||
query: { 'spec.type': { $in: ['service', 'library'] } },
|
||||
});
|
||||
expect(result.items).toEqual([entity1, entity2, entity3]);
|
||||
});
|
||||
|
||||
it('filters by predicate query with $exists', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.queryEntities({
|
||||
query: { 'spec.lifecycle': { $exists: false } },
|
||||
});
|
||||
expect(result.items).toEqual([entity4]);
|
||||
});
|
||||
|
||||
it('preserves query predicate through cursor pagination', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const page1 = await client.queryEntities({
|
||||
query: { kind: 'CustomKind' },
|
||||
orderFields: { field: 'metadata.name', order: 'asc' },
|
||||
limit: 1,
|
||||
});
|
||||
expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']);
|
||||
expect(page1.totalItems).toBe(2);
|
||||
expect(page1.pageInfo.nextCursor).toBeDefined();
|
||||
|
||||
const page2 = await client.queryEntities({
|
||||
cursor: page1.pageInfo.nextCursor!,
|
||||
limit: 1,
|
||||
});
|
||||
expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']);
|
||||
expect(page2.pageInfo.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws InputError for invalid cursor', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
await expect(
|
||||
@@ -898,6 +994,29 @@ describe('InMemoryCatalogClient', () => {
|
||||
});
|
||||
expect(result.facets['spec.nonexistent']).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports query predicate filter', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.getEntityFacets({
|
||||
facets: ['spec.type'],
|
||||
query: { kind: 'CustomKind' },
|
||||
});
|
||||
expect(result.facets['spec.type']).toEqual([
|
||||
{ value: 'service', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports both filter and query predicate together', async () => {
|
||||
const client = new InMemoryCatalogClient({ entities });
|
||||
const result = await client.getEntityFacets({
|
||||
facets: ['spec.type'],
|
||||
filter: { kind: 'CustomKind' },
|
||||
query: { 'metadata.namespace': 'default' },
|
||||
});
|
||||
expect(result.facets['spec.type']).toEqual([
|
||||
{ value: 'service', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('not implemented methods', () => {
|
||||
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
} from '@backstage/errors';
|
||||
import {
|
||||
FilterPredicate,
|
||||
filterPredicateToFilterFunction,
|
||||
} from '@backstage/filter-predicates';
|
||||
import lodash from 'lodash';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch';
|
||||
@@ -357,10 +361,15 @@ export class InMemoryCatalogClient implements CatalogApi {
|
||||
request: GetEntitiesByRefsRequest,
|
||||
): Promise<GetEntitiesByRefsResponse> {
|
||||
const filter = createFilter(request.filter);
|
||||
const queryFilter = request.query
|
||||
? filterPredicateToFilterFunction(request.query)
|
||||
: undefined;
|
||||
const refMap = this.#createEntityRefMap();
|
||||
const items = request.entityRefs
|
||||
.map(ref => refMap.get(ref))
|
||||
.map(e => (e && filter(e) ? e : undefined));
|
||||
.map(e =>
|
||||
e && filter(e) && (!queryFilter || queryFilter(e)) ? e : undefined,
|
||||
);
|
||||
return {
|
||||
items: request.fields
|
||||
? items.map(e => (e ? applyFieldsFilter(e, request.fields) : undefined))
|
||||
@@ -373,6 +382,7 @@ export class InMemoryCatalogClient implements CatalogApi {
|
||||
): Promise<QueryEntitiesResponse> {
|
||||
// Decode query parameters from cursor or from the request directly
|
||||
let filter: EntityFilterQuery | undefined;
|
||||
let query: FilterPredicate | undefined;
|
||||
let orderFields: EntityOrderQuery | undefined;
|
||||
let fullTextFilter: { term: string; fields?: string[] } | undefined;
|
||||
let offset: number;
|
||||
@@ -386,12 +396,14 @@ export class InMemoryCatalogClient implements CatalogApi {
|
||||
throw new InputError('Invalid cursor');
|
||||
}
|
||||
filter = deserializeFilter(c.filter as any[]);
|
||||
query = c.query as FilterPredicate | undefined;
|
||||
orderFields = c.orderFields as EntityOrderQuery | undefined;
|
||||
fullTextFilter = c.fullTextFilter as typeof fullTextFilter;
|
||||
offset = c.offset as number;
|
||||
limit = request.limit;
|
||||
} else {
|
||||
filter = request?.filter;
|
||||
query = request?.query;
|
||||
orderFields = request?.orderFields;
|
||||
fullTextFilter = request?.fullTextFilter;
|
||||
offset = request?.offset ?? 0;
|
||||
@@ -401,6 +413,11 @@ export class InMemoryCatalogClient implements CatalogApi {
|
||||
// Apply filter
|
||||
let items = this.#entities.filter(createFilter(filter));
|
||||
|
||||
// Apply predicate-based query filter
|
||||
if (query) {
|
||||
items = items.filter(filterPredicateToFilterFunction(query));
|
||||
}
|
||||
|
||||
// Apply full-text filter, defaulting to the sort field or metadata.uid
|
||||
if (fullTextFilter) {
|
||||
const orderFieldsList = orderFields ? [orderFields].flat() : [];
|
||||
@@ -432,6 +449,7 @@ export class InMemoryCatalogClient implements CatalogApi {
|
||||
|
||||
const cursorBase = {
|
||||
filter: serializeFilter(filter),
|
||||
query,
|
||||
orderFields,
|
||||
fullTextFilter,
|
||||
totalItems,
|
||||
@@ -493,7 +511,12 @@ export class InMemoryCatalogClient implements CatalogApi {
|
||||
request: GetEntityFacetsRequest,
|
||||
): Promise<GetEntityFacetsResponse> {
|
||||
const filter = createFilter(request.filter);
|
||||
const filteredEntities = this.#entities.filter(filter);
|
||||
let filteredEntities = this.#entities.filter(filter);
|
||||
if (request.query) {
|
||||
filteredEntities = filteredEntities.filter(
|
||||
filterPredicateToFilterFunction(request.query),
|
||||
);
|
||||
}
|
||||
const facets = Object.fromEntries(
|
||||
request.facets.map(facet => {
|
||||
const facetValues = new Map<string, number>();
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
|
||||
import { SerializedError } from '@backstage/errors';
|
||||
import type { CompoundEntityRef, Entity } from '@backstage/catalog-model';
|
||||
import type { SerializedError } from '@backstage/errors';
|
||||
import type {
|
||||
AnalyzeLocationRequest,
|
||||
AnalyzeLocationResponse,
|
||||
} from '@backstage/plugin-catalog-common';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import type { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* This symbol can be used in place of a value when passed to filters in e.g.
|
||||
@@ -212,6 +212,16 @@ export interface GetEntitiesByRefsRequest {
|
||||
* If given, return only entities that match the given filter.
|
||||
*/
|
||||
filter?: EntityFilterQuery;
|
||||
/**
|
||||
* If given, return only entities that match the given predicate query.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`,
|
||||
* `$contains`, and `$hasPrefix`. When both `filter` and `query` are
|
||||
* provided, they are combined with `$all`.
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,6 +307,16 @@ export interface GetEntityFacetsRequest {
|
||||
* of that key, no matter what its value is.
|
||||
*/
|
||||
filter?: EntityFilterQuery;
|
||||
/**
|
||||
* If given, return only entities that match the given predicate query.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Supports operators like `$all`, `$any`, `$not`, `$exists`, `$in`,
|
||||
* `$contains`, and `$hasPrefix`. When both `filter` and `query` are
|
||||
* provided, they are combined with `$all`.
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
/**
|
||||
* Dot separated paths for the facets to extract from each entity.
|
||||
*
|
||||
@@ -418,16 +438,43 @@ export type QueryEntitiesRequest =
|
||||
* The method takes this type in an initial pagination request,
|
||||
* when requesting the first batch of entities.
|
||||
*
|
||||
* The properties filter, sortField, query and sortFieldOrder, are going
|
||||
* The properties filter, query, sortField and sortFieldOrder, are going
|
||||
* to be immutable for the entire lifecycle of the following requests.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Either `filter` or `query` can be provided, or even both:
|
||||
* - `filter`: Uses the traditional key-value filter syntax (GET endpoint)
|
||||
* - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint)
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type QueryEntitiesInitialRequest = {
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/**
|
||||
* Traditional key-value based filter.
|
||||
*/
|
||||
filter?: EntityFilterQuery;
|
||||
/**
|
||||
* Predicate-based filter with operators for logical expressions (`$all`,
|
||||
* `$any`, and `$not`) and matching (`$exists`, `$in`, `$hasPrefix`, and
|
||||
* (partially) `$contains`).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* query: {
|
||||
* $all: [
|
||||
* { kind: 'component' },
|
||||
* { 'spec.type': { $in: ['service', 'website'] } }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
orderFields?: EntityOrderQuery;
|
||||
fullTextFilter?: {
|
||||
term: string;
|
||||
@@ -567,6 +614,7 @@ export interface CatalogApi {
|
||||
* const response = await catalogClient.queryEntities({
|
||||
* filter: [{ kind: 'group' }],
|
||||
* limit: 20,
|
||||
* fields: ['metadata', 'kind'],
|
||||
* fullTextFilter: {
|
||||
* term: 'A',
|
||||
* },
|
||||
@@ -583,11 +631,15 @@ export interface CatalogApi {
|
||||
*
|
||||
* ```
|
||||
* const secondBatchResponse = await catalogClient
|
||||
* .queryEntities({ cursor: response.nextCursor });
|
||||
* .queryEntities({
|
||||
* cursor: response.nextCursor,
|
||||
* limit: 20,
|
||||
* fields: ['metadata', 'kind'],
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* secondBatchResponse will contain the next batch of (maximum) 20 entities,
|
||||
* together with a prevCursor property, useful to fetch the previous batch.
|
||||
* `secondBatchResponse` will contain the next batch of (maximum) 20 entities,
|
||||
* together with a `prevCursor` property, useful to fetch the previous batch.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
|
||||
@@ -14,7 +14,87 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { splitRefsIntoChunks } from './utils';
|
||||
import { CATALOG_FILTER_EXISTS } from './types/api';
|
||||
import { convertFilterToPredicate, splitRefsIntoChunks } from './utils';
|
||||
|
||||
describe('convertFilterToPredicate', () => {
|
||||
it('converts a single string value', () => {
|
||||
expect(convertFilterToPredicate({ kind: 'component' })).toEqual({
|
||||
kind: 'component',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts multiple keys into $all', () => {
|
||||
expect(
|
||||
convertFilterToPredicate({
|
||||
kind: 'component',
|
||||
'spec.type': 'service',
|
||||
}),
|
||||
).toEqual({
|
||||
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts an array of string values into $in', () => {
|
||||
expect(
|
||||
convertFilterToPredicate({ 'spec.type': ['service', 'website'] }),
|
||||
).toEqual({
|
||||
'spec.type': { $in: ['service', 'website'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('converts CATALOG_FILTER_EXISTS into $exists', () => {
|
||||
expect(
|
||||
convertFilterToPredicate({ 'spec.owner': CATALOG_FILTER_EXISTS }),
|
||||
).toEqual({
|
||||
'spec.owner': { $exists: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('converts an array of records into $any (OR)', () => {
|
||||
expect(
|
||||
convertFilterToPredicate([{ kind: 'component' }, { kind: 'api' }]),
|
||||
).toEqual({
|
||||
$any: [{ kind: 'component' }, { kind: 'api' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts array of records with multiple keys each', () => {
|
||||
expect(
|
||||
convertFilterToPredicate([
|
||||
{ kind: 'component', 'spec.type': 'service' },
|
||||
{ kind: 'api' },
|
||||
]),
|
||||
).toEqual({
|
||||
$any: [
|
||||
{ $all: [{ kind: 'component' }, { 'spec.type': 'service' }] },
|
||||
{ kind: 'api' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats CATALOG_FILTER_EXISTS mixed with string values as just existence', () => {
|
||||
expect(
|
||||
convertFilterToPredicate({
|
||||
'spec.owner': [CATALOG_FILTER_EXISTS, 'team-a'],
|
||||
}),
|
||||
).toEqual({
|
||||
'spec.owner': { $exists: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('converts a single-element array filter without wrapping in $any', () => {
|
||||
expect(convertFilterToPredicate([{ kind: 'component' }])).toEqual({
|
||||
kind: 'component',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores entries with no valid values', () => {
|
||||
expect(
|
||||
convertFilterToPredicate({ kind: 'component', other: [] as string[] }),
|
||||
).toEqual({ kind: 'component' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitRefsIntoChunks', () => {
|
||||
it('splits by count limit', () => {
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FilterPredicate,
|
||||
FilterPredicateExpression,
|
||||
} from '@backstage/filter-predicates';
|
||||
import {
|
||||
CATALOG_FILTER_EXISTS,
|
||||
EntityFilterQuery,
|
||||
QueryEntitiesCursorRequest,
|
||||
QueryEntitiesInitialRequest,
|
||||
QueryEntitiesRequest,
|
||||
@@ -26,6 +32,58 @@ export function isQueryEntitiesInitialRequest(
|
||||
return !(request as QueryEntitiesCursorRequest).cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cursor contains a predicate query by attempting to decode it.
|
||||
* @internal
|
||||
*/
|
||||
export function cursorContainsQuery(cursor: string): boolean {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(cursor));
|
||||
return 'query' in decoded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an {@link EntityFilterQuery} into a predicate query object.
|
||||
* @internal
|
||||
*/
|
||||
export function convertFilterToPredicate(filter: EntityFilterQuery):
|
||||
| FilterPredicateExpression
|
||||
| {
|
||||
$all: FilterPredicate[];
|
||||
}
|
||||
| {
|
||||
$any: FilterPredicate[];
|
||||
} {
|
||||
const records = [filter].flat();
|
||||
|
||||
const clauses = records.map(record => {
|
||||
const parts: FilterPredicateExpression[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const values = [value].flat();
|
||||
const strings = values.filter((v): v is string => typeof v === 'string');
|
||||
const hasExists = values.some(v => v === CATALOG_FILTER_EXISTS);
|
||||
|
||||
if (hasExists) {
|
||||
// Ignore whether there ALSO were some strings - that would boil down to
|
||||
// just existence anyway since there's effectively an OR between them
|
||||
parts.push({ [key]: { $exists: true } } as FilterPredicateExpression);
|
||||
} else if (strings.length === 1) {
|
||||
parts.push({ [key]: strings[0] } as FilterPredicateExpression);
|
||||
} else if (strings.length > 1) {
|
||||
parts.push({ [key]: { $in: strings } } as FilterPredicateExpression);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length === 1 ? parts[0] : { $all: parts };
|
||||
});
|
||||
|
||||
return clauses.length === 1 ? clauses[0] : { $any: clauses };
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a set of entity refs, and splits them into chunks (groups) such that
|
||||
* the total string length in each chunk does not exceed the default Express.js
|
||||
|
||||
@@ -16,18 +16,18 @@
|
||||
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths';
|
||||
import { findPaths, findRootPath, findOwnRootDir, findOwnPaths } from './paths';
|
||||
|
||||
describe('paths', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('findOwnDir and findOwnRootDir should find owns paths', () => {
|
||||
const dir = findOwnDir(__dirname);
|
||||
const root = findOwnRootDir(dir);
|
||||
it('findOwnPaths and findOwnRootDir should find own paths', () => {
|
||||
const own = findOwnPaths(__dirname);
|
||||
const root = findOwnRootDir(own.dir);
|
||||
|
||||
expect(dir).toBe(resolvePath(__dirname, '..'));
|
||||
expect(own.dir).toBe(resolvePath(__dirname, '..'));
|
||||
expect(root).toBe(resolvePath(__dirname, '../../..'));
|
||||
});
|
||||
|
||||
|
||||
@@ -119,7 +119,23 @@ export function findOwnRootDir(ownDir: string) {
|
||||
);
|
||||
}
|
||||
|
||||
return resolvePath(ownDir, '../..');
|
||||
const rootDir = findRootPath(ownDir, pkgJsonPath => {
|
||||
try {
|
||||
const content = fs.readFileSync(pkgJsonPath, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
return Boolean(data.workspaces);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read package.json at '${pkgJsonPath}', ${error}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (!rootDir) {
|
||||
throw new Error(`No monorepo root found when searching from '${ownDir}'`);
|
||||
}
|
||||
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
// Hierarchical directory cache shared across all OwnPathsImpl instances.
|
||||
@@ -199,11 +215,6 @@ class OwnPathsImpl implements OwnPaths {
|
||||
};
|
||||
}
|
||||
|
||||
// Finds the root of a given package
|
||||
export function findOwnDir(searchDir: string) {
|
||||
return OwnPathsImpl.findDir(searchDir);
|
||||
}
|
||||
|
||||
// Used by the test utility in testUtils.ts to override targetPaths
|
||||
export let targetPathsOverride: TargetPaths | undefined;
|
||||
|
||||
|
||||
@@ -35,14 +35,17 @@
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@manypkg/get-packages": "^1.1.3",
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"@yarnpkg/parsers": "^3.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"semver": "^7.5.3",
|
||||
"yaml": "^2.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ export class GitUtils {
|
||||
static readFileAtRef(path: string, ref: string): Promise<string>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function hasBackstageYarnPlugin(workspaceDir?: string): Promise<boolean>;
|
||||
|
||||
// @public
|
||||
export function isMonoRepo(): Promise<boolean>;
|
||||
|
||||
@@ -111,6 +114,7 @@ export class Lockfile {
|
||||
keys(): IterableIterator<string>;
|
||||
static load(path: string): Promise<Lockfile>;
|
||||
static parse(content: string): Lockfile;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
// @public
|
||||
@@ -220,6 +224,17 @@ export function runWorkerQueueThreads<TItem, TResult, TContext>(
|
||||
results: TResult[];
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export class SuccessCache {
|
||||
// (undocumented)
|
||||
static create(options: { name: string; basePath?: string }): SuccessCache;
|
||||
// (undocumented)
|
||||
read(): Promise<Set<string>>;
|
||||
static trimPaths(input: string): string;
|
||||
// (undocumented)
|
||||
write(newEntries: Iterable<string>): Promise<void>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type WorkerQueueThreadsOptions<TItem, TResult, TContext> = {
|
||||
items: Iterable<TItem>;
|
||||
|
||||
+15
-3
@@ -22,6 +22,12 @@ const DEFAULT_CACHE_BASE_PATH = 'node_modules/.cache/backstage-cli';
|
||||
|
||||
const CACHE_MAX_AGE_MS = 7 * 24 * 3600_000;
|
||||
|
||||
/**
|
||||
* A file-system-based cache that tracks successful operations by storing
|
||||
* timestamped marker files.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class SuccessCache {
|
||||
readonly #path: string;
|
||||
|
||||
@@ -34,8 +40,15 @@ export class SuccessCache {
|
||||
return input.replaceAll(targetPaths.rootDir, '');
|
||||
}
|
||||
|
||||
constructor(name: string, basePath?: string) {
|
||||
this.#path = resolvePath(basePath ?? DEFAULT_CACHE_BASE_PATH, name);
|
||||
static create(options: { name: string; basePath?: string }): SuccessCache {
|
||||
return new SuccessCache(options);
|
||||
}
|
||||
|
||||
private constructor(options: { name: string; basePath?: string }) {
|
||||
this.#path = resolvePath(
|
||||
options.basePath ?? DEFAULT_CACHE_BASE_PATH,
|
||||
options.name,
|
||||
);
|
||||
}
|
||||
|
||||
async read(): Promise<Set<string>> {
|
||||
@@ -89,7 +102,6 @@ export class SuccessCache {
|
||||
|
||||
const empty = Buffer.alloc(0);
|
||||
for (const key of newEntries) {
|
||||
// Remove any existing items with the key we're about to add
|
||||
const trimmedItems = existingItems.filter(item =>
|
||||
item.endsWith(`_${key}`),
|
||||
);
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
export { SuccessCache } from './SuccessCache';
|
||||
@@ -20,7 +20,9 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './cache';
|
||||
export * from './concurrency';
|
||||
export * from './git';
|
||||
export * from './monorepo';
|
||||
export * from './concurrency';
|
||||
export * from './roles';
|
||||
export * from './yarn';
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Lockfile } from './Lockfile';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
@@ -29,7 +30,74 @@ __metadata:
|
||||
cacheKey: 8
|
||||
`;
|
||||
|
||||
describe('New Lockfile', () => {
|
||||
const mockLegacy = `${LEGACY_HEADER}
|
||||
a@^1:
|
||||
version "1.0.1"
|
||||
resolved "https://my-registry/a-1.0.01.tgz#abc123"
|
||||
integrity sha512-xyz
|
||||
dependencies:
|
||||
b "^2"
|
||||
|
||||
b@2.0.x:
|
||||
version "2.0.1"
|
||||
|
||||
b@^2:
|
||||
version "2.0.0"
|
||||
`;
|
||||
|
||||
const mockModern = `${MODERN_HEADER}
|
||||
a@^1:
|
||||
version: 1.0.1
|
||||
dependencies:
|
||||
b: ^2
|
||||
integrity: sha512-xyz
|
||||
resolved: "https://my-registry/a-1.0.01.tgz#abc123"
|
||||
|
||||
"b@2.0.x, b@^2.0.1":
|
||||
version: 2.0.1
|
||||
|
||||
b@^2:
|
||||
version: 2.0.0
|
||||
`;
|
||||
|
||||
describe('Lockfile', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
it('should load and serialize a legacy lockfile', async () => {
|
||||
mockDir.setContent({
|
||||
'yarn.lock': mockLegacy,
|
||||
});
|
||||
|
||||
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
|
||||
expect(lockfile.get('a')).toEqual([
|
||||
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
|
||||
]);
|
||||
expect(lockfile.get('b')).toEqual([
|
||||
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' },
|
||||
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
|
||||
]);
|
||||
expect(lockfile.toString()).toBe(mockLegacy);
|
||||
});
|
||||
|
||||
it('should load and serialize a modern lockfile', async () => {
|
||||
mockDir.setContent({
|
||||
'yarn.lock': mockModern,
|
||||
});
|
||||
|
||||
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
|
||||
expect(lockfile.get('a')).toEqual([
|
||||
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
|
||||
]);
|
||||
expect(lockfile.get('b')).toEqual([
|
||||
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
|
||||
{ range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
|
||||
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
|
||||
]);
|
||||
expect(lockfile.toString()).toBe(mockModern);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lockfile advanced', () => {
|
||||
describe('diff', () => {
|
||||
const lockfileLegacyA = Lockfile.parse(`${LEGACY_HEADER}
|
||||
a@^1:
|
||||
|
||||
@@ -14,12 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseSyml } from '@yarnpkg/parsers';
|
||||
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
|
||||
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
|
||||
|
||||
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746
|
||||
const NEW_HEADER = `${[
|
||||
`# This file is generated by running "yarn install" inside your project.\n`,
|
||||
`# Manual changes might be lost - proceed with caution!\n`,
|
||||
].join(``)}\n`;
|
||||
|
||||
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136
|
||||
const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;
|
||||
|
||||
/** @internal */
|
||||
type LockfileData = {
|
||||
[entry: string]: {
|
||||
@@ -97,6 +107,8 @@ export class Lockfile {
|
||||
* @public
|
||||
*/
|
||||
static parse(content: string): Lockfile {
|
||||
const legacy = LEGACY_REGEX.test(content);
|
||||
|
||||
let data: LockfileData;
|
||||
try {
|
||||
data = parseSyml(content);
|
||||
@@ -130,18 +142,21 @@ export class Lockfile {
|
||||
}
|
||||
}
|
||||
|
||||
return new Lockfile(packages, data);
|
||||
return new Lockfile(packages, data, legacy);
|
||||
}
|
||||
|
||||
private readonly packages: Map<string, LockfileQueryEntry[]>;
|
||||
private readonly data: LockfileData;
|
||||
private readonly legacy: boolean;
|
||||
|
||||
private constructor(
|
||||
packages: Map<string, LockfileQueryEntry[]>,
|
||||
data: LockfileData,
|
||||
legacy: boolean = false,
|
||||
) {
|
||||
this.packages = packages;
|
||||
this.data = data;
|
||||
this.legacy = legacy;
|
||||
}
|
||||
|
||||
/** Returns the name of all packages available in the lockfile */
|
||||
@@ -154,6 +169,15 @@ export class Lockfile {
|
||||
return this.packages.keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the lockfile back to a string.
|
||||
*/
|
||||
toString(): string {
|
||||
return this.legacy
|
||||
? legacyStringifyLockfile(this.data)
|
||||
: NEW_HEADER + stringifySyml(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simplified dependency graph from the lockfile data, where each
|
||||
* key is a package, and the value is a set of all packages that it depends on
|
||||
|
||||
@@ -14,6 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { Lockfile } from './Lockfile';
|
||||
export { fetchPackageInfo, mapDependencies } from './packages';
|
||||
export type { YarnInfoInspectData } from './packages';
|
||||
export { hasBackstageYarnPlugin } from './yarnPlugin';
|
||||
+15
-14
@@ -16,12 +16,12 @@
|
||||
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import { getHasYarnPlugin } from './yarnPlugin';
|
||||
import { hasBackstageYarnPlugin } from './yarnPlugin';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
describe('getHasYarnPlugin', () => {
|
||||
describe('hasBackstageYarnPlugin', () => {
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('getHasYarnPlugin', () => {
|
||||
it('should return false when .yarnrc.yml does not exist', async () => {
|
||||
mockDir.setContent({});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('getHasYarnPlugin', () => {
|
||||
'.yarnrc.yml': '',
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('getHasYarnPlugin', () => {
|
||||
'.yarnrc.yml': 'plugins: []',
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ plugins:
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ plugins:
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ plugins:
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ plugins:
|
||||
'.yarnrc.yml': 'invalid: yaml: content: [',
|
||||
});
|
||||
|
||||
await expect(getHasYarnPlugin()).rejects.toThrow();
|
||||
await expect(hasBackstageYarnPlugin()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when .yarnrc.yml has unexpected structure', async () => {
|
||||
@@ -105,21 +105,22 @@ plugins: "not an array"
|
||||
`,
|
||||
});
|
||||
|
||||
await expect(getHasYarnPlugin()).rejects.toThrow(
|
||||
await expect(hasBackstageYarnPlugin()).rejects.toThrow(
|
||||
'Unexpected content in .yarnrc.yml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle plugins with different structure', async () => {
|
||||
it('should resolve from a custom workspace directory', async () => {
|
||||
mockDir.setContent({
|
||||
'.yarnrc.yml': `
|
||||
'custom-dir': {
|
||||
'.yarnrc.yml': `
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-backstage.cjs
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getHasYarnPlugin();
|
||||
const result = await hasBackstageYarnPlugin(mockDir.resolve('custom-dir'));
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import yaml from 'yaml';
|
||||
import z from 'zod';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
@@ -30,15 +31,21 @@ const yarnRcSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Detects whether the Backstage Yarn plugin is installed in the target repository.
|
||||
* Detects whether the Backstage Yarn plugin is installed in the given workspace directory.
|
||||
*
|
||||
* @returns Promise<boolean> - true if the plugin is installed, false otherwise
|
||||
* @param workspaceDir - The workspace root directory to check. Defaults to the target root.
|
||||
* @returns Promise resolving to true if the plugin is installed, false otherwise
|
||||
* @public
|
||||
*/
|
||||
export async function getHasYarnPlugin(): Promise<boolean> {
|
||||
const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml');
|
||||
export async function hasBackstageYarnPlugin(
|
||||
workspaceDir?: string,
|
||||
): Promise<boolean> {
|
||||
const yarnRcPath = resolvePath(
|
||||
workspaceDir ?? targetPaths.rootDir,
|
||||
'.yarnrc.yml',
|
||||
);
|
||||
const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => {
|
||||
if (e.code === 'ENOENT') {
|
||||
// gracefully continue in case the file doesn't exist
|
||||
return '';
|
||||
}
|
||||
throw e;
|
||||
@@ -77,8 +77,6 @@
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
||||
"@typescript-eslint/parser": "^8.16.0",
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"@yarnpkg/parsers": "^3.0.0",
|
||||
"bfj": "^9.0.2",
|
||||
"buffer": "^6.0.3",
|
||||
"chalk": "^4.0.0",
|
||||
@@ -182,7 +180,6 @@
|
||||
"@types/tar": "^6.1.1",
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webpack-sources": "^3.2.3",
|
||||
"@types/yarnpkg__lockfile": "^1.1.4",
|
||||
"del": "^8.0.0",
|
||||
"esbuild-loader": "^4.0.0",
|
||||
"eslint-webpack-plugin": "^4.2.0",
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 { Lockfile } from './Lockfile';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
`;
|
||||
|
||||
const MODERN_HEADER = `# This file is generated by running "yarn install" inside your project.
|
||||
# Manual changes might be lost - proceed with caution!
|
||||
|
||||
__metadata:
|
||||
version: 6
|
||||
cacheKey: 8
|
||||
`;
|
||||
|
||||
const mockA = `${LEGACY_HEADER}
|
||||
a@^1:
|
||||
version "1.0.1"
|
||||
resolved "https://my-registry/a-1.0.01.tgz#abc123"
|
||||
integrity sha512-xyz
|
||||
dependencies:
|
||||
b "^2"
|
||||
|
||||
b@2.0.x:
|
||||
version "2.0.1"
|
||||
|
||||
b@^2:
|
||||
version "2.0.0"
|
||||
`;
|
||||
|
||||
describe('Lockfile', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
it('should load and serialize mockA', async () => {
|
||||
mockDir.setContent({
|
||||
'yarn.lock': mockA,
|
||||
});
|
||||
|
||||
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
|
||||
expect(lockfile.get('a')).toEqual([
|
||||
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
|
||||
]);
|
||||
expect(lockfile.get('b')).toEqual([
|
||||
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' },
|
||||
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
|
||||
]);
|
||||
expect(lockfile.toString()).toBe(mockA);
|
||||
});
|
||||
});
|
||||
|
||||
const mockANew = `${MODERN_HEADER}
|
||||
a@^1:
|
||||
version: 1.0.1
|
||||
dependencies:
|
||||
b: ^2
|
||||
integrity: sha512-xyz
|
||||
resolved: "https://my-registry/a-1.0.01.tgz#abc123"
|
||||
|
||||
"b@2.0.x, b@^2.0.1":
|
||||
version: 2.0.1
|
||||
|
||||
b@^2:
|
||||
version: 2.0.0
|
||||
`;
|
||||
|
||||
describe('New Lockfile', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
it('should load and serialize mockANew', async () => {
|
||||
mockDir.setContent({
|
||||
'yarn.lock': mockANew,
|
||||
});
|
||||
|
||||
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
|
||||
expect(lockfile.get('a')).toEqual([
|
||||
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
|
||||
]);
|
||||
expect(lockfile.get('b')).toEqual([
|
||||
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
|
||||
{ range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
|
||||
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
|
||||
]);
|
||||
expect(lockfile.toString()).toBe(mockANew);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 fs from 'fs-extra';
|
||||
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
|
||||
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
|
||||
|
||||
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
|
||||
|
||||
type LockfileData = {
|
||||
[entry: string]: {
|
||||
version: string;
|
||||
resolved?: string;
|
||||
integrity?: string /* old */;
|
||||
checksum?: string /* new */;
|
||||
dependencies?: { [name: string]: string };
|
||||
peerDependencies?: { [name: string]: string };
|
||||
};
|
||||
};
|
||||
|
||||
type LockfileQueryEntry = {
|
||||
range: string;
|
||||
version: string;
|
||||
dataKey: string;
|
||||
};
|
||||
|
||||
// the new yarn header is handled out of band of the parsing
|
||||
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746
|
||||
const NEW_HEADER = `${[
|
||||
`# This file is generated by running "yarn install" inside your project.\n`,
|
||||
`# Manual changes might be lost - proceed with caution!\n`,
|
||||
].join(``)}\n`;
|
||||
|
||||
// taken from yarn parser package
|
||||
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136
|
||||
const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;
|
||||
|
||||
// these are special top level yarn keys.
|
||||
// https://github.com/yarnpkg/berry/blob/9bd61fbffb83d0b8166a9cc26bec3a58743aa453/packages/yarnpkg-parsers/sources/syml.ts#L9
|
||||
const SPECIAL_OBJECT_KEYS = [
|
||||
`__metadata`,
|
||||
`version`,
|
||||
`resolution`,
|
||||
`dependencies`,
|
||||
`peerDependencies`,
|
||||
`dependenciesMeta`,
|
||||
`peerDependenciesMeta`,
|
||||
`binaries`,
|
||||
];
|
||||
|
||||
export class Lockfile {
|
||||
static async load(path: string) {
|
||||
const lockfileContents = await fs.readFile(path, 'utf8');
|
||||
return Lockfile.parse(lockfileContents);
|
||||
}
|
||||
|
||||
static parse(content: string) {
|
||||
const legacy = LEGACY_REGEX.test(content);
|
||||
|
||||
let data: LockfileData;
|
||||
try {
|
||||
data = parseSyml(content);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed yarn.lock parse, ${err}`);
|
||||
}
|
||||
|
||||
const packages = new Map<string, LockfileQueryEntry[]>();
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (SPECIAL_OBJECT_KEYS.includes(key)) continue;
|
||||
|
||||
const [, name, ranges] = ENTRY_PATTERN.exec(key) ?? [];
|
||||
if (!name) {
|
||||
throw new Error(`Failed to parse yarn.lock entry '${key}'`);
|
||||
}
|
||||
|
||||
let queries = packages.get(name);
|
||||
if (!queries) {
|
||||
queries = [];
|
||||
packages.set(name, queries);
|
||||
}
|
||||
for (let range of ranges.split(/\s*,\s*/)) {
|
||||
if (range.startsWith(`${name}@`)) {
|
||||
range = range.slice(`${name}@`.length);
|
||||
}
|
||||
if (range.startsWith('npm:')) {
|
||||
range = range.slice('npm:'.length);
|
||||
}
|
||||
queries.push({ range, version: value.version, dataKey: key });
|
||||
}
|
||||
}
|
||||
|
||||
return new Lockfile(packages, data, legacy);
|
||||
}
|
||||
|
||||
private readonly packages: Map<string, LockfileQueryEntry[]>;
|
||||
private readonly data: LockfileData;
|
||||
private readonly legacy: boolean;
|
||||
|
||||
private constructor(
|
||||
packages: Map<string, LockfileQueryEntry[]>,
|
||||
data: LockfileData,
|
||||
legacy: boolean = false,
|
||||
) {
|
||||
this.packages = packages;
|
||||
this.data = data;
|
||||
this.legacy = legacy;
|
||||
}
|
||||
|
||||
/** Get the entries for a single package in the lockfile */
|
||||
get(name: string): LockfileQueryEntry[] | undefined {
|
||||
return this.packages.get(name);
|
||||
}
|
||||
|
||||
/** Returns the name of all packages available in the lockfile */
|
||||
keys(): IterableIterator<string> {
|
||||
return this.packages.keys();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.legacy
|
||||
? legacyStringifyLockfile(this.data)
|
||||
: NEW_HEADER + stringifySyml(this.data);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -17,12 +17,12 @@
|
||||
import {
|
||||
productionPack,
|
||||
revertProductionPack,
|
||||
} from '../../../../modules/build/lib/packager/productionPack';
|
||||
} from '../../lib/packager/productionPack';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { publishPreflightCheck } from '../../lib/publishing';
|
||||
import { createTypeDistProject } from '../../../../lib/typeDistProject';
|
||||
import { createTypeDistProject } from '../../lib/typeDistProject';
|
||||
|
||||
export const pre = async () => {
|
||||
publishPreflightCheck({
|
||||
@@ -19,11 +19,11 @@ import { resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
getModuleFederationRemoteOptions,
|
||||
serveBundle,
|
||||
} from '../../../../build/lib/bundler';
|
||||
} from '../../../lib/bundler';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { hasReactDomClient } from '../../../../build/lib/bundler/hasReactDomClient';
|
||||
import { hasReactDomClient } from '../../../lib/bundler/hasReactDomClient';
|
||||
|
||||
interface StartAppOptions {
|
||||
verifyVersions?: boolean;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '@backstage/cli-node';
|
||||
import { buildFrontend } from '../../lib/buildFrontend';
|
||||
import { buildBackend } from '../../lib/buildBackend';
|
||||
import { createScriptOptionsParser } from '../../../../lib/optionsParser';
|
||||
import { createScriptOptionsParser } from '../../lib/optionsParser';
|
||||
|
||||
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
let packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
|
||||
import { Command, Option } from 'commander';
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { configOption } from '../config';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
const configOption = [
|
||||
'--config <path>',
|
||||
'Config files to load instead of app-config.yaml',
|
||||
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
|
||||
Array<string>(),
|
||||
] as const;
|
||||
|
||||
export function registerPackageCommands(command: Command) {
|
||||
command
|
||||
@@ -197,6 +203,58 @@ export const buildPlugin = createCliPlugin({
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'clean'],
|
||||
description: 'Delete cache directories',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/package/clean'), 'default'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'prepack'],
|
||||
description: 'Prepares a package for packaging before publishing',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/package/pack'), 'pre'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'postpack'],
|
||||
description: 'Restores the changes made by the prepack command',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/package/pack'), 'post'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'clean'],
|
||||
description: 'Delete cache and output directories',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/repo/clean'), 'command'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['build-workspace'],
|
||||
description:
|
||||
|
||||
@@ -18,7 +18,7 @@ import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { buildBundle, getModuleFederationRemoteOptions } from './bundler';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { loadCliConfig } from '../../config/lib/config';
|
||||
import { loadCliConfig } from './config';
|
||||
|
||||
interface BuildAppOptions {
|
||||
targetDir: string;
|
||||
|
||||
@@ -32,7 +32,7 @@ import pickBy from 'lodash/pickBy';
|
||||
import { runOutput, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { transforms } from './transforms';
|
||||
import { version } from '../../../../lib/version';
|
||||
import { version } from '../../../../wiring/version';
|
||||
import yn from 'yn';
|
||||
import { hasReactDomClient } from './hasReactDomClient';
|
||||
import { createWorkspaceLinkingPlugins } from './linkWorkspaces';
|
||||
|
||||
@@ -20,7 +20,7 @@ import { readEntryPoints } from '../entryPoints';
|
||||
import {
|
||||
createTypeDistProject,
|
||||
getEntryPointDefaultFeatureType,
|
||||
} from '../../../../lib/typeDistProject';
|
||||
} from '../typeDistProject';
|
||||
import {
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
defaultRemoteSharedDependencies,
|
||||
|
||||
@@ -24,7 +24,7 @@ import { RspackDevServer } from '@rspack/dev-server';
|
||||
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { loadCliConfig } from '../../../config/lib/config';
|
||||
import { loadCliConfig } from '../config';
|
||||
import { createConfig, resolveBaseUrl, resolveEndpoint } from './config';
|
||||
import { createDetectedModulesEntryPoint } from './packageDetection';
|
||||
import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths';
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2020 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 { ConfigSources, loadConfigSchema } from '@backstage/config-loader';
|
||||
import { AppConfig, ConfigReader } from '@backstage/config';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { getPackages } from '@manypkg/get-packages';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
|
||||
type Options = {
|
||||
args: string[];
|
||||
targetDir?: string;
|
||||
fromPackage?: string;
|
||||
withFilteredKeys?: boolean;
|
||||
watch?: (newFrontendAppConfigs: AppConfig[]) => void;
|
||||
};
|
||||
|
||||
export async function loadCliConfig(options: Options) {
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
|
||||
const { packages } = await getPackages(targetDir);
|
||||
|
||||
let localPackageNames;
|
||||
if (options.fromPackage) {
|
||||
if (packages.length) {
|
||||
const graph = PackageGraph.fromPackages(packages);
|
||||
localPackageNames = Array.from(
|
||||
graph.collectPackageNames([options.fromPackage], node => {
|
||||
// Workaround for Backstage main repo only, since the CLI has some artificial devDependencies
|
||||
if (node.name === '@backstage/cli') {
|
||||
return undefined;
|
||||
}
|
||||
return node.localDependencies.keys();
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
localPackageNames = [options.fromPackage];
|
||||
}
|
||||
} else {
|
||||
localPackageNames = packages.map(p => p.packageJson.name);
|
||||
}
|
||||
|
||||
const schema = await loadConfigSchema({
|
||||
dependencies: localPackageNames,
|
||||
packagePaths: [targetPaths.resolveRoot('package.json')],
|
||||
});
|
||||
|
||||
const source = ConfigSources.default({
|
||||
allowMissingDefaultConfig: true,
|
||||
watch: Boolean(options.watch),
|
||||
rootDir: targetPaths.rootDir,
|
||||
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
|
||||
});
|
||||
|
||||
const appConfigs = await new Promise<AppConfig[]>((resolve, reject) => {
|
||||
async function loadConfigReaderLoop() {
|
||||
let loaded = false;
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
for await (const { configs } of source.readConfigData({
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
if (loaded) {
|
||||
const newFrontendAppConfigs = schema.process(configs, {
|
||||
visibility: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
ignoreSchemaErrors: true,
|
||||
});
|
||||
options.watch?.(newFrontendAppConfigs);
|
||||
} else {
|
||||
resolve(configs);
|
||||
loaded = true;
|
||||
|
||||
if (!options.watch) {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (loaded) {
|
||||
console.error(`Failed to reload configuration, ${error}`);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadConfigReaderLoop();
|
||||
});
|
||||
|
||||
const configurationLoadedMessage = appConfigs.length
|
||||
? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`
|
||||
: `No configuration files found, running without config`;
|
||||
|
||||
process.stderr.write(`${configurationLoadedMessage}\n`);
|
||||
|
||||
const frontendAppConfigs = schema.process(appConfigs, {
|
||||
visibility: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
ignoreSchemaErrors: true,
|
||||
});
|
||||
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
|
||||
|
||||
const fullConfig = ConfigReader.fromConfigs(appConfigs);
|
||||
|
||||
return {
|
||||
schema,
|
||||
appConfigs,
|
||||
frontendConfig,
|
||||
frontendAppConfigs,
|
||||
fullConfig,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -62,8 +62,8 @@ export function createScriptOptionsParser(
|
||||
// Triggers the writing of options to the result object
|
||||
cmd.parseOptions(argsStr.split(' '));
|
||||
|
||||
(cmd as any)._storeOptionsAsProperties = currentOpts;
|
||||
(cmd as any)._optionValues = currentStore;
|
||||
(cmd as any)._optionValues = currentOpts;
|
||||
(cmd as any)._storeOptionsAsProperties = currentStore;
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
PackageGraphNode,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import { createTypeDistProject } from '../../../../lib/typeDistProject';
|
||||
import { createTypeDistProject } from '../typeDistProject';
|
||||
|
||||
// These packages aren't safe to pack in parallel since the CLI depends on them
|
||||
const UNSAFE_PACKAGES = [
|
||||
|
||||
@@ -19,7 +19,7 @@ import npmPackList from 'npm-packlist';
|
||||
import { resolve as resolvePath, posix as posixPath } from 'node:path';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { readEntryPoints } from '../entryPoints';
|
||||
import { getEntryPointDefaultFeatureType } from '../../../../lib/typeDistProject';
|
||||
import { getEntryPointDefaultFeatureType } from '../typeDistProject';
|
||||
import { Project } from 'ts-morph';
|
||||
|
||||
const PKG_PATH = 'package.json';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import yargs from 'yargs';
|
||||
import { Command } from 'commander';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export const configOption = [
|
||||
'--config <path>',
|
||||
|
||||
@@ -27,11 +27,9 @@ type Options = {
|
||||
targetDir?: string;
|
||||
fromPackage?: string;
|
||||
mockEnv?: boolean;
|
||||
withFilteredKeys?: boolean;
|
||||
withDeprecatedKeys?: boolean;
|
||||
fullVisibility?: boolean;
|
||||
strict?: boolean;
|
||||
watch?: (newFrontendAppConfigs: AppConfig[]) => void;
|
||||
};
|
||||
|
||||
export async function loadCliConfig(options: Options) {
|
||||
@@ -73,48 +71,29 @@ export async function loadCliConfig(options: Options) {
|
||||
substitutionFunc: options.mockEnv
|
||||
? async name => process.env[name] || 'x'
|
||||
: undefined,
|
||||
watch: Boolean(options.watch),
|
||||
rootDir: targetPaths.rootDir,
|
||||
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
|
||||
});
|
||||
|
||||
const appConfigs = await new Promise<AppConfig[]>((resolve, reject) => {
|
||||
async function loadConfigReaderLoop() {
|
||||
async function readConfig() {
|
||||
let loaded = false;
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
for await (const { configs } of source.readConfigData({
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
if (loaded) {
|
||||
const newFrontendAppConfigs = schema.process(configs, {
|
||||
visibility: options.fullVisibility
|
||||
? ['frontend', 'backend', 'secret']
|
||||
: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
withDeprecatedKeys: options.withDeprecatedKeys,
|
||||
ignoreSchemaErrors: !options.strict,
|
||||
});
|
||||
options.watch?.(newFrontendAppConfigs);
|
||||
} else {
|
||||
resolve(configs);
|
||||
loaded = true;
|
||||
|
||||
if (!options.watch) {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
resolve(configs);
|
||||
loaded = true;
|
||||
abortController.abort();
|
||||
}
|
||||
} catch (error) {
|
||||
if (loaded) {
|
||||
console.error(`Failed to reload configuration, ${error}`);
|
||||
} else {
|
||||
if (!loaded) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadConfigReaderLoop();
|
||||
readConfig();
|
||||
});
|
||||
|
||||
const configurationLoadedMessage = appConfigs.length
|
||||
@@ -130,7 +109,6 @@ export async function loadCliConfig(options: Options) {
|
||||
visibility: options.fullVisibility
|
||||
? ['frontend', 'backend', 'secret']
|
||||
: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
withDeprecatedKeys: options.withDeprecatedKeys,
|
||||
ignoreSchemaErrors: !options.strict,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { Command } from 'commander';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'new',
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
import { version as cliVersion } from '../../../../package.json';
|
||||
import os from 'node:os';
|
||||
import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common';
|
||||
import { Lockfile } from '../../../lib/versioning';
|
||||
import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node';
|
||||
import {
|
||||
BackstagePackageJson,
|
||||
Lockfile,
|
||||
PackageGraph,
|
||||
} from '@backstage/cli-node';
|
||||
import { minimatch } from 'minimatch';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import yargs from 'yargs';
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'info',
|
||||
|
||||
@@ -24,11 +24,11 @@ import {
|
||||
BackstagePackageJson,
|
||||
Lockfile,
|
||||
runWorkerQueueThreads,
|
||||
SuccessCache,
|
||||
} from '@backstage/cli-node';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { createScriptOptionsParser } from '../../../../lib/optionsParser';
|
||||
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
|
||||
import { createScriptOptionsParser } from '../../lib/optionsParser';
|
||||
|
||||
function depCount(pkg: BackstagePackageJson) {
|
||||
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
|
||||
@@ -41,7 +41,10 @@ function depCount(pkg: BackstagePackageJson) {
|
||||
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
let packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
const cache = new SuccessCache('lint', opts.successCacheDir);
|
||||
const cache = SuccessCache.create({
|
||||
name: 'lint',
|
||||
basePath: opts.successCacheDir,
|
||||
});
|
||||
const cacheContext = opts.successCache
|
||||
? {
|
||||
entries: await cache.read(),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { Command } from 'commander';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export function registerPackageLintCommand(command: Command) {
|
||||
command.arguments('[directories...]');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2024 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 { Command } from 'commander';
|
||||
|
||||
export function createScriptOptionsParser(
|
||||
anyCmd: Command,
|
||||
commandPath: string[],
|
||||
) {
|
||||
// Regardless of what command instance is passed in we want to find
|
||||
// the root command and resolve the path from there
|
||||
let rootCmd = anyCmd;
|
||||
while (rootCmd.parent) {
|
||||
rootCmd = rootCmd.parent;
|
||||
}
|
||||
|
||||
// Now find the command that was requested
|
||||
let targetCmd = rootCmd as Command | undefined;
|
||||
for (const name of commandPath) {
|
||||
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
|
||||
| Command
|
||||
| undefined;
|
||||
}
|
||||
|
||||
if (!targetCmd) {
|
||||
throw new Error(
|
||||
`Could not find package command '${commandPath.join(' ')}'`,
|
||||
);
|
||||
}
|
||||
const cmd = targetCmd;
|
||||
|
||||
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
|
||||
|
||||
return (scriptStr?: string) => {
|
||||
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const argsStr = scriptStr.slice(expectedScript.length).trim();
|
||||
|
||||
// Can't clone or copy or even use commands as prototype, so we mutate
|
||||
// the necessary members instead, and then reset them once we're done
|
||||
const currentOpts = (cmd as any)._optionValues;
|
||||
const currentStore = (cmd as any)._storeOptionsAsProperties;
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
(cmd as any)._storeOptionsAsProperties = false;
|
||||
(cmd as any)._optionValues = result;
|
||||
|
||||
// Triggers the writing of options to the result object
|
||||
cmd.parseOptions(argsStr.split(' '));
|
||||
|
||||
(cmd as any)._optionValues = currentOpts;
|
||||
(cmd as any)._storeOptionsAsProperties = currentStore;
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -31,8 +31,6 @@ import {
|
||||
} from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { publishPreflightCheck } from '../../lib/publishing';
|
||||
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
|
||||
|
||||
/**
|
||||
@@ -507,8 +505,6 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
fixPluginId,
|
||||
fixPluginPackages,
|
||||
fixPeerModules,
|
||||
// Run the publish preflight check too, to make sure we don't uncover errors during publishing
|
||||
publishPreflightCheck,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,50 +15,11 @@
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'maintenance',
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
path: ['package', 'clean'],
|
||||
description: 'Delete cache directories',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/package/clean'), 'default'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'prepack'],
|
||||
description: 'Prepares a package for packaging before publishing',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/package/pack'), 'pre'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'postpack'],
|
||||
description: 'Restores the changes made by the prepack command',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/package/pack'), 'post'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'fix'],
|
||||
description: 'Automatically fix packages in the project',
|
||||
@@ -79,19 +40,6 @@ export default createCliPlugin({
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'clean'],
|
||||
description: 'Delete cache and output directories',
|
||||
execute: async ({ args }) => {
|
||||
const command = new Command();
|
||||
const defaultCommand = command.action(
|
||||
lazy(() => import('./commands/repo/clean'), 'command'),
|
||||
);
|
||||
|
||||
await defaultCommand.parseAsync(args, { from: 'user' });
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'list-deprecations'],
|
||||
description: 'List deprecations',
|
||||
|
||||
@@ -14,21 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
fixPackageExports,
|
||||
readFixablePackages,
|
||||
writeFixedPackages,
|
||||
} from '../../maintenance/commands/repo/fix';
|
||||
|
||||
export async function command() {
|
||||
console.log(
|
||||
'The `migrate package-exports` command is deprecated, use `repo fix` instead.',
|
||||
throw new Error(
|
||||
'The `migrate package-exports` command has been removed, use `repo fix` instead.',
|
||||
);
|
||||
const packages = await readFixablePackages();
|
||||
|
||||
for (const pkg of packages) {
|
||||
fixPackageExports(pkg);
|
||||
}
|
||||
|
||||
await writeFixedPackages(packages);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import * as runObj from '@backstage/cli-common';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump';
|
||||
import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils';
|
||||
import { YarnInfoInspectData } from '../../../../lib/versioning/packages';
|
||||
import { YarnInfoInspectData } from '../../lib/versioning/packages';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { rest } from 'msw';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
@@ -69,8 +69,8 @@ jest.mock('@backstage/cli-common', () => {
|
||||
});
|
||||
|
||||
const mockFetchPackageInfo = jest.fn();
|
||||
jest.mock('../../../../lib/versioning/packages', () => {
|
||||
const actual = jest.requireActual('../../../../lib/versioning/packages');
|
||||
jest.mock('../../lib/versioning/packages', () => {
|
||||
const actual = jest.requireActual('../../lib/versioning/packages');
|
||||
return {
|
||||
...actual,
|
||||
fetchPackageInfo: (name: string) => mockFetchPackageInfo(name),
|
||||
|
||||
@@ -30,14 +30,16 @@ import { OptionValues } from 'commander';
|
||||
import { isError, NotFoundError } from '@backstage/errors';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
import {
|
||||
hasBackstageYarnPlugin,
|
||||
Lockfile,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import {
|
||||
fetchPackageInfo,
|
||||
Lockfile,
|
||||
mapDependencies,
|
||||
YarnInfoInspectData,
|
||||
} from '../../../../lib/versioning';
|
||||
import { runConcurrentTasks } from '@backstage/cli-node';
|
||||
} from '../../lib/versioning/packages';
|
||||
import {
|
||||
getManifestByReleaseLine,
|
||||
getManifestByVersion,
|
||||
@@ -74,7 +76,7 @@ function extendsDefaultPattern(pattern: string): boolean {
|
||||
export default async (opts: OptionValues) => {
|
||||
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
|
||||
const lockfile = await Lockfile.load(lockfilePath);
|
||||
const hasYarnPlugin = await getHasYarnPlugin();
|
||||
const yarnPluginEnabled = await hasBackstageYarnPlugin();
|
||||
|
||||
let pattern = opts.pattern;
|
||||
|
||||
@@ -128,7 +130,7 @@ export default async (opts: OptionValues) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (hasYarnPlugin) {
|
||||
if (yarnPluginEnabled) {
|
||||
console.log();
|
||||
console.log(
|
||||
`Updating yarn plugin to v${releaseManifest.releaseVersion}...`,
|
||||
@@ -212,7 +214,7 @@ export default async (opts: OptionValues) => {
|
||||
const oldLockfileRange = await asLockfileVersion(oldRange);
|
||||
|
||||
const useBackstageRange =
|
||||
hasYarnPlugin &&
|
||||
yarnPluginEnabled &&
|
||||
// Only use backstage:^ versions if the package is present in
|
||||
// the manifest for the release we're bumping to.
|
||||
releaseManifest.packages.find(
|
||||
@@ -252,7 +254,7 @@ export default async (opts: OptionValues) => {
|
||||
if (extendsDefaultPattern(pattern)) {
|
||||
await bumpBackstageJsonVersion(
|
||||
releaseManifest.releaseVersion,
|
||||
hasYarnPlugin,
|
||||
yarnPluginEnabled,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
@@ -317,7 +319,7 @@ export default async (opts: OptionValues) => {
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (hasYarnPlugin) {
|
||||
if (yarnPluginEnabled) {
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.blue(
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { Command } from 'commander';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'migrate',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { Command } from 'commander';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
import { NotImplementedError } from '@backstage/errors';
|
||||
|
||||
export default createCliPlugin({
|
||||
|
||||
@@ -24,11 +24,11 @@ import startCase from 'lodash/startCase';
|
||||
import upperCase from 'lodash/upperCase';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import lowerFirst from 'lodash/lowerFirst';
|
||||
import { Lockfile } from '../../../../lib/versioning';
|
||||
import { Lockfile } from '@backstage/cli-node';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { createPackageVersionProvider } from '../../../../lib/version';
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
import { createPackageVersionProvider } from '../version';
|
||||
import { hasBackstageYarnPlugin } from '@backstage/cli-node';
|
||||
|
||||
const builtInHelpers = {
|
||||
camelCase,
|
||||
@@ -55,9 +55,9 @@ export class PortableTemplater {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
const hasYarnPlugin = await getHasYarnPlugin();
|
||||
const yarnPluginEnabled = await hasBackstageYarnPlugin();
|
||||
const versionProvider = createPackageVersionProvider(lockfile, {
|
||||
preferBackstageProtocol: hasYarnPlugin,
|
||||
preferBackstageProtocol: yarnPluginEnabled,
|
||||
});
|
||||
|
||||
const templater = new PortableTemplater(
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { packageVersions, createPackageVersionProvider } from './version';
|
||||
import { Lockfile } from './versioning';
|
||||
import { Lockfile } from '@backstage/cli-node';
|
||||
import corePluginApiPkg from '@backstage/core-plugin-api/package.json';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
@@ -14,13 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import semver from 'semver';
|
||||
import { findOwnPaths } from '@backstage/cli-common';
|
||||
import { Lockfile } from './versioning';
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const ownPaths = findOwnPaths(__dirname);
|
||||
import { Lockfile } from '@backstage/cli-node';
|
||||
|
||||
/* eslint-disable @backstage/no-relative-monorepo-imports */
|
||||
/*
|
||||
@@ -35,28 +30,28 @@ This does not create an actual dependency on these packages and does not bring i
|
||||
Rollup will extract the value of the version field in each package at build time without
|
||||
leaving any imports in place.
|
||||
*/
|
||||
import { version as backendPluginApi } from '../../../../packages/backend-plugin-api/package.json';
|
||||
import { version as backendTestUtils } from '../../../../packages/backend-test-utils/package.json';
|
||||
import { version as catalogClient } from '../../../../packages/catalog-client/package.json';
|
||||
import { version as cli } from '../../../../packages/cli/package.json';
|
||||
import { version as config } from '../../../../packages/config/package.json';
|
||||
import { version as coreAppApi } from '../../../../packages/core-app-api/package.json';
|
||||
import { version as coreComponents } from '../../../../packages/core-components/package.json';
|
||||
import { version as corePluginApi } from '../../../../packages/core-plugin-api/package.json';
|
||||
import { version as devUtils } from '../../../../packages/dev-utils/package.json';
|
||||
import { version as errors } from '../../../../packages/errors/package.json';
|
||||
import { version as frontendDefaults } from '../../../../packages/frontend-defaults/package.json';
|
||||
import { version as frontendPluginApi } from '../../../../packages/frontend-plugin-api/package.json';
|
||||
import { version as frontendTestUtils } from '../../../../packages/frontend-test-utils/package.json';
|
||||
import { version as testUtils } from '../../../../packages/test-utils/package.json';
|
||||
import { version as scaffolderNode } from '../../../../plugins/scaffolder-node/package.json';
|
||||
import { version as scaffolderNodeTestUtils } from '../../../../plugins/scaffolder-node-test-utils/package.json';
|
||||
import { version as authBackend } from '../../../../plugins/auth-backend/package.json';
|
||||
import { version as authBackendModuleGuestProvider } from '../../../../plugins/auth-backend-module-guest-provider/package.json';
|
||||
import { version as catalogNode } from '../../../../plugins/catalog-node/package.json';
|
||||
import { version as theme } from '../../../../packages/theme/package.json';
|
||||
import { version as types } from '../../../../packages/types/package.json';
|
||||
import { version as backendDefaults } from '../../../../packages/backend-defaults/package.json';
|
||||
import { version as backendPluginApi } from '../../../../../../packages/backend-plugin-api/package.json';
|
||||
import { version as backendTestUtils } from '../../../../../../packages/backend-test-utils/package.json';
|
||||
import { version as catalogClient } from '../../../../../../packages/catalog-client/package.json';
|
||||
import { version as cli } from '../../../../../../packages/cli/package.json';
|
||||
import { version as config } from '../../../../../../packages/config/package.json';
|
||||
import { version as coreAppApi } from '../../../../../../packages/core-app-api/package.json';
|
||||
import { version as coreComponents } from '../../../../../../packages/core-components/package.json';
|
||||
import { version as corePluginApi } from '../../../../../../packages/core-plugin-api/package.json';
|
||||
import { version as devUtils } from '../../../../../../packages/dev-utils/package.json';
|
||||
import { version as errors } from '../../../../../../packages/errors/package.json';
|
||||
import { version as frontendDefaults } from '../../../../../../packages/frontend-defaults/package.json';
|
||||
import { version as frontendPluginApi } from '../../../../../../packages/frontend-plugin-api/package.json';
|
||||
import { version as frontendTestUtils } from '../../../../../../packages/frontend-test-utils/package.json';
|
||||
import { version as testUtils } from '../../../../../../packages/test-utils/package.json';
|
||||
import { version as scaffolderNode } from '../../../../../../plugins/scaffolder-node/package.json';
|
||||
import { version as scaffolderNodeTestUtils } from '../../../../../../plugins/scaffolder-node-test-utils/package.json';
|
||||
import { version as authBackend } from '../../../../../../plugins/auth-backend/package.json';
|
||||
import { version as authBackendModuleGuestProvider } from '../../../../../../plugins/auth-backend-module-guest-provider/package.json';
|
||||
import { version as catalogNode } from '../../../../../../plugins/catalog-node/package.json';
|
||||
import { version as theme } from '../../../../../../packages/theme/package.json';
|
||||
import { version as types } from '../../../../../../packages/types/package.json';
|
||||
import { version as backendDefaults } from '../../../../../../packages/backend-defaults/package.json';
|
||||
|
||||
export const packageVersions: Record<string, string> = {
|
||||
'@backstage/backend-defaults': backendDefaults,
|
||||
@@ -84,14 +79,6 @@ export const packageVersions: Record<string, string> = {
|
||||
'@backstage/plugin-catalog-node': catalogNode,
|
||||
};
|
||||
|
||||
export function findVersion() {
|
||||
const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8');
|
||||
return JSON.parse(pkgContent).version;
|
||||
}
|
||||
|
||||
export const version = findVersion();
|
||||
export const isDev = fs.pathExistsSync(ownPaths.resolve('src'));
|
||||
|
||||
export function createPackageVersionProvider(
|
||||
lockfile?: Lockfile,
|
||||
options?: {
|
||||
@@ -22,7 +22,7 @@ import yargs from 'yargs';
|
||||
import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli';
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { Command, OptionValues } from 'commander';
|
||||
import { Lockfile, PackageGraph } from '@backstage/cli-node';
|
||||
import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node';
|
||||
|
||||
import {
|
||||
runCheck,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
findOwnPaths,
|
||||
isChildPath,
|
||||
} from '@backstage/cli-common';
|
||||
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
|
||||
|
||||
type JestProject = {
|
||||
displayName: string;
|
||||
@@ -333,7 +332,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new SuccessCache('test', opts.successCacheDir);
|
||||
const cache = SuccessCache.create({
|
||||
name: 'test',
|
||||
basePath: opts.successCacheDir,
|
||||
});
|
||||
const graph = await getPackageGraph();
|
||||
|
||||
// Shared state for the bridge
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { Command } from 'commander';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'test',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
import yargs from 'yargs';
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { lazy } from '../../wiring/lazy';
|
||||
import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath';
|
||||
|
||||
export default createCliPlugin({
|
||||
|
||||
@@ -18,9 +18,9 @@ import { CommandGraph } from './CommandGraph';
|
||||
import { CliFeature, OpaqueCliPlugin } from './types';
|
||||
import { CommandRegistry } from './CommandRegistry';
|
||||
import { Command } from 'commander';
|
||||
import { version } from '../lib/version';
|
||||
import { version } from './version';
|
||||
import chalk from 'chalk';
|
||||
import { exitWithError } from '../lib/errors';
|
||||
import { exitWithError } from './errors';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
import { isPromise } from 'node:util/types';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { exitWithError } from '../lib/errors';
|
||||
import { exitWithError } from './errors';
|
||||
|
||||
type ActionFunc = (...args: any[]) => Promise<void>;
|
||||
type ActionExports<TModule extends object> = {
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2020 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 fs from 'fs-extra';
|
||||
import { findOwnPaths } from '@backstage/cli-common';
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const ownPaths = findOwnPaths(__dirname);
|
||||
|
||||
export function findVersion() {
|
||||
const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8');
|
||||
return JSON.parse(pkgContent).version;
|
||||
}
|
||||
|
||||
export const version = findVersion();
|
||||
export const isDev = fs.pathExistsSync(ownPaths.resolve('src'));
|
||||
@@ -165,7 +165,7 @@ describe('dynamicFrontendFeaturesLoader', () => {
|
||||
shareConfig: { singleton: true, requiredVersion: '*', eager: true },
|
||||
},
|
||||
{
|
||||
name: '@mui/material/styles/',
|
||||
name: '@mui/material/styles',
|
||||
version: '5.16.14',
|
||||
lib: async () => ({ default: {} }),
|
||||
shareConfig: { singleton: true, requiredVersion: '*', eager: true },
|
||||
|
||||
@@ -55,7 +55,7 @@ const defaultSharedDependencies = {
|
||||
// MUI v5
|
||||
// not setting import: false for MUI packages as this
|
||||
// will break once Backstage moves to BUI
|
||||
'@mui/material/styles/': {
|
||||
'@mui/material/styles': {
|
||||
host: {},
|
||||
remote: {},
|
||||
},
|
||||
|
||||
@@ -76,7 +76,7 @@ export const AccordionDefinition: {
|
||||
readonly propDefs: {
|
||||
readonly bg: {
|
||||
readonly dataAttribute: true;
|
||||
readonly default: 'neutral-auto';
|
||||
readonly default: 'neutral';
|
||||
};
|
||||
readonly children: {};
|
||||
readonly className: {};
|
||||
@@ -897,6 +897,7 @@ export const DialogDefinition: {
|
||||
readonly classNames: {
|
||||
readonly overlay: 'bui-DialogOverlay';
|
||||
readonly dialog: 'bui-Dialog';
|
||||
readonly content: 'bui-DialogContent';
|
||||
readonly header: 'bui-DialogHeader';
|
||||
readonly headerTitle: 'bui-DialogHeaderTitle';
|
||||
readonly body: 'bui-DialogBody';
|
||||
@@ -1011,14 +1012,7 @@ export const FlexDefinition: {
|
||||
'direction',
|
||||
];
|
||||
readonly dataAttributes: {
|
||||
readonly bg: readonly [
|
||||
'neutral-1',
|
||||
'neutral-2',
|
||||
'neutral-3',
|
||||
'danger',
|
||||
'warning',
|
||||
'success',
|
||||
];
|
||||
readonly bg: readonly ['neutral', 'danger', 'warning', 'success'];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1095,14 +1089,7 @@ export const GridDefinition: {
|
||||
'py',
|
||||
];
|
||||
readonly dataAttributes: {
|
||||
readonly bg: readonly [
|
||||
'neutral-1',
|
||||
'neutral-2',
|
||||
'neutral-3',
|
||||
'danger',
|
||||
'warning',
|
||||
'success',
|
||||
];
|
||||
readonly bg: readonly ['neutral', 'danger', 'warning', 'success'];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1113,14 +1100,7 @@ export const GridItemDefinition: {
|
||||
};
|
||||
readonly utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan'];
|
||||
readonly dataAttributes: {
|
||||
readonly bg: readonly [
|
||||
'neutral-1',
|
||||
'neutral-2',
|
||||
'neutral-3',
|
||||
'danger',
|
||||
'warning',
|
||||
'success',
|
||||
];
|
||||
readonly bg: readonly ['neutral', 'danger', 'warning', 'success'];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1331,6 +1311,7 @@ export const MenuDefinition: {
|
||||
readonly classNames: {
|
||||
readonly root: 'bui-Menu';
|
||||
readonly popover: 'bui-MenuPopover';
|
||||
readonly inner: 'bui-MenuInner';
|
||||
readonly content: 'bui-MenuContent';
|
||||
readonly section: 'bui-MenuSection';
|
||||
readonly sectionHeader: 'bui-MenuSectionHeader';
|
||||
@@ -1602,7 +1583,7 @@ export interface PopoverProps extends Omit<PopoverProps_2, 'children'> {
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ProviderBg = ContainerBg | 'neutral-auto';
|
||||
export type ProviderBg = 'neutral' | 'danger' | 'warning' | 'success';
|
||||
|
||||
// @public (undocumented)
|
||||
export interface QueryOptions<TFilter> {
|
||||
@@ -2218,6 +2199,7 @@ export const Tooltip: ForwardRefExoticComponent<
|
||||
export const TooltipDefinition: {
|
||||
readonly classNames: {
|
||||
readonly tooltip: 'bui-Tooltip';
|
||||
readonly content: 'bui-TooltipContent';
|
||||
readonly arrow: 'bui-TooltipArrow';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ export const AutoBg = meta.story({
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</Flex>
|
||||
<Box bg="neutral-1" p="4">
|
||||
<Box bg="neutral" p="4">
|
||||
<Text>Neutral 1 container</Text>
|
||||
<Flex mt="2">
|
||||
<Accordion defaultExpanded>
|
||||
@@ -210,35 +210,41 @@ export const AutoBg = meta.story({
|
||||
</Accordion>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box bg="neutral-2" p="4">
|
||||
<Text>Neutral 2 container</Text>
|
||||
<Flex mt="2">
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionTrigger title="Auto (neutral-3)" />
|
||||
<AccordionPanel>
|
||||
<Content />
|
||||
<Flex mt="3" gap="2">
|
||||
<Button>Action</Button>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4">
|
||||
<Text>Neutral 2 container</Text>
|
||||
<Flex mt="2">
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionTrigger title="Auto (neutral-3)" />
|
||||
<AccordionPanel>
|
||||
<Content />
|
||||
<Flex mt="3" gap="2">
|
||||
<Button>Action</Button>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box bg="neutral-3" p="4">
|
||||
<Text>Neutral 3 container</Text>
|
||||
<Flex mt="2">
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionTrigger title="Auto (neutral-3)" />
|
||||
<AccordionPanel>
|
||||
<Content />
|
||||
<Flex mt="3" gap="2">
|
||||
<Button>Action</Button>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4">
|
||||
<Text>Neutral 3 container</Text>
|
||||
<Flex mt="2">
|
||||
<Accordion defaultExpanded>
|
||||
<AccordionTrigger title="Auto (neutral-3)" />
|
||||
<AccordionPanel>
|
||||
<Content />
|
||||
<Flex mt="3" gap="2">
|
||||
<Button>Action</Button>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</Flex>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
),
|
||||
|
||||
@@ -34,7 +34,7 @@ export const AccordionDefinition = defineComponent<AccordionOwnProps>()({
|
||||
},
|
||||
bg: 'provider',
|
||||
propDefs: {
|
||||
bg: { dataAttribute: true, default: 'neutral-auto' },
|
||||
bg: { dataAttribute: true, default: 'neutral' },
|
||||
children: {},
|
||||
className: {},
|
||||
},
|
||||
|
||||
@@ -297,7 +297,7 @@ export const OnDifferentBackgrounds = meta.story({
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>On Neutral 1</Text>
|
||||
<Flex direction="column" gap="2" bg="neutral-1" p="4">
|
||||
<Flex direction="column" gap="2" bg="neutral" p="4">
|
||||
<Alert status="info" icon={true} title="Alert on neutral-1" />
|
||||
<Alert status="success" icon={true} title="Alert on neutral-1" />
|
||||
</Flex>
|
||||
@@ -305,18 +305,24 @@ export const OnDifferentBackgrounds = meta.story({
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>On Neutral 2</Text>
|
||||
<Flex direction="column" gap="2" bg="neutral-2" p="4">
|
||||
<Alert status="info" icon={true} title="Alert on neutral-2" />
|
||||
<Alert status="success" icon={true} title="Alert on neutral-2" />
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Flex direction="column" gap="2" bg="neutral" p="4">
|
||||
<Alert status="info" icon={true} title="Alert on neutral-2" />
|
||||
<Alert status="success" icon={true} title="Alert on neutral-2" />
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>On Neutral 3</Text>
|
||||
<Flex direction="column" gap="2" bg="neutral-3" p="4">
|
||||
<Alert status="info" icon={true} title="Alert on neutral-3" />
|
||||
<Alert status="success" icon={true} title="Alert on neutral-3" />
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Flex direction="column" gap="2" bg="neutral" p="4">
|
||||
<Alert status="info" icon={true} title="Alert on neutral-3" />
|
||||
<Alert status="success" icon={true} title="Alert on neutral-3" />
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
@@ -337,7 +343,7 @@ export const Responsive = meta.story({
|
||||
|
||||
export const WithUtilityProps = meta.story({
|
||||
render: () => (
|
||||
<Box bg="neutral-1" py="4">
|
||||
<Box bg="neutral" py="4">
|
||||
<Alert
|
||||
status="success"
|
||||
icon={true}
|
||||
|
||||
@@ -353,17 +353,20 @@ export const BackgroundColors = meta.story({
|
||||
render: args => (
|
||||
<Flex align="center" style={{ flexWrap: 'wrap' }}>
|
||||
<Box {...args}>Default</Box>
|
||||
<Box bg="neutral-1" {...args}>
|
||||
Neutral 1
|
||||
<Box bg="neutral" {...args}>
|
||||
Neutral (level 1)
|
||||
</Box>
|
||||
<Box bg="neutral-2" {...args}>
|
||||
Neutral 2
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" {...args}>
|
||||
Neutral (level 2)
|
||||
</Box>
|
||||
</Box>
|
||||
<Box bg="neutral-3" {...args}>
|
||||
Neutral 3
|
||||
</Box>
|
||||
<Box bg={{ initial: 'neutral-1', sm: 'neutral-2' }} {...args}>
|
||||
Responsive Neutral
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" {...args}>
|
||||
Neutral (level 3)
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box bg="danger" {...args}>
|
||||
Danger
|
||||
@@ -381,11 +384,11 @@ export const BackgroundColors = meta.story({
|
||||
export const NestedNeutralColors = meta.story({
|
||||
args: { px: '6', py: '4', children: null },
|
||||
render: args => (
|
||||
<Box {...args} bg="neutral-1">
|
||||
<Box {...args} bg="neutral">
|
||||
<Button variant="secondary">Button (on neutral-1)</Button>
|
||||
<Box {...args} bg="neutral-2" mt="4">
|
||||
<Box {...args} bg="neutral" mt="4">
|
||||
<Button variant="secondary">Button (on neutral-2)</Button>
|
||||
<Box {...args} bg="neutral-3" mt="4">
|
||||
<Box {...args} bg="neutral" mt="4">
|
||||
<Button variant="secondary">Button (on neutral-3)</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import preview from '../../../../../.storybook/preview';
|
||||
import { allModes } from '../../../../../.storybook/modes';
|
||||
import { Button } from './Button';
|
||||
import { Flex } from '../Flex';
|
||||
import { Box } from '../Box';
|
||||
@@ -55,100 +56,37 @@ export const Variants = meta.story({
|
||||
control: false,
|
||||
},
|
||||
},
|
||||
chromatic: {
|
||||
modes: {
|
||||
'light spotify neutral-1': allModes['light spotify neutral-1'],
|
||||
'light spotify neutral-2': allModes['light spotify neutral-2'],
|
||||
'light spotify neutral-3': allModes['light spotify neutral-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Default</Text>
|
||||
<Flex align="center" p="4">
|
||||
<Button iconStart={<RiCloudLine />} variant="primary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<Button iconStart={<RiCloudLine />} variant="primary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Neutral 1</Text>
|
||||
<Flex align="center" bg="neutral-1" p="4">
|
||||
<Button iconStart={<RiCloudLine />} variant="primary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Neutral 2</Text>
|
||||
<Flex align="center" bg="neutral-2" p="4">
|
||||
<Button iconStart={<RiCloudLine />} variant="primary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Neutral 3</Text>
|
||||
<Flex align="center" bg="neutral-3" p="4">
|
||||
<Button iconStart={<RiCloudLine />} variant="primary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary">
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<Button iconStart={<RiCloudLine />} variant="primary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="secondary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
<Button iconStart={<RiCloudLine />} variant="tertiary" destructive>
|
||||
Button
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
@@ -208,20 +146,6 @@ export const Destructive = meta.story({
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>On Neutral 1</Text>
|
||||
<Flex align="center" bg="neutral-1" p="4" gap="4">
|
||||
<Button variant="primary" destructive>
|
||||
Primary
|
||||
</Button>
|
||||
<Button variant="secondary" destructive>
|
||||
Secondary
|
||||
</Button>
|
||||
<Button variant="tertiary" destructive>
|
||||
Tertiary
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Sizes</Text>
|
||||
<Flex align="center" p="4" gap="4">
|
||||
@@ -433,26 +357,32 @@ export const AutoBg = meta.story({
|
||||
neutral level by 1. No prop is needed on the button -- it's fully
|
||||
automatic.
|
||||
</div>
|
||||
<Box bg="neutral-1" p="4">
|
||||
<Box bg="neutral" p="4">
|
||||
<Text>Neutral 1 container</Text>
|
||||
<Flex gap="2" mt="2">
|
||||
<Button variant="secondary">Auto (neutral-2)</Button>
|
||||
<Button variant="tertiary">Auto (neutral-2)</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box bg="neutral-2" p="4">
|
||||
<Text>Neutral 2 container</Text>
|
||||
<Flex gap="2" mt="2">
|
||||
<Button variant="secondary">Auto (neutral-3)</Button>
|
||||
<Button variant="tertiary">Auto (neutral-3)</Button>
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4">
|
||||
<Text>Neutral 2 container</Text>
|
||||
<Flex gap="2" mt="2">
|
||||
<Button variant="secondary">Auto (neutral-3)</Button>
|
||||
<Button variant="tertiary">Auto (neutral-3)</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box bg="neutral-3" p="4">
|
||||
<Text>Neutral 3 container</Text>
|
||||
<Flex gap="2" mt="2">
|
||||
<Button variant="secondary">Auto (neutral-4)</Button>
|
||||
<Button variant="tertiary">Auto (neutral-4)</Button>
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4">
|
||||
<Text>Neutral 3 container</Text>
|
||||
<Flex gap="2" mt="2">
|
||||
<Button variant="secondary">Auto (neutral-4)</Button>
|
||||
<Button variant="tertiary">Auto (neutral-4)</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
),
|
||||
|
||||
@@ -135,23 +135,29 @@ export const Backgrounds = meta.story({
|
||||
<CardHeader>No parent</CardHeader>
|
||||
<CardBody>Defaults to neutral-1</CardBody>
|
||||
</Card>
|
||||
<Box bg="neutral-1" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-1</CardHeader>
|
||||
<CardBody>Auto-increments to neutral-2</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
<Box bg="neutral-2" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-2</CardHeader>
|
||||
<CardBody>Auto-increments to neutral-3</CardBody>
|
||||
</Card>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-2</CardHeader>
|
||||
<CardBody>Auto-increments to neutral-3</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box bg="neutral-3" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-3</CardHeader>
|
||||
<CardBody>Steps up to neutral-4</CardBody>
|
||||
</Card>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-3</CardHeader>
|
||||
<CardBody>Steps up to neutral-4</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
),
|
||||
@@ -197,23 +203,29 @@ export const BgOnProviders = meta.story({
|
||||
<CardHeader>No provider</CardHeader>
|
||||
<CardBody>Card defaults to neutral-1</CardBody>
|
||||
</Card>
|
||||
<Box bg="neutral-1" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-1</CardHeader>
|
||||
<CardBody>Card auto-increments to neutral-2</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
<Box bg="neutral-2" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-2</CardHeader>
|
||||
<CardBody>Card auto-increments to neutral-3</CardBody>
|
||||
</Card>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-2</CardHeader>
|
||||
<CardBody>Card auto-increments to neutral-3</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box bg="neutral-3" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-3</CardHeader>
|
||||
<CardBody>Card visually at neutral-4</CardBody>
|
||||
</Card>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px' }}>
|
||||
<Card {...args} style={{ width: '200px' }}>
|
||||
<CardHeader>On neutral-3</CardHeader>
|
||||
<CardBody>Card visually at neutral-4</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
),
|
||||
@@ -226,11 +238,7 @@ export const CustomCardWithBox = meta.story({
|
||||
A custom card built with Box. Use Box with an explicit bg prop to create
|
||||
a card-like container that participates in the bg system as a provider.
|
||||
</Box>
|
||||
<Box
|
||||
bg="neutral-auto"
|
||||
p="4"
|
||||
style={{ borderRadius: '8px', width: '300px' }}
|
||||
>
|
||||
<Box bg="neutral" p="4" style={{ borderRadius: '8px', width: '300px' }}>
|
||||
<Button variant="secondary" style={{ marginTop: '8px' }}>
|
||||
Button (on neutral-1)
|
||||
</Button>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="neutral-auto"
|
||||
bg="neutral"
|
||||
ref={ref}
|
||||
className={classes.root}
|
||||
{...dataAttributes}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
max-width: 120rem;
|
||||
padding-inline: var(--bui-space-4);
|
||||
margin-inline: auto;
|
||||
transition: padding 0.2s ease-in-out;
|
||||
padding-bottom: var(--bui-space-8);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
||||
@@ -43,8 +43,10 @@
|
||||
}
|
||||
|
||||
.bui-Dialog {
|
||||
background: var(--bui-bg-popover);
|
||||
border-radius: 0.5rem;
|
||||
--dialog-border-radius: 0.5rem;
|
||||
background: var(--bui-bg-app);
|
||||
box-shadow: var(--bui-shadow);
|
||||
border-radius: var(--dialog-border-radius);
|
||||
border: 1px solid var(--bui-border-1);
|
||||
color: var(--bui-fg-primary);
|
||||
position: relative;
|
||||
@@ -52,9 +54,14 @@
|
||||
max-width: calc(100vw - 3rem);
|
||||
height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem));
|
||||
max-height: calc(100vh - 3rem);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bui-DialogContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
border-radius: var(--dialog-border-radius);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Dialog entering animation */
|
||||
|
||||
@@ -63,6 +63,24 @@ export const Default = meta.story({
|
||||
});
|
||||
|
||||
export const Open = Default.extend({
|
||||
parameters: { layout: 'fullscreen' },
|
||||
decorators: [
|
||||
Story => (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, var(--bui-border-1) 1px, transparent 1px)',
|
||||
backgroundSize: '16px 16px',
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
defaultOpen: true,
|
||||
},
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Button } from '../Button';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { DialogDefinition } from './definition';
|
||||
import { Flex } from '../Flex';
|
||||
import { Box } from '../Box';
|
||||
import { BgReset } from '../../hooks/useBg';
|
||||
import styles from './Dialog.module.css';
|
||||
|
||||
/** @public */
|
||||
@@ -71,7 +73,14 @@ export const Dialog = forwardRef<React.ElementRef<typeof Modal>, DialogProps>(
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<BgReset>
|
||||
<Box
|
||||
bg="neutral"
|
||||
className={clsx(classNames.content, styles[classNames.content])}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</BgReset>
|
||||
</RADialog>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export const DialogDefinition = {
|
||||
classNames: {
|
||||
overlay: 'bui-DialogOverlay',
|
||||
dialog: 'bui-Dialog',
|
||||
content: 'bui-DialogContent',
|
||||
header: 'bui-DialogHeader',
|
||||
headerTitle: 'bui-DialogHeaderTitle',
|
||||
body: 'bui-DialogBody',
|
||||
|
||||
@@ -253,18 +253,21 @@ export const Backgrounds = meta.story({
|
||||
render: args => (
|
||||
<Flex align="center" style={{ flexWrap: 'wrap' }}>
|
||||
<Flex {...args}>Default</Flex>
|
||||
<Flex bg="neutral-1" {...args}>
|
||||
Neutral 1
|
||||
</Flex>
|
||||
<Flex bg="neutral-2" {...args}>
|
||||
Neutral 2
|
||||
</Flex>
|
||||
<Flex bg="neutral-3" {...args}>
|
||||
Neutral 3
|
||||
</Flex>
|
||||
<Flex bg={{ initial: 'neutral-1', sm: 'neutral-2' }} {...args}>
|
||||
Responsive Bg
|
||||
<Flex bg="neutral" {...args}>
|
||||
Neutral (level 1)
|
||||
</Flex>
|
||||
<Box bg="neutral">
|
||||
<Flex bg="neutral" {...args}>
|
||||
Neutral (level 2)
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Flex bg="neutral" {...args}>
|
||||
Neutral (level 3)
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
<Flex bg="danger" {...args}>
|
||||
Danger
|
||||
</Flex>
|
||||
@@ -278,20 +281,20 @@ export const Backgrounds = meta.story({
|
||||
),
|
||||
});
|
||||
|
||||
export const BgNeutralAuto = meta.story({
|
||||
export const BgNeutral = meta.story({
|
||||
args: { px: '6', py: '4', gap: '4' },
|
||||
render: args => (
|
||||
<Flex direction="column">
|
||||
<div style={{ maxWidth: '600px', marginBottom: '16px' }}>
|
||||
Using bg="neutral-auto" on Flex auto-increments from the parent context.
|
||||
The first Flex defaults to neutral-1 (no parent), then each nested Flex
|
||||
Using bg="neutral" on Flex auto-increments from the parent context. The
|
||||
first Flex defaults to neutral-1 (no parent), then each nested Flex
|
||||
increments by one, capping at neutral-3.
|
||||
</div>
|
||||
<Flex {...args} bg="neutral-auto" direction="column">
|
||||
<div>Neutral 1 (auto, no parent)</div>
|
||||
<Flex {...args} bg="neutral-auto" direction="column">
|
||||
<Flex {...args} bg="neutral" direction="column">
|
||||
<div>Neutral 1 (no parent)</div>
|
||||
<Flex {...args} bg="neutral" direction="column">
|
||||
<div>Neutral 2 (auto-incremented)</div>
|
||||
<Flex {...args} bg="neutral-auto" direction="column">
|
||||
<Flex {...args} bg="neutral" direction="column">
|
||||
<div>Neutral 3 (auto-incremented, capped)</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -45,13 +45,6 @@ export const FlexDefinition = {
|
||||
'direction',
|
||||
],
|
||||
dataAttributes: {
|
||||
bg: [
|
||||
'neutral-1',
|
||||
'neutral-2',
|
||||
'neutral-3',
|
||||
'danger',
|
||||
'warning',
|
||||
'success',
|
||||
] as const,
|
||||
bg: ['neutral', 'danger', 'warning', 'success'] as const,
|
||||
},
|
||||
} as const satisfies ComponentDefinition;
|
||||
|
||||
@@ -113,18 +113,21 @@ export const Backgrounds = meta.story({
|
||||
render: args => (
|
||||
<Flex direction="column">
|
||||
<Flex style={{ flexWrap: 'wrap' }}>
|
||||
<Grid.Root {...args} bg="neutral-1">
|
||||
Neutral 1
|
||||
</Grid.Root>
|
||||
<Grid.Root {...args} bg="neutral-2">
|
||||
Neutral 2
|
||||
</Grid.Root>
|
||||
<Grid.Root {...args} bg="neutral-3">
|
||||
Neutral 3
|
||||
</Grid.Root>
|
||||
<Grid.Root {...args} bg={{ initial: 'neutral-1', sm: 'neutral-2' }}>
|
||||
Responsive Bg
|
||||
<Grid.Root {...args} bg="neutral">
|
||||
Neutral (level 1)
|
||||
</Grid.Root>
|
||||
<Box bg="neutral">
|
||||
<Grid.Root {...args} bg="neutral">
|
||||
Neutral (level 2)
|
||||
</Grid.Root>
|
||||
</Box>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Grid.Root {...args} bg="neutral">
|
||||
Neutral (level 3)
|
||||
</Grid.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
<Grid.Root {...args} bg="danger">
|
||||
Danger
|
||||
</Grid.Root>
|
||||
@@ -137,28 +140,26 @@ export const Backgrounds = meta.story({
|
||||
</Flex>
|
||||
<Flex style={{ flexWrap: 'wrap' }}>
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item bg="neutral-1" style={{ padding: '4px' }}>
|
||||
Neutral 1
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item bg="neutral-2" style={{ padding: '4px' }}>
|
||||
Neutral 2
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item bg="neutral-3" style={{ padding: '4px' }}>
|
||||
Neutral 3
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item
|
||||
bg={{ initial: 'neutral-1', sm: 'neutral-2' }}
|
||||
style={{ padding: '4px' }}
|
||||
>
|
||||
Responsive Bg
|
||||
<Grid.Item bg="neutral" style={{ padding: '4px' }}>
|
||||
Neutral (level 1)
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
<Box bg="neutral">
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item bg="neutral" style={{ padding: '4px' }}>
|
||||
Neutral (level 2)
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
</Box>
|
||||
<Box bg="neutral">
|
||||
<Box bg="neutral">
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item bg="neutral" style={{ padding: '4px' }}>
|
||||
Neutral (level 3)
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
<Grid.Root {...args}>
|
||||
<Grid.Item bg="danger" style={{ padding: '4px' }}>
|
||||
Danger
|
||||
@@ -179,7 +180,7 @@ export const Backgrounds = meta.story({
|
||||
),
|
||||
});
|
||||
|
||||
export const BgNeutralAuto = meta.story({
|
||||
export const BgNeutral = meta.story({
|
||||
args: { px: '6', py: '4', columns: '2', gap: '4' },
|
||||
render: args => (
|
||||
<Flex direction="column">
|
||||
@@ -188,12 +189,12 @@ export const BgNeutralAuto = meta.story({
|
||||
default. Only an explicit bg prop establishes a new bg level. Nested
|
||||
grids without a bg prop inherit the parent context unchanged.
|
||||
</div>
|
||||
<Grid.Root {...args} bg="neutral-1">
|
||||
<Grid.Root {...args} bg="neutral">
|
||||
<Grid.Item>Neutral 1 (Grid.Root)</Grid.Item>
|
||||
<Grid.Item>
|
||||
<Grid.Root {...args} bg="neutral-2">
|
||||
<Grid.Item>Nested: neutral-2 (explicit)</Grid.Item>
|
||||
<Grid.Item>Nested: neutral-2 (explicit)</Grid.Item>
|
||||
<Grid.Root {...args} bg="neutral">
|
||||
<Grid.Item>Nested: neutral-2 (auto-incremented)</Grid.Item>
|
||||
<Grid.Item>Nested: neutral-2 (auto-incremented)</Grid.Item>
|
||||
</Grid.Root>
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
|
||||
@@ -43,14 +43,7 @@ export const GridDefinition = {
|
||||
'py',
|
||||
],
|
||||
dataAttributes: {
|
||||
bg: [
|
||||
'neutral-1',
|
||||
'neutral-2',
|
||||
'neutral-3',
|
||||
'danger',
|
||||
'warning',
|
||||
'success',
|
||||
] as const,
|
||||
bg: ['neutral', 'danger', 'warning', 'success'] as const,
|
||||
},
|
||||
} as const satisfies ComponentDefinition;
|
||||
|
||||
@@ -64,13 +57,6 @@ export const GridItemDefinition = {
|
||||
},
|
||||
utilityProps: ['colSpan', 'colEnd', 'colStart', 'rowSpan'],
|
||||
dataAttributes: {
|
||||
bg: [
|
||||
'neutral-1',
|
||||
'neutral-2',
|
||||
'neutral-3',
|
||||
'danger',
|
||||
'warning',
|
||||
'success',
|
||||
] as const,
|
||||
bg: ['neutral', 'danger', 'warning', 'success'] as const,
|
||||
},
|
||||
} as const satisfies ComponentDefinition;
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
|
||||
@layer components {
|
||||
.bui-MenuPopover {
|
||||
--menu-border-radius: var(--bui-radius-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--bui-shadow);
|
||||
border: 1px solid var(--bui-border-1);
|
||||
border-radius: var(--bui-radius-2);
|
||||
background: var(--bui-bg-popover);
|
||||
border-radius: var(--menu-border-radius);
|
||||
background: var(--bui-bg-app);
|
||||
color: var(--bui-fg-primary);
|
||||
outline: none;
|
||||
transition: transform 200ms, opacity 200ms;
|
||||
@@ -55,6 +56,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.bui-MenuInner {
|
||||
border-radius: var(--menu-border-radius);
|
||||
}
|
||||
|
||||
.bui-MenuContent {
|
||||
max-height: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user