rename to if and add examples of dynamic cards
Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
9770aed23b
commit
ce97558e11
@@ -23,6 +23,9 @@ import {
|
||||
PageBlueprint,
|
||||
FrontendPluginInfo,
|
||||
useAppNode,
|
||||
createExtensionBlueprint,
|
||||
createExtensionInput,
|
||||
coreExtensionData,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
@@ -88,8 +91,8 @@ const IndexPage = PageBlueprint.make({
|
||||
<h2>Permission Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>enabled</code> predicate using permissions. They
|
||||
will only appear when the user has the required permissions.
|
||||
via the <code>if</code> predicate using permissions. They will
|
||||
only appear when the user has the required permissions.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -98,13 +101,20 @@ const IndexPage = PageBlueprint.make({
|
||||
</Link>{' '}
|
||||
— requires <code>catalog.entity.create</code>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/permission-card-example">
|
||||
Permission Card Example
|
||||
</Link>{' '}
|
||||
— a page that is always visible, but individual cards on it are
|
||||
toggled by permissions
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Feature Flag Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>enabled</code> predicate. They will only appear in
|
||||
the router tree when their conditions are satisfied. Toggle the
|
||||
via the <code>if</code> predicate. They will only appear in the
|
||||
router tree when their conditions are satisfied. Toggle the
|
||||
relevant feature flags in <Link to="/settings">Settings</Link>,
|
||||
then refresh the app to see the pages appear.
|
||||
</p>
|
||||
@@ -194,7 +204,7 @@ const ExternalPage = PageBlueprint.make({
|
||||
|
||||
// Example: Page enabled only when a single feature flag is active.
|
||||
//
|
||||
// The `enabled` predicate is evaluated once at app startup (before the router
|
||||
// The `if` predicate is evaluated once at app startup (before the router
|
||||
// tree is built), so this page simply won't exist in the app until the flag is
|
||||
// toggled and the page is refreshed.
|
||||
//
|
||||
@@ -227,7 +237,7 @@ const FeatureFlagPage = PageBlueprint.make({
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: { featureFlags: { $contains: 'experimental-features' } },
|
||||
if: { featureFlags: { $contains: 'experimental-features' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when ALL of several feature flags are active.
|
||||
@@ -263,7 +273,7 @@ const AllFlagsPage = PageBlueprint.make({
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
@@ -304,7 +314,7 @@ const AnyFlagPage = PageBlueprint.make({
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
@@ -312,9 +322,113 @@ const AnyFlagPage = PageBlueprint.make({
|
||||
},
|
||||
});
|
||||
|
||||
// Blueprint for cards that attach to the PermissionCardPage below.
|
||||
//
|
||||
// Each card receives a title and description and renders a simple bordered card.
|
||||
// Individual card instances can be selectively enabled via the `if`
|
||||
// predicate, so only the cards the user is allowed to see will be instantiated.
|
||||
const PermissionExampleCardBlueprint = createExtensionBlueprint({
|
||||
kind: 'permission-example-card',
|
||||
attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { title: string; description: string }) {
|
||||
yield coreExtensionData.reactElement(
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>{params.title}</h3>
|
||||
<p style={{ marginBottom: 0 }}>{params.description}</p>
|
||||
</div>,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page with cards that are individually toggled by permissions.
|
||||
//
|
||||
// The page itself is always present. What changes is which cards are
|
||||
// instantiated inside it — each card declares its own `enabled` predicate
|
||||
// and is only wired into the page if that predicate is satisfied at startup.
|
||||
//
|
||||
// To test: make sure you do NOT have the catalog.entity.create permission and
|
||||
// refresh the page — the "Restricted Card" below should disappear.
|
||||
const PermissionCardPage = PageBlueprint.makeWithOverrides({
|
||||
name: 'permissionCardExample',
|
||||
inputs: {
|
||||
cards: createExtensionInput([coreExtensionData.reactElement]),
|
||||
},
|
||||
factory(originalFactory, { inputs }) {
|
||||
return originalFactory({
|
||||
path: '/permission-card-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
const cards = inputs.cards.map(card =>
|
||||
card.get(coreExtensionData.reactElement),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<h1>Permission-Gated Card Example</h1>
|
||||
<p>
|
||||
This page is always visible. The cards below are individually
|
||||
gated — each one declares its own{' '}
|
||||
<code>{'if: { permissions: { $contains: "..." } }'}</code>{' '}
|
||||
predicate. Cards whose predicate fails are never instantiated,
|
||||
so they simply won't appear here.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{cards.length > 0 ? (
|
||||
cards
|
||||
) : (
|
||||
<p>
|
||||
No cards are visible — you may lack the required
|
||||
permissions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Always-visible card — no predicate, every user sees this.
|
||||
const PublicCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'public',
|
||||
params: {
|
||||
title: 'Public Card',
|
||||
description: 'This card is visible to everyone regardless of permissions.',
|
||||
},
|
||||
});
|
||||
|
||||
// Permission-gated card — only instantiated when the user has
|
||||
// the catalog.entity.create permission.
|
||||
const RestrictedCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'restricted',
|
||||
params: {
|
||||
title: 'Restricted Card',
|
||||
description:
|
||||
'This card is only visible to users who have the catalog.entity.create permission.',
|
||||
},
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when the user is allowed to create catalog entities.
|
||||
//
|
||||
// The `enabled` predicate is evaluated once at app startup (after sign-in),
|
||||
// The `if` predicate is evaluated once at app startup (after sign-in),
|
||||
// so this page simply won't exist in the router tree if the user lacks the
|
||||
// required permission.
|
||||
const PermissionGatedPage = PageBlueprint.make({
|
||||
@@ -338,7 +452,7 @@ const PermissionGatedPage = PageBlueprint.make({
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
export const pagesPlugin = createFrontendPlugin({
|
||||
@@ -373,6 +487,9 @@ export const pagesPlugin = createFrontendPlugin({
|
||||
FeatureFlagPage,
|
||||
AllFlagsPage,
|
||||
AnyFlagPage,
|
||||
PermissionCardPage,
|
||||
PublicCard,
|
||||
RestrictedCard,
|
||||
PermissionGatedPage,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-defaults": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -49,6 +48,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
@@ -1783,9 +1783,9 @@ describe('instantiateAppNodeTree', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled predicate', () => {
|
||||
describe('if predicate', () => {
|
||||
function makeNodeWithEnabled(
|
||||
enabled: AppNodeSpec['enabled'],
|
||||
enabled: AppNodeSpec['if'],
|
||||
disabled = false,
|
||||
): AppNode {
|
||||
const ext = resolveExtensionDefinition(
|
||||
@@ -1801,7 +1801,7 @@ describe('instantiateAppNodeTree', () => {
|
||||
id: ext.id,
|
||||
attachTo: ext.attachTo,
|
||||
disabled,
|
||||
enabled,
|
||||
if: enabled,
|
||||
extension: ext as Extension<unknown, unknown>,
|
||||
plugin: createFrontendPlugin({ pluginId: 'app' }),
|
||||
},
|
||||
|
||||
@@ -538,8 +538,8 @@ export function instantiateAppNodeTree(
|
||||
}
|
||||
if (
|
||||
options?.predicateContext !== undefined &&
|
||||
node.spec.enabled !== undefined &&
|
||||
!evaluateFilterPredicate(node.spec.enabled, options.predicateContext)
|
||||
node.spec.if !== undefined &&
|
||||
!evaluateFilterPredicate(node.spec.if, options.predicateContext)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -509,15 +509,15 @@ describe('resolveAppNodeSpecs', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry enabled predicate through to AppNodeSpec', () => {
|
||||
it('should carry if predicate through to AppNodeSpec', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const enabledPredicate = { featureFlags: { $contains: 'my-flag' } };
|
||||
const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
enabled: enabledPredicate,
|
||||
if: ifPredicate,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('value')],
|
||||
}),
|
||||
@@ -530,6 +530,6 @@ describe('resolveAppNodeSpecs', () => {
|
||||
collector,
|
||||
});
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0].enabled).toEqual(enabledPredicate);
|
||||
expect(specs[0].if).toEqual(ifPredicate);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,9 +116,9 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
enabled:
|
||||
if:
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
@@ -133,9 +133,9 @@ export function resolveAppNodeSpecs(options: {
|
||||
plugin: appPlugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
enabled:
|
||||
if:
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
@@ -156,10 +156,8 @@ export function resolveAppNodeSpecs(options: {
|
||||
configuredExtensions[index].extension = internalExtension;
|
||||
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
|
||||
configuredExtensions[index].params.disabled = internalExtension.disabled;
|
||||
configuredExtensions[index].params.enabled =
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
: undefined;
|
||||
configuredExtensions[index].params.if =
|
||||
internalExtension.version === 'v2' ? internalExtension.if : undefined;
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
@@ -169,9 +167,9 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: extension.plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
enabled:
|
||||
if:
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
config: undefined,
|
||||
},
|
||||
@@ -251,7 +249,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
attachTo: param.params.attachTo,
|
||||
extension: param.extension,
|
||||
disabled: param.params.disabled,
|
||||
enabled: param.params.enabled,
|
||||
if: param.params.if,
|
||||
plugin: param.params.plugin,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
|
||||
@@ -23,7 +23,6 @@ export function createApp(options?: CreateAppOptions): {
|
||||
// @public
|
||||
export interface CreateAppOptions {
|
||||
advanced?: {
|
||||
allowUnknownExtensionConfig?: boolean;
|
||||
configLoader?: () => Promise<{
|
||||
config: ConfigApi;
|
||||
}>;
|
||||
|
||||
@@ -71,7 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
readonly name?: string;
|
||||
readonly attachTo: ExtensionDefinitionAttachTo;
|
||||
readonly disabled: boolean;
|
||||
readonly enabled?: FilterPredicate;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly configSchema?: PortableSchema<any, any>;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface AppNodeSpec {
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly enabled?: FilterPredicate;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly config?: unknown;
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -257,7 +257,7 @@ export interface OverridableExtensionDefinition<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -477,7 +477,7 @@ export function createExtension<
|
||||
name: options.name,
|
||||
attachTo: options.attachTo,
|
||||
disabled: options.disabled ?? false,
|
||||
enabled: options.enabled,
|
||||
if: options.if,
|
||||
inputs: bindInputs(options.inputs, options.kind, options.name),
|
||||
output: options.output,
|
||||
configSchema,
|
||||
@@ -555,7 +555,7 @@ export function createExtension<
|
||||
attachTo: (overrideOptions.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: overrideOptions.disabled ?? options.disabled,
|
||||
enabled: overrideOptions.enabled ?? options.enabled,
|
||||
if: overrideOptions.if ?? options.if,
|
||||
inputs: bindInputs(
|
||||
{
|
||||
...(options.inputs ?? {}),
|
||||
|
||||
@@ -115,7 +115,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -223,7 +223,7 @@ export interface ExtensionBlueprint<
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
@@ -264,7 +264,7 @@ export interface ExtensionBlueprint<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -514,7 +514,7 @@ export function createExtensionBlueprint<
|
||||
attachTo: (args.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: args.disabled ?? options.disabled,
|
||||
enabled: args.enabled ?? options.enabled,
|
||||
if: args.if ?? options.if,
|
||||
inputs: options.inputs,
|
||||
output: options.output as ExtensionDataRef[],
|
||||
config: options.config,
|
||||
@@ -532,7 +532,7 @@ export function createExtensionBlueprint<
|
||||
attachTo: (args.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: args.disabled ?? options.disabled,
|
||||
enabled: args.enabled ?? options.enabled,
|
||||
if: args.if ?? options.if,
|
||||
inputs: { ...args.inputs, ...options.inputs },
|
||||
output: (args.output ?? options.output) as ExtensionDataRef[],
|
||||
config:
|
||||
|
||||
@@ -75,7 +75,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
|
||||
}
|
||||
| {
|
||||
readonly version: 'v2';
|
||||
readonly enabled?: FilterPredicate;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
factory(options: {
|
||||
|
||||
Reference in New Issue
Block a user