Merge remote-tracking branch 'origin/master' into orkohunter/techdocs-publish-to-cloud-storage
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': minor
|
||||
---
|
||||
|
||||
Add support for multiple types of entity cost breakdown.
|
||||
|
||||
This change is backwards-incompatible with plugin-cost-insights 0.3.x; the `entities` field on Entity returned in product cost queries changed from `Entity[]` to `Record<string, Entity[]`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Update experimental backend bundle command to only output archives to `dist/` instead of a full workspace mirror in `dist-workspace/`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Batch the fetching of relations
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Add `"files": ["dist"]` to both app and backend packages. This ensures that packaged versions of these packages do not contain unnecessary files.
|
||||
|
||||
To apply this change to an existing app, add the following to `packages/app/package.json` and `packages/backend/package.json`:
|
||||
|
||||
```json
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/catalog-model': minor
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity.
|
||||
|
||||
See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': minor
|
||||
---
|
||||
|
||||
Removed modifyCss transformer and moved the css to injectCss transformer
|
||||
Fixed issue where some internal doc links would cause a reload of the page
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/theme': patch
|
||||
---
|
||||
|
||||
Add a little more padding in dense tables
|
||||
@@ -20,4 +20,5 @@ stringData:
|
||||
AZURE_TOKEN: {{ .Values.auth.azure.api.token }}
|
||||
NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }}
|
||||
TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }}
|
||||
PAGERDUTY_TOKEN: {{ .Values.auth.pagerdutyToken }}
|
||||
{{- end }}
|
||||
|
||||
@@ -250,3 +250,4 @@ auth:
|
||||
gitlabToken: g
|
||||
newRelicRestApiKey: r
|
||||
travisciAuthToken: fake-travis-ci-auth-token
|
||||
pagerdutyToken: h
|
||||
|
||||
@@ -724,9 +724,7 @@ metadata:
|
||||
spec:
|
||||
type: business-unit
|
||||
parent: ops
|
||||
ancestors: [ops, global-synergies, acme-corp]
|
||||
children: [backstage, other]
|
||||
descendants: [backstage, other, team-a, team-b, team-c, team-d]
|
||||
```
|
||||
|
||||
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
|
||||
@@ -762,25 +760,6 @@ namespace as the user. Only `Group` entities may be referenced. Most commonly,
|
||||
this field points to a group in the same namespace, so in those cases it is
|
||||
sufficient to enter only the `metadata.name` field of that group.
|
||||
|
||||
### `spec.ancestors` [required]
|
||||
|
||||
**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
|
||||
removed entirely from the model on Dec 6th, 2020 in the repository and will not
|
||||
be present in released packages following the next release after that. Please
|
||||
update your code to not consume this field before the removal date.
|
||||
|
||||
The recursive list of parents up the hierarchy, by stepping through parents one
|
||||
by one. The list must be present, but may be empty if `parent` is not present.
|
||||
The first entry in the list is equal to `parent`, and then the following ones
|
||||
are progressively farther up the hierarchy.
|
||||
|
||||
The entries of this array are
|
||||
[entity references](https://backstage.io/docs/features/software-catalog/references),
|
||||
with the default kind `Group` and the default namespace equal to the same
|
||||
namespace as the user. Only `Group` entities may be referenced. Most commonly,
|
||||
these entries point to groups in the same namespace, so in those cases it is
|
||||
sufficient to enter only the `metadata.name` field of those groups.
|
||||
|
||||
### `spec.children` [required]
|
||||
|
||||
The immediate child groups of this group in the hierarchy (whose `parent` field
|
||||
@@ -795,25 +774,6 @@ namespace as the user. Only `Group` entities may be referenced. Most commonly,
|
||||
these entries point to groups in the same namespace, so in those cases it is
|
||||
sufficient to enter only the `metadata.name` field of those groups.
|
||||
|
||||
### `spec.descendants` [required]
|
||||
|
||||
**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
|
||||
removed entirely from the model on Dec 6th, 2020 in the repository and will not
|
||||
be present in released packages following the next release after that. Please
|
||||
update your code to not consume this field before the removal date.
|
||||
|
||||
The immediate and recursive child groups of this group in the hierarchy
|
||||
(children, and children's children, etc.). The list must be present, but may be
|
||||
empty if there are no child groups. The items are not guaranteed to be ordered
|
||||
in any particular way.
|
||||
|
||||
The entries of this array are
|
||||
[entity references](https://backstage.io/docs/features/software-catalog/references),
|
||||
with the default kind `Group` and the default namespace equal to the same
|
||||
namespace as the user. Only `Group` entities may be referenced. Most commonly,
|
||||
these entries point to groups in the same namespace, so in those cases it is
|
||||
sufficient to enter only the `metadata.name` field of those groups.
|
||||
|
||||
## Kind: User
|
||||
|
||||
Describes the following entity kind:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spotify/prettier-config": "^9.0.0",
|
||||
"docusaurus": "^2.0.0-alpha.66",
|
||||
"docusaurus": "^2.0.0-alpha.378053ac5",
|
||||
"js-yaml": "^3.14.0",
|
||||
"prettier": "^2.2.1"
|
||||
},
|
||||
|
||||
+568
-500
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
||||
"@backstage/plugin-github-actions": "^0.2.3",
|
||||
"@backstage/plugin-gitops-profiles": "^0.2.1",
|
||||
"@backstage/plugin-graphiql": "^0.2.1",
|
||||
"@backstage/plugin-org": "^0.3.0",
|
||||
"@backstage/plugin-jenkins": "^0.3.2",
|
||||
"@backstage/plugin-kubernetes": "^0.3.1",
|
||||
"@backstage/plugin-lighthouse": "^0.2.4",
|
||||
@@ -89,5 +90,8 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiEntity, Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiEntity,
|
||||
Entity,
|
||||
GroupEntity,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { EmptyState } from '@backstage/core';
|
||||
import {
|
||||
ApiDefinitionCard,
|
||||
@@ -48,6 +53,12 @@ import {
|
||||
isPluginApplicableToEntity as isLighthouseAvailable,
|
||||
LastLighthouseAuditCard,
|
||||
} from '@backstage/plugin-lighthouse';
|
||||
import {
|
||||
OwnershipCard,
|
||||
MembersListCard,
|
||||
GroupProfileCard,
|
||||
UserProfileCard,
|
||||
} from '@backstage/plugin-org';
|
||||
import { Router as SentryRouter } from '@backstage/plugin-sentry';
|
||||
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
|
||||
import { Button, Grid } from '@material-ui/core';
|
||||
@@ -323,6 +334,51 @@ const ApiEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const UserOverviewContent = ({ entity }: { entity: UserEntity }) => (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<UserProfileCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<OwnershipCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const UserEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<UserOverviewContent entity={entity as UserEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<GroupProfileCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<OwnershipCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<MembersListCard entity={entity} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const GroupEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<GroupOverviewContent entity={entity as GroupEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
export const EntityPage = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
@@ -331,6 +387,10 @@ export const EntityPage = () => {
|
||||
return <ComponentEntityPage entity={entity} />;
|
||||
case 'api':
|
||||
return <ApiEntityPage entity={entity} />;
|
||||
case 'group':
|
||||
return <GroupEntityPage entity={entity} />;
|
||||
case 'user':
|
||||
return <UserEntityPage entity={entity} />;
|
||||
default:
|
||||
return <DefaultEntityPage entity={entity} />;
|
||||
}
|
||||
|
||||
@@ -42,3 +42,4 @@ export { plugin as UserSettings } from '@backstage/plugin-user-settings';
|
||||
export { plugin as PagerDuty } from '@backstage/plugin-pagerduty';
|
||||
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
|
||||
export { plugin as Search } from '@backstage/plugin-search';
|
||||
export { plugin as Org } from '@backstage/plugin-org';
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-serve-static-core": "^4.17.5",
|
||||
"@types/helmet": "^0.0.48"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,4 @@ metadata:
|
||||
spec:
|
||||
type: sub-department
|
||||
parent: infrastructure
|
||||
ancestors: [infrastructure, acme-corp]
|
||||
children: [team-a, team-b]
|
||||
descendants: [team-a, team-b]
|
||||
|
||||
@@ -6,6 +6,4 @@ metadata:
|
||||
spec:
|
||||
type: sub-department
|
||||
parent: infrastructure
|
||||
ancestors: [infrastructure, acme-corp]
|
||||
children: [team-c, team-d]
|
||||
descendants: [team-c, team-d]
|
||||
|
||||
@@ -6,6 +6,4 @@ metadata:
|
||||
spec:
|
||||
type: department
|
||||
parent: acme-corp
|
||||
ancestors: [acme-corp]
|
||||
children: [backstage, boxoffice]
|
||||
descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d]
|
||||
|
||||
@@ -5,10 +5,7 @@ metadata:
|
||||
description: The acme-corp organization
|
||||
spec:
|
||||
type: organization
|
||||
ancestors: []
|
||||
children: [infrastructure]
|
||||
descendants:
|
||||
[infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d]
|
||||
---
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Location
|
||||
|
||||
@@ -6,9 +6,7 @@ metadata:
|
||||
spec:
|
||||
type: team
|
||||
parent: backstage
|
||||
ancestors: [backstage, infrastructure, acme-corp]
|
||||
children: []
|
||||
descendants: []
|
||||
---
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: User
|
||||
|
||||
@@ -6,9 +6,7 @@ metadata:
|
||||
spec:
|
||||
type: team
|
||||
parent: backstage
|
||||
ancestors: [backstage, infrastructure, acme-corp]
|
||||
children: []
|
||||
descendants: []
|
||||
---
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: User
|
||||
|
||||
@@ -6,9 +6,7 @@ metadata:
|
||||
spec:
|
||||
type: team
|
||||
parent: boxoffice
|
||||
ancestors: [boxoffice, infrastructure, acme-corp]
|
||||
children: []
|
||||
descendants: []
|
||||
---
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: User
|
||||
|
||||
@@ -6,9 +6,7 @@ metadata:
|
||||
spec:
|
||||
type: team
|
||||
parent: boxoffice
|
||||
ancestors: [boxoffice, infrastructure, acme-corp]
|
||||
children: []
|
||||
descendants: []
|
||||
---
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: User
|
||||
|
||||
@@ -34,9 +34,7 @@ describe('GroupV1alpha1Validator', () => {
|
||||
spec: {
|
||||
type: 'squad',
|
||||
parent: 'group-a',
|
||||
ancestors: ['group-a', 'global-synergies', 'acme-corp'],
|
||||
children: ['child-a', 'child-b'],
|
||||
descendants: ['desc-a', 'desc-b'],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -85,26 +83,6 @@ describe('GroupV1alpha1Validator', () => {
|
||||
await expect(validator.check(entity)).rejects.toThrow(/parent/);
|
||||
});
|
||||
|
||||
it('rejects missing ancestors', async () => {
|
||||
delete (entity as any).spec.ancestors;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
|
||||
});
|
||||
|
||||
it('rejects empty ancestors', async () => {
|
||||
(entity as any).spec.ancestors = [''];
|
||||
await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
|
||||
});
|
||||
|
||||
it('rejects undefined ancestors', async () => {
|
||||
(entity as any).spec.ancestors = [undefined];
|
||||
await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
|
||||
});
|
||||
|
||||
it('accepts no ancestors', async () => {
|
||||
(entity as any).spec.ancestors = [];
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing children', async () => {
|
||||
delete (entity as any).spec.children;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/children/);
|
||||
@@ -124,24 +102,4 @@ describe('GroupV1alpha1Validator', () => {
|
||||
(entity as any).spec.children = [];
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing descendants', async () => {
|
||||
delete (entity as any).spec.descendants;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/descendants/);
|
||||
});
|
||||
|
||||
it('rejects empty descendants', async () => {
|
||||
(entity as any).spec.descendants = [''];
|
||||
await expect(validator.check(entity)).rejects.toThrow(/descendants/);
|
||||
});
|
||||
|
||||
it('rejects undefined descendants', async () => {
|
||||
(entity as any).spec.descendants = [undefined];
|
||||
await expect(validator.check(entity)).rejects.toThrow(/descendants/);
|
||||
});
|
||||
|
||||
it('accepts no descendants', async () => {
|
||||
(entity as any).spec.descendants = [];
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,21 +32,11 @@ const schema = yup.object<Partial<GroupEntityV1alpha1>>({
|
||||
// one element and there is no simple workaround -_-
|
||||
// the cast is there to convince typescript that the array itself is
|
||||
// required without using .required()
|
||||
ancestors: yup.array(yup.string().required()).test({
|
||||
name: 'isDefined',
|
||||
message: 'ancestors must be defined',
|
||||
test: v => Boolean(v),
|
||||
}) as yup.ArraySchema<string, object>,
|
||||
children: yup.array(yup.string().required()).test({
|
||||
name: 'isDefined',
|
||||
message: 'children must be defined',
|
||||
test: v => Boolean(v),
|
||||
}) as yup.ArraySchema<string, object>,
|
||||
descendants: yup.array(yup.string().required()).test({
|
||||
name: 'isDefined',
|
||||
message: 'descendants must be defined',
|
||||
test: v => Boolean(v),
|
||||
}) as yup.ArraySchema<string, object>,
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
@@ -57,23 +47,7 @@ export interface GroupEntityV1alpha1 extends Entity {
|
||||
spec: {
|
||||
type: string;
|
||||
parent?: string;
|
||||
/**
|
||||
* @deprecated This field will disappear on Dec 6th, 2020. Please remove
|
||||
* any consuming code. Producers can stop producing this field
|
||||
* before that date, as long as the catalog backend uses the
|
||||
* BuiltinKindsEntityProcessor which inserts the fields in the
|
||||
* mean time.
|
||||
*/
|
||||
ancestors: string[];
|
||||
children: string[];
|
||||
/**
|
||||
* @deprecated This field will disappear on Dec 6th, 2020. Please remove
|
||||
* any consuming code. Producers can stop producing this field
|
||||
* before that date, as long as the catalog backend uses the
|
||||
* BuiltinKindsEntityProcessor which inserts the fields in the
|
||||
* mean time.
|
||||
*/
|
||||
descendants: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,26 +14,59 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import os from 'os';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import tar, { CreateOptions } from 'tar';
|
||||
import { Command } from 'commander';
|
||||
import { createDistWorkspace } from '../../lib/packager';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
|
||||
const PKG_PATH = 'package.json';
|
||||
const TARGET_DIR = 'dist-workspace';
|
||||
const BUNDLE_FILE = 'bundle.tar.gz';
|
||||
const SKELETON_FILE = 'skeleton.tar.gz';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const targetDir = paths.resolveTarget(TARGET_DIR);
|
||||
const pkgPath = paths.resolveTarget(PKG_PATH);
|
||||
const pkg = await fs.readJson(pkgPath);
|
||||
const targetDir = paths.resolveTarget('dist');
|
||||
const pkg = await fs.readJson(paths.resolveTarget('package.json'));
|
||||
|
||||
await fs.remove(targetDir);
|
||||
await fs.mkdir(targetDir);
|
||||
await createDistWorkspace([pkg.name], {
|
||||
targetDir: targetDir,
|
||||
buildDependencies: Boolean(cmd.build),
|
||||
parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
|
||||
skeleton: 'skeleton.tar',
|
||||
});
|
||||
// We build the target package without generating type declarations.
|
||||
await buildPackage({ outputs: new Set([Output.cjs]) });
|
||||
|
||||
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
|
||||
try {
|
||||
await createDistWorkspace([pkg.name], {
|
||||
targetDir: tmpDir,
|
||||
buildDependencies: Boolean(cmd.build),
|
||||
buildExcludes: [pkg.name],
|
||||
parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
|
||||
skeleton: SKELETON_FILE,
|
||||
});
|
||||
|
||||
// We built the target backend package using the regular build process, but the result of
|
||||
// that has now been packed into the dist workspace, so clean up the dist dir.
|
||||
await fs.remove(targetDir);
|
||||
await fs.mkdir(targetDir);
|
||||
|
||||
// Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice.
|
||||
await fs.move(
|
||||
resolvePath(tmpDir, SKELETON_FILE),
|
||||
resolvePath(targetDir, SKELETON_FILE),
|
||||
);
|
||||
|
||||
// Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache.
|
||||
await tar.create(
|
||||
{
|
||||
file: resolvePath(targetDir, BUNDLE_FILE),
|
||||
cwd: tmpDir,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
gzip: true,
|
||||
} as CreateOptions & { noMtime: boolean },
|
||||
[''],
|
||||
);
|
||||
} finally {
|
||||
await fs.remove(tmpDir);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,6 +67,11 @@ type Options = {
|
||||
*/
|
||||
buildDependencies?: boolean;
|
||||
|
||||
/**
|
||||
* When `buildDependencies` is set, this list of packages will not be built even if they are dependencies.
|
||||
*/
|
||||
buildExcludes?: string[];
|
||||
|
||||
/**
|
||||
* Enable (true/false) or control amount of (number) parallelism in some build steps.
|
||||
*/
|
||||
@@ -76,7 +81,7 @@ type Options = {
|
||||
* If set, creates a skeleton tarball that contains all package.json files
|
||||
* with the same structure as the workspace dir.
|
||||
*/
|
||||
skeleton?: 'skeleton.tar';
|
||||
skeleton?: 'skeleton.tar' | 'skeleton.tar.gz';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,7 +103,11 @@ export async function createDistWorkspace(
|
||||
const targets = await findTargetPackages(packageNames);
|
||||
|
||||
if (options.buildDependencies) {
|
||||
const scopeArgs = targets.flatMap(target => ['--scope', target.name]);
|
||||
const exclude = options.buildExcludes ?? [];
|
||||
const scopeArgs = targets
|
||||
.filter(target => !exclude.includes(target.name))
|
||||
.flatMap(target => ['--scope', target.name]);
|
||||
|
||||
const lernaArgs =
|
||||
options.parallel && Number.isInteger(options.parallel)
|
||||
? ['--concurrency', options.parallel.toString()]
|
||||
@@ -131,6 +140,7 @@ export async function createDistWorkspace(
|
||||
cwd: targetDir,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
gzip: options.skeleton.endsWith('.gz'),
|
||||
} as CreateOptions & { noMtime: boolean },
|
||||
skeletonFiles,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React, { useState } from 'react';
|
||||
import {
|
||||
Drawer,
|
||||
Button,
|
||||
Typography,
|
||||
makeStyles,
|
||||
IconButton,
|
||||
createStyles,
|
||||
Theme,
|
||||
} from '@material-ui/core';
|
||||
import Close from '@material-ui/icons/Close';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Drawer',
|
||||
component: Drawer,
|
||||
};
|
||||
|
||||
const useDrawerStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
paper: {
|
||||
width: '50%',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing(2.5),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const useDrawerContentStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
header: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
icon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
content: {
|
||||
height: '80%',
|
||||
backgroundColor: '#EEEEEE',
|
||||
},
|
||||
secondaryAction: {
|
||||
marginLeft: theme.spacing(2.5),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/* Example content wrapped inside the Drawer component */
|
||||
const DrawerContent = ({
|
||||
toggleDrawer,
|
||||
}: {
|
||||
toggleDrawer: (isOpen: boolean) => void;
|
||||
}) => {
|
||||
const classes = useDrawerContentStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.header}>
|
||||
<Typography variant="h5">Side Panel Title</Typography>
|
||||
<IconButton
|
||||
key="dismiss"
|
||||
title="Close the drawer"
|
||||
onClick={() => toggleDrawer(false)}
|
||||
color="inherit"
|
||||
>
|
||||
<Close className={classes.icon} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={classes.content} />
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(false)}
|
||||
>
|
||||
Primary Action
|
||||
</Button>
|
||||
<Button
|
||||
className={classes.secondaryAction}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(false)}
|
||||
>
|
||||
Secondary Action
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* Default drawer can toggle open or closed.
|
||||
* It can be cancelled by clicking the overlay
|
||||
* or pressing the esc key.
|
||||
*/
|
||||
export const DefaultDrawer = () => {
|
||||
const [isOpen, toggleDrawer] = useState(false);
|
||||
const classes = useDrawerStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(true)}
|
||||
>
|
||||
Open Default Drawer
|
||||
</Button>
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.paper,
|
||||
}}
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={() => toggleDrawer(false)}
|
||||
>
|
||||
<DrawerContent toggleDrawer={toggleDrawer} />
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* Persistent drawer works like the default one -
|
||||
* except that the content sits on the same level
|
||||
* as the main content and you can't cancel it by
|
||||
* clicking the overlay or pressing the esc key.
|
||||
*
|
||||
* Set the Drawer variant props: 'persistent'
|
||||
*/
|
||||
export const PersistentDrawer = () => {
|
||||
const [isOpen, toggleDrawer] = useState(false);
|
||||
const classes = useDrawerStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(true)}
|
||||
>
|
||||
Open Persistent Drawer
|
||||
</Button>
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.paper,
|
||||
}}
|
||||
variant="persistent"
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={() => toggleDrawer(false)}
|
||||
>
|
||||
<DrawerContent toggleDrawer={toggleDrawer} />
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -61,5 +61,8 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -47,5 +47,8 @@
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-serve-static-core": "^4.17.5",
|
||||
"@types/helmet": "^0.0.47"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export class DirectoryPreparer implements PreparerBase {
|
||||
switch (type) {
|
||||
case 'github':
|
||||
case 'gitlab':
|
||||
case 'url':
|
||||
case 'azure/api': {
|
||||
const parsedGitLocation = parseGitUrl(target);
|
||||
const repoLocation = await checkoutGitRepository(target, this.logger);
|
||||
|
||||
@@ -135,7 +135,7 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides {
|
||||
borderBottom: 0,
|
||||
},
|
||||
sizeSmall: {
|
||||
padding: theme.spacing(1, 2, 1, 2.5),
|
||||
padding: theme.spacing(1.5, 2, 1.5, 2.5),
|
||||
},
|
||||
head: {
|
||||
wordBreak: 'break-word',
|
||||
|
||||
@@ -271,7 +271,7 @@ export class CommonDatabase implements Database {
|
||||
.select('entities.*')
|
||||
.orderBy('full_name', 'asc');
|
||||
|
||||
return Promise.all(rows.map(row => this.toEntityResponse(tx, row)));
|
||||
return this.toEntityResponses(tx, rows);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
@@ -290,7 +290,7 @@ export class CommonDatabase implements Database {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.toEntityResponse(tx, rows[0]);
|
||||
return this.toEntityResponses(tx, rows).then(r => r[0]);
|
||||
}
|
||||
|
||||
async entityByUid(
|
||||
@@ -307,7 +307,7 @@ export class CommonDatabase implements Database {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.toEntityResponse(tx, rows[0]);
|
||||
return this.toEntityResponses(tx, rows).then(r => r[0]);
|
||||
}
|
||||
|
||||
async removeEntityByUid(txOpaque: Transaction, uid: string): Promise<void> {
|
||||
@@ -494,31 +494,60 @@ export class CommonDatabase implements Database {
|
||||
};
|
||||
}
|
||||
|
||||
private async toEntityResponse(
|
||||
private async toEntityResponses(
|
||||
tx: Knex.Transaction<any, any>,
|
||||
row: DbEntitiesRow,
|
||||
): Promise<DbEntityResponse> {
|
||||
const entity = JSON.parse(row.data) as Entity;
|
||||
entity.metadata.uid = row.id;
|
||||
entity.metadata.etag = row.etag;
|
||||
entity.metadata.generation = Number(row.generation); // cast due to sqlite
|
||||
|
||||
rows: DbEntitiesRow[],
|
||||
): Promise<DbEntityResponse[]> {
|
||||
// TODO(Rugvip): This is here because it's simple for now, but we likely
|
||||
// need to refactor this to be more efficient or introduce pagination.
|
||||
const relations = await tx<DbEntitiesRelationsRow>('entities_relations')
|
||||
.where({ source_full_name: row.full_name })
|
||||
.orderBy(['type', 'target_full_name'])
|
||||
.select();
|
||||
const relations = await this.getRelationsPerFullName(
|
||||
tx,
|
||||
rows.map(r => r.full_name),
|
||||
);
|
||||
|
||||
entity.relations = deduplicateRelations(relations).map(r => ({
|
||||
target: parseEntityName(r.target_full_name),
|
||||
type: r.type,
|
||||
}));
|
||||
const result = new Array<DbEntityResponse>();
|
||||
for (const row of rows) {
|
||||
const entity = JSON.parse(row.data) as Entity;
|
||||
entity.metadata.uid = row.id;
|
||||
entity.metadata.etag = row.etag;
|
||||
entity.metadata.generation = Number(row.generation); // cast due to sqlite
|
||||
|
||||
return {
|
||||
locationId: row.location_id || undefined,
|
||||
entity,
|
||||
};
|
||||
entity.relations = (relations[row.full_name] ?? []).map(r => ({
|
||||
target: parseEntityName(r.target_full_name),
|
||||
type: r.type,
|
||||
}));
|
||||
|
||||
result.push({
|
||||
locationId: row.location_id || undefined,
|
||||
entity,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Returns a mapping from e.g. component:default/foo to the relations whose
|
||||
// source_full_name matches that.
|
||||
private async getRelationsPerFullName(
|
||||
tx: Knex.Transaction<any, any>,
|
||||
sourceFullNames: string[],
|
||||
): Promise<Record<string, DbEntitiesRelationsRow[]>> {
|
||||
const batches = lodash.chunk(lodash.uniq(sourceFullNames), 500);
|
||||
|
||||
const relations = new Array<DbEntitiesRelationsRow>();
|
||||
for (const batch of batches) {
|
||||
relations.push(
|
||||
...(await tx<DbEntitiesRelationsRow>('entities_relations')
|
||||
.whereIn('source_full_name', batch)
|
||||
.orderBy(['type', 'target_full_name'])
|
||||
.select()),
|
||||
);
|
||||
}
|
||||
|
||||
return lodash.groupBy(
|
||||
deduplicateRelations(relations),
|
||||
r => r.source_full_name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
// Write
|
||||
if (!previousLocation && !dryRun) {
|
||||
// TODO: We do not include location operations in the dryRun. We might perform
|
||||
// this operation as a seperate dry run.
|
||||
// this operation as a separate dry run.
|
||||
await this.locationsCatalog.addLocation(location);
|
||||
}
|
||||
if (readerOutput.entities.length === 0) {
|
||||
|
||||
@@ -23,34 +23,6 @@ import {
|
||||
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
|
||||
|
||||
describe('BuiltinKindsEntityProcessor', () => {
|
||||
it('fills in fields for #3049', async () => {
|
||||
const p = new BuiltinKindsEntityProcessor();
|
||||
const result = await p.preProcessEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
},
|
||||
spec: {
|
||||
type: 't',
|
||||
children: [],
|
||||
} as any,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
},
|
||||
spec: {
|
||||
type: 't',
|
||||
children: [],
|
||||
ancestors: [],
|
||||
descendants: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('postProcessEntity', () => {
|
||||
const processor = new BuiltinKindsEntityProcessor();
|
||||
const location = { type: 'a', target: 'b' };
|
||||
@@ -215,9 +187,7 @@ describe('BuiltinKindsEntityProcessor', () => {
|
||||
spec: {
|
||||
type: 't',
|
||||
parent: 'p',
|
||||
ancestors: [],
|
||||
children: ['c'],
|
||||
descendants: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -53,25 +53,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
|
||||
userEntityV1alpha1Validator,
|
||||
];
|
||||
|
||||
async preProcessEntity(entity: Entity): Promise<Entity> {
|
||||
// NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields
|
||||
// scheduled for removal Dec 6th, 2020. This code can be deleted after that
|
||||
// point. See https://github.com/backstage/backstage/issues/3049
|
||||
if (
|
||||
entity.apiVersion === 'backstage.io/v1alpha1' &&
|
||||
entity.kind === 'Group' &&
|
||||
entity.spec
|
||||
) {
|
||||
if (!entity.spec.ancestors) {
|
||||
entity.spec.ancestors = [];
|
||||
}
|
||||
if (!entity.spec.descendants) {
|
||||
entity.spec.descendants = [];
|
||||
}
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async validateEntityKind(entity: Entity): Promise<boolean> {
|
||||
for (const validator of this.validators) {
|
||||
const result = await validator.check(entity);
|
||||
|
||||
@@ -47,7 +47,7 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: { name: 'name' },
|
||||
spec: { type: 'type', ancestors: [], children: [], descendants: [] },
|
||||
spec: { type: 'type', children: [] },
|
||||
} as GroupEntity,
|
||||
data,
|
||||
);
|
||||
@@ -173,9 +173,7 @@ describe('readLdapGroups', () => {
|
||||
},
|
||||
spec: {
|
||||
type: 'type-value',
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -150,9 +150,7 @@ export async function readLdapGroups(
|
||||
},
|
||||
spec: {
|
||||
type: 'unknown',
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -49,9 +49,7 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
|
||||
name: 'name',
|
||||
},
|
||||
spec: {
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
type: 'team',
|
||||
},
|
||||
} as GroupEntity,
|
||||
@@ -306,33 +304,20 @@ describe('read microsoft graph', () => {
|
||||
resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf);
|
||||
|
||||
expect(rootGroup.spec.parent).toBeUndefined();
|
||||
expect(rootGroup.spec.ancestors).toEqual(expect.arrayContaining([]));
|
||||
expect(rootGroup.spec.children).toEqual(
|
||||
expect.arrayContaining(['a', 'b']),
|
||||
);
|
||||
expect(rootGroup.spec.descendants).toEqual(
|
||||
expect.arrayContaining(['a', 'b', 'c']),
|
||||
);
|
||||
|
||||
expect(groupA.spec.parent).toEqual('root');
|
||||
expect(groupA.spec.ancestors).toEqual(expect.arrayContaining(['root']));
|
||||
expect(groupA.spec.children).toEqual(expect.arrayContaining([]));
|
||||
expect(groupA.spec.descendants).toEqual(expect.arrayContaining([]));
|
||||
|
||||
expect(groupB.spec.parent).toEqual('root');
|
||||
expect(groupB.spec.ancestors).toEqual(expect.arrayContaining(['root']));
|
||||
expect(groupB.spec.children).toEqual(expect.arrayContaining(['c']));
|
||||
expect(groupB.spec.descendants).toEqual(expect.arrayContaining(['c']));
|
||||
|
||||
expect(groupC.spec.parent).toEqual('b');
|
||||
expect(groupC.spec.ancestors).toEqual(
|
||||
expect.arrayContaining(['root', 'b']),
|
||||
);
|
||||
expect(groupC.spec.children).toEqual(expect.arrayContaining([]));
|
||||
expect(groupC.spec.descendants).toEqual(expect.arrayContaining([]));
|
||||
|
||||
expect(user1.spec.memberOf).toEqual(expect.arrayContaining(['a']));
|
||||
|
||||
expect(user2.spec.memberOf).toEqual(expect.arrayContaining(['b', 'c']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
|
||||
import limiterFactory from 'p-limit';
|
||||
import { buildMemberOf, buildOrgHierarchy } from '../util/org';
|
||||
import { MicrosoftGraphClient } from './client';
|
||||
import {
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
MICROSOFT_GRAPH_TENANT_ID_ANNOTATION,
|
||||
MICROSOFT_GRAPH_USER_ID_ANNOTATION,
|
||||
} from './constants';
|
||||
import limiterFactory from 'p-limit';
|
||||
|
||||
export function normalizeEntityName(name: string): string {
|
||||
return name
|
||||
@@ -98,7 +98,7 @@ export async function readMicrosoftGraphOrganization(
|
||||
): Promise<{
|
||||
rootGroup: GroupEntity; // With all relations empty
|
||||
}> {
|
||||
// For now we expect a single root orgranization
|
||||
// For now we expect a single root organization
|
||||
const organization = await client.getOrganization(tenantId);
|
||||
const name = normalizeEntityName(organization.displayName!);
|
||||
const rootGroup: GroupEntity = {
|
||||
@@ -113,9 +113,7 @@ export async function readMicrosoftGraphOrganization(
|
||||
},
|
||||
spec: {
|
||||
type: 'root',
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -165,9 +163,7 @@ export async function readMicrosoftGraphGroups(
|
||||
spec: {
|
||||
type: 'team',
|
||||
// TODO: We could include a group email and picture
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -278,7 +274,7 @@ export function resolveRelations(
|
||||
});
|
||||
});
|
||||
|
||||
// Make sure that all groups have proper ancestors and descendants
|
||||
// Make sure that all groups have proper parents and children
|
||||
buildOrgHierarchy(groups);
|
||||
|
||||
// Set relations for all users
|
||||
|
||||
@@ -100,9 +100,7 @@ describe('github', () => {
|
||||
spec: {
|
||||
type: 'team',
|
||||
parent: 'parent',
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -162,9 +162,7 @@ export async function getOrganizationTeams(
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
ancestors: [],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function g(
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: { name },
|
||||
spec: { type: 'team', parent, children, ancestors: [], descendants: [] },
|
||||
spec: { type: 'team', parent, children },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,28 +43,16 @@ describe('buildOrgHierarchy', () => {
|
||||
expect(d.spec.children).toEqual([]);
|
||||
});
|
||||
|
||||
it('fills out descendants', () => {
|
||||
const a = g('a', undefined, []);
|
||||
const b = g('b', 'a', []);
|
||||
const c = g('c', 'b', []);
|
||||
const d = g('d', 'a', []);
|
||||
it('sets parent of groups children', () => {
|
||||
const a = g('a', undefined, ['b', 'd']);
|
||||
const b = g('b', undefined, ['c']);
|
||||
const c = g('c', undefined, []);
|
||||
const d = g('d', undefined, []);
|
||||
buildOrgHierarchy([a, b, c, d]);
|
||||
expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd']));
|
||||
expect(b.spec.descendants).toEqual(expect.arrayContaining(['c']));
|
||||
expect(c.spec.descendants).toEqual([]);
|
||||
expect(d.spec.descendants).toEqual([]);
|
||||
});
|
||||
|
||||
it('fills out ancestors', () => {
|
||||
const a = g('a', undefined, []);
|
||||
const b = g('b', 'a', []);
|
||||
const c = g('c', 'b', []);
|
||||
const d = g('d', 'a', []);
|
||||
buildOrgHierarchy([a, b, c, d]);
|
||||
expect(a.spec.ancestors).toEqual([]);
|
||||
expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a']));
|
||||
expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b']));
|
||||
expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a']));
|
||||
expect(a.spec.parent).toBeUndefined();
|
||||
expect(b.spec.parent).toBe('a');
|
||||
expect(c.spec.parent).toBe('b');
|
||||
expect(d.spec.parent).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,62 +35,17 @@ export function buildOrgHierarchy(groups: GroupEntity[]) {
|
||||
}
|
||||
|
||||
//
|
||||
// Make sure that g.descendants is complete
|
||||
// Make sure that g.children.parent is g
|
||||
//
|
||||
|
||||
function visitDescendants(current: GroupEntity): string[] {
|
||||
if (current.spec.descendants.length) {
|
||||
return current.spec.descendants;
|
||||
}
|
||||
|
||||
const accumulator = new Set<string>();
|
||||
for (const childName of current.spec.children) {
|
||||
accumulator.add(childName);
|
||||
for (const group of groups) {
|
||||
const selfName = group.metadata.name;
|
||||
for (const childName of group.spec.children) {
|
||||
const child = groupsByName.get(childName);
|
||||
if (child) {
|
||||
for (const d of visitDescendants(child)) {
|
||||
accumulator.add(d);
|
||||
}
|
||||
if (child && !child.spec.parent) {
|
||||
child.spec.parent = selfName;
|
||||
}
|
||||
}
|
||||
|
||||
const descendants = Array.from(accumulator);
|
||||
current.spec.descendants = descendants;
|
||||
return descendants;
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
visitDescendants(group);
|
||||
}
|
||||
|
||||
//
|
||||
// Make sure that g.ancestors is complete
|
||||
//
|
||||
|
||||
function visitAncestors(current: GroupEntity): string[] {
|
||||
if (current.spec.ancestors.length) {
|
||||
return current.spec.ancestors;
|
||||
}
|
||||
|
||||
let ancestors: string[];
|
||||
const parentName = current.spec.parent;
|
||||
if (!parentName) {
|
||||
ancestors = [];
|
||||
} else {
|
||||
const parent = groupsByName.get(parentName);
|
||||
if (parent) {
|
||||
ancestors = [parentName, ...visitAncestors(parent)];
|
||||
} else {
|
||||
ancestors = [parentName];
|
||||
}
|
||||
}
|
||||
|
||||
current.spec.ancestors = ancestors;
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
visitAncestors(group);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,15 +55,24 @@ export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) {
|
||||
const groupsByName = new Map(groups.map(g => [g.metadata.name, g]));
|
||||
|
||||
users.forEach(user => {
|
||||
const transitiveMemberOf = new Set([...user.spec.memberOf]);
|
||||
const transitiveMemberOf = new Set<string>();
|
||||
|
||||
user.spec.memberOf.forEach(groupName => {
|
||||
const group = groupsByName.get(groupName);
|
||||
|
||||
if (group) {
|
||||
group.spec.ancestors.forEach(g => transitiveMemberOf.add(g));
|
||||
const todo = [...user.spec.memberOf];
|
||||
for (;;) {
|
||||
const current = todo.pop();
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (!transitiveMemberOf.has(current)) {
|
||||
transitiveMemberOf.add(current);
|
||||
const group = groupsByName.get(current);
|
||||
if (group?.spec.parent) {
|
||||
todo.push(group.spec.parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.spec.memberOf = [...transitiveMemberOf];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ const EntityPageTitle = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
function headerProps(
|
||||
const headerProps = (
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
entity: Entity | undefined,
|
||||
): { headerTitle: string; headerType: string } {
|
||||
): { headerTitle: string; headerType: string } => {
|
||||
return {
|
||||
headerTitle: `${name}${
|
||||
namespace && namespace !== ENTITY_DEFAULT_NAMESPACE
|
||||
@@ -60,7 +60,7 @@ function headerProps(
|
||||
return t;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
const { kind, namespace, name } = useEntityCompoundName();
|
||||
@@ -88,7 +88,8 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
pageTitleOverride={headerTitle}
|
||||
type={headerType}
|
||||
>
|
||||
{entity && (
|
||||
{/* TODO: fix after catalog page customization is added */}
|
||||
{entity && kind !== 'user' && (
|
||||
<>
|
||||
<HeaderLabel
|
||||
label="Owner"
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"dayjs": "^1.9.4",
|
||||
"history": "^5.0.0",
|
||||
"moment": "^2.27.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"qs": "^6.9.4",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
@@ -54,6 +55,7 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/recharts": "^1.8.14",
|
||||
"@types/regression": "^2.0.0",
|
||||
"@types/yup": "^0.29.8",
|
||||
|
||||
@@ -139,6 +139,7 @@ export type CostInsightsApi = {
|
||||
* @param options Options to use when fetching insights for a particular cloud product and interval timeframe.
|
||||
*/
|
||||
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
|
||||
|
||||
/**
|
||||
* Get current cost alerts for a given group. These show up as Action Items for the group on the
|
||||
* Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure
|
||||
|
||||
@@ -40,7 +40,7 @@ const MockComputeEngine: Product = {
|
||||
|
||||
const MockComputeEngineInsights: Entity = {
|
||||
id: 'compute-engine',
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [0, 0],
|
||||
change: {
|
||||
ratio: 0,
|
||||
@@ -55,7 +55,7 @@ const MockCloudDataflow: Product = {
|
||||
|
||||
const MockCloudDataflowInsights: Entity = {
|
||||
id: MockCloudDataflow.kind,
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [1_000, 2_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
@@ -70,7 +70,7 @@ const MockCloudStorage: Product = {
|
||||
|
||||
const MockCloudStorageInsights: Entity = {
|
||||
id: MockCloudStorage.kind,
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [2_000, 4_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
@@ -85,7 +85,7 @@ const MockBigQuery: Product = {
|
||||
|
||||
const MockBigQueryInsights: Entity = {
|
||||
id: MockBigQuery.kind,
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [8_000, 16_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
@@ -100,7 +100,7 @@ const MockBigTable: Product = {
|
||||
|
||||
const MockBigTableInsights: Entity = {
|
||||
id: MockBigTable.kind,
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [16_000, 32_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
@@ -115,7 +115,7 @@ const MockCloudPubSub: Product = {
|
||||
|
||||
const MockCloudPubSubInsights: Entity = {
|
||||
id: MockCloudPubSub.kind,
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [32_000, 64_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ProductEntityDialog } from './ProductEntityDialog';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Entity } from '../../types';
|
||||
|
||||
const atomicEntity: Entity = {
|
||||
id: null,
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: {},
|
||||
};
|
||||
|
||||
const singleBreakdownEntity = {
|
||||
...atomicEntity,
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'sku-1',
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'sku-2',
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: {},
|
||||
},
|
||||
] as Entity[],
|
||||
},
|
||||
};
|
||||
|
||||
const multiBreakdownEntity = {
|
||||
...singleBreakdownEntity,
|
||||
entities: {
|
||||
...singleBreakdownEntity.entities,
|
||||
deployment: [
|
||||
{
|
||||
id: 'd-1',
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'd-2',
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: {},
|
||||
},
|
||||
] as Entity[],
|
||||
},
|
||||
};
|
||||
|
||||
describe('<ProductEntityDialog/>', () => {
|
||||
it('Should error if no sub-entities exist', () => {
|
||||
expect(() =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={atomicEntity}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('Should show a tab for a single sub-entity type', () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={singleBreakdownEntity}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should show tabs when multiple sub-entity types exist', () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ProductEntityDialog
|
||||
open
|
||||
entity={multiBreakdownEntity}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
|
||||
expect(getByText('Breakdown by deployment')).toBeInTheDocument();
|
||||
expect(getByText('sku-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,179 +14,55 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Dialog, IconButton, Typography } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
import { HeaderTabs } from '@backstage/core';
|
||||
import { Dialog, IconButton } from '@material-ui/core';
|
||||
import { default as CloseButton } from '@material-ui/icons/Close';
|
||||
import { CostGrowthIndicator } from '../CostGrowth';
|
||||
import { costFormatter, formatPercent } from '../../utils/formatters';
|
||||
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
|
||||
import { BarChartOptions, Entity } from '../../types';
|
||||
|
||||
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
return function render(rowData: {}): JSX.Element {
|
||||
const row = rowData as RowData;
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'ratio',
|
||||
});
|
||||
|
||||
switch (col) {
|
||||
case 'previous':
|
||||
case 'current':
|
||||
return (
|
||||
<Typography className={rowStyles}>
|
||||
{costFormatter.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'ratio':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
ratio={row.ratio}
|
||||
formatter={amount => formatPercent(Math.abs(amount))}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Typography className={rowStyles}>{row.label}</Typography>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// material-table does not support fixed rows. Override the sorting algorithm
|
||||
// to force Total row to bottom by default or when a user sort toggles a column.
|
||||
function createSorter(field?: keyof Omit<RowData, 'id'>) {
|
||||
return function rowSort(data1: {}, data2: {}): number {
|
||||
const a = data1 as RowData;
|
||||
const b = data2 as RowData;
|
||||
if (a.id === 'total') return 1;
|
||||
if (b.id === 'total') return 1;
|
||||
if (field === 'label') return a.label.localeCompare(b.label);
|
||||
|
||||
return field
|
||||
? a[field] - b[field]
|
||||
: b.previous + b.current - (a.previous - a.current);
|
||||
};
|
||||
}
|
||||
|
||||
const defaultEntity: Entity = {
|
||||
id: null,
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
entities: [],
|
||||
};
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
label: string;
|
||||
previous: number;
|
||||
current: number;
|
||||
ratio: number;
|
||||
};
|
||||
|
||||
type ProductEntityDialogOptions = Partial<
|
||||
Pick<BarChartOptions, 'previousName' | 'currentName'>
|
||||
>;
|
||||
import { Entity } from '../../types';
|
||||
import {
|
||||
ProductEntityTable,
|
||||
ProductEntityTableOptions,
|
||||
} from './ProductEntityTable';
|
||||
import { findAlways } from '../../utils/assert';
|
||||
|
||||
type ProductEntityDialogProps = {
|
||||
open: boolean;
|
||||
entity?: Entity;
|
||||
entitiesLabel: string;
|
||||
options?: ProductEntityDialogOptions;
|
||||
entity: Entity;
|
||||
options?: ProductEntityTableOptions;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ProductEntityDialog = ({
|
||||
open,
|
||||
entity = defaultEntity,
|
||||
entitiesLabel,
|
||||
entity,
|
||||
options = {},
|
||||
onClose,
|
||||
}: ProductEntityDialogProps) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const data = Object.assign(
|
||||
{
|
||||
previousName: 'Previous',
|
||||
currentName: 'Current',
|
||||
},
|
||||
options,
|
||||
const labels = Object.keys(entity.entities);
|
||||
const [selectedLabel, setSelectedLabel] = useState(
|
||||
findAlways(labels, _ => true),
|
||||
);
|
||||
|
||||
const firstColClasses = classnames(classes.column, classes.colFirst);
|
||||
const lastColClasses = classnames(classes.column, classes.colLast);
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
field: 'label',
|
||||
title: (
|
||||
<Typography className={firstColClasses}>{entitiesLabel}</Typography>
|
||||
),
|
||||
render: createRenderer('label', classes),
|
||||
customSort: createSorter('label'),
|
||||
width: '33.33%',
|
||||
},
|
||||
{
|
||||
field: 'previous',
|
||||
title: (
|
||||
<Typography className={classes.column}>{data.previousName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('previous', classes),
|
||||
customSort: createSorter('previous'),
|
||||
},
|
||||
{
|
||||
field: 'current',
|
||||
title: (
|
||||
<Typography className={classes.column}>{data.currentName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('current', classes),
|
||||
customSort: createSorter('current'),
|
||||
},
|
||||
{
|
||||
field: 'ratio',
|
||||
title: <Typography className={lastColClasses}>M/M</Typography>,
|
||||
align: 'right',
|
||||
render: createRenderer('ratio', classes),
|
||||
customSort: createSorter('ratio'),
|
||||
},
|
||||
];
|
||||
|
||||
const rowData: RowData[] = entity.entities
|
||||
.map(e => ({
|
||||
id: e.id || 'Unknown',
|
||||
label: e.id || 'Unknown',
|
||||
previous: e.aggregation[0],
|
||||
current: e.aggregation[1],
|
||||
ratio: e.change.ratio,
|
||||
}))
|
||||
.concat({
|
||||
id: 'total',
|
||||
label: 'Total',
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
ratio: entity.change.ratio,
|
||||
})
|
||||
.sort(createSorter());
|
||||
const tabs = labels.map((label, index) => ({
|
||||
id: index.toString(),
|
||||
label: `Breakdown by ${label}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} scroll="body" fullWidth maxWidth="lg">
|
||||
<IconButton className={classes.closeButton} onClick={onClose}>
|
||||
<CloseButton />
|
||||
</IconButton>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={rowData}
|
||||
title={entity.id || 'Unlabeled'}
|
||||
subtitle="Resource breakdown"
|
||||
options={{
|
||||
paging: false,
|
||||
search: false,
|
||||
hideFilterIcons: true,
|
||||
}}
|
||||
<HeaderTabs
|
||||
tabs={tabs}
|
||||
onChange={index => setSelectedLabel(labels[index])}
|
||||
/>
|
||||
<ProductEntityTable
|
||||
entityLabel={selectedLabel}
|
||||
entity={entity}
|
||||
options={options}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { costFormatter, formatPercent } from '../../utils/formatters';
|
||||
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
|
||||
import { CostGrowthIndicator } from '../CostGrowth';
|
||||
import { BarChartOptions, Entity } from '../../types';
|
||||
|
||||
export type ProductEntityTableOptions = Partial<
|
||||
Pick<BarChartOptions, 'previousName' | 'currentName'>
|
||||
>;
|
||||
|
||||
type RowData = {
|
||||
id: string;
|
||||
label: string;
|
||||
previous: number;
|
||||
current: number;
|
||||
ratio: number;
|
||||
};
|
||||
|
||||
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
return function render(rowData: {}): JSX.Element {
|
||||
const row = rowData as RowData;
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'ratio',
|
||||
});
|
||||
|
||||
switch (col) {
|
||||
case 'previous':
|
||||
case 'current':
|
||||
return (
|
||||
<Typography className={rowStyles}>
|
||||
{costFormatter.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'ratio':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
ratio={row.ratio}
|
||||
formatter={amount => formatPercent(Math.abs(amount))}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Typography className={rowStyles}>{row.label}</Typography>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// material-table does not support fixed rows. Override the sorting algorithm
|
||||
// to force Total row to bottom by default or when a user sort toggles a column.
|
||||
function createSorter(field?: keyof Omit<RowData, 'id'>) {
|
||||
return function rowSort(data1: {}, data2: {}): number {
|
||||
const a = data1 as RowData;
|
||||
const b = data2 as RowData;
|
||||
if (a.id === 'total') return 1;
|
||||
if (b.id === 'total') return 1;
|
||||
if (field === 'label') return a.label.localeCompare(b.label);
|
||||
|
||||
return field
|
||||
? a[field] - b[field]
|
||||
: b.previous + b.current - (a.previous - a.current);
|
||||
};
|
||||
}
|
||||
|
||||
type ProductEntityTableProps = {
|
||||
entityLabel: string;
|
||||
entity: Entity;
|
||||
options: ProductEntityTableOptions;
|
||||
};
|
||||
|
||||
export const ProductEntityTable = ({
|
||||
entityLabel,
|
||||
entity,
|
||||
options,
|
||||
}: ProductEntityTableProps) => {
|
||||
const classes = useStyles();
|
||||
const entities = entity.entities[entityLabel];
|
||||
|
||||
const data = Object.assign(
|
||||
{
|
||||
previousName: 'Previous',
|
||||
currentName: 'Current',
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const firstColClasses = classnames(classes.column, classes.colFirst);
|
||||
const lastColClasses = classnames(classes.column, classes.colLast);
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
field: 'label',
|
||||
title: <Typography className={firstColClasses}>{entityLabel}</Typography>,
|
||||
render: createRenderer('label', classes),
|
||||
customSort: createSorter('label'),
|
||||
width: '33.33%',
|
||||
},
|
||||
{
|
||||
field: 'previous',
|
||||
title: (
|
||||
<Typography className={classes.column}>{data.previousName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('previous', classes),
|
||||
customSort: createSorter('previous'),
|
||||
},
|
||||
{
|
||||
field: 'current',
|
||||
title: (
|
||||
<Typography className={classes.column}>{data.currentName}</Typography>
|
||||
),
|
||||
align: 'right',
|
||||
render: createRenderer('current', classes),
|
||||
customSort: createSorter('current'),
|
||||
},
|
||||
{
|
||||
field: 'ratio',
|
||||
title: <Typography className={lastColClasses}>Change</Typography>,
|
||||
align: 'right',
|
||||
render: createRenderer('ratio', classes),
|
||||
customSort: createSorter('ratio'),
|
||||
},
|
||||
];
|
||||
|
||||
const rowData: RowData[] = entities
|
||||
.map(e => ({
|
||||
id: e.id || 'Unknown',
|
||||
label: e.id || 'Unknown',
|
||||
previous: e.aggregation[0],
|
||||
current: e.aggregation[1],
|
||||
ratio: e.change.ratio,
|
||||
}))
|
||||
.concat({
|
||||
id: 'total',
|
||||
label: 'Total',
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
ratio: entity.change.ratio,
|
||||
})
|
||||
.sort(createSorter());
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={rowData}
|
||||
title={entity.id || 'Unlabeled'}
|
||||
options={{
|
||||
paging: false,
|
||||
search: false,
|
||||
hideFilterIcons: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+7
-7
@@ -42,7 +42,7 @@ const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
|
||||
|
||||
const mockProductCost = createMockEntity(() => ({
|
||||
id: 'test-id',
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [3000, 4000],
|
||||
change: {
|
||||
ratio: 0.23,
|
||||
@@ -91,21 +91,21 @@ describe('<ProductInsightsCard/>', () => {
|
||||
it('Should render the right subheader for products with cost data', async () => {
|
||||
const entity = {
|
||||
...mockProductCost,
|
||||
entities: [...Array(1000)].map(createMockEntity),
|
||||
entities: { entity: [...Array(1000)].map(createMockEntity) },
|
||||
};
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
entity,
|
||||
MockComputeEngine,
|
||||
);
|
||||
const subheader = 'entities, sorted by cost';
|
||||
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
|
||||
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByText(/1000 entities, sorted by cost/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should render the right subheader if there is no cost data or change data', async () => {
|
||||
const entity: Entity = {
|
||||
id: 'test-id',
|
||||
entities: [],
|
||||
entities: {},
|
||||
aggregation: [0, 0],
|
||||
change: { ratio: 0, amount: 0 },
|
||||
};
|
||||
@@ -135,7 +135,7 @@ describe('<ProductInsightsCard/>', () => {
|
||||
it(`Should display the correct relative time for ${duration}`, async () => {
|
||||
const entity = {
|
||||
...mockProductCost,
|
||||
entities: [...Array(3)].map(createMockEntity),
|
||||
entities: { entity: [...Array(3)].map(createMockEntity) },
|
||||
};
|
||||
const rendered = await renderProductInsightsCardInTestApp(
|
||||
entity,
|
||||
|
||||
@@ -21,6 +21,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import pluralize from 'pluralize';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { default as Alert } from '@material-ui/lab/Alert';
|
||||
@@ -30,12 +31,12 @@ import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
|
||||
import { DefaultLoadingAction } from '../../utils/loading';
|
||||
import { Duration, Entity, Maybe, Product } from '../../types';
|
||||
import {
|
||||
useLastCompleteBillingDate,
|
||||
useScroll,
|
||||
useLoading,
|
||||
MapLoadingToProps,
|
||||
useLastCompleteBillingDate,
|
||||
useLoading,
|
||||
useScroll,
|
||||
} from '../../hooks';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { findAnyKey } from '../../utils/assert';
|
||||
|
||||
type LoadingProps = (isLoading: boolean) => void;
|
||||
|
||||
@@ -91,13 +92,12 @@ export const ProductInsightsCard = ({
|
||||
}
|
||||
}, [product, duration, onSelectAsync, dispatchLoadingProduct]);
|
||||
|
||||
const entities = entity?.entities ?? [];
|
||||
const subheader = entities.length
|
||||
? `${entities.length} ${pluralOf(
|
||||
entities.length,
|
||||
'entity',
|
||||
'entities',
|
||||
)}, sorted by cost`
|
||||
// Only a single entities Record for the root product entity is supported
|
||||
const entityKey = findAnyKey(entity?.entities);
|
||||
const entities = entityKey ? entity!.entities[entityKey] : [];
|
||||
|
||||
const subheader = entityKey
|
||||
? `${pluralize(entityKey, entities.length, true)}, sorted by cost`
|
||||
: null;
|
||||
const headerProps = {
|
||||
classes: classes,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TooltipProps as RechartsTooltipProps,
|
||||
RechartsFunction,
|
||||
} from 'recharts';
|
||||
import pluralize from 'pluralize';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
|
||||
import { LegendItem } from '../LegendItem';
|
||||
@@ -32,8 +33,13 @@ import {
|
||||
BarChartTooltipItem,
|
||||
BarChartLegendOptions,
|
||||
} from '../BarChart';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { findAlways, notEmpty, isUndefined } from '../../utils/assert';
|
||||
import {
|
||||
findAlways,
|
||||
notEmpty,
|
||||
isUndefined,
|
||||
findAnyKey,
|
||||
assertAlways,
|
||||
} from '../../utils/assert';
|
||||
import { formatPeriod, formatPercent } from '../../utils/formatters';
|
||||
import {
|
||||
titleOf,
|
||||
@@ -62,19 +68,26 @@ export const ProductInsightsChart = ({
|
||||
}: ProductInsightsChartProps) => {
|
||||
const classes = useStyles();
|
||||
const layoutClasses = useLayoutStyles();
|
||||
|
||||
// Only a single entities Record for the root product entity is supported
|
||||
const entityLabel = assertAlways(findAnyKey(entity.entities));
|
||||
const entities = entity.entities[entityLabel] ?? [];
|
||||
|
||||
const [activeLabel, setActive] = useState<Maybe<string>>();
|
||||
const [selectLabel, setSelected] = useState<Maybe<string>>();
|
||||
const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]);
|
||||
|
||||
const isClickable = useMemo(() => {
|
||||
const breakdownEntities =
|
||||
entity.entities.find(e => e.id === activeLabel)?.entities ?? [];
|
||||
return breakdownEntities.length > 0;
|
||||
}, [entity, activeLabel]);
|
||||
const breakdowns = Object.keys(
|
||||
entities.find(e => e.id === activeLabel)?.entities ?? {},
|
||||
);
|
||||
return breakdowns.length > 0;
|
||||
}, [entities, activeLabel]);
|
||||
|
||||
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
|
||||
const costStart = entity.aggregation[0];
|
||||
const costEnd = entity.aggregation[1];
|
||||
const resources = entity.entities.map(resourceOf);
|
||||
const resources = entities.map(resourceOf);
|
||||
|
||||
const options: Partial<BarChartLegendOptions> = {
|
||||
previousName: formatPeriod(duration, billingDate, false),
|
||||
@@ -120,15 +133,14 @@ export const ProductInsightsChart = ({
|
||||
const title = titleOf(label);
|
||||
const items = payload.map(tooltipItemOf).filter(notEmpty);
|
||||
|
||||
const activeEntity = findAlways(entity.entities, e => e.id === id);
|
||||
const activeEntity = findAlways(entities, e => e.id === id);
|
||||
const ratio = activeEntity.change.ratio;
|
||||
const breakdownEntities = activeEntity.entities;
|
||||
const subtitle = `${breakdownEntities.length} ${pluralOf(
|
||||
breakdownEntities.length,
|
||||
entity.entitiesLabel || 'SKU',
|
||||
)}`;
|
||||
const breakdowns = Object.keys(activeEntity.entities);
|
||||
|
||||
if (breakdownEntities.length) {
|
||||
if (breakdowns.length) {
|
||||
const subtitle = breakdowns
|
||||
.map(b => pluralize(b, activeEntity.entities[b].length, true))
|
||||
.join(', ');
|
||||
return (
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
@@ -194,13 +206,12 @@ export const ProductInsightsChart = ({
|
||||
options={options}
|
||||
{...barChartProps}
|
||||
/>
|
||||
{isSelected && entity.entities.length && (
|
||||
{isSelected && entities.length && (
|
||||
<ProductEntityDialog
|
||||
open={isSelected}
|
||||
onClose={() => setSelected(undefined)}
|
||||
entity={entity.entities.find(e => e.id === selectLabel)}
|
||||
entity={findAlways(entities, e => e.id === selectLabel)}
|
||||
options={options}
|
||||
entitiesLabel={entity.entitiesLabel || 'SKU'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import pluralize from 'pluralize';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart';
|
||||
import { ProjectGrowthData } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
|
||||
type ProjectGrowthAlertProps = {
|
||||
alert: ProjectGrowthData;
|
||||
@@ -26,7 +26,7 @@ type ProjectGrowthAlertProps = {
|
||||
|
||||
export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
|
||||
const subheader = `
|
||||
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
|
||||
${pluralize('product', alert.products.length, true)}${
|
||||
alert.products.length > 1 ? ', sorted by cost' : ''
|
||||
}`;
|
||||
|
||||
|
||||
+22
-20
@@ -72,26 +72,28 @@ export const ProjectGrowthInstructionsPage = () => {
|
||||
ratio: 3,
|
||||
amount: 40_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'service-one',
|
||||
aggregation: [18_200, 58_500],
|
||||
entities: [],
|
||||
change: { ratio: 2.21, amount: 40_300 },
|
||||
},
|
||||
{
|
||||
id: 'service-two',
|
||||
aggregation: [1200, 1300],
|
||||
entities: [],
|
||||
change: { ratio: 0.083, amount: 100 },
|
||||
},
|
||||
{
|
||||
id: 'service-three',
|
||||
aggregation: [600, 200],
|
||||
entities: [],
|
||||
change: { ratio: -0.666, amount: -400 },
|
||||
},
|
||||
],
|
||||
entities: {
|
||||
service: [
|
||||
{
|
||||
id: 'service-one',
|
||||
aggregation: [18_200, 58_500],
|
||||
entities: {},
|
||||
change: { ratio: 2.21, amount: 40_300 },
|
||||
},
|
||||
{
|
||||
id: 'service-two',
|
||||
aggregation: [1200, 1300],
|
||||
entities: {},
|
||||
change: { ratio: 0.083, amount: 100 },
|
||||
},
|
||||
{
|
||||
id: 'service-three',
|
||||
aggregation: [600, 200],
|
||||
entities: {},
|
||||
change: { ratio: -0.666, amount: -400 },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+3
-3
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import pluralize from 'pluralize';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { BarChart, BarChartLegend } from '../BarChart';
|
||||
import { UnlabeledDataflowData, ResourceData } from '../../types';
|
||||
import { pluralOf } from '../../utils/grammar';
|
||||
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
type UnlabeledDataflowAlertProps = {
|
||||
@@ -30,9 +30,9 @@ export const UnlabeledDataflowAlertCard = ({
|
||||
alert,
|
||||
}: UnlabeledDataflowAlertProps) => {
|
||||
const classes = useStyles();
|
||||
const projects = pluralOf(alert.projects.length, 'project');
|
||||
const projects = pluralize('project', alert.projects.length, true);
|
||||
const subheader = `
|
||||
Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days.
|
||||
Showing costs from ${projects} with unlabeled Dataflow jobs in the last 30 days.
|
||||
`;
|
||||
const options = {
|
||||
previousName: 'Unlabeled Cost',
|
||||
|
||||
@@ -20,9 +20,8 @@ import { Maybe } from './Maybe';
|
||||
export interface Entity {
|
||||
id: Maybe<string>;
|
||||
aggregation: [number, number];
|
||||
entities: Entity[];
|
||||
entities: Record<string, Entity[]>;
|
||||
change: ChangeStatistic;
|
||||
entitiesLabel?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -31,8 +30,15 @@ export interface Entity {
|
||||
An entity could be atomic or composite. An atomic entity is indivisible
|
||||
and cannot be broken into sub-entities.
|
||||
|
||||
A composite entity can be broken down recursively into sub-entities
|
||||
that generate cost **over the same time period**. All costs must sum to the root cost.
|
||||
A composite entity is divided into sub-entities that account for portions
|
||||
of the total cost **over the same time period**. The root entity is
|
||||
expected to only have _one_ Record consisting of the sub-entities to display
|
||||
in the product panel (keyed by the entity type, such as "service" for
|
||||
compute entities).
|
||||
|
||||
The root sub-entities may have multiple breakdowns - for example, a
|
||||
breakdown of an entity cost by SKU vs deployment environment. The sum
|
||||
aggregated cost of each keyed breakdown should equal the sub-entity's cost.
|
||||
|
||||
Entities with null ids are considered "unlabeled" - costs without attribution.
|
||||
If an entity is a composite, it may only have one (1) null child but may have any number of
|
||||
@@ -45,44 +51,68 @@ export interface Entity {
|
||||
ratio: 2000,
|
||||
amount: 200
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'service-a',
|
||||
aggregation: [0, 100],
|
||||
change: {
|
||||
ratio: 100,
|
||||
amount: 100
|
||||
},
|
||||
entities: []
|
||||
},
|
||||
{
|
||||
id: 'service-b',
|
||||
aggregation: [0, 100],
|
||||
change: {
|
||||
ratio: 100,
|
||||
amount: 100
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'service-b-sku-a',
|
||||
aggregation: [0, 25],
|
||||
change: {
|
||||
ratio: 25,
|
||||
amount: 25
|
||||
},
|
||||
entities: []
|
||||
entities: {
|
||||
service: [
|
||||
{
|
||||
id: 'service-a',
|
||||
aggregation: [0, 100],
|
||||
change: {
|
||||
ratio: 100,
|
||||
amount: 100
|
||||
},
|
||||
{
|
||||
id: null, // Unlabeled cost for service-b
|
||||
aggregation: [0, 75],
|
||||
change: {
|
||||
ratio: 75,
|
||||
amount: 75
|
||||
},
|
||||
entities: []
|
||||
entities: {}
|
||||
},
|
||||
{
|
||||
id: 'service-b',
|
||||
aggregation: [0, 100],
|
||||
change: {
|
||||
ratio: 100,
|
||||
amount: 100
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'service-b-sku-a',
|
||||
aggregation: [0, 25],
|
||||
change: {
|
||||
ratio: 25,
|
||||
amount: 25
|
||||
},
|
||||
entities: {}
|
||||
},
|
||||
{
|
||||
id: null, // Unlabeled cost for service-b
|
||||
aggregation: [0, 75],
|
||||
change: {
|
||||
ratio: 75,
|
||||
amount: 75
|
||||
},
|
||||
entities: {}
|
||||
},
|
||||
],
|
||||
deployment: [
|
||||
{
|
||||
id: 'service-b-env-a',
|
||||
aggregation: [0, 50],
|
||||
change: {
|
||||
ratio: 50,
|
||||
amount: 50
|
||||
},
|
||||
entities: {}
|
||||
},
|
||||
{
|
||||
id: 'service-b-env-b',
|
||||
aggregation: [0, 50],
|
||||
change: {
|
||||
ratio: 50,
|
||||
amount: 50
|
||||
},
|
||||
entities: {}
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -50,3 +50,9 @@ export function findAlways<T>(
|
||||
): T {
|
||||
return assertAlways(collection.find(callback));
|
||||
}
|
||||
|
||||
export function findAnyKey<T>(
|
||||
record: Record<string, T> | undefined,
|
||||
): string | undefined {
|
||||
return Object.keys(record ?? {}).find(_ => true);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import pluralize from 'pluralize';
|
||||
import { Duration, DEFAULT_DATE_FORMAT } from '../types';
|
||||
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
|
||||
import { pluralOf } from '../utils/grammar';
|
||||
|
||||
export type Period = {
|
||||
periodStart: string;
|
||||
@@ -75,7 +75,7 @@ export function formatCurrency(amount: number, currency?: string): string {
|
||||
const n = Math.round(amount);
|
||||
const numString = numberFormatter.format(n);
|
||||
|
||||
return currency ? `${numString} ${pluralOf(n, currency)}` : numString;
|
||||
return currency ? `${numString} ${pluralize(currency, n)}` : numString;
|
||||
}
|
||||
|
||||
export function formatPercent(n: number): string {
|
||||
|
||||
@@ -22,20 +22,6 @@ const vowels = {
|
||||
u: 'U',
|
||||
};
|
||||
|
||||
export const pluralOf = (
|
||||
n: number,
|
||||
string: string,
|
||||
plural?: string,
|
||||
): string => {
|
||||
if (n !== 1) {
|
||||
if (plural) {
|
||||
return plural;
|
||||
}
|
||||
return string.concat('s');
|
||||
}
|
||||
return string;
|
||||
};
|
||||
|
||||
export const indefiniteArticleOf = (
|
||||
articles: [string, string],
|
||||
word: string,
|
||||
|
||||
@@ -52,7 +52,7 @@ export const createMockEntity = (
|
||||
const defaultEntity: Entity = {
|
||||
id: 'test-entity',
|
||||
aggregation: [100, 200],
|
||||
entities: [],
|
||||
entities: {},
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
@@ -517,35 +517,37 @@ export const SampleBigQueryInsights: Entity = {
|
||||
ratio: 3,
|
||||
amount: 20_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
entities: {
|
||||
dataset: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SampleCloudDataflowInsights: Entity = {
|
||||
@@ -555,110 +557,118 @@ export const SampleCloudDataflowInsights: Entity = {
|
||||
ratio: 0.58,
|
||||
amount: 58_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: null,
|
||||
aggregation: [10_000, 12_000],
|
||||
change: {
|
||||
ratio: 0.2,
|
||||
amount: 2_000,
|
||||
entities: {
|
||||
pipeline: [
|
||||
{
|
||||
id: null,
|
||||
aggregation: [10_000, 12_000],
|
||||
change: {
|
||||
ratio: 0.2,
|
||||
amount: 2_000,
|
||||
},
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [3_000, 4_000],
|
||||
change: {
|
||||
ratio: 0.333333,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [7_000, 8_000],
|
||||
change: {
|
||||
ratio: 0.14285714,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [3_000, 4_000],
|
||||
change: {
|
||||
ratio: 0.333333,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [60_000, 70_000],
|
||||
change: {
|
||||
ratio: 0.16666666666666666,
|
||||
amount: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [7_000, 8_000],
|
||||
change: {
|
||||
ratio: 0.14285714,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [20_000, 15_000],
|
||||
change: {
|
||||
ratio: -0.25,
|
||||
amount: -5_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [30_000, 35_000],
|
||||
change: {
|
||||
ratio: -0.16666666666666666,
|
||||
amount: -5_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [10_000, 20_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [60_000, 70_000],
|
||||
change: {
|
||||
ratio: 0.16666666666666666,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [20_000, 15_000],
|
||||
change: {
|
||||
ratio: -0.25,
|
||||
amount: -5_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [12_000, 8_000],
|
||||
change: {
|
||||
ratio: -0.33333,
|
||||
amount: -4_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [30_000, 35_000],
|
||||
change: {
|
||||
ratio: -0.16666666666666666,
|
||||
amount: -5_000,
|
||||
},
|
||||
entities: [],
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [4_000, 4_000],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [8_000, 4_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -4_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [10_000, 20_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [12_000, 8_000],
|
||||
change: {
|
||||
ratio: -0.33333,
|
||||
amount: -4_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [4_000, 4_000],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [8_000, 4_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -4_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
entities: {},
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SampleCloudStorageInsights: Entity = {
|
||||
@@ -668,91 +678,97 @@ export const SampleCloudStorageInsights: Entity = {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
change: {
|
||||
ratio: 0.333,
|
||||
amount: 5_000,
|
||||
entities: {
|
||||
bucket: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 20_000],
|
||||
change: {
|
||||
ratio: 0.333,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [10_000, 11_000],
|
||||
change: {
|
||||
ratio: 0.1,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [2_000, 5_000],
|
||||
change: {
|
||||
ratio: 1.5,
|
||||
amount: 3_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [3_000, 4_000],
|
||||
change: {
|
||||
ratio: 0.3333,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [10_000, 11_000],
|
||||
change: {
|
||||
ratio: 0.1,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [30_000, 25_000],
|
||||
change: {
|
||||
ratio: -0.16666,
|
||||
amount: -5_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [2_000, 5_000],
|
||||
change: {
|
||||
ratio: 1.5,
|
||||
amount: 3_000,
|
||||
},
|
||||
entities: [],
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [12_000, 13_000],
|
||||
change: {
|
||||
ratio: 0.08333333333333333,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [16_000, 12_000],
|
||||
change: {
|
||||
ratio: -0.25,
|
||||
amount: -4_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [2_000, 0],
|
||||
change: {
|
||||
ratio: -1,
|
||||
amount: -2000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [3_000, 4_000],
|
||||
change: {
|
||||
ratio: 0.3333,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [30_000, 25_000],
|
||||
change: {
|
||||
ratio: -0.16666,
|
||||
amount: -5_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [12_000, 13_000],
|
||||
change: {
|
||||
ratio: 0.08333333333333333,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 0],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [16_000, 12_000],
|
||||
change: {
|
||||
ratio: -0.25,
|
||||
amount: -4_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [2_000, 0],
|
||||
change: {
|
||||
ratio: -1,
|
||||
amount: -2000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 0],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
entities: {},
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SampleComputeEngineInsights: Entity = {
|
||||
@@ -762,91 +778,137 @@ export const SampleComputeEngineInsights: Entity = {
|
||||
ratio: 0.125,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [20_000, 10_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -10_000,
|
||||
entities: {
|
||||
service: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [20_000, 10_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -10_000,
|
||||
},
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [4_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -2_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [7_000, 6_000],
|
||||
change: {
|
||||
ratio: -0.14285714285714285,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [9_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.7777777777777778,
|
||||
amount: -7000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
deployment: [
|
||||
{
|
||||
id: 'Compute Engine',
|
||||
aggregation: [7_000, 6_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -2_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Kubernetes',
|
||||
aggregation: [4_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.14285714285714285,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [4_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -2_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [10_000, 20_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [7_000, 6_000],
|
||||
change: {
|
||||
ratio: -0.14285714285714285,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: [],
|
||||
entities: {
|
||||
SKU: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [1_000, 2_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [4_000, 8_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 4_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
deployment: [
|
||||
{
|
||||
id: 'Compute Engine',
|
||||
aggregation: [7_000, 6_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -2_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Kubernetes',
|
||||
aggregation: [4_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.14285714285714285,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [9_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.7777777777777778,
|
||||
amount: -7000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [10_000, 20_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample SKU A',
|
||||
aggregation: [1_000, 2_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 1_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU B',
|
||||
aggregation: [4_000, 8_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 4_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample SKU C',
|
||||
aggregation: [5_000, 10_000],
|
||||
change: {
|
||||
ratio: 1,
|
||||
amount: 5_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
entities: {},
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SampleEventsInsights: Entity = {
|
||||
@@ -856,83 +918,88 @@ export const SampleEventsInsights: Entity = {
|
||||
ratio: -0.5,
|
||||
amount: -10_000,
|
||||
},
|
||||
entitiesLabel: 'Product',
|
||||
entities: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 7_000],
|
||||
change: {
|
||||
ratio: -0.53333333333,
|
||||
amount: -8_000,
|
||||
entities: {
|
||||
event: [
|
||||
{
|
||||
id: 'entity-a',
|
||||
aggregation: [15_000, 7_000],
|
||||
change: {
|
||||
ratio: -0.53333333333,
|
||||
amount: -8_000,
|
||||
},
|
||||
entities: {
|
||||
product: [
|
||||
{
|
||||
id: 'Sample Product A',
|
||||
aggregation: [5_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.6,
|
||||
amount: -3_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample Product B',
|
||||
aggregation: [7_000, 2_500],
|
||||
change: {
|
||||
ratio: -0.64285714285,
|
||||
amount: -4_500,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample Product C',
|
||||
aggregation: [3_000, 2_500],
|
||||
change: {
|
||||
ratio: -0.16666666666,
|
||||
amount: -500,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample Product A',
|
||||
aggregation: [5_000, 2_000],
|
||||
change: {
|
||||
ratio: -0.6,
|
||||
amount: -3_000,
|
||||
},
|
||||
entities: [],
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [5_000, 3_000],
|
||||
change: {
|
||||
ratio: -0.4,
|
||||
amount: -2_000,
|
||||
},
|
||||
{
|
||||
id: 'Sample Product B',
|
||||
aggregation: [7_000, 2_500],
|
||||
change: {
|
||||
ratio: -0.64285714285,
|
||||
amount: -4_500,
|
||||
},
|
||||
entities: [],
|
||||
entities: {
|
||||
product: [
|
||||
{
|
||||
id: 'Sample Product A',
|
||||
aggregation: [2_000, 1_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample Product B',
|
||||
aggregation: [1_000, 1_500],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
amount: 500,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
{
|
||||
id: 'Sample Product C',
|
||||
aggregation: [2_000, 500],
|
||||
change: {
|
||||
ratio: -0.75,
|
||||
amount: -1_500,
|
||||
},
|
||||
entities: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product C',
|
||||
aggregation: [3_000, 2_500],
|
||||
change: {
|
||||
ratio: -0.16666666666,
|
||||
amount: -500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'entity-b',
|
||||
aggregation: [5_000, 3_000],
|
||||
change: {
|
||||
ratio: -0.4,
|
||||
amount: -2_000,
|
||||
},
|
||||
entities: [
|
||||
{
|
||||
id: 'Sample Product A',
|
||||
aggregation: [2_000, 1_000],
|
||||
change: {
|
||||
ratio: -0.5,
|
||||
amount: -1_000,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product B',
|
||||
aggregation: [1_000, 1_500],
|
||||
change: {
|
||||
ratio: 0.5,
|
||||
amount: 500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
{
|
||||
id: 'Sample Product C',
|
||||
aggregation: [2_000, 500],
|
||||
change: {
|
||||
ratio: -0.75,
|
||||
amount: -1_500,
|
||||
},
|
||||
entities: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function entityOf(product: string): Entity {
|
||||
|
||||
@@ -27,8 +27,7 @@ your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/pac
|
||||
to enable the plugin:
|
||||
|
||||
```js
|
||||
import { default as LighthousePlugin } from '@backstage/plugin-lighthouse';
|
||||
export LighthousePlugin;
|
||||
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
|
||||
```
|
||||
|
||||
Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in
|
||||
@@ -74,7 +73,7 @@ metadata:
|
||||
lighthouse.com/website-url: # A single website url e.g. https://backstage.io/
|
||||
```
|
||||
|
||||
> NOTE: The lighthouse plugin only supports one website url per component at this time.
|
||||
> NOTE: The lighthouse plugin only supports one website URL per component at this time.
|
||||
|
||||
Add a lighthouse tab to the EntityPage:
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const LIGHTHOUSE_INTRO_LOCAL_STORAGE =
|
||||
|
||||
const USE_CASES = `
|
||||
Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites
|
||||
is a great open-source resource forbenchmarking and improving the accessibility, performance, SEO, and best practices of your site.
|
||||
is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site.
|
||||
At Spotify, we keep track of Lighthouse audit scores over time to look at trends and overall areas for investment.
|
||||
|
||||
This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the
|
||||
@@ -46,8 +46,7 @@ your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/p
|
||||
to enable the plugin:
|
||||
|
||||
\`\`\`js
|
||||
import { default as LighthousePlugin } from '@backstage/plugin-lighthouse';
|
||||
export LighthousePlugin;
|
||||
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
|
||||
\`\`\`
|
||||
|
||||
Then, you need to use the \`lighthouseApiRef\` exported from the plugin to initialize the Rest API in
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
# Org Plugin for Backstage
|
||||
|
||||
## Features
|
||||
|
||||
- Show Group Page
|
||||
- Show User Profile
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@backstage/plugin-org",
|
||||
"version": "0.3.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"diff": "backstage-cli plugin:diff",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.4.0",
|
||||
"@backstage/core": "^0.3.2",
|
||||
"@backstage/plugin-catalog": "^0.2.5",
|
||||
"@backstage/theme": "^0.2.1",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^15.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.4.0",
|
||||
"@backstage/dev-utils": "^0.1.5",
|
||||
"@backstage/test-utils": "^0.1.4",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React, { CSSProperties } from 'react';
|
||||
import {
|
||||
Avatar as MaterialAvatar,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
Theme,
|
||||
} from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
avatar: {
|
||||
width: '4rem',
|
||||
height: '4rem',
|
||||
color: '#fff',
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
letterSpacing: '1px',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const stringToColour = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
let colour = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
colour += `00${value.toString(16)}`.substr(-2);
|
||||
}
|
||||
return colour;
|
||||
};
|
||||
|
||||
export const Avatar = ({
|
||||
displayName,
|
||||
picture,
|
||||
customStyles,
|
||||
}: {
|
||||
displayName: string | undefined;
|
||||
picture: string | undefined;
|
||||
customStyles?: CSSProperties;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<MaterialAvatar
|
||||
alt={displayName}
|
||||
src={picture}
|
||||
className={classes.avatar}
|
||||
style={{
|
||||
backgroundColor: stringToColour(displayName || picture || ''),
|
||||
...customStyles,
|
||||
}}
|
||||
>
|
||||
{displayName && displayName.match(/\b\w/g)!.join('').substring(0, 2)}
|
||||
</MaterialAvatar>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { Avatar } from './Avatar';
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
Entity,
|
||||
GroupEntity,
|
||||
RELATION_CHILD_OF,
|
||||
RELATION_PARENT_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import AccountTreeIcon from '@material-ui/icons/AccountTree';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import { Link as RouterLink, generatePath } from 'react-router-dom';
|
||||
|
||||
const GroupLink = ({
|
||||
groupName,
|
||||
index = 0,
|
||||
entity,
|
||||
}: {
|
||||
groupName: string;
|
||||
index?: number;
|
||||
entity: Entity;
|
||||
}) => (
|
||||
<>
|
||||
{index >= 1 ? ', ' : ''}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/:namespace/group/${groupName}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
[{groupName}]
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
const CardTitle = ({ title }: { title: string }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
<GroupIcon fontSize="inherit" />
|
||||
<Box ml={1}>{title}</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const GroupProfileCard = ({
|
||||
entity: group,
|
||||
variant,
|
||||
}: {
|
||||
entity: GroupEntity;
|
||||
variant: string;
|
||||
}) => {
|
||||
const {
|
||||
metadata: { name, description },
|
||||
} = group;
|
||||
const parent = group?.relations
|
||||
?.filter(r => r.type === RELATION_CHILD_OF)
|
||||
?.map(group => group.target.name)
|
||||
.toString();
|
||||
|
||||
const childrens = group?.relations
|
||||
?.filter(r => r.type === RELATION_PARENT_OF)
|
||||
?.map(group => group.target.name);
|
||||
|
||||
if (!group) return <Alert severity="error">User not found</Alert>;
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={<CardTitle title={name} />}
|
||||
subheader={description}
|
||||
variant={variant}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item>
|
||||
{parent ? (
|
||||
<Typography variant="subtitle1">
|
||||
<Box display="flex" alignItems="center">
|
||||
<Tooltip title="Group Parent">
|
||||
<AccountTreeIcon fontSize="inherit" />
|
||||
</Tooltip>
|
||||
<Box ml={1} display="inline">
|
||||
<GroupLink groupName={parent} entity={group} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Typography>
|
||||
) : null}
|
||||
{childrens?.length ? (
|
||||
<Typography variant="subtitle1">
|
||||
<Box display="flex" alignItems="center">
|
||||
<Tooltip title="Parent of">
|
||||
<GroupIcon fontSize="inherit" />
|
||||
</Tooltip>
|
||||
<Box ml={1} display="inline">
|
||||
{childrens.map((children, index) => (
|
||||
<GroupLink
|
||||
groupName={children}
|
||||
entity={group}
|
||||
index={index}
|
||||
key={children}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Typography>
|
||||
) : null}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './GroupProfileCard';
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { MembersListCard } from './MembersListCard';
|
||||
|
||||
describe('MemberTab Test', () => {
|
||||
const groupEntity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-d',
|
||||
description: 'The evil-corp organization',
|
||||
namespace: 'default',
|
||||
},
|
||||
spec: {
|
||||
type: 'team',
|
||||
parent: 'boxoffice',
|
||||
ancestors: ['boxoffice', 'acme-corp'],
|
||||
children: [],
|
||||
descendants: [],
|
||||
},
|
||||
};
|
||||
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'tara.macgovern',
|
||||
namespace: 'default',
|
||||
uid: 'a5gerth56',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'memberOf',
|
||||
target: {
|
||||
kind: 'group',
|
||||
name: 'team-d',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Tara MacGovern',
|
||||
email: 'tara-macgovern@example.com',
|
||||
picture: 'https://example.com/staff/tara.jpeg',
|
||||
},
|
||||
memberOf: ['team-d'],
|
||||
},
|
||||
},
|
||||
] as Entity[],
|
||||
}),
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]);
|
||||
|
||||
it('Display Profile Card', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<MembersListCard entity={groupEntity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(rendered.getByAltText('Tara MacGovern')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/staff/tara.jpeg',
|
||||
);
|
||||
expect(
|
||||
rendered.getByText('tara-macgovern@example.com'),
|
||||
).toBeInTheDocument();
|
||||
expect(rendered.getByText('Tara MacGovern')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/user/tara.macgovern',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import {
|
||||
Box,
|
||||
createStyles,
|
||||
Grid,
|
||||
Link,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { InfoCard, Progress, useApi } from '@backstage/core';
|
||||
import {
|
||||
UserEntity,
|
||||
RELATION_MEMBER_OF,
|
||||
Entity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Link as RouterLink, generatePath } from 'react-router-dom';
|
||||
import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Avatar } from '../../../Avatar';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
card: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: theme.shadows[2],
|
||||
borderRadius: '4px',
|
||||
overflow: 'visible',
|
||||
position: 'relative',
|
||||
margin: theme.spacing(3, 0, 0),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const MemberComponent = ({
|
||||
member,
|
||||
groupEntity,
|
||||
}: {
|
||||
member: UserEntity;
|
||||
groupEntity: Entity;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const { name: metaName } = member.metadata;
|
||||
const { profile } = member.spec;
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={3} xl={2}>
|
||||
<Box className={classes.card}>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
m={3}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Avatar
|
||||
displayName={profile?.displayName}
|
||||
picture={profile?.picture}
|
||||
customStyles={{
|
||||
position: 'absolute',
|
||||
top: '-2rem',
|
||||
}}
|
||||
/>
|
||||
<Box pt={2} textAlign="center">
|
||||
<Typography variant="h5">
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/:namespace/user/${metaName}`,
|
||||
entityRouteParams(groupEntity),
|
||||
)}
|
||||
>
|
||||
{profile?.displayName}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography variant="caption">{profile?.email}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export const MembersListCard = ({
|
||||
entity: groupEntity,
|
||||
}: {
|
||||
entity: Entity;
|
||||
}) => {
|
||||
const {
|
||||
metadata: { name: groupName },
|
||||
} = groupEntity;
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const { loading, error, value: members } = useAsync(async () => {
|
||||
const membersList = await catalogApi.getEntities({
|
||||
filter: {
|
||||
kind: 'User',
|
||||
},
|
||||
});
|
||||
const groupMembersList = ((membersList.items as unknown) as Array<
|
||||
UserEntity
|
||||
>).filter(member =>
|
||||
member?.relations?.some(
|
||||
r => r.type === RELATION_MEMBER_OF && r.target.name === groupName,
|
||||
),
|
||||
);
|
||||
return groupMembersList;
|
||||
}, [catalogApi]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item>
|
||||
<InfoCard
|
||||
title={`Members (${members?.length || 0})`}
|
||||
subheader={`of ${groupName}`}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{members && members.length ? (
|
||||
members.map(member => (
|
||||
<MemberComponent
|
||||
member={member}
|
||||
groupEntity={groupEntity}
|
||||
key={member.metadata.uid}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Box p={2}>
|
||||
<Typography>This group has no members.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './MembersListCard';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './MembersList';
|
||||
export * from './GroupProfile';
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import { InfoCard, useApi, Progress } from '@backstage/core';
|
||||
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { useAsync } from 'react-use';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import {
|
||||
Box,
|
||||
createStyles,
|
||||
Grid,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { pageTheme } from '@backstage/theme';
|
||||
|
||||
type EntitiesKinds = 'Component' | 'API';
|
||||
type EntitiesTypes =
|
||||
| 'service'
|
||||
| 'website'
|
||||
| 'library'
|
||||
| 'documentation'
|
||||
| 'api'
|
||||
| 'tool';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
card: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: theme.shadows[2],
|
||||
borderRadius: '4px',
|
||||
padding: theme.spacing(2),
|
||||
color: '#fff',
|
||||
transition: `${theme.transitions.duration.standard}ms`,
|
||||
'&:hover': {
|
||||
boxShadow: theme.shadows[4],
|
||||
},
|
||||
},
|
||||
bold: {
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
},
|
||||
service: {
|
||||
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.service.colors})`,
|
||||
},
|
||||
website: {
|
||||
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.website.colors})`,
|
||||
},
|
||||
library: {
|
||||
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.library.colors})`,
|
||||
},
|
||||
documentation: {
|
||||
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.documentation.colors})`,
|
||||
},
|
||||
api: {
|
||||
background: `${pageTheme.home.shape}, linear-gradient(90deg, #005B4B, #005B4B)`,
|
||||
},
|
||||
tool: {
|
||||
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.tool.colors})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const countEntitiesBy = (
|
||||
entities: Array<Entity>,
|
||||
kind: EntitiesKinds,
|
||||
type?: EntitiesTypes,
|
||||
) =>
|
||||
entities.filter(
|
||||
e => e.kind === kind && (type ? e?.spec?.type === type : true),
|
||||
).length;
|
||||
|
||||
const EntityCountTile = ({
|
||||
counter,
|
||||
className,
|
||||
name,
|
||||
}: {
|
||||
counter: number;
|
||||
className: EntitiesTypes;
|
||||
name: string;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Box
|
||||
className={`${classes.card} ${classes[className]}`}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography className={classes.bold} variant="h6">
|
||||
{counter}
|
||||
</Typography>
|
||||
<Typography className={classes.bold} variant="h6">
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const OwnershipCard = ({
|
||||
entity,
|
||||
variant,
|
||||
}: {
|
||||
entity: Entity;
|
||||
variant: string;
|
||||
}) => {
|
||||
const {
|
||||
metadata: { name: groupName },
|
||||
} = entity;
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
value: componentsWithCounters,
|
||||
} = useAsync(async () => {
|
||||
const entitiesList = await catalogApi.getEntities();
|
||||
const ownedEntitiesList = entitiesList.items.filter(component =>
|
||||
component?.relations?.some(
|
||||
r => r.type === RELATION_OWNED_BY && r.target.name === groupName,
|
||||
),
|
||||
) as Array<Entity>;
|
||||
|
||||
return [
|
||||
{
|
||||
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'service'),
|
||||
className: 'service',
|
||||
name: 'Services',
|
||||
},
|
||||
{
|
||||
counter: countEntitiesBy(
|
||||
ownedEntitiesList,
|
||||
'Component',
|
||||
'documentation',
|
||||
),
|
||||
className: 'documentation',
|
||||
name: 'Documentation',
|
||||
},
|
||||
{
|
||||
counter: countEntitiesBy(ownedEntitiesList, 'API'),
|
||||
className: 'api',
|
||||
name: 'APIs',
|
||||
},
|
||||
{
|
||||
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'library'),
|
||||
className: 'library',
|
||||
name: 'Libraries',
|
||||
},
|
||||
{
|
||||
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'website'),
|
||||
className: 'website',
|
||||
name: 'Websites',
|
||||
},
|
||||
{
|
||||
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'tool'),
|
||||
className: 'tool',
|
||||
name: 'Tools',
|
||||
},
|
||||
] as Array<{ counter: number; className: EntitiesTypes; name: string }>;
|
||||
}, [catalogApi]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard title="Ownership" variant={variant}>
|
||||
<Grid container>
|
||||
{componentsWithCounters?.map(c => (
|
||||
<Grid item xs={12} md={6} lg={4} key={c.name}>
|
||||
<EntityCountTile
|
||||
counter={c.counter}
|
||||
className={c.className}
|
||||
name={c.name}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './OwnershipCard';
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { UserEntity } from '@backstage/catalog-model';
|
||||
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { UserProfileCard } from './UserProfileCard';
|
||||
|
||||
describe('UserSummary Test', () => {
|
||||
const userEntity: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'calum.leavy',
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Calum Leavy',
|
||||
email: 'calum-leavy@example.com',
|
||||
picture: 'https://example.com/staff/calum.jpeg',
|
||||
},
|
||||
memberOf: ['ExampleGroup'],
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
type: 'memberOf',
|
||||
target: {
|
||||
kind: 'group',
|
||||
name: 'ExampleGroup',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('Display Profile Card', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(<UserProfileCard entity={userEntity} variant="gridItem" />),
|
||||
);
|
||||
|
||||
expect(rendered.getByText('calum-leavy@example.com')).toBeInTheDocument();
|
||||
expect(rendered.getByAltText('Calum Leavy')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/staff/calum.jpeg',
|
||||
);
|
||||
expect(rendered.getByText('[ExampleGroup]')).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/group/ExampleGroup',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 React from 'react';
|
||||
import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { InfoCard } from '@backstage/core';
|
||||
import { entityRouteParams } from '@backstage/plugin-catalog';
|
||||
import {
|
||||
Entity,
|
||||
RELATION_MEMBER_OF,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import GroupIcon from '@material-ui/icons/Group';
|
||||
import PersonIcon from '@material-ui/icons/Person';
|
||||
import { Link as RouterLink, generatePath } from 'react-router-dom';
|
||||
import { Avatar } from '../../../Avatar';
|
||||
|
||||
const GroupLink = ({
|
||||
groupName,
|
||||
index,
|
||||
entity,
|
||||
}: {
|
||||
groupName: string;
|
||||
index: number;
|
||||
entity: Entity;
|
||||
}) => (
|
||||
<>
|
||||
{index >= 1 ? ', ' : ''}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/:namespace/group/${groupName}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
[{groupName}]
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
const CardTitle = ({ title }: { title?: string }) =>
|
||||
title ? (
|
||||
<Box display="flex" alignItems="center">
|
||||
<PersonIcon fontSize="inherit" />
|
||||
<Box ml={1}>{title}</Box>
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
export const UserProfileCard = ({
|
||||
entity: user,
|
||||
variant,
|
||||
}: {
|
||||
entity: UserEntity;
|
||||
variant: string;
|
||||
}) => {
|
||||
const {
|
||||
spec: { profile },
|
||||
} = user;
|
||||
const groupNames =
|
||||
user?.relations
|
||||
?.filter(r => r.type === RELATION_MEMBER_OF)
|
||||
?.map(group => group.target.name) || [];
|
||||
|
||||
if (!user) return <Alert severity="error">User not found</Alert>;
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
title={<CardTitle title={profile?.displayName} />}
|
||||
variant={variant}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={2} xl={1}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-start"
|
||||
justifyContent="center"
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<Avatar
|
||||
displayName={profile?.displayName}
|
||||
picture={profile?.picture}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item md={10} xl={11}>
|
||||
<Typography variant="subtitle1">
|
||||
<Box display="flex" alignItems="center">
|
||||
<Tooltip title="Email">
|
||||
<EmailIcon fontSize="inherit" />
|
||||
</Tooltip>
|
||||
{profile?.email && (
|
||||
<Box ml={1} display="inline">
|
||||
{profile.email}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography variant="subtitle1">
|
||||
<Box display="flex" alignItems="center">
|
||||
<Tooltip title="Member of">
|
||||
<GroupIcon />
|
||||
</Tooltip>
|
||||
<Box ml={1} display="inline">
|
||||
{groupNames.map((groupName, index) => (
|
||||
<GroupLink
|
||||
groupName={groupName}
|
||||
index={index}
|
||||
key={groupName}
|
||||
entity={user}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './UserProfileCard';
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './UserProfileCard';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './Group';
|
||||
export * from './User';
|
||||
export * from './OwnershipCard';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './Cards';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { plugin } from './plugin';
|
||||
export * from './components';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { plugin } from './plugin';
|
||||
|
||||
describe('groups', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { createPlugin } from '@backstage/core';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'org',
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
@@ -30,7 +30,6 @@ import transformer, {
|
||||
addLinkClickListener,
|
||||
removeMkdocsHeader,
|
||||
simplifyMkdocsFooter,
|
||||
modifyCss,
|
||||
onCssReady,
|
||||
sanitizeDOM,
|
||||
injectCss,
|
||||
@@ -71,15 +70,6 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
modifyCss({
|
||||
cssTransforms: {
|
||||
'.md-main__inner': [{ 'margin-top': '0' }],
|
||||
'.md-sidebar': [{ top: '0' }, { width: '20rem' }],
|
||||
'.md-typeset': [{ 'font-size': '1rem' }],
|
||||
'.md-nav': [{ 'font-size': '1rem' }],
|
||||
'.md-grid': [{ 'max-width': '80vw' }],
|
||||
},
|
||||
}),
|
||||
removeMkdocsHeader(),
|
||||
simplifyMkdocsFooter(),
|
||||
injectCss({
|
||||
@@ -92,6 +82,11 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
--md-code-fg-color: ${theme.palette.text.primary};
|
||||
--md-code-bg-color: ${theme.palette.background.paper};
|
||||
}
|
||||
.md-main__inner { margin-top: 0; }
|
||||
.md-sidebar { top: 0; width: 20rem; }
|
||||
.md-typeset { font-size: 1rem; }
|
||||
.md-nav { font-size: 1rem; }
|
||||
.md-grid { max-width: 80vw; }
|
||||
`,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -28,13 +28,13 @@ export const addLinkClickListener = ({
|
||||
return dom => {
|
||||
Array.from(dom.getElementsByTagName('a')).forEach(elem => {
|
||||
elem.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLAnchorElement;
|
||||
const href = target?.getAttribute('href');
|
||||
const target = elem as HTMLAnchorElement;
|
||||
const href = target.getAttribute('href');
|
||||
|
||||
if (!href) return;
|
||||
if (href.startsWith(baseUrl)) {
|
||||
e.preventDefault();
|
||||
onClick(e, target.getAttribute('href')!);
|
||||
onClick(e, href);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ export * from './rewriteDocLinks';
|
||||
export * from './addLinkClickListener';
|
||||
export * from './removeMkdocsHeader';
|
||||
export * from './simplifyMkdocsFooter';
|
||||
export * from './modifyCss';
|
||||
export * from './onCssReady';
|
||||
export * from './sanitizeDOM';
|
||||
export * from './injectCss';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { createTestShadowDom } from '../../test-utils';
|
||||
import { modifyCss } from '../transformers';
|
||||
|
||||
describe('modifyCss', () => {
|
||||
it('does not modify css', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`<div class="md-typeset" style="font-size: 0.8em"></div>`,
|
||||
{
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
const { fontSize } = getComputedStyle(
|
||||
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
|
||||
);
|
||||
|
||||
expect(fontSize).toBe('0.8em');
|
||||
});
|
||||
|
||||
it('does modify css', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
`<div class="md-typeset" style="font-size: 1px"></div>`,
|
||||
{
|
||||
preTransformers: [
|
||||
modifyCss({
|
||||
cssTransforms: {
|
||||
'.md-typeset': [{ 'font-size': '1em' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
const { fontSize } = getComputedStyle(
|
||||
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
|
||||
);
|
||||
|
||||
expect(fontSize).toBe('1em');
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 type { Transformer } from './index';
|
||||
|
||||
type ModifyCssOptions = {
|
||||
// Example: { '.md-container': { 'marginTop': '10px' }}
|
||||
cssTransforms: { [key: string]: { [key: string]: string }[] };
|
||||
};
|
||||
|
||||
export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => {
|
||||
return dom => {
|
||||
Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => {
|
||||
const elementsToChange = Array.from(
|
||||
dom.querySelectorAll<HTMLElement>(cssSelector),
|
||||
);
|
||||
if (elementsToChange.length < 1) return;
|
||||
|
||||
cssChanges.forEach(changes => {
|
||||
elementsToChange.forEach((element: HTMLElement) => {
|
||||
Object.entries(changes).forEach(([cssProperty, cssValue]) => {
|
||||
element.style.setProperty(cssProperty, cssValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return dom;
|
||||
};
|
||||
};
|
||||
@@ -2540,16 +2540,6 @@
|
||||
source-map "^0.6.1"
|
||||
write-file-atomic "^3.0.0"
|
||||
|
||||
"@jest/types@^25.5.0":
|
||||
version "25.5.0"
|
||||
resolved "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d"
|
||||
integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==
|
||||
dependencies:
|
||||
"@types/istanbul-lib-coverage" "^2.0.0"
|
||||
"@types/istanbul-reports" "^1.1.1"
|
||||
"@types/yargs" "^15.0.0"
|
||||
chalk "^3.0.0"
|
||||
|
||||
"@jest/types@^26.5.2", "@jest/types@^26.6.1":
|
||||
version "26.6.1"
|
||||
resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5"
|
||||
@@ -5447,14 +5437,6 @@
|
||||
dependencies:
|
||||
"@types/istanbul-lib-coverage" "*"
|
||||
|
||||
"@types/istanbul-reports@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz#7a8cbf6a406f36c8add871625b278eaf0b0d255a"
|
||||
integrity sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==
|
||||
dependencies:
|
||||
"@types/istanbul-lib-coverage" "*"
|
||||
"@types/istanbul-lib-report" "*"
|
||||
|
||||
"@types/istanbul-reports@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821"
|
||||
@@ -5462,15 +5444,7 @@
|
||||
dependencies:
|
||||
"@types/istanbul-lib-report" "*"
|
||||
|
||||
"@types/jest@*", "@types/jest@^26.0.7":
|
||||
version "26.0.14"
|
||||
resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.14.tgz#078695f8f65cb55c5a98450d65083b2b73e5a3f3"
|
||||
integrity sha512-Hz5q8Vu0D288x3iWXePSn53W7hAjP0H7EQ6QvDO9c7t46mR0lNOLlfuwQ+JkVxuhygHzlzPX+0jKdA3ZgSh+Vg==
|
||||
dependencies:
|
||||
jest-diff "^25.2.1"
|
||||
pretty-format "^25.2.1"
|
||||
|
||||
"@types/jest@26.x":
|
||||
"@types/jest@*", "@types/jest@26.x", "@types/jest@^26.0.7":
|
||||
version "26.0.15"
|
||||
resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe"
|
||||
integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog==
|
||||
@@ -5790,6 +5764,11 @@
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
|
||||
"@types/pluralize@^0.0.29":
|
||||
version "0.0.29"
|
||||
resolved "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c"
|
||||
integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==
|
||||
|
||||
"@types/prettier@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4"
|
||||
@@ -6096,9 +6075,9 @@
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/testing-library__jest-dom@^5.9.1":
|
||||
version "5.9.1"
|
||||
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5"
|
||||
integrity sha512-yYn5EKHO3MPEMSOrcAb1dLWY+68CG29LiXKsWmmpVHqoP5+ZRiAVLyUHvPNrO2dABDdUGZvavMsaGpWNjM6N2g==
|
||||
version "5.9.5"
|
||||
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0"
|
||||
integrity sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==
|
||||
dependencies:
|
||||
"@types/jest" "*"
|
||||
|
||||
@@ -10640,11 +10619,6 @@ dicer@0.3.0:
|
||||
dependencies:
|
||||
streamsearch "0.1.2"
|
||||
|
||||
diff-sequences@^25.2.6:
|
||||
version "25.2.6"
|
||||
resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
|
||||
integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==
|
||||
|
||||
diff-sequences@^26.5.0:
|
||||
version "26.5.0"
|
||||
resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd"
|
||||
@@ -14995,16 +14969,6 @@ jest-css-modules@^2.1.0:
|
||||
dependencies:
|
||||
identity-obj-proxy "3.0.0"
|
||||
|
||||
jest-diff@^25.2.1:
|
||||
version "25.5.0"
|
||||
resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9"
|
||||
integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==
|
||||
dependencies:
|
||||
chalk "^3.0.0"
|
||||
diff-sequences "^25.2.6"
|
||||
jest-get-type "^25.2.6"
|
||||
pretty-format "^25.5.0"
|
||||
|
||||
jest-diff@^26.0.0, jest-diff@^26.5.2:
|
||||
version "26.6.1"
|
||||
resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c"
|
||||
@@ -15066,11 +15030,6 @@ jest-esm-transformer@^1.0.0:
|
||||
"@babel/core" "^7.4.4"
|
||||
"@babel/plugin-transform-modules-commonjs" "^7.4.4"
|
||||
|
||||
jest-get-type@^25.2.6:
|
||||
version "25.2.6"
|
||||
resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877"
|
||||
integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==
|
||||
|
||||
jest-get-type@^26.3.0:
|
||||
version "26.3.0"
|
||||
resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
|
||||
@@ -19154,6 +19113,11 @@ please-upgrade-node@^3.2.0:
|
||||
dependencies:
|
||||
semver-compare "^1.0.0"
|
||||
|
||||
pluralize@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
|
||||
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
|
||||
|
||||
pn@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
|
||||
@@ -19684,16 +19648,6 @@ pretty-error@^2.1.1:
|
||||
renderkid "^2.0.1"
|
||||
utila "~0.4"
|
||||
|
||||
pretty-format@^25.2.1, pretty-format@^25.5.0:
|
||||
version "25.5.0"
|
||||
resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a"
|
||||
integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ==
|
||||
dependencies:
|
||||
"@jest/types" "^25.5.0"
|
||||
ansi-regex "^5.0.0"
|
||||
ansi-styles "^4.0.0"
|
||||
react-is "^16.12.0"
|
||||
|
||||
pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.5.2, pretty-format@^26.6.1:
|
||||
version "26.6.1"
|
||||
resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8"
|
||||
@@ -20368,7 +20322,7 @@ react-inspector@^5.0.1:
|
||||
is-dom "^1.1.0"
|
||||
prop-types "^15.6.1"
|
||||
|
||||
react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0:
|
||||
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
Reference in New Issue
Block a user