diff --git a/.github/workflows/cli-win.yml b/.github/workflows/cli-win.yml index 00c7c26a6c83c..1355ed251e63d 100644 --- a/.github/workflows/cli-win.yml +++ b/.github/workflows/cli-win.yml @@ -43,23 +43,4 @@ jobs: - run: yarn tsc - run: yarn build - name: verify app and plugin creation - working-directory: ${{ runner.temp }} run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: lint newly created app and plugin - run: yarn lint:all - working-directory: ${{ runner.temp }}/test-app - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: test newly created app and plugin - run: yarn test:all - working-directory: ${{ runner.temp }}/test-app - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: e2e test newly created app - run: yarn test:e2e:ci - working-directory: ${{ runner.temp }}/test-app/packages/app - env: - APP_CONFIG_app_baseUrl: '"http://localhost:3001"' - BACKSTAGE_E2E_CLI_TEST: true diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 71b207c0d3e38..7704aa36cd13d 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -44,25 +44,6 @@ jobs: - run: yarn tsc - run: yarn build - name: verify app and plugin creation - working-directory: ${{ runner.temp }} run: | sudo sysctl fs.inotify.max_user_watches=524288 node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: lint newly created app and plugin - run: yarn lint:all - working-directory: ${{ runner.temp }}/test-app - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: test newly created app and plugin - run: yarn test:all - working-directory: ${{ runner.temp }}/test-app - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: e2e test newly created app - run: yarn test:e2e:ci - working-directory: ${{ runner.temp }}/test-app/packages/app - env: - APP_CONFIG_app_baseUrl: '"http://localhost:3001"' - BACKSTAGE_E2E_CLI_TEST: true diff --git a/README.md b/README.md index 2f3740c36d66a..867d857fa48e8 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,20 @@ For more information go to [backstage.io](https://backstage.io) or join our [Discord chatroom](https://discord.gg/EBHEGzX). ### Features -* Create and manage all of your organization’s software and microservices in one place -* Services catalog keeps track of all software and its ownership -* Visualizations provide information about your backend services and tooling, and help you monitor them -* A unified method for managing microservices offers both visibility and control -* Preset templates allow engineers to quickly create microservices in a standardized way ([coming soon](https://github.com/spotify/backstage/milestone/11)) -* Centralized, full-featured technical documentation with integrated tooling that makes it easy for developers to set up, publish, and maintain alongside their code ([coming soon](https://github.com/spotify/backstage/milestone/15)) + +- Create and manage all of your organization’s software and microservices in one place +- Services catalog keeps track of all software and its ownership +- Visualizations provide information about your backend services and tooling, and help you monitor them +- A unified method for managing microservices offers both visibility and control +- Preset templates allow engineers to quickly create microservices in a standardized way ([coming soon](https://github.com/spotify/backstage/milestone/11)) +- Centralized, full-featured technical documentation with integrated tooling that makes it easy for developers to set up, publish, and maintain alongside their code ([coming soon](https://github.com/spotify/backstage/milestone/15)) ### Benefits -* For engineering managers, it allows you to maintain standards and best practices across the organization, and can help you manage your whole tech ecosystem, from migrations to test certification. -* For end users (developers), it makes it fast and simple to build software components in a standardized way, and it provides a central place to manage all projects and documentation. -* For platform engineers, it enables extensibility and scalability by letting you easily integrate new tools and services (via plugins), as well as extending the functionality of existing ones. -* For everyone, it’s a single, consistent experience that ties all your infrastructure tooling, resources, standards, owners, contributors, and administrators together in one place. + +- For engineering managers, it allows you to maintain standards and best practices across the organization, and can help you manage your whole tech ecosystem, from migrations to test certification. +- For end users (developers), it makes it fast and simple to build software components in a standardized way, and it provides a central place to manage all projects and documentation. +- For platform engineers, it enables extensibility and scalability by letting you easily integrate new tools and services (via plugins), as well as extending the functionality of existing ones. +- For everyone, it’s a single, consistent experience that ties all your infrastructure tooling, resources, standards, owners, contributors, and administrators together in one place. ## Backstage Service Catalog (alpha) @@ -54,7 +56,7 @@ Our vision for Backstage is for it to become the trusted standard toolbox (read: The Backstage platform consists of a number of different components: -- **app** - Main web application that users interact with. It's built up by a number of different _Plugins_. This repo contains an example implementation of an app (located in `packages/example-app`) and you can easily get started with your own app by [creating one](docs/create-an-app.md). +- **app** - Main web application that users interact with. It's built up by a number of different _Plugins_. This repo contains an example implementation of an app (located in `packages/app`) and you can easily get started with your own app by [creating one](docs/create-an-app.md). - [**plugins**](https://github.com/spotify/backstage/tree/master/plugins) - Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform API's and reusable UI components. Plugins can fetch data either from the _backend_ or through any RESTful API exposed through the _proxy_. - [**service catalog**](https://github.com/spotify/backstage/tree/master/packages/backend) - Service that holds the model of your software ecosystem, including organisational information and what team owns what software. The backend also has a Plugin model for extending its graph. - **proxy** \* - Terminates HTTPS and exposes any RESTful API to Plugins. diff --git a/docs/architecture-decisions/adr006-avoid-react-fc.md b/docs/architecture-decisions/adr006-avoid-react-fc.md new file mode 100644 index 0000000000000..21e6d5b637dbc --- /dev/null +++ b/docs/architecture-decisions/adr006-avoid-react-fc.md @@ -0,0 +1,47 @@ +# ADR006: Avoid React.FC and React.SFC + +## Context + +Facebook has removed `React.FC` from their base template for a Typescript +project. The reason for this was that it was found to be an unnecessary feature +with next to no benefits in combination with a few downsides. + +The main reasons were: + +- **children props** were implicitly added +- **Generic Type** were not supported on children + +Read more about the removal in +[this PR](https://github.com/facebook/create-react-app/pull/8177). + +## Decision + +To keep our codebase up to date, we have decided that `React.FC` and `React.SFC` +should be avoided in our codebase when adding new code. + +Here is an example: + +```ts +/* Avoid this: */ +type BadProps = { text: string }; +const BadComponent: FC = ({ text, children }) => ( +
+
{text}
+ {children} +
+); + +/* Do this instead: */ +type GoodProps = { text: string; children?: React.ReactNode }; +const GoodComponent = ({ text, children }: GoodProps) => ( +
+
{text}
+ {children} +
+); +``` + +## Consequences + +We will gradually remove the current usage of `React.FC` and `React.SFC` from +our codebase. diff --git a/install/kubernetes/app.yaml b/install/kubernetes/app.yaml new file mode 100644 index 0000000000000..ed4108f11be63 --- /dev/null +++ b/install/kubernetes/app.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage + labels: + app: backstage + component: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: backstage + component: frontend + template: + metadata: + labels: + app: backstage + component: frontend + spec: + containers: + - name: app + image: spotify/backstage:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + name: app + protocol: TCP diff --git a/install/kubernetes/backend.yaml b/install/kubernetes/backend.yaml new file mode 100644 index 0000000000000..669426fecd128 --- /dev/null +++ b/install/kubernetes/backend.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage-backend + labels: + app: backstage + component: backend +spec: + replicas: 1 + selector: + matchLabels: + app: backstage + component: backend + template: + metadata: + labels: + app: backstage + component: backend + spec: + containers: + - name: backend + image: spotify/backstage-backend:latest + imagePullPolicy: Always + ports: + - containerPort: 7000 + name: backend + protocol: TCP diff --git a/install/kubernetes/backstage/.helmignore b/install/kubernetes/backstage/.helmignore new file mode 100644 index 0000000000000..50af031725419 --- /dev/null +++ b/install/kubernetes/backstage/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/install/kubernetes/backstage/Chart.yaml b/install/kubernetes/backstage/Chart.yaml new file mode 100644 index 0000000000000..23ea41ec41363 --- /dev/null +++ b/install/kubernetes/backstage/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Spotify Backstage +name: backstage +version: 0.1.1-alpha.12 diff --git a/install/kubernetes/backstage/README.md b/install/kubernetes/backstage/README.md new file mode 100644 index 0000000000000..e8d809c228696 --- /dev/null +++ b/install/kubernetes/backstage/README.md @@ -0,0 +1,51 @@ +# Backstage Helm Chart + +## App/Frontend Values + +| Parameter | Description | Default | +| --------------------------- | --------------------------------------------------------- | ------------------- | +| app.enabled | Whether to render the frontend app config or not | `true` | +| app.nameOverride | Override the name given to the app/frontend | `""` | +| app.fullnameOverride | Override the full name of the app/frontend | `""` | +| app.replicaCount | The number of replicas for the app/frontend | `1` | +| app.image.repository | The image repository of the app/frontend container | `spotify/backstage` | +| app.image.tag | The app/frontend tag to pull | `latest` | +| app.image.pullPolicy | Image pull policy | `Always` | +| app.service.type | The service type for the app/frontend service | `ClusterIP` | +| app.service.port | The port for the app/frontend | `80` | +| app.ingress.enabled | Whether to create ingress or not | `false` | +| app.ingress.annotations | Annotations for the app/frontend ingress | `{}` | +| app.ingress.hosts[].host | Hostname for the app/frontend | `backstage.local` | +| app.ingress.hosts[].paths[] | Path name to serve the app/frontend on | `["/"]` | +| app.imagePullSecrets[] | Any image secrets you need to pull `app.image.repository` | `[]` | +| app.podSecurityContext | Security context for the app/frontend pods | `{}` | +| app.securityContext | Security context settings for the deployment | `{}` | +| app.resources | Kubernetes Pod resource requests/limits | `{}` | +| app.nodeSelector | Node selectors for scheduling app/frontend pods | `{}` | +| app.tolerations | Tolerations for scheduling app/frontend pods | `{}` | +| app.affinity | Affinity setttings for scheduling app/frontend pods | `{}` | + +## Backend Values + +| Parameter | Description | Default | +| ------------------------------- | ------------------------------------------------------------- | ------------------- | +| backend.enabled | Whether to render the backend config or not | `true` | +| backend.nameOverride | Override the name given to the backend | `""` | +| backend.fullnameOverride | Override the full name of the backend | `""` | +| backend.replicaCount | The number of replicas for the backend | `1` | +| backend.image.repository | The image repository of the backend container | `spotify/backstage` | +| backend.image.tag | The backend tag to pull | `latest` | +| backend.image.pullPolicy | Image pull policy | `Always` | +| backend.service.type | The service type for the backend service | `ClusterIP` | +| backend.service.port | The port for the backend | `80` | +| backend.ingress.enabled | Whether to create ingress or not | `false` | +| backend.ingress.annotations | Annotations for the backend ingress | `{}` | +| backend.ingress.hosts[].host | Hostname for the backend | `backstage.local` | +| backend.ingress.hosts[].paths[] | Path name to serve the backend on | `["/"]` | +| backend.imagePullSecrets[] | Any image secrets you need to pull `backend.image.repository` | `[]` | +| backend.podSecurityContext | Security context for the backend pods | `{}` | +| backend.securityContext | Security context settings for the deployment | `{}` | +| backend.resources | Kubernetes Pod resource requests/limits | `{}` | +| backend.nodeSelector | Node selectors for scheduling backend pods | `{}` | +| backend.tolerations | Tolerations for scheduling backend pods | `{}` | +| backend.affinity | Affinity setttings for scheduling backend pods | `{}` | diff --git a/install/kubernetes/backstage/templates/_helpers.tpl b/install/kubernetes/backstage/templates/_helpers.tpl new file mode 100644 index 0000000000000..af630ab9cd3f2 --- /dev/null +++ b/install/kubernetes/backstage/templates/_helpers.tpl @@ -0,0 +1,80 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "backstage.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "backstage.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "backstage.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common App labels +*/}} +{{- define "backstage.app.labels" -}} +app.kubernetes.io/name: {{ include "backstage.name" . }}-app +helm.sh/chart: {{ include "backstage.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Common Backend labels +*/}} +{{- define "backstage.backend.labels" -}} +app.kubernetes.io/name: {{ include "backstage.name" . }}-backend +helm.sh/chart: {{ include "backstage.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Create the name of the service account to use for the app +*/}} +{{- define "backstage.app.serviceAccountName" -}} +{{- if .Values.app.serviceAccount.create -}} + {{ default "default" .Values.app.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.app.serviceAccount.name }} +{{- end -}} +{{- end -}} + +{{/* +Create the name of the service account to use for the backend +*/}} +{{- define "backstage.backend.serviceAccountName" -}} +{{- if .Values.backend.serviceAccount.create -}} + {{ default default .Values.backend.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.backend.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/install/kubernetes/backstage/templates/deployment.yaml b/install/kubernetes/backstage/templates/deployment.yaml new file mode 100644 index 0000000000000..85446772b7b86 --- /dev/null +++ b/install/kubernetes/backstage/templates/deployment.yaml @@ -0,0 +1,119 @@ +{{- if .Values.app.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "backstage.fullname" . }}-app + labels: +{{ include "backstage.app.labels" . | indent 4 }} +spec: + replicas: {{ .Values.app.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "backstage.name" . }}-app + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "backstage.name" . }}-app + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + {{- with .Values.app.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ template "backstage.app.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.app.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }}-app + securityContext: + {{- toYaml .Values.app.securityContext | nindent 12 }} + image: "{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}" + imagePullPolicy: {{ .Values.app.image.pullPolicy }} + ports: + - name: app + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.app.resources | nindent 12 }} + {{- with .Values.app.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.app.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} +{{- if .Values.backend.enabled }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "backstage.fullname" . }}-backend + labels: +{{ include "backstage.backend.labels" . | indent 4 }} +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "backstage.name" . }}-backend + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "backstage.name" . }}-backend + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + {{- with .Values.backend.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ template "backstage.backend.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.backend.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }}-backend + securityContext: + {{- toYaml .Values.backend.securityContext | nindent 12 }} + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + ports: + - name: backend + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: backend + readinessProbe: + httpGet: + path: / + port: backend + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/install/kubernetes/backstage/templates/ingress.yaml b/install/kubernetes/backstage/templates/ingress.yaml new file mode 100644 index 0000000000000..77d304cb20147 --- /dev/null +++ b/install/kubernetes/backstage/templates/ingress.yaml @@ -0,0 +1,83 @@ +{{- if .Values.app.ingress.enabled -}} +{{- $fullName := include "backstage.fullname" . -}} +{{- $svcPort := .Values.app.service.port -}} +{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-app + labels: +{{ include "backstage.app.labels" . | indent 4 }} + {{- with .Values.app.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.app.ingress.tls }} + tls: + {{- range .Values.app.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.app.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }}-app + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} +{{ end }} +{{- if .Values.backend.ingress.enabled }} +--- +{{- $fullName := include "backstage.fullname" . -}} +{{- $svcPort := .Values.backend.service.port -}} +{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }} +apiVersion: networking.k8s.io/v1beta1 +{{- else }} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-backend + labels: +{{ include "backstage.backend.labels" . | indent 4 }} + {{- with .Values.backend.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.backend.ingress.tls }} + tls: + {{- range .Values.backend.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.backend.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }}-backend + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} +{{ end }} diff --git a/install/kubernetes/backstage/templates/service.yaml b/install/kubernetes/backstage/templates/service.yaml new file mode 100644 index 0000000000000..77a113ae5c002 --- /dev/null +++ b/install/kubernetes/backstage/templates/service.yaml @@ -0,0 +1,37 @@ +{{- if .Values.app.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "backstage.fullname" . }}-app + labels: +{{ include "backstage.app.labels" . | indent 4 }} +spec: + type: {{ .Values.app.service.type }} + ports: + - port: {{ .Values.app.service.port }} + targetPort: app + protocol: TCP + name: app + selector: + app.kubernetes.io/name: {{ include "backstage.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} +{{- if .Values.app.enabled }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "backstage.fullname" . }}-backend + labels: +{{ include "backstage.backend.labels" . | indent 4 }} +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: backend + protocol: TCP + name: backend + selector: + app.kubernetes.io/name: {{ include "backstage.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/install/kubernetes/backstage/values.yaml b/install/kubernetes/backstage/values.yaml new file mode 100644 index 0000000000000..fd4dd66b5352e --- /dev/null +++ b/install/kubernetes/backstage/values.yaml @@ -0,0 +1,95 @@ +app: + enabled: true + nameOverride: "" + fullnameOverride: "" + replicaCount: 1 + serviceAccount: + create: false + Name: "" + image: + repository: spotify/backstage + tag: latest + pullPolicy: Always + service: + type: ClusterIP + port: 80 + ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: "nginx" + hosts: + - host: backstage.local + paths: + - / + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + imagePullSecrets: [] + podSecurityContext: {} + # fsGroup: 2000 + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + nodeSelector: {} + tolerations: [] + affinity: {} + +backend: + enabled: false + nameOverride: "" + fullnameOverride: "" + replicaCount: 1 + serviceAccount: + create: false + Name: "" + image: + repository: spotify/backstage-backend + tag: latest + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 7000 + ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: "nginx" + hosts: + - host: backstage.local + paths: + - /backend + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + imagePullSecrets: [] + podSecurityContext: {} + # fsGroup: 2000 + securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + nodeSelector: {} + tolerations: [] + affinity: {} diff --git a/install/kubernetes/ingress.yaml b/install/kubernetes/ingress.yaml new file mode 100644 index 0000000000000..d2ecae74c6634 --- /dev/null +++ b/install/kubernetes/ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: backstage + labels: + app: backstage + component: ingress +spec: + rules: + - host: + http: + paths: + - backend: + serviceName: backstage + servicePort: frontend + path: / + - backend: + serviceName: backstage-backend + servicePort: backend + path: /backend diff --git a/install/kubernetes/service.yaml b/install/kubernetes/service.yaml new file mode 100644 index 0000000000000..bbbc3d6e175d5 --- /dev/null +++ b/install/kubernetes/service.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: Service +metadata: + name: backstage + labels: + app: backstage + component: frontend +spec: + type: ClusterIP + selector: + app: backstage + component: frontend + ports: + - name: frontend + port: 80 + protocol: TCP + targetPort: app +--- +apiVersion: v1 +kind: Service +metadata: + name: backstage-backend + labels: + app: backstage + component: backend +spec: + type: ClusterIP + selector: + app: backstage + component: backend + ports: + - name: backend + port: 7000 + protocol: TCP + targetPort: backend diff --git a/packages/app/package.json b/packages/app/package.json index 46769e7dfb4ea..e6e8254cb4d9a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "@backstage/plugin-catalog": "^0.1.1-alpha.12", "@backstage/plugin-circleci": "^0.1.1-alpha.12", "@backstage/plugin-explore": "^0.1.1-alpha.12", + "@backstage/plugin-github-actions": "^0.1.1-alpha.12", "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.12", "@backstage/plugin-graphiql": "^0.1.1-alpha.12", "@backstage/plugin-lighthouse": "^0.1.1-alpha.12", @@ -21,12 +22,13 @@ "@backstage/theme": "^0.1.1-alpha.12", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", + "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", "react-hot-loader": "^4.12.21", - "react-router": "6.0.0-alpha.5", - "react-router-dom": "6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0", "zen-observable": "^0.8.15" }, diff --git a/packages/app/public/android-chrome-192x192.png b/packages/app/public/android-chrome-192x192.png index 3d56edbb0dce7..4660f988c1a30 100644 Binary files a/packages/app/public/android-chrome-192x192.png and b/packages/app/public/android-chrome-192x192.png differ diff --git a/packages/app/public/android-chrome-512x512.png b/packages/app/public/android-chrome-512x512.png new file mode 100644 index 0000000000000..d9b0a6ba78cbe Binary files /dev/null and b/packages/app/public/android-chrome-512x512.png differ diff --git a/packages/app/public/apple-touch-icon.png b/packages/app/public/apple-touch-icon.png index 0977175f6fafa..57c05cfc9a460 100644 Binary files a/packages/app/public/apple-touch-icon.png and b/packages/app/public/apple-touch-icon.png differ diff --git a/packages/app/public/favicon-16x16.png b/packages/app/public/favicon-16x16.png index a455ffac7b944..58cf61a35eb6f 100644 Binary files a/packages/app/public/favicon-16x16.png and b/packages/app/public/favicon-16x16.png differ diff --git a/packages/app/public/favicon-32x32.png b/packages/app/public/favicon-32x32.png index e2707f2d1e6f1..c0915ece75949 100644 Binary files a/packages/app/public/favicon-32x32.png and b/packages/app/public/favicon-32x32.png differ diff --git a/packages/app/public/favicon.ico b/packages/app/public/favicon.ico index 5b582704a10d2..5e45e5dfbde6f 100644 Binary files a/packages/app/public/favicon.ico and b/packages/app/public/favicon.ico differ diff --git a/packages/app/public/favicon.svg b/packages/app/public/favicon.svg new file mode 100644 index 0000000000000..351dcc88099cc --- /dev/null +++ b/packages/app/public/favicon.svg @@ -0,0 +1,17 @@ + + + Backstage favicon + + + + + diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 61104ddadf53c..bc7170c93ae57 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,12 +26,14 @@ import { FeatureFlags, GoogleAuth, GithubAuth, + OAuth2, OktaAuth, GitlabAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oauth2ApiRef, oktaAuthApiRef, gitlabAuthApiRef, storageApiRef, @@ -110,7 +112,16 @@ export const apis = (config: ConfigApi) => { builder.add( gitlabAuthApiRef, GitlabAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, + basePath: '/auth/', + oauthRequestApi, + }), + ); + + builder.add( + oauth2ApiRef, + OAuth2.create({ + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 09cd117fc0a41..a5461c1bea38d 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -25,3 +25,4 @@ export { plugin as Sentry } from '@backstage/plugin-sentry'; export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles'; export { plugin as TechDocs } from '@backstage/plugin-techdocs'; export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; +export { plugin as GithubActions } from '@backstage/plugin-github-actions'; diff --git a/packages/backend-common/src/setupTests.ts b/packages/backend-common/src/setupTests.ts index a3b2b7e123c75..f7b6ca962d70b 100644 --- a/packages/backend-common/src/setupTests.ts +++ b/packages/backend-common/src/setupTests.ts @@ -15,4 +15,5 @@ */ require('jest-fetch-mock').enableMocks(); + export {}; diff --git a/packages/backend/README.md b/packages/backend/README.md index cd71b50fb0a29..47574e12af0c8 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -25,7 +25,13 @@ You should only need to do this once. After that, go to the `packages/backend` directory and run ```bash -AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start +AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \ +AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \ +AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \ +AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \ +SENTRY_TOKEN=x \ +LOG_LEVEL=debug \ +yarn start ``` Substitute `x` for actual values, or leave them as diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 86db4b2e6a5dc..a184e6219cf8c 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -36,6 +36,7 @@ module.exports = { rules: { 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' + 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ 'error', diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 7435b79264d39..2416a250e1f16 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -41,6 +41,7 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**'], rules: { + 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ 'error', diff --git a/packages/cli/e2e-test/cli-e2e-test.js b/packages/cli/e2e-test/cli-e2e-test.js index c8374af0eb0de..ef5d6ab2434a6 100644 --- a/packages/cli/e2e-test/cli-e2e-test.js +++ b/packages/cli/e2e-test/cli-e2e-test.js @@ -16,65 +16,238 @@ const os = require('os'); const fs = require('fs-extra'); -const { resolve: resolvePath } = require('path'); +const killTree = require('tree-kill'); +const { resolve: resolvePath, join: joinPath } = require('path'); const Browser = require('zombie'); - const { spawnPiped, + runPlain, handleError, waitForPageWithText, + waitFor, waitForExit, print, } = require('./helpers'); -const createTestApp = require('./createTestApp'); -const createTestPlugin = require('./createTestPlugin'); +async function main() { + const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); + print(`CLI E2E test root: ${rootDir}\n`); + + print('Building dist workspace'); + const workspaceDir = await buildDistWorkspace('workspace', rootDir); + + print('Creating a Backstage App'); + const appDir = await createApp('test-app', workspaceDir, rootDir); -Browser.localhost('localhost', 3000); + print('Creating a Backstage Plugin'); + const pluginName = await createPlugin('test-plugin', appDir); -async function createTempDir() { - return fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); + print('Starting the app'); + await testAppServe(pluginName, appDir); + + print('All tests successful, removing test dir'); + await fs.remove(rootDir); } -async function main() { - process.env.BACKSTAGE_E2E_CLI_TEST = 'true'; +/** + * Builds a dist workspace that contains the cli and core packages + */ +async function buildDistWorkspace(workspaceName, rootDir) { + const workspaceDir = resolvePath(rootDir, workspaceName); + await fs.ensureDir(workspaceDir); + + print(`Preparing workspace`); + await runPlain([ + 'yarn', + 'backstage-cli', + 'build-workspace', + workspaceDir, + '@backstage/cli', + '@backstage/core', + '@backstage/dev-utils', + '@backstage/test-utils', + ]); + + print('Pinning yarn version in workspace'); + await pinYarnVersion(workspaceDir); + + print('Installing workspace dependencies'); + await runPlain(['yarn', 'install', '--production', '--frozen-lockfile'], { + cwd: workspaceDir, + }); + + return workspaceDir; +} - const workDir = process.env.CI ? process.cwd() : await createTempDir(); +/** + * Pin the yarn version in a directory to the one we're using in the Backstage repo + */ +async function pinYarnVersion(dir) { + const repoRoot = resolvePath(__dirname, '../../..'); - process.stdout.write(`Initial directory: ${process.cwd()}\n`); - process.chdir(workDir); - process.stdout.write(`Working directory: ${process.cwd()}\n`); + const yarnRc = await fs.readFile(resolvePath(repoRoot, '.yarnrc'), 'utf8'); + const yarnRcLines = yarnRc.split('\n'); + const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarn-path')); + const [, localYarnPath] = yarnPathLine.match(/"(.*)"/); + const yarnPath = resolvePath(repoRoot, localYarnPath); - await createTestApp(); + await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`); +} - const appDir = resolvePath(workDir, 'test-app'); - process.chdir(appDir); - process.stdout.write(`App directory: ${appDir}\n`); +/** + * Creates a new app inside rootDir called test-app, using packages from the workspaceDir + */ +async function createApp(appName, workspaceDir, rootDir) { + const child = spawnPiped( + [ + 'node', + resolvePath(workspaceDir, 'packages/cli/bin/backstage-cli'), + 'create-app', + '--skip-install', + ], + { + cwd: rootDir, + }, + ); - await createTestPlugin(); + try { + let stdout = ''; + child.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); - print('Starting the app'); - const startApp = spawnPiped(['yarn', 'start']); + await waitFor(() => stdout.includes('Enter a name for the app')); + child.stdin.write(`${appName}\n`); + + print('Waiting for app create script to be done'); + await waitForExit(child); + + const appDir = resolvePath(rootDir, appName); + + print('Rewriting module resolutions of app to use workspace packages'); + await overrideModuleResolutions(appDir, workspaceDir); + + print('Pinning yarn version and registry in app'); + await pinYarnVersion(appDir); + await fs.writeFile( + resolvePath(appDir, '.npmrc'), + 'registry=https://registry.npmjs.org/\n', + ); + + print('Test app created'); + + for (const cmd of ['install', 'tsc', 'build', 'lint:all', 'test:all']) { + print(`Running 'yarn ${cmd}' in newly created app`); + await runPlain(['yarn', cmd], { cwd: appDir }); + } + + print(`Running 'yarn test:e2e:ci' in newly created app`); + await runPlain(['yarn', 'test:e2e:ci'], { + cwd: resolvePath(appDir, 'packages', 'app'), + env: { + ...process.env, + APP_CONFIG_app_baseUrl: '"http://localhost:3001"', + }, + }); + + return appDir; + } finally { + child.kill(); + } +} + +/** + * This points dependency resolutions into the workspace for each package that is present there + */ +async function overrideModuleResolutions(appDir, workspaceDir) { + const pkgJsonPath = resolvePath(appDir, 'package.json'); + const pkgJson = await fs.readJson(pkgJsonPath); + + pkgJson.resolutions = pkgJson.resolutions || {}; + pkgJson.dependencies = pkgJson.dependencies || {}; + + const packageNames = await fs.readdir(resolvePath(workspaceDir, 'packages')); + for (const name of packageNames) { + const pkgPath = joinPath('..', 'workspace', 'packages', name); + + pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`; + pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`; + delete pkgJson.devDependencies[`@backstage/${name}`]; + } + fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); +} + +/** + * Uses create-plugin command to create a new plugin in the app + */ +async function createPlugin(pluginName, appDir) { + const child = spawnPiped(['yarn', 'create-plugin'], { + cwd: appDir, + }); + try { + let stdout = ''; + child.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); + + await waitFor(() => stdout.includes('Enter an ID for the plugin')); + child.stdin.write(`${pluginName}\n`); + + // await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); + // child.stdin.write('@someuser\n'); + + print('Waiting for plugin create script to be done'); + await waitForExit(child); + + const pluginDir = resolvePath(appDir, 'plugins', pluginName); + for (const cmd of [['lint'], ['test', '--no-watch']]) { + print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`); + await runPlain(['yarn', ...cmd], { cwd: pluginDir }); + } + + return pluginName; + } finally { + child.kill(); + } +} + +/** + * Start serving the newly created app and make sure that the create plugin is rendering correctly + */ +async function testAppServe(pluginName, appDir) { + const startApp = spawnPiped(['yarn', 'start'], { + cwd: appDir, + }); + Browser.localhost('localhost', 3000); + + let successful = false; try { const browser = new Browser(); await waitForPageWithText(browser, '/', 'Welcome to Backstage'); await waitForPageWithText( browser, - '/test-plugin', - 'Welcome to test-plugin!', + `/${pluginName}`, + `Welcome to ${pluginName}!`, ); print('Both App and Plugin loaded correctly'); + successful = true; + } catch (error) { + throw new Error(`App serve test failed, ${error}`); } finally { - startApp.kill(); + // Kill entire process group, otherwise we'll end up with hanging serve processes + killTree(startApp.pid); } - await waitForExit(startApp); - - print('All tests done'); - process.exit(0); + try { + await waitForExit(startApp); + } catch (error) { + if (!successful) { + throw error; + } + } } process.on('unhandledRejection', handleError); diff --git a/packages/cli/e2e-test/createTestApp.js b/packages/cli/e2e-test/createTestApp.js deleted file mode 100644 index 54378049754d4..0000000000000 --- a/packages/cli/e2e-test/createTestApp.js +++ /dev/null @@ -1,44 +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. - */ - -const { resolve: resolvePath } = require('path'); -const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); - -async function createTestApp() { - const cliPath = resolvePath(__dirname, '../bin/backstage-cli'); - - print('Creating a Backstage App'); - const createApp = spawnPiped(['node', cliPath, 'create-app']); - - try { - let stdout = ''; - createApp.stdout.on('data', data => { - stdout = stdout + data.toString('utf8'); - }); - - await waitFor(() => stdout.includes('Enter a name for the app')); - createApp.stdin.write('test-app\n'); - - print('Waiting for app create script to be done'); - await waitForExit(createApp); - - print('Test app created'); - } finally { - createApp.kill(); - } -} - -module.exports = createTestApp; diff --git a/packages/cli/e2e-test/createTestPlugin.js b/packages/cli/e2e-test/createTestPlugin.js deleted file mode 100644 index 59e8d6dd77841..0000000000000 --- a/packages/cli/e2e-test/createTestPlugin.js +++ /dev/null @@ -1,44 +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. - */ - -const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); - -async function createTestPlugin() { - print('Creating a Backstage Plugin'); - const createPlugin = spawnPiped(['yarn', 'create-plugin']); - - try { - let stdout = ''; - createPlugin.stdout.on('data', data => { - stdout = stdout + data.toString('utf8'); - }); - - await waitFor(() => stdout.includes('Enter an ID for the plugin')); - createPlugin.stdin.write('test-plugin\n'); - - // await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); - // createPlugin.stdin.write('@someuser\n'); - - print('Waiting for plugin create script to be done'); - await waitForExit(createPlugin); - - print('Test plugin created'); - } finally { - createPlugin.kill(); - } -} - -module.exports = createTestPlugin; diff --git a/packages/cli/e2e-test/helpers.js b/packages/cli/e2e-test/helpers.js index ed3b7914cc65e..da2ec343ab7da 100644 --- a/packages/cli/e2e-test/helpers.js +++ b/packages/cli/e2e-test/helpers.js @@ -14,8 +14,10 @@ * limitations under the License. */ -const childProcess = require('child_process'); -const { spawn } = childProcess; +const { spawn, execFile: execFileCb } = require('child_process'); +const { promisify } = require('util'); + +const execFile = promisify(execFileCb); const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/; @@ -36,24 +38,35 @@ function spawnPiped(cmd, options) { ...options, }); child.on('error', handleError); - child.on('exit', code => { - if (code) { - print(`Child '${cmd.join(' ')}' exited with code ${code}`); - process.exit(code); - } - }); + + const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' '); child.stdout.on( 'data', - pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `), + pipeWithPrefix(process.stdout, `[${logPrefix}].out: `), ); child.stderr.on( 'data', - pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `), + pipeWithPrefix(process.stderr, `[${logPrefix}].err: `), ); return child; } +async function runPlain(cmd, options) { + try { + const { stdout } = await execFile(cmd[0], cmd.slice(1), { + ...options, + shell: true, + }); + return stdout.trim(); + } catch (error) { + if (error.stderr) { + process.stderr.write(error.stderr); + } + throw error; + } +} + function handleError(err) { process.stdout.write(`${err.name}: ${err.stack || err.message}\n`); if (typeof err.code === 'number') { @@ -133,7 +146,7 @@ async function waitForPageWithText( if (findTextAttempts <= maxFindTextAttempts) { await browser.visit(path); await new Promise(resolve => setTimeout(resolve, intervalMs)); - continue + continue; } else { throw error; } @@ -147,6 +160,7 @@ function print(msg) { module.exports = { spawnPiped, + runPlain, handleError, waitFor, waitForExit, diff --git a/packages/cli/package.json b/packages/cli/package.json index f74c1b1fb0355..1959e9cc075e0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -112,6 +112,7 @@ "@types/webpack-dev-server": "^3.10.0", "del": "^5.1.0", "nodemon": "^2.0.2", + "tree-kill": "^1.2.2", "ts-node": "^8.6.2", "zombie": "^6.1.4" }, diff --git a/plugins/techdocs/src/reader/urlParser.ts b/packages/cli/src/commands/buildWorkspace.ts similarity index 60% rename from plugins/techdocs/src/reader/urlParser.ts rename to packages/cli/src/commands/buildWorkspace.ts index b1429b763539b..624f104b3fc41 100644 --- a/plugins/techdocs/src/reader/urlParser.ts +++ b/packages/cli/src/commands/buildWorkspace.ts @@ -14,18 +14,16 @@ * limitations under the License. */ -const normalizeBaseURL = (baseURL: string): string => { - const url = new URL(baseURL); - url.pathname = url.pathname.replace(/([^/])$/, '$1/'); - return url.toString(); -}; +import fs from 'fs-extra'; +import { Command } from 'commander'; +import { createDistWorkspace } from '../lib/packager'; -export default class URLParser { - constructor(public baseURL: string, public pathname: string) { - this.baseURL = normalizeBaseURL(baseURL); +export default async (dir: string, _cmd: Command, packages: string[]) => { + if (!(await fs.pathExists(dir))) { + throw new Error(`Target workspace directory doesn't exist, '${dir}'`); } - parse(): string { - return new URL(this.pathname, this.baseURL).toString(); - } -} + await createDistWorkspace(packages, { + targetDir: dir, + }); +}; diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index a42271435b042..4f4134957db60 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -17,13 +17,15 @@ import fs from 'fs-extra'; import { promisify } from 'util'; import chalk from 'chalk'; +import { Command } from 'commander'; import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; import { resolve as resolvePath } from 'path'; import os from 'os'; -import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks'; +import { Task, templatingTask } from '../../lib/tasks'; import { paths } from '../../lib/paths'; import { version } from '../../lib/version'; + const exec = promisify(execCb); async function checkExists(rootDir: string, name: string) { @@ -39,7 +41,7 @@ async function checkExists(rootDir: string, name: string) { }); } -export async function createTemporaryAppFolder(tempDir: string) { +async function createTemporaryAppFolder(tempDir: string) { await Task.forItem('creating', 'temporary directory', async () => { try { await fs.mkdir(tempDir); @@ -70,16 +72,12 @@ async function buildApp(appDir: string) { }); }; - await installWithLocalDeps(appDir); + await runCmd('yarn install'); await runCmd('yarn tsc'); await runCmd('yarn build'); } -export async function moveApp( - tempDir: string, - destination: string, - id: string, -) { +async function moveApp(tempDir: string, destination: string, id: string) { await Task.forItem('moving', id, async () => { await fs.move(tempDir, destination).catch(error => { throw new Error( @@ -89,7 +87,7 @@ export async function moveApp( }); } -export default async () => { +export default async (cmd: Command): Promise => { const questions: Question[] = [ { type: 'input', @@ -129,8 +127,10 @@ export default async () => { Task.section('Moving to final location'); await moveApp(tempDir, appDir, answers.name); - Task.section('Building the app'); - await buildApp(appDir); + if (!cmd.skipInstall) { + Task.section('Building the app'); + await buildApp(appDir); + } Task.log(); Task.log( diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 3a91044500cf1..1898285a22c77 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -28,7 +28,8 @@ import { } from '../../lib/codeowners'; import { paths } from '../../lib/paths'; import { version } from '../../lib/version'; -import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks'; +import { Task, templatingTask } from '../../lib/tasks'; + const exec = promisify(execCb); async function checkExists(rootDir: string, id: string) { @@ -141,9 +142,7 @@ async function cleanUp(tempDir: string) { } async function buildPlugin(pluginFolder: string) { - await installWithLocalDeps(paths.targetRoot); - - const commands = ['yarn tsc', 'yarn build']; + const commands = ['yarn install', 'yarn tsc', 'yarn build']; for (const command of commands) { await Task.forItem('executing', command, async () => { process.chdir(pluginFolder); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bed32bcf8bbd8..9a102ab7d37ba 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -25,6 +25,10 @@ const main = (argv: string[]) => { program .command('create-app') .description('Creates a new app in a new directory') + .option( + '--skip-install', + 'Skip the install and builds steps after creating the app', + ) .action( lazyAction(() => import('./commands/create-app/createApp'), 'default'), ); @@ -140,6 +144,11 @@ const main = (argv: string[]) => { .description('Delete cache directories') .action(lazyAction(() => import('./commands/clean/clean'), 'default')); + program + .command('build-workspace ...') + .description('Builds a temporary dist workspace from the provided packages') + .action(lazyAction(() => import('./commands/buildWorkspace'), 'default')); + program.on('command:*', () => { console.log(); console.log( diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 5e309ab953ca9..d3ba1f6b12416 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -59,7 +59,7 @@ type Options = { */ export async function createDistWorkspace( packageNames: string[], - options: Options, + options: Options = {}, ) { const targetDir = options.targetDir ?? diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 6fff85452f9e2..15b827f0c6649 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -23,6 +23,7 @@ import { import { ExitCodeError } from './errors'; import { promisify } from 'util'; import { LogFunc } from './logging'; + const execFile = promisify(execFileCb); type SpawnOptionsPartialEnv = Omit & { diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index b6e2bebb41fa7..0753301b7847f 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -18,12 +18,8 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import handlebars from 'handlebars'; import ora from 'ora'; -import { resolve as resolvePath, basename, dirname } from 'path'; +import { basename, dirname } from 'path'; import recursive from 'recursive-readdir'; -import { promisify } from 'util'; -import { exec as execCb } from 'child_process'; -import { paths } from './paths'; -const exec = promisify(execCb); const TASK_NAME_MAX_LENGTH = 14; @@ -107,104 +103,3 @@ export async function templatingTask( } } } - -// List of local packages that we need to modify as a part of an E2E test -const PATCH_PACKAGES = [ - 'cli', - 'config', - 'config-loader', - 'core', - 'core-api', - 'dev-utils', - 'test-utils', - 'test-utils-core', - 'theme', -]; - -// This runs a `yarn install` task, but with special treatment for e2e tests -export async function installWithLocalDeps(dir: string) { - // This makes us install any package inside this repo as a local file dependency. - // For example, instead of trying to fetch @backstage/core from npm, we point it - // to /packages/core. This makes yarn use a simple file copy to install it instead. - if (process.env.BACKSTAGE_E2E_CLI_TEST) { - Task.section('Linking packages locally for e2e tests'); - - const pkgJsonPath = resolvePath(dir, 'package.json'); - const pkgJson = await fs.readJson(pkgJsonPath); - - pkgJson.resolutions = pkgJson.resolutions || {}; - pkgJson.dependencies = pkgJson.dependencies || {}; - - if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) { - for (const name of PATCH_PACKAGES) { - await Task.forItem( - 'adding', - `@backstage/${name} link to package.json`, - async () => { - const pkgPath = paths.resolveOwnRoot('packages', name); - // Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry. - pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`; - pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`; - delete pkgJson.devDependencies[`@backstage/${name}`]; - - await fs - .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 }) - .catch(error => { - throw new Error( - `Failed to add resolutions to package.json: ${error.message}`, - ); - }); - }, - ); - } - } - } - - await Task.forItem('executing', 'yarn install', async () => { - await exec('yarn install', { cwd: dir }).catch(error => { - process.stdout.write(error.stderr); - process.stdout.write(error.stdout); - throw new Error( - `Could not execute command ${chalk.cyan('yarn install')}`, - ); - }); - }); - - // This takes care of pointing all the installed packages from this repo to - // dist instead of the local src, using the field overrides in publishConfig. - // Without this we get type checking errors in the e2e test - if (process.env.BACKSTAGE_E2E_CLI_TEST) { - Task.section('Patching local dependencies for e2e tests'); - - for (const name of PATCH_PACKAGES) { - await Task.forItem( - 'patching', - `node_modules/@backstage/${name} package.json`, - async () => { - const depJsonPath = resolvePath( - dir, - 'node_modules/@backstage', - name, - 'package.json', - ); - const depJson = await fs.readJson(depJsonPath); - - // We want dist to be used for e2e tests - for (const key of Object.keys(depJson.publishConfig)) { - if (key !== 'access') { - depJson[key] = depJson.publishConfig[key]; - } - } - - await fs - .writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 }) - .catch(error => { - throw new Error( - `Failed to add resolutions to package.json: ${error.message}`, - ); - }); - }, - ); - } - } -} diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs index 31e6baa63e780..26cce2c5e6e33 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -10,11 +10,12 @@ "@backstage/core": "^{{version}}", "@backstage/test-utils": "^{{version}}", "@backstage/theme": "^{{version}}", + "history": "^5.0.0", "plugin-welcome": "0.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router": "6.0.0-alpha.5", - "react-router-dom": "6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs index b686abab9a7d0..52e7a04f72f71 100644 --- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs +++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs @@ -26,7 +26,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-use": "^14.2.0", - "react-router-dom": "6.0.0-alpha.5" + "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { "@backstage/cli": "^{{version}}", diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 52cce54002f25..ad5db2e711c11 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -5,7 +5,7 @@ * 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 + * 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, diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/packages/cli/templates/default-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-plugin/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/packages/core-api/package.json b/packages/core-api/package.json index f9f206daf5740..4e488206f19b6 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -36,7 +36,7 @@ "@types/react": "^16.9", "prop-types": "^15.7.2", "react": "^16.12.0", - "react-router-dom": "6.0.0-alpha.5", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0", "zen-observable": "^0.8.15" }, diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index c9a17f0153677..ec7aba81c4623 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -263,3 +263,13 @@ export const gitlabAuthApiRef = createApiRef< id: 'core.auth.gitlab', description: 'Provides authentication towards Gitlab APIs', }); + +/** + * Provides authentication for custom identity providers. + */ +export const oauth2ApiRef = createApiRef< + OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi +>({ + id: 'core.auth.oauth2', + description: 'Example of how to use oauth2 custom provider', +}); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 9d89ba5b04dca..786e9fa771a26 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -export * from './google'; export * from './github'; export * from './gitlab'; +export * from './google'; +export * from './oauth2'; export * from './okta'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts new file mode 100644 index 0000000000000..d2a3531d059f6 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -0,0 +1,164 @@ +/* + * 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 OAuth2Icon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { Observable } from '../../../../types'; +import { AuthProvider, OAuthRequestApi } from '../../../definitions'; +import { + AuthRequestOptions, + BackstageIdentity, + OAuthApi, + OpenIdConnectApi, + ProfileInfo, + ProfileInfoApi, + SessionState, + SessionStateApi, +} from '../../../definitions/auth'; +import { OAuth2Session } from './types'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'oauth2', + title: 'Your Identity Provider', + icon: OAuth2Icon, +}; + +const SCOPE_PREFIX = ''; + +class OAuth2 + implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OAuth2Response): OAuth2Session { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OAuth2.normalizeScopes(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ]), + sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes, + sessionShouldRefresh: (session: OAuth2Session) => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OAuth2(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ) { + const normalizedScopes = OAuth2.normalizeScopes(scope); + const session = await this.sessionManager.getSession({ + ...options, + scopes: normalizedScopes, + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s]/).filter(Boolean); + + return new Set(scopeList); + } +} + +export default OAuth2; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/index.ts b/packages/core-api/src/apis/implementations/auth/oauth2/index.ts new file mode 100644 index 0000000000000..52bcb1df2cec0 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/index.ts @@ -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 { default as OAuth2 } from './OAuth2'; +export * from './types'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-api/src/apis/implementations/auth/oauth2/types.ts new file mode 100644 index 0000000000000..0e9c6f124d52f --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/types.ts @@ -0,0 +1,28 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type OAuth2Session = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-api/src/index.ts b/packages/core-api/src/index.ts index f08e65809ed37..90bde248f64a4 100644 --- a/packages/core-api/src/index.ts +++ b/packages/core-api/src/index.ts @@ -16,4 +16,5 @@ export * from './public'; import * as privateExports from './private'; + export default privateExports; diff --git a/packages/core-api/src/setupTests.ts b/packages/core-api/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/packages/core-api/src/setupTests.ts +++ b/packages/core-api/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/packages/core/package.json b/packages/core/package.json index e49e4616ae885..562f3894fa506 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,8 +47,8 @@ "react-dom": "^16.12.0", "react-helmet": "6.1.0", "react-hook-form": "^5.7.2", - "react-router": "6.0.0-alpha.5", - "react-router-dom": "6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^12.2.1", "react-use": "^14.2.0" diff --git a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx index 9ef91969669a1..1d863db764868 100644 --- a/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx +++ b/packages/core/src/components/StructuredMetadataTable/MetadataTable.tsx @@ -25,6 +25,7 @@ import { WithStyles, Theme, } from '@material-ui/core'; + const tableTitleCellStyles = (theme: Theme) => createStyles({ root: { diff --git a/packages/core/src/components/SupportButton/SupportButton.test.tsx b/packages/core/src/components/SupportButton/SupportButton.test.tsx new file mode 100644 index 0000000000000..6d6b9fd477fd1 --- /dev/null +++ b/packages/core/src/components/SupportButton/SupportButton.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { + render, + act, + RenderResult, + waitFor, + fireEvent, +} from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { SupportButton } from './SupportButton'; + +const SUPPORT_BUTTON_ID = 'support-button'; +const POPOVER_ID = 'support-button-popover'; + +describe('', () => { + it('renders without exploding', async () => { + let renderResult: RenderResult; + + await act(async () => { + renderResult = render(wrapInTestApp()); + }); + + await waitFor(() => + expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument(), + ); + }); + + it('shows popover on click', async () => { + let renderResult: RenderResult; + + await act(async () => { + renderResult = render(wrapInTestApp()); + }); + + let button: HTMLElement; + + await waitFor(() => { + expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument(); + button = renderResult.getByTestId(SUPPORT_BUTTON_ID); + }); + + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => { + expect(renderResult.getByTestId(POPOVER_ID)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core/src/components/SupportButton/SupportButton.tsx b/packages/core/src/components/SupportButton/SupportButton.tsx index f8c09f5073434..537ee3d6eaa5f 100644 --- a/packages/core/src/components/SupportButton/SupportButton.tsx +++ b/packages/core/src/components/SupportButton/SupportButton.tsx @@ -87,6 +87,7 @@ export const SupportButton: FC = ({ Support = ({ Support} secondary={
{slackChannels.map((channel, i) => ( @@ -137,7 +139,8 @@ export const SupportButton: FC = ({ Contact} secondary={
{contactEmails.map((em, index) => ( diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx index 1aadf9fd3e0b9..4d81c398b766e 100644 --- a/packages/core/src/layout/Sidebar/Bar.tsx +++ b/packages/core/src/layout/Sidebar/Bar.tsx @@ -44,6 +44,9 @@ const useStyles = makeStyles(theme => ({ easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.shortest, }), + '& > *': { + flexShrink: 0, + }, }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 937169c8bfad6..5f66fc65583e5 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -14,25 +14,26 @@ * limitations under the License. */ -import React, { useContext, useEffect } from 'react'; -import Collapse from '@material-ui/core/Collapse'; -import Star from '@material-ui/icons/Star'; -import SignOutIcon from '@material-ui/icons/MeetingRoom'; -import { SidebarContext } from './config'; import { - googleAuthApiRef, githubAuthApiRef, gitlabAuthApiRef, + googleAuthApiRef, identityApiRef, + oauth2ApiRef, oktaAuthApiRef, useApi, } from '@backstage/core-api'; +import Collapse from '@material-ui/core/Collapse'; +import SignOutIcon from '@material-ui/icons/MeetingRoom'; +import Star from '@material-ui/icons/Star'; +import React, { useContext, useEffect } from 'react'; +import { SidebarContext } from './config'; +import { SidebarItem } from './Items'; import { OAuthProviderSettings, OIDCProviderSettings, UserProfile as SidebarUserProfile, } from './Settings'; -import { SidebarItem } from './Items'; export function SidebarUserSettings() { const { isOpen: sidebarOpen } = useContext(SidebarContext); @@ -68,6 +69,11 @@ export function SidebarUserSettings() { apiRef={oktaAuthApiRef} icon={Star} /> + { diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 7fa375bb6b405..4c9caf214bb36 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -15,14 +15,15 @@ */ import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../identity'; import { createGithubProvider } from './github'; -import { createGoogleProvider } from './google'; import { createGitlabProvider } from './gitlab'; -import { createSamlProvider } from './saml'; +import { createGoogleProvider } from './google'; +import { createOAuth2Provider } from './oauth2'; import { createOktaProvider } from './okta'; -import { AuthProviderFactory, AuthProviderConfig } from './types'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../identity'; +import { createSamlProvider } from './saml'; +import { AuthProviderConfig, AuthProviderFactory } from './types'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -30,6 +31,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { gitlab: createGitlabProvider, saml: createSamlProvider, okta: createOktaProvider, + oauth2: createOAuth2Provider, }; export const createAuthProviderRouter = ( diff --git a/plugins/auth-backend/src/providers/oauth2/index.ts b/plugins/auth-backend/src/providers/oauth2/index.ts new file mode 100644 index 0000000000000..e607aa10e1523 --- /dev/null +++ b/plugins/auth-backend/src/providers/oauth2/index.ts @@ -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 { createOAuth2Provider } from './provider'; diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts new file mode 100644 index 0000000000000..1b5ee6d17ec73 --- /dev/null +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -0,0 +1,198 @@ +/* + * 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 express from 'express'; +import passport from 'passport'; +import { Strategy as OAuth2Strategy } from 'passport-oauth2'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../../identity'; +import { + EnvironmentHandler, + EnvironmentHandlers, +} from '../../lib/EnvironmentHandler'; +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, +} from '../../lib/PassportStrategyHelper'; +import { + AuthProviderConfig, + EnvironmentProviderConfig, + GenericOAuth2ProviderConfig, + GenericOAuth2ProviderOptions, + OAuthProviderHandlers, + OAuthResponse, + PassportDoneCallback, + RedirectInfo, +} from '../types'; + +type PrivateInfo = { + refreshToken: string; +}; + +export class OAuth2AuthProvider implements OAuthProviderHandlers { + private readonly _strategy: OAuth2Strategy; + + constructor(options: GenericOAuth2ProviderOptions) { + this._strategy = new OAuth2Strategy( + { ...options, passReqToCallback: false as true }, + ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); + done( + undefined, + { + providerInfo: { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + profile, + }, + { + refreshToken, + }, + ); + }, + ); + } + + async start( + req: express.Request, + options: Record, + ): Promise { + const providerOptions = { + ...options, + accessType: 'offline', + prompt: 'consent', + }; + return await executeRedirectStrategy(req, this._strategy, providerOptions); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(refreshToken: string, scope: string): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + refreshToken, + scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + } + + // Use this function to grab the user profile info from the token + // Then populate the profile with it + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Profile does not contain a profile'); + } + + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export function createOAuth2Provider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const config = (envConfig as unknown) as GenericOAuth2ProviderConfig; + const { secure, appOrigin } = config; + const callbackURLParam = `?env=${env}`; + const opts = { + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${baseUrl}/oauth2/handler/frame${callbackURLParam}`, + authorizationURL: config.authorizationURL, + tokenURL: config.tokenURL, + }; + + if ( + !opts.clientID || + !opts.clientSecret || + !opts.authorizationURL || + !opts.tokenURL + ) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', + ); + } + + logger.warn( + 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), { + disableRefresh: false, + providerId: 'oauth2', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index aec4f9dfd0e05..de4e076919668 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -33,6 +33,11 @@ export type OAuthProviderOptions = { callbackURL: string; }; +export type GenericOAuth2ProviderOptions = OAuthProviderOptions & { + authorizationURL: string; + tokenURL: string; +}; + export type OAuthProviderConfig = { /** * Cookies can be marked with a secure flag to send cookies only when the request @@ -61,6 +66,11 @@ export type OAuthProviderConfig = { audience?: string; }; +export type GenericOAuth2ProviderConfig = OAuthProviderConfig & { + authorizationURL: string; + tokenURL: string; +}; + export type EnvironmentProviderConfig = { /** * key, values are environment names and OAuthProviderConfigs diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index fb533a4bdd26b..973b78858fea3 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -100,6 +100,16 @@ export async function createRouter( audience: process.env.AUTH_OKTA_AUDIENCE, }, }, + oauth2: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_OAUTH2_CLIENT_ID!, + clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!, + authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!, + tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!, + }, + }, }, }, }; diff --git a/plugins/auth-backend/src/setupTests.ts b/plugins/auth-backend/src/setupTests.ts index a3b2b7e123c75..f7b6ca962d70b 100644 --- a/plugins/auth-backend/src/setupTests.ts +++ b/plugins/auth-backend/src/setupTests.ts @@ -15,4 +15,5 @@ */ require('jest-fetch-mock').enableMocks(); + export {}; diff --git a/plugins/catalog-backend/src/setupTests.ts b/plugins/catalog-backend/src/setupTests.ts index a3b2b7e123c75..f7b6ca962d70b 100644 --- a/plugins/catalog-backend/src/setupTests.ts +++ b/plugins/catalog-backend/src/setupTests.ts @@ -15,4 +15,5 @@ */ require('jest-fetch-mock').enableMocks(); + export {}; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 818a8c4bc6453..06c83136f587a 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -32,8 +32,8 @@ "moment": "^2.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router": "^6.0.0-alpha.5", - "react-router-dom": "^6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0", "swr": "^0.2.2" }, diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/plugins/catalog/src/api/CatalogClient.test.ts index 719513529c68e..7803f2e173b3c 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/plugins/catalog/src/api/CatalogClient.test.ts @@ -18,6 +18,7 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; import { Entity } from '@backstage/catalog-model'; + const server = setupServer(); describe('CatalogClient', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index e59b753363df7..e58b2d407b03c 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -19,6 +19,7 @@ import { ContentHeader, identityApiRef, SupportButton, + configApiRef, useApi, } from '@backstage/core'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; @@ -51,6 +52,8 @@ const CatalogPageContents = () => { const userId = useApi(identityApiRef).getUserId(); const [selectedTab, setSelectedTab] = useState(); const [selectedSidebarItem, setSelectedSidebarItem] = useState(); + const orgName = + useApi(configApiRef).getOptionalString('organization.name') ?? 'Company'; const tabs = useMemo( () => [ @@ -98,7 +101,7 @@ const CatalogPageContents = () => { ], }, { - name: 'Company', // TODO: Replace with Company name, read from app config. + name: orgName, items: [ { id: 'all', @@ -108,7 +111,7 @@ const CatalogPageContents = () => { ], }, ], - [isStarredEntity, userId], + [isStarredEntity, userId, orgName], ); return ( diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index b507b96f0550b..b5afbcaa0bd87 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -18,14 +18,16 @@ import { Table, TableColumn, TableProps } from '@backstage/core'; import { Link } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import GitHub from '@material-ui/icons/GitHub'; -import Star from '@material-ui/icons/Star'; -import StarOutline from '@material-ui/icons/StarBorder'; import { Alert } from '@material-ui/lab'; import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntites'; import { entityRoute } from '../../routes'; +import { + favouriteEntityIcon, + favouriteEntityTooltip, +} from '../FavouriteEntity/FavouriteEntity'; const columns: TableColumn[] = [ { @@ -125,13 +127,8 @@ export const CatalogTable = ({ const isStarred = isStarredEntity(rowData); return { cellStyle: { paddingLeft: '1em' }, - icon: () => - isStarred ? ( - - ) : ( - - ), - tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites', + icon: () => favouriteEntityIcon(isStarred), + tooltip: favouriteEntityTooltip(isStarred), onClick: () => toggleStarredEntity(rowData), }; }, diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx index e5db5b9230d13..1c569245891c3 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -28,7 +28,7 @@ import { useApi, } from '@backstage/core'; import { SentryIssuesWidget } from '@backstage/plugin-sentry'; -import { Grid } from '@material-ui/core'; +import { Grid, Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { FC, useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; @@ -37,6 +37,7 @@ import { catalogApiRef } from '../..'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; const REDIRECT_DELAY = 1000; function headerProps( @@ -63,6 +64,16 @@ export const getPageTheme = (entity?: Entity): PageTheme => { return pageTheme[themeKey] ?? pageTheme.home; }; +const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({ + entity, + title, +}) => ( + + {title} + {entity && } + +); + export const EntityPage: FC<{}> = () => { const { optionalNamespaceAndName, kind } = useParams() as { optionalNamespaceAndName: string; @@ -138,7 +149,11 @@ export const EntityPage: FC<{}> = () => { return ( -
+
} + pageTitleOverride={headerTitle} + type={headerType} + > {entity && ( <> & { entity: Entity }; + +const YellowStar = withStyles({ + root: { + color: '#f3ba37', + }, +})(Star); + +export const favouriteEntityTooltip = (isStarred: boolean) => + isStarred ? 'Remove from favorites' : 'Add to favorites'; + +export const favouriteEntityIcon = (isStarred: boolean) => + isStarred ? : ; + +/** + * IconButton for showing if a current entity is starred and adding/removing it from the favourite entities + * @param props MaterialUI IconButton props extended by required `entity` prop + */ +export const FavouriteEntity: React.FC = props => { + const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); + const isStarred = isStarredEntity(props.entity); + return ( + toggleStarredEntity(props.entity)} + > + + {favouriteEntityIcon(isStarred)} + + + ); +}; diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 8eede54580ae6..89f6335dcc75d 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -40,8 +40,8 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-lazylog": "^4.5.2", - "react-router": "^6.0.0-alpha.5", - "react-router-dom": "^6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/circleci/src/setupTests.ts b/plugins/circleci/src/setupTests.ts index 1a907ab8e6113..a83824619874b 100644 --- a/plugins/circleci/src/setupTests.ts +++ b/plugins/circleci/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom/extend-expect'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/circleci/src/state/AppState.tsx b/plugins/circleci/src/state/AppState.tsx index 49d55b602e8a4..2ee00362ee22a 100644 --- a/plugins/circleci/src/state/AppState.tsx +++ b/plugins/circleci/src/state/AppState.tsx @@ -16,6 +16,7 @@ import React, { FC, useReducer, Dispatch, Reducer } from 'react'; import { circleCIApiRef } from '../api'; import type { State, Action, SettingsState } from './types'; + export type { SettingsState }; export const AppContext = React.createContext<[State, Dispatch]>( diff --git a/plugins/explore/src/setupTests.ts b/plugins/explore/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/explore/src/setupTests.ts +++ b/plugins/explore/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/github-actions/.eslintrc.js b/plugins/github-actions/.eslintrc.js new file mode 100644 index 0000000000000..13573efa9c466 --- /dev/null +++ b/plugins/github-actions/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/github-actions/README.md b/plugins/github-actions/README.md new file mode 100644 index 0000000000000..b0b339a1b06e9 --- /dev/null +++ b/plugins/github-actions/README.md @@ -0,0 +1,13 @@ +# github-actions + +Welcome to the github-actions plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/github-actions](http://localhost:3000/github-actions). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/github-actions/dev/index.tsx b/plugins/github-actions/dev/index.tsx new file mode 100644 index 0000000000000..812a5585d43f4 --- /dev/null +++ b/plugins/github-actions/dev/index.tsx @@ -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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json new file mode 100644 index 0000000000000..13c2215dea962 --- /dev/null +++ b/plugins/github-actions/package.json @@ -0,0 +1,47 @@ +{ + "name": "@backstage/plugin-github-actions", + "version": "0.1.1-alpha.12", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "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/core": "^0.1.1-alpha.12", + "@backstage/theme": "^0.1.1-alpha.12", + "@material-ui/core": "^4.9.1", + "@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": "^14.2.0" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.12", + "@backstage/dev-utils": "^0.1.1-alpha.12", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^25.2.2", + "@types/node": "^12.0.0", + "jest-fetch-mock": "^3.0.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/github-actions/src/apis/builds/BuildsClient.ts b/plugins/github-actions/src/apis/builds/BuildsClient.ts new file mode 100644 index 0000000000000..6fbc6a6c53585 --- /dev/null +++ b/plugins/github-actions/src/apis/builds/BuildsClient.ts @@ -0,0 +1,44 @@ +/* + * 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 { Build, BuildDetails, BuildStatus } from './types'; + +export class BuildsClient { + static create(): BuildsClient { + return new BuildsClient(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async listBuilds(_entityUri: string): Promise { + return []; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getBuild(_buildUri: string): Promise { + return { + build: { + commitId: 'TODO', + branch: 'TODO', + uri: 'TODO', + status: BuildStatus.Running, + message: 'TODO', + }, + author: 'TODO', + logUrl: 'TODO', + overviewUrl: 'TODO', + }; + } +} diff --git a/plugins/github-actions/src/apis/builds/index.ts b/plugins/github-actions/src/apis/builds/index.ts new file mode 100644 index 0000000000000..9ce2150893a46 --- /dev/null +++ b/plugins/github-actions/src/apis/builds/index.ts @@ -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 { BuildsClient } from './BuildsClient'; +export * from './types'; diff --git a/plugins/github-actions/src/apis/builds/types.ts b/plugins/github-actions/src/apis/builds/types.ts new file mode 100644 index 0000000000000..134a663d02b20 --- /dev/null +++ b/plugins/github-actions/src/apis/builds/types.ts @@ -0,0 +1,38 @@ +/* + * 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 enum BuildStatus { + Null, + Success, + Failure, + Pending, + Running, +} + +export type Build = { + commitId: string; + message: string; + branch: string; + status: BuildStatus; + uri: string; +}; + +export type BuildDetails = { + build: Build; + author: string; + logUrl: string; + overviewUrl: string; +}; diff --git a/plugins/github-actions/src/components/BuildDetailsPage/BuildDetailsPage.tsx b/plugins/github-actions/src/components/BuildDetailsPage/BuildDetailsPage.tsx new file mode 100644 index 0000000000000..bb07e0d6aaff0 --- /dev/null +++ b/plugins/github-actions/src/components/BuildDetailsPage/BuildDetailsPage.tsx @@ -0,0 +1,143 @@ +/* + * 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 { Link } from '@backstage/core'; +import { + Button, + ButtonGroup, + LinearProgress, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Theme, + Typography, +} from '@material-ui/core'; +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { useAsync } from 'react-use'; +import { BuildsClient } from '../../apis/builds'; +import { BuildStatusIndicator } from '../BuildStatusIndicator'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + title: { + padding: theme.spacing(1, 0, 2, 0), + }, + table: { + padding: theme.spacing(1), + }, +})); + +const client = BuildsClient.create(); + +export const BuildDetailsPage = () => { + const classes = useStyles(); + const { buildUri } = useParams(); + const status = useAsync(() => client.getBuild(buildUri), [buildUri]); + + if (status.loading) { + return ; + } else if (status.error) { + return ( + + Failed to load build, {status.error.message} + + ); + } + + const details = status.value; + + return ( +
+ + + + < + + + Build Details + + + + + + + Branch + + {details?.build.branch} + + + + Message + + {details?.build.message} + + + + Commit ID + + {details?.build.commitId} + + + + Status + + + + + + + + Author + + {details?.author} + + + + Links + + + + {details?.overviewUrl && ( + + )} + {details?.logUrl && ( + + )} + + + + +
+
+
+ ); +}; diff --git a/plugins/github-actions/src/components/BuildDetailsPage/index.ts b/plugins/github-actions/src/components/BuildDetailsPage/index.ts new file mode 100644 index 0000000000000..d59d0fc396a47 --- /dev/null +++ b/plugins/github-actions/src/components/BuildDetailsPage/index.ts @@ -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 { BuildDetailsPage } from './BuildDetailsPage'; diff --git a/plugins/github-actions/src/components/BuildInfoCard/BuildInfoCard.tsx b/plugins/github-actions/src/components/BuildInfoCard/BuildInfoCard.tsx new file mode 100644 index 0000000000000..a04189debc7ec --- /dev/null +++ b/plugins/github-actions/src/components/BuildInfoCard/BuildInfoCard.tsx @@ -0,0 +1,102 @@ +/* + * 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 { Link } from '@backstage/core'; +import { + LinearProgress, + makeStyles, + Table, + TableBody, + TableCell, + TableRow, + Theme, + Typography, +} from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { BuildsClient } from '../../apis/builds'; +import { BuildStatusIndicator } from '../BuildStatusIndicator'; + +const client = BuildsClient.create(); + +const useStyles = makeStyles(theme => ({ + root: { + // height: 400, + }, + title: { + paddingBottom: theme.spacing(1), + }, +})); + +export const BuildInfoCard = () => { + const classes = useStyles(); + const status = useAsync(() => client.listBuilds('entity:spotify:backstage')); + + let content: JSX.Element; + + if (status.loading) { + content = ; + } else if (status.error) { + content = ( + + Failed to load builds, {status.error.message} + + ); + } else { + const [build] = + status.value?.filter(({ branch }) => branch === 'master') ?? []; + + content = ( + + + + + Message + + + + {build?.message} + + + + + + Commit ID + + {build?.commitId} + + + + Status + + + + + + +
+ ); + } + + return ( +
+ + Master Build + + {content} +
+ ); +}; diff --git a/plugins/github-actions/src/components/BuildInfoCard/index.ts b/plugins/github-actions/src/components/BuildInfoCard/index.ts new file mode 100644 index 0000000000000..a6409e682abbf --- /dev/null +++ b/plugins/github-actions/src/components/BuildInfoCard/index.ts @@ -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 { BuildInfoCard } from './BuildInfoCard'; diff --git a/plugins/github-actions/src/components/BuildListPage/BuildListPage.tsx b/plugins/github-actions/src/components/BuildListPage/BuildListPage.tsx new file mode 100644 index 0000000000000..fc784108d4227 --- /dev/null +++ b/plugins/github-actions/src/components/BuildListPage/BuildListPage.tsx @@ -0,0 +1,128 @@ +/* + * 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 { Link } from '@backstage/core'; +import { + LinearProgress, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Theme, + Tooltip, + Typography, +} from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { BuildsClient } from '../../apis/builds'; +import { BuildStatusIndicator } from '../BuildStatusIndicator'; + +const client = BuildsClient.create(); + +const LongText = ({ text, max }: { text: string; max: number }) => { + if (text.length < max) { + return {text}; + } + return ( + + {text.slice(0, max)}... + + ); +}; + +const useStyles = makeStyles(theme => ({ + root: { + padding: theme.spacing(2), + }, + title: { + padding: theme.spacing(1, 0, 2, 0), + }, +})); + +const PageContents = () => { + const { loading, error, value } = useAsync(() => + client.listBuilds('entity:spotify:backstage'), + ); + + if (loading) { + return ; + } + + if (error) { + return ( + + Failed to load builds, {error.message}{' '} + + ); + } + + return ( + + + + + Status + Branch + Message + Commit + + + + {value!.map(build => ( + + + + + + + + + + + + + + + + + + + {build.commitId.slice(0, 10)} + + + + ))} + +
+
+ ); +}; + +export const BuildListPage = () => { + const classes = useStyles(); + return ( +
+ + CI/CD Builds + + +
+ ); +}; diff --git a/plugins/github-actions/src/components/BuildListPage/index.ts b/plugins/github-actions/src/components/BuildListPage/index.ts new file mode 100644 index 0000000000000..2134913e51d33 --- /dev/null +++ b/plugins/github-actions/src/components/BuildListPage/index.ts @@ -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 { BuildListPage } from './BuildListPage'; diff --git a/plugins/github-actions/src/components/BuildStatusIndicator/BuildStatusIndicator.tsx b/plugins/github-actions/src/components/BuildStatusIndicator/BuildStatusIndicator.tsx new file mode 100644 index 0000000000000..332a03d67af7d --- /dev/null +++ b/plugins/github-actions/src/components/BuildStatusIndicator/BuildStatusIndicator.tsx @@ -0,0 +1,74 @@ +/* + * 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 { IconComponent } from '@backstage/core'; +import { makeStyles, Theme } from '@material-ui/core'; +import ProgressIcon from '@material-ui/icons/Autorenew'; +import SuccessIcon from '@material-ui/icons/CheckCircle'; +import FailureIcon from '@material-ui/icons/Error'; +import UnknownIcon from '@material-ui/icons/Help'; +import React from 'react'; +import { BuildStatus } from '../../apis/builds'; + +type Props = { + status?: BuildStatus; +}; + +type StatusStyle = { + icon: IconComponent; + color: string; +}; + +const styles: { [key in BuildStatus]: StatusStyle } = { + [BuildStatus.Null]: { + icon: UnknownIcon, + color: '#f49b20', + }, + [BuildStatus.Success]: { + icon: SuccessIcon, + color: '#1db855', + }, + [BuildStatus.Failure]: { + icon: FailureIcon, + color: '#CA001B', + }, + [BuildStatus.Pending]: { + icon: UnknownIcon, + color: '#5BC0DE', + }, + [BuildStatus.Running]: { + icon: ProgressIcon, + color: '#BEBEBE', + }, +}; + +const useStyles = makeStyles({ + icon: style => ({ + color: style.color, + }), +}); + +export const BuildStatusIndicator = ({ status }: Props) => { + const style = (status && styles[status]) || styles[BuildStatus.Null]; + const classes = useStyles(style); + const Icon = style.icon; + + return ( +
+ +
+ ); +}; diff --git a/plugins/github-actions/src/components/BuildStatusIndicator/index.ts b/plugins/github-actions/src/components/BuildStatusIndicator/index.ts new file mode 100644 index 0000000000000..520de525ece33 --- /dev/null +++ b/plugins/github-actions/src/components/BuildStatusIndicator/index.ts @@ -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 { BuildStatusIndicator } from './BuildStatusIndicator'; diff --git a/plugins/github-actions/src/index.ts b/plugins/github-actions/src/index.ts new file mode 100644 index 0000000000000..3a0a0fe2d3cb6 --- /dev/null +++ b/plugins/github-actions/src/index.ts @@ -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'; diff --git a/plugins/github-actions/src/plugin.test.ts b/plugins/github-actions/src/plugin.test.ts new file mode 100644 index 0000000000000..fa1075cbc87f7 --- /dev/null +++ b/plugins/github-actions/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * 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('github-actions', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts new file mode 100644 index 0000000000000..638c8d2c8ca97 --- /dev/null +++ b/plugins/github-actions/src/plugin.ts @@ -0,0 +1,37 @@ +/* + * 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, createRouteRef } from '@backstage/core'; +import { BuildDetailsPage } from './components/BuildDetailsPage'; +import { BuildListPage } from './components/BuildListPage'; + +// TODO(freben): This is just a demo route for now +export const rootRouteRef = createRouteRef({ + path: '/github-actions', + title: 'GitHub Actions', +}); +export const buildRouteRef = createRouteRef({ + path: '/github-actions/builds/:buildUri', + title: 'GitHub Actions Build', +}); + +export const plugin = createPlugin({ + id: 'github-actions', + register({ router }) { + router.addRoute(rootRouteRef, BuildListPage); + router.addRoute(buildRouteRef, BuildDetailsPage); + }, +}); diff --git a/plugins/github-actions/src/setupTests.ts b/plugins/github-actions/src/setupTests.ts new file mode 100644 index 0000000000000..8553642152084 --- /dev/null +++ b/plugins/github-actions/src/setupTests.ts @@ -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 '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 67a76ef8726d2..b96843d412b52 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -28,7 +28,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "6.0.0-alpha.5", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/gitops-profiles/src/setupTests.ts b/plugins/gitops-profiles/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/gitops-profiles/src/setupTests.ts +++ b/plugins/gitops-profiles/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index af971f9160dea..45cad52ac881e 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -53,7 +53,7 @@ "@types/jest": "^25.2.2", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", - "react-router-dom": "6.0.0-alpha.5" + "react-router-dom": "6.0.0-beta.0" }, "files": [ "dist" diff --git a/plugins/graphiql/src/setupTests.ts b/plugins/graphiql/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/graphiql/src/setupTests.ts +++ b/plugins/graphiql/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/identity-backend/src/setupTests.ts b/plugins/identity-backend/src/setupTests.ts index a3b2b7e123c75..f7b6ca962d70b 100644 --- a/plugins/identity-backend/src/setupTests.ts +++ b/plugins/identity-backend/src/setupTests.ts @@ -15,4 +15,5 @@ */ require('jest-fetch-mock').enableMocks(); + export {}; diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index bb79e57179793..6f3a67ef6412a 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -29,7 +29,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-markdown": "^4.3.1", - "react-router-dom": "6.0.0-alpha.5", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/lighthouse/src/setupTests.ts b/plugins/lighthouse/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/lighthouse/src/setupTests.ts +++ b/plugins/lighthouse/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/lighthouse/src/utils.ts b/plugins/lighthouse/src/utils.ts index a243b5f8d18cf..2dfedd05e4746 100644 --- a/plugins/lighthouse/src/utils.ts +++ b/plugins/lighthouse/src/utils.ts @@ -15,6 +15,7 @@ */ import { useLocation } from 'react-router-dom'; import { Website, Audit, LighthouseCategoryId, AuditCompleted } from './api'; + export function useQuery(): URLSearchParams { return new URLSearchParams(useLocation().search); } @@ -53,13 +54,13 @@ export function buildSparklinesDataForItem( (audit: Audit): audit is AuditCompleted => audit.status === 'COMPLETED', ) .reduce((scores, audit) => { - Object.values(audit.categories).forEach((category) => { + Object.values(audit.categories).forEach(category => { scores[category.id] = scores[category.id] || []; scores[category.id].unshift(category.score); }); // edge case: if only one audit exists, force a "flat" sparkline - Object.values(scores).forEach((arr) => { + Object.values(scores).forEach(arr => { if (arr.length === 1) arr.push(arr[0]); }); diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 738cabea4b087..de57e8a233e48 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -31,8 +31,8 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^5.7.2", - "react-router": "^6.0.0-alpha.5", - "react-router-dom": "^6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/register-component/src/setupTests.ts b/plugins/register-component/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/register-component/src/setupTests.ts +++ b/plugins/register-component/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/__mocks__/@octokit/rest/index.ts b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/stages/publish/__mocks__/@octokit/rest/index.ts rename to plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/__mocks__/nodegit/index.ts b/plugins/scaffolder-backend/src/scaffolder/__mocks__/nodegit/index.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/stages/publish/__mocks__/nodegit/index.ts rename to plugins/scaffolder-backend/src/scaffolder/__mocks__/nodegit/index.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts index 5905d26ebb955..12f893c146590 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { makeLogStream } from './logger'; + describe('Logger', () => { const mockMeta = { test: 'blob' }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index a6db297bde76e..17b357a4efa5a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -17,7 +17,17 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { RequiredTemplateValues } from '../templater'; import { JsonValue } from '@backstage/config'; +/** + * Publisher is in charge of taking a folder created by + * the templater, and pushing it to a remote storage + */ export type Publisher = { + /** + * + * @param opts object containing the template entity from the service + * catalog, plus the values from the form and the directory that has + * been templated + */ publish(opts: { entity: TemplateEntityV1alpha1; values: RequiredTemplateValues & Record; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 27e4cd4b01f00..31bcdf189b7f0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -131,13 +131,13 @@ describe('CookieCutter Templater', () => { component_id: 'newthing', }; - const returnPath = await cookie.run({ + const { resultDir } = await cookie.run({ directory: tempdir, values, dockerClient: mockDocker, }); - expect(returnPath.startsWith(`${tempdir}-result`)).toBeTruthy(); + expect(resultDir.startsWith(`${tempdir}-result`)).toBeTruthy(); }); it('should pass through the streamer to the run docker helper', async () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index 3e94d67c6e842..fff349cf82a80 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -18,6 +18,8 @@ import { JsonValue } from '@backstage/config'; import { runDockerContainer } from './helpers'; import { TemplaterBase, TemplaterRunOptions } from '.'; import path from 'path'; +import { TemplaterRunResult } from './types'; + export class CookieCutter implements TemplaterBase { private async fetchTemplateCookieCutter( directory: string, @@ -33,7 +35,7 @@ export class CookieCutter implements TemplaterBase { } } - public async run(options: TemplaterRunOptions): Promise { + public async run(options: TemplaterRunOptions): Promise { // First lets grab the default cookiecutter.json file const cookieCutterJson = await this.fetchTemplateCookieCutter( options.directory, @@ -65,6 +67,8 @@ export class CookieCutter implements TemplaterBase { dockerClient: options.dockerClient, }); - return path.resolve(resultDir, options.values.component_id as string); + return { + resultDir: path.resolve(resultDir, options.values.component_id as string), + }; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts index d27eb4f93f4f6..02bc801385d2e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts @@ -26,6 +26,9 @@ describe('helpers', () => { jest .spyOn(mockDocker, 'run') .mockResolvedValue([{ Error: null, StatusCode: 0 }]); + jest + .spyOn(mockDocker, 'pull') + .mockResolvedValue([{ Error: null, StatusCode: 0 }]); }); describe('runDockerContainer', () => { @@ -34,6 +37,17 @@ describe('helpers', () => { const templateDir = os.tmpdir(); const resultDir = os.tmpdir(); + it('will pull the docker container before running', async () => { + await runDockerContainer({ + imageName, + args, + templateDir, + resultDir, + dockerClient: mockDocker, + }); + + expect(mockDocker.pull).toHaveBeenCalledWith(imageName, {}); + }); it('should call the dockerClient run command with the correct arguments passed through', async () => { await runDockerContainer({ imageName, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts index 2856a016e49ab..9ef85224d498c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts @@ -44,6 +44,7 @@ export const runDockerContainer = async ({ templateDir, dockerClient, }: RunDockerContainerOptions) => { + await dockerClient.pull(imageName, {}); const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( imageName, args, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts index cdefa7821e03e..519c24e38103f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts @@ -18,11 +18,28 @@ import type { Writable } from 'stream'; import Docker from 'dockerode'; import { JsonValue } from '@backstage/config'; +/** + * Currently the required template values. The owner + * and where to store the result from templating + */ export type RequiredTemplateValues = { owner: string; storePath: string; }; +/** + * The returned directory from the templater which is ready + * to pass to the next stage of the scaffolder which is publishing + */ +export type TemplaterRunResult = { + resultDir: string; +}; + +/** + * The values that the templater will recieve. The directory of the + * skeleton, with the values from the frontend. A dedicated log stream and a docker + * client to run any templater on top of your directory. + */ export type TemplaterRunOptions = { directory: string; values: RequiredTemplateValues & Record; @@ -32,7 +49,7 @@ export type TemplaterRunOptions = { export type TemplaterBase = { // runs the templating with the values and returns the directory to push the VCS - run(opts: TemplaterRunOptions): Promise; + run(opts: TemplaterRunOptions): Promise; }; export type TemplaterConfig = { diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 25121b29e83a2..67f491b60980b 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -35,8 +35,8 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-lazylog": "^4.5.2", - "react-router": "6.0.0-alpha.5", - "react-router-dom": "6.0.0-alpha.5", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0", "swr": "^0.2.2" }, diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx index cebd5563f4f74..aae8009d9894a 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Table, TableColumn } from '@backstage/core'; import { SentryIssue } from '../../data/sentry-issue'; import { format } from 'timeago.js'; @@ -60,7 +60,7 @@ type SentryIssuesTableProps = { sentryIssues: SentryIssue[]; }; -const SentryIssuesTable: FC = ({ sentryIssues }) => { +const SentryIssuesTable = ({ sentryIssues }: SentryIssuesTableProps) => { return ( { +const useFetch = (url: string): AsyncState => { const state = useAsync(async () => { - const response = await fetch(url); - const raw = await response.text(); - return raw; + const request = await fetch(url); + if (request.status === 404) { + return [request.url, new Error('Page not found')]; + } + const response = await request.text(); + return [request.url, response]; }, [url]); - return state; + const [fetchedUrl, fetchedValue] = state.value ?? []; + + if (url !== fetchedUrl) { + // Fixes a race condition between two pages + return { loading: true }; + } + + return Object.assign(state, fetchedValue ? { value: fetchedValue } : {}); }; const useEnforcedTrailingSlash = (): void => { React.useEffect(() => { const actualUrl = window.location.href; - const expectedUrl = new URLParser(window.location.href, '.').parse(); + const expectedUrl = new URLFormatter(window.location.href).formatBaseURL(); if (actualUrl !== expectedUrl) { window.history.replaceState({}, document.title, expectedUrl); @@ -54,85 +65,84 @@ const useEnforcedTrailingSlash = (): void => { }; export const Reader = () => { + useEnforcedTrailingSlash(); + const location = useLocation(); const { componentId, '*': path } = useParams(); - const shadowDomRef = useShadowDom(); + const [shadowDomRef, shadowRoot] = useShadowDom(); const navigate = useNavigate(); - const normalizedUrl = new URLParser( + const normalizedUrl = new URLFormatter( `${docStorageURL}${location.pathname.replace('/docs', '')}`, - '.', - ).parse(); + ).formatBaseURL(); const state = useFetch(`${normalizedUrl}index.html`); - useEnforcedTrailingSlash(); - React.useEffect(() => { - const divElement = shadowDomRef.current; - if (divElement?.shadowRoot && state.value) { - const transformedElement = transformer(state.value, [ - addBaseUrl({ - docStorageURL, - componentId, - 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(), - ]); - - divElement.shadowRoot.innerHTML = ''; - if (transformedElement) { - divElement.shadowRoot.appendChild(transformedElement); - transformer(divElement.shadowRoot.children[0], [ - addEventListener({ - onClick: navigate, - }), - ]); - } + if (!shadowRoot) { + return; // Shadow DOM isn't ready } - }, [shadowDomRef, state, componentId, path, navigate]); + + if (state.loading) { + return; // Page isn't ready + } + + // Pre-render + const transformedElement = transformer(state.value as string, [ + addBaseUrl({ + docStorageURL, + componentId, + 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(), + ]); + + if (!transformedElement) { + return; // An unexpected error occurred + } + + Array.from(shadowRoot.children).forEach(child => + shadowRoot.removeChild(child), + ); + shadowRoot.appendChild(transformedElement); + + // Post-render + transformer(shadowRoot.children[0], [ + dom => { + setTimeout(() => { + if (window.location.hash) { + const hash = window.location.hash.slice(1); + shadowRoot?.getElementById(hash)?.scrollIntoView(); + } + }, 200); + return dom; + }, + addLinkClickListener({ + onClick: (_: MouseEvent, url: string) => { + const parsedUrl = new URL(url); + navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); + + shadowRoot?.querySelector(parsedUrl.hash)?.scrollIntoView(); + }, + }), + ]); + }, [componentId, path, shadowRoot, state]); // eslint-disable-line react-hooks/exhaustive-deps + + if (state.value instanceof Error) return ; return ( <> -
- - - {componentId ? ( -
- ) : ( - - - navigate('/docs/mkdocs')} - tags={['Developer Tool']} - title="MkDocs" - label="Read Docs" - description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. " - /> - - - navigate('/docs/backstage-microsite')} - tags={['Service']} - title="Backstage" - label="Read Docs" - description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. " - /> - - - )} - + +
+ ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx new file mode 100644 index 0000000000000..dd31e6a318594 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 { TechDocsHome } from './TechDocsHome'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('TechDocs Home', () => { + it('should render a TechDocs home page', () => { + const { getByTestId, queryByText } = render( + wrapInTestApp(), + ); + + // Header + expect(queryByText('Documentation')).toBeInTheDocument(); + expect( + queryByText(/Documentation available in Backstage/i), + ).toBeInTheDocument(); + + // Explore Content + expect(getByTestId('docs-explore')).toBeDefined(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx new file mode 100644 index 0000000000000..3f9763912c211 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -0,0 +1,74 @@ +/* + * 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 { useNavigate } from 'react-router-dom'; +import { Grid } from '@material-ui/core'; +import { ItemCard } from '@backstage/core'; +import { TechDocsPageWrapper } from './TechDocsPageWrapper'; + +type DocumentationSite = { + title: string; + description: string; + tags: Array; + path: string; + btnLabel: string; +}; + +const documentationSites: Array = [ + { + title: 'MkDocs', + description: + "MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. ", + tags: ['Developer Tool'], + path: '/docs/mkdocs', + btnLabel: 'Read Docs', + }, + { + title: 'Backstage Docs', + description: + 'Getting started guides, API Overview, documentation around how to Create a Plugin and more. ', + tags: ['Service'], + path: '/docs/backstage-microsite', + btnLabel: 'Read Docs', + }, +]; +export const TechDocsHome = () => { + const navigate = useNavigate(); + + return ( + <> + + + {documentationSites.map((site: DocumentationSite, index: number) => ( + + navigate(site.path)} + tags={site.tags} + title={site.title} + label={site.btnLabel} + description={site.description} + /> + + ))} + + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx new file mode 100644 index 0000000000000..7792164b14555 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { TechDocsNotFound } from './TechDocsNotFound'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('TechDocs Not Found', () => { + it('should render a Documentation not found page', async () => { + const { queryByText } = render(wrapInTestApp()); + expect(queryByText(/error: documentation not found/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx new file mode 100644 index 0000000000000..2a961b5bc0a3d --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx @@ -0,0 +1,34 @@ +/* + * 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 { Typography, Button } from '@material-ui/core'; +import { TechDocsPageWrapper } from './TechDocsPageWrapper'; + +export const TechDocsNotFound = () => { + return ( + + Error: Documentation not found + Path: {window.location.pathname} + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx new file mode 100644 index 0000000000000..d4cc49c919ae9 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { TechDocsPageWrapper } from './TechDocsPageWrapper'; +import { TechDocsHome } from './TechDocsHome'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('TechDocs Page Wrapper', () => { + it('should render a TechDocs Page Wrapper', async () => { + const { queryByText } = render( + wrapInTestApp( + + + , + ), + ); + expect(queryByText(/test-title/i)).toBeInTheDocument(); + expect(queryByText(/test-subtitle/i)).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx new file mode 100644 index 0000000000000..abb702535b8b3 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx @@ -0,0 +1,37 @@ +/* + * 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 { Header, Content } from '@backstage/core'; + +type TechDocsPageWrapperProps = { + title: string; + subtitle: string; + children: any; +}; + +export const TechDocsPageWrapper = ({ + children, + title, + subtitle, +}: TechDocsPageWrapperProps) => { + return ( + <> +
+ {children} + + ); +}; diff --git a/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx b/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx index 1d58bf26e707c..c4fab0dbc688f 100644 --- a/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx +++ b/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx @@ -23,7 +23,7 @@ const ComponentWithoutHook = () => { }; const ComponentWithHook = () => { - const ref = useShadowDom(); + const [ref] = useShadowDom(); return
; }; diff --git a/plugins/techdocs/src/reader/hooks/shadowDom.ts b/plugins/techdocs/src/reader/hooks/shadowDom.ts index 5edb8f4e4de98..93e4c1d375dd3 100644 --- a/plugins/techdocs/src/reader/hooks/shadowDom.ts +++ b/plugins/techdocs/src/reader/hooks/shadowDom.ts @@ -17,14 +17,15 @@ import { useEffect, useRef } from 'react'; import type { RefObject } from 'react'; -type IShadowDOMRefObject = RefObject; -export const useShadowDom: () => IShadowDOMRefObject = () => { - const ref: IShadowDOMRefObject = useRef(null); +type IUseShadowDOM = () => [RefObject, ShadowRoot?]; + +export const useShadowDom: IUseShadowDOM = () => { + const ref = useRef(null); useEffect(() => { const divElement = ref.current; divElement?.attachShadow({ mode: 'open' }); - }, [ref]); + }, []); - return ref; + return [ref, ref.current?.shadowRoot || undefined]; }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index b9cf9745eeb14..8629dce0d3b8d 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -62,28 +62,73 @@ describe('addBaseUrl', () => { ]); }); - it('includes path option', () => { - const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { - transformers: [ - addBaseUrl({ - docStorageURL: DOC_STORAGE_URL, - componentId: 'example-docs', - path: 'examplepath', - }), - ], - }); + it('includes path option without slash', () => { + const shadowDom = createTestShadowDom( + ` + + + + + + + `, + { + transformers: [ + addBaseUrl({ + docStorageURL: DOC_STORAGE_URL, + componentId: 'example-docs', + path: 'examplepath', + }), + ], + }, + ); expect(getSample(shadowDom, 'img', 'src')).toEqual([ - 'https://example-host.storage.googleapis.com/example-docs/examplepath/img/win-py-install.png', - 'https://example-host.storage.googleapis.com/example-docs/examplepath/img/initial-layout.png', + 'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png', + 'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png', ]); expect(getSample(shadowDom, 'link', 'href')).toEqual([ 'https://www.mkdocs.org/', - 'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/images/favicon.png', + 'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png', ]); expect(getSample(shadowDom, 'script', 'src')).toEqual([ 'https://www.google-analytics.com/analytics.js', - 'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/javascripts/vendor.d710d30a.min.js', + 'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js', + ]); + }); + + it('includes path option with slash', () => { + const shadowDom = createTestShadowDom( + ` + + + + + + + `, + { + transformers: [ + addBaseUrl({ + docStorageURL: DOC_STORAGE_URL, + componentId: 'example-docs', + path: 'examplepath/', + }), + ], + }, + ); + + expect(getSample(shadowDom, 'img', 'src')).toEqual([ + 'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png', + 'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png', + ]); + expect(getSample(shadowDom, 'link', 'href')).toEqual([ + 'https://www.mkdocs.org/', + 'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png', + ]); + expect(getSample(shadowDom, 'script', 'src')).toEqual([ + 'https://www.google-analytics.com/analytics.js', + 'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js', ]); }); }); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 653f0b2f71bee..91986db72968f 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import URLParser from '../urlParser'; +import URLFormatter from '../urlFormatter'; import type { Transformer } from './index'; type AddBaseUrlOptions = { @@ -36,11 +36,16 @@ export const addBaseUrl = ({ Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) .forEach((elem: T) => { - const newUrl = new URLParser( - `${docStorageURL}/${componentId}/${path}`, - elem.getAttribute(attributeName)!, - ).parse(); - elem.setAttribute(attributeName, newUrl); + const urlFormatter = new URLFormatter( + path.length < 1 || path.endsWith('/') + ? `${docStorageURL}/${componentId}/${path}` + : `${docStorageURL}/${componentId}/${path}/`, + ); + + elem.setAttribute( + attributeName, + urlFormatter.formatURL(elem.getAttribute(attributeName)!), + ); }); }; diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.test.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts similarity index 89% rename from plugins/techdocs/src/reader/transformers/addEventListener.test.ts rename to plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts index e87ab81ab5a95..0554ff85e9121 100644 --- a/plugins/techdocs/src/reader/transformers/addEventListener.test.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.test.ts @@ -15,14 +15,14 @@ */ import { createTestShadowDom, FIXTURES } from '../../test-utils'; -import { addEventListener } from '../transformers'; +import { addLinkClickListener } from '.'; -describe('addEventListener', () => { +describe('addLinkClickListener', () => { it('calls onClick when a link has been clicked', () => { const fn = jest.fn(); const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { transformers: [ - addEventListener({ + addLinkClickListener({ onClick: fn, }), ], diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts similarity index 78% rename from plugins/techdocs/src/reader/transformers/addEventListener.ts rename to plugins/techdocs/src/reader/transformers/addLinkClickListener.ts index 32b9d6b0b9499..93e4b67a1b596 100644 --- a/plugins/techdocs/src/reader/transformers/addEventListener.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts @@ -16,22 +16,20 @@ import type { Transformer } from './index'; -type AddEventListenerOptions = { - onClick: (newUrl: string) => void; +type AddLinkClickListenerOptions = { + onClick: (e: MouseEvent, newUrl: string) => void; }; -export const addEventListener = ({ +export const addLinkClickListener = ({ onClick, -}: AddEventListenerOptions): Transformer => { +}: AddLinkClickListenerOptions): Transformer => { return dom => { Array.from(dom.getElementsByTagName('a')).forEach(elem => { elem.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); const target = e.target as HTMLAnchorElement; if (target?.getAttribute('href')) { - onClick( - target.getAttribute('href')!.replace(window.location.origin, ''), - ); + onClick(e, target.getAttribute('href')!); } }); }); diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index f49f49627744c..f35d6aa478ad1 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -16,7 +16,7 @@ export * from './addBaseUrl'; export * from './rewriteDocLinks'; -export * from './addEventListener'; +export * from './addLinkClickListener'; export * from './removeMkdocsHeader'; export * from './modifyCss'; diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts index 83ca846142e6b..b4ceab0b38b4c 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -34,7 +34,7 @@ describe('rewriteDocLinks', () => { ]); }); - it('should transform a href with licalhost as baseUrl', () => { + it('should transform a href with localhost as baseUrl', () => { const shadowDom = createTestShadowDom( ` Test @@ -49,9 +49,9 @@ describe('rewriteDocLinks', () => { expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([ 'http://example.org/', - 'http://localhost/example', - 'http://localhost/example-docs', - 'http://localhost/example-docs/example-page', + 'http://localhost/example/', + 'http://localhost/example-docs/', + 'http://localhost/example-docs/example-page/', ]); }); }); diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 7b8495b6b7c70..4bf4ab3898cd8 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import URLParser from '../urlParser'; +import URLFormatter from '../urlFormatter'; import type { Transformer } from './index'; export const rewriteDocLinks = (): Transformer => { @@ -26,12 +26,10 @@ export const rewriteDocLinks = (): Transformer => { Array.from(list) .filter(elem => elem.hasAttribute(attributeName)) .forEach((elem: T) => { + const urlFormatter = new URLFormatter(window.location.href); elem.setAttribute( attributeName, - new URLParser( - window.location.href, - elem.getAttribute(attributeName)!, - ).parse(), + urlFormatter.formatURL(elem.getAttribute(attributeName)!), ); }); }; diff --git a/plugins/techdocs/src/reader/urlFormatter.test.ts b/plugins/techdocs/src/reader/urlFormatter.test.ts new file mode 100644 index 0000000000000..2659f9f4dc679 --- /dev/null +++ b/plugins/techdocs/src/reader/urlFormatter.test.ts @@ -0,0 +1,84 @@ +/* + * 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 URLFormatter from './urlFormatter'; + +describe('URLFormatter', () => { + describe('formatURL', () => { + it('should not change an absolute url', () => { + const formatter = new URLFormatter('https://www.google.com/'); + expect(formatter.formatURL('https://www.mkdocs.org/')).toEqual( + 'https://www.mkdocs.org/', + ); + }); + + it('should convert a relative url to an absolute url', () => { + const formatter = new URLFormatter( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + expect(formatter.formatURL('../../support/installing/')).toEqual( + 'https://www.mkdocs.org/support/installing/', + ); + }); + + it('should add a trailing slash', () => { + const formatter = new URLFormatter( + 'https://www.mkdocs.org/user-guide/getting-started', + ); + expect(formatter.formatURL('./getting-started')).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); + + it('should not add a trailing slash', () => { + const formatter = new URLFormatter( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + expect(formatter.formatURL('.')).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); + + it('should not add multiple hashes', () => { + const formatter = new URLFormatter( + 'https://www.mkdocs.org/user-guide/getting-started/#hash1', + ); + expect(formatter.formatURL('./#hash2')).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/#hash2', + ); + }); + }); + + describe('formatBaseURL', () => { + it('should keep query params in URL', () => { + const formatter = new URLFormatter( + 'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world', + ); + expect(formatter.formatBaseURL()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world', + ); + }); + + it('should keep hash in URL', () => { + const formatter = new URLFormatter( + 'https://www.mkdocs.org/user-guide/getting-started/#hash', + ); + expect(formatter.formatBaseURL()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/#hash', + ); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/urlFormatter.ts b/plugins/techdocs/src/reader/urlFormatter.ts new file mode 100644 index 0000000000000..166f0d996384b --- /dev/null +++ b/plugins/techdocs/src/reader/urlFormatter.ts @@ -0,0 +1,39 @@ +/* + * 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 default class URLFormatter { + constructor(public baseURL: string) {} + + formatBaseURL(): string { + return this.normalizeURL(this.baseURL); + } + + formatURL(pathname: string): string { + return this.normalizeURL(new URL(pathname, this.baseURL).toString()); + } + + private normalizeURL(urlString: string): string { + const url = new URL(urlString); + const filename: string = url.pathname.split('/').pop() ?? url.pathname; + const isDir: boolean = filename.includes('.') === false; + + if (isDir) { + url.pathname = url.pathname.replace(/([^/])$/, '$1/'); + } + + return url.toString(); + } +} diff --git a/plugins/techdocs/src/reader/urlParser.test.ts b/plugins/techdocs/src/reader/urlParser.test.ts deleted file mode 100644 index 4322fc42e1a0d..0000000000000 --- a/plugins/techdocs/src/reader/urlParser.test.ts +++ /dev/null @@ -1,61 +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 URLParser from './urlParser'; - -describe('URLParser', () => { - it('should not change an absolute url', () => { - const urlParser = new URLParser( - 'https://www.google.com/', - 'https://www.mkdocs.org/', - ); - - expect(urlParser.parse()).toEqual('https://www.mkdocs.org/'); - }); - - it('should convert a relative url to an absolute url', () => { - const urlParser = new URLParser( - 'https://www.mkdocs.org/user-guide/getting-started/', - '../../support/installing/', - ); - - expect(urlParser.parse()).toEqual( - 'https://www.mkdocs.org/support/installing/', - ); - }); - - it('should add a trailing slash', () => { - const urlParser = new URLParser( - 'https://www.mkdocs.org/user-guide/getting-started', - '.', - ); - - expect(urlParser.parse()).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/', - ); - }); - - it('should not add a trailing slash', () => { - const urlParser = new URLParser( - 'https://www.mkdocs.org/user-guide/getting-started/', - '.', - ); - - expect(urlParser.parse()).toEqual( - 'https://www.mkdocs.org/user-guide/getting-started/', - ); - }); -}); diff --git a/plugins/techdocs/src/setupTests.ts b/plugins/techdocs/src/setupTests.ts index e34bc46f4b128..8553642152084 100644 --- a/plugins/techdocs/src/setupTests.ts +++ b/plugins/techdocs/src/setupTests.ts @@ -15,4 +15,5 @@ */ import '@testing-library/jest-dom'; + require('jest-fetch-mock').enableMocks(); diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index c4b9b5e5bd969..df33c222f4bc7 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -28,7 +28,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "6.0.0-alpha.5", + "react-router-dom": "6.0.0-beta.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index ce151737b31e6..272297ed45925 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { Typography, @@ -38,7 +38,7 @@ import { configApiRef, } from '@backstage/core'; -const WelcomePage: FC<{}> = () => { +const WelcomePage = () => { const appTitle = useApi(configApiRef).getOptionalString('app.title') ?? 'Backstage'; const profile = { givenName: '' }; diff --git a/yarn.lock b/yarn.lock index 30eca40054f8d..231e3ac884865 100644 --- a/yarn.lock +++ b/yarn.lock @@ -892,14 +892,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2", "@babel/runtime@^7.9.6": - version "7.10.2" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839" - integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg== - dependencies: - regenerator-runtime "^0.13.4" - -"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2", "@babel/runtime@^7.9.6": version "7.10.3" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz#670d002655a7c366540c67f6fd3342cd09500364" integrity sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw== @@ -3259,9 +3252,9 @@ "@types/testing-library__react-hooks" "^3.0.0" "@testing-library/react@^10.4.1": - version "10.4.1" - resolved "https://registry.npmjs.org/@testing-library/react/-/react-10.4.1.tgz#d38dee4abab172c06f6cf8894c51190e6c503f61" - integrity sha512-QX31fRDGLnOdBYoQ95VEOYgRahaPfsI+toOaYhlvuGNFQrcagZv/KLWCIctRGB0h1PTsQt3JpLBbbLGM63yy5Q== + version "10.4.3" + resolved "https://registry.npmjs.org/@testing-library/react/-/react-10.4.3.tgz#c6f356688cffc51f6b35385583d664bb11a161f4" + integrity sha512-A/ydYXcwAcfY7vkPrfUkUTf9HQLL3/GtixTefcu3OyGQtAYQ7XBQj1S9FWbLEhfWa0BLwFwTBFS3Ao1O0tbMJg== dependencies: "@babel/runtime" "^7.10.3" "@testing-library/dom" "^7.17.1" @@ -4546,7 +4539,7 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1: resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.0, ajv@^6.5.5, ajv@^6.7.0: +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.5.5, ajv@^6.7.0: version "6.12.2" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== @@ -6865,41 +6858,23 @@ css-in-js-utils@^2.0.0: hyphenate-style-name "^1.0.2" isobject "^3.0.1" -css-loader@^3.0.0: - version "3.4.2" - resolved "https://registry.npmjs.org/css-loader/-/css-loader-3.4.2.tgz#d3fdb3358b43f233b78501c5ed7b1c6da6133202" - integrity sha512-jYq4zdZT0oS0Iykt+fqnzVLRIeiPWhka+7BqPn+oSIpWJAHak5tmB/WZrJ2a21JhCeFyNnnlroSl8c+MtVndzA== - dependencies: - camelcase "^5.3.1" - cssesc "^3.0.0" - icss-utils "^4.1.1" - loader-utils "^1.2.3" - normalize-path "^3.0.0" - postcss "^7.0.23" - postcss-modules-extract-imports "^2.0.0" - postcss-modules-local-by-default "^3.0.2" - postcss-modules-scope "^2.1.1" - postcss-modules-values "^3.0.0" - postcss-value-parser "^4.0.2" - schema-utils "^2.6.0" - -css-loader@^3.5.3: - version "3.5.3" - resolved "https://registry.npmjs.org/css-loader/-/css-loader-3.5.3.tgz#95ac16468e1adcd95c844729e0bb167639eb0bcf" - integrity sha512-UEr9NH5Lmi7+dguAm+/JSPovNjYbm2k3TK58EiwQHzOHH5Jfq1Y+XoP2bQO6TMn7PptMd0opxxedAWcaSTRKHw== +css-loader@^3.0.0, css-loader@^3.5.3: + version "3.6.0" + resolved "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645" + integrity sha512-M5lSukoWi1If8dhQAUCvj4H8vUt3vOnwbQBH9DdTm/s4Ym2B/3dPMtYZeJmq7Q3S3Pa+I94DcZ7pc9bP14cWIQ== dependencies: camelcase "^5.3.1" cssesc "^3.0.0" icss-utils "^4.1.1" loader-utils "^1.2.3" normalize-path "^3.0.0" - postcss "^7.0.27" + postcss "^7.0.32" postcss-modules-extract-imports "^2.0.0" postcss-modules-local-by-default "^3.0.2" postcss-modules-scope "^2.2.0" postcss-modules-values "^3.0.0" - postcss-value-parser "^4.0.3" - schema-utils "^2.6.6" + postcss-value-parser "^4.1.0" + schema-utils "^2.7.0" semver "^6.3.0" css-modules-loader-core@^1.1.0: @@ -9905,10 +9880,10 @@ highlight.js@~9.15.0, highlight.js@~9.15.1: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== -history@5.0.0-beta.9: - version "5.0.0-beta.9" - resolved "https://registry.npmjs.org/history/-/history-5.0.0-beta.9.tgz#fe230706c18c5f7f132001e55215e71b4aaab6d6" - integrity sha512-iLpu0fzu3iM041KDMNsawyB6YZjPLB+Bn+Pvq2lMnY7xxpxDIYvEz7r4et3Na8FthWzbYeukjl74ZKGWXcLhIA== +history@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" + integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg== dependencies: "@babel/runtime" "^7.7.6" @@ -11724,10 +11699,10 @@ json3@^3.3.2: resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== -json5@2.x, json5@^2.1.1, json5@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/json5/-/json5-2.1.2.tgz#43ef1f0af9835dd624751a6b7fa48874fb2d608e" - integrity sha512-MoUOQ4WdiN3yxhm7NEVJSJrieAo5hNSLQ5sj05OTRHPL9HOBy8u4Bu88jsC1jvqAdN+E1bJmsUcZH+1HQxliqQ== +json5@2.x, json5@^2.1.0, json5@^2.1.1, json5@^2.1.2: + version "2.1.3" + resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" + integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== dependencies: minimist "^1.2.5" @@ -11738,13 +11713,6 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.0: - version "2.1.3" - resolved "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" - integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== - dependencies: - minimist "^1.2.5" - jsonfile@^2.1.0: version "2.4.0" resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" @@ -14686,7 +14654,7 @@ postcss-modules-scope@1.1.0: css-selector-tokenizer "^0.7.0" postcss "^6.0.1" -postcss-modules-scope@^2.1.1, postcss-modules-scope@^2.2.0: +postcss-modules-scope@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee" integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ== @@ -14873,20 +14841,15 @@ postcss-value-parser@^3.0.0: resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== -postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2: - version "4.0.3" - resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d" - integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg== - -postcss-value-parser@^4.0.3: +postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -"postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.23, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.5, postcss@^7.0.6: - version "7.0.27" - resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9" - integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ== +"postcss@5 - 7", postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: + version "7.0.32" + resolved "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" + integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -15651,20 +15614,19 @@ react-redux@^7.1.1: prop-types "^15.7.2" react-is "^16.9.0" -react-router-dom@6.0.0-alpha.5, react-router-dom@^6.0.0-alpha.5: - version "6.0.0-alpha.5" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-alpha.5.tgz#3c3e22226ee610eb91042a351741ce3f53596323" - integrity sha512-xo3VM55aE563uyZBPoUplfCPOYKJmTP2oA8wamm0k4K07e/6T4x4DDunS5Gu2VIy+m2+5mZp8n0rT6S+tYCb6Q== +react-router-dom@6.0.0-beta.0: + version "6.0.0-beta.0" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6" + integrity sha512-36yNNGMT8RB9FRPL9nKJi6HKDkgOakU+o/2hHpSzR6e37gN70MpOU6QQlmif4oAWWBwjyGc3ZNOMFCsFuHUY5w== dependencies: - history "5.0.0-beta.9" prop-types "^15.7.2" + react-router "6.0.0-beta.0" -react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5: - version "6.0.0-alpha.5" - resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b" - integrity sha512-cDj70bTUAgcfx6b5Fx1+wVlBSDVZGo8N+GUDk/yNFDCyGLfAsFlRpS3BhQqx8c49w2cCW+OrXxFhB4cbLZxWJw== +react-router@6.0.0-beta.0: + version "6.0.0-beta.0" + resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" + integrity sha512-VgMdfpVcmFQki/LZuLh8E/MNACekDetz4xqft+a6fBZvvJnVqKbLqebF7hyoawGrV1HcO5tVaUang2Og4W2j1Q== dependencies: - history "5.0.0-beta.9" prop-types "^15.7.2" react-side-effect@^2.1.0: @@ -16604,12 +16566,13 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" -schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6.0, schema-utils@^2.6.4, schema-utils@^2.6.5, schema-utils@^2.6.6: - version "2.6.6" - resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.6.tgz#299fe6bd4a3365dc23d99fd446caff8f1d6c330c" - integrity sha512-wHutF/WPSbIi9x6ctjGGk2Hvl0VOz5l3EKEuKbjPlB30mKZUzb9A5k9yEXRX3pwyqVLPvpfZZEllaFq/M718hA== +schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6.4, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0: + version "2.7.0" + resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" + integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== dependencies: - ajv "^6.12.0" + "@types/json-schema" "^7.0.4" + ajv "^6.12.2" ajv-keywords "^3.4.1" screenfull@^5.0.0: @@ -18222,6 +18185,11 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" @@ -18278,9 +18246,9 @@ ts-interface-checker@^0.1.9: integrity sha512-UJYuKET7ez7ry0CnvfY6fPIUIZDw+UI3qvTUQeS2MyI4TgEeWAUBqy185LeaHcdJ9zG2dgFpPJU/AecXU0Afug== ts-jest@^26.0.0: - version "26.1.0" - resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.0.tgz#e9070fc97b3ea5557a48b67c631c74eb35e15417" - integrity sha512-JbhQdyDMYN5nfKXaAwCIyaWLGwevcT2/dbqRPsQeh6NZPUuXjZQZEfeLb75tz0ubCIgEELNm6xAzTe5NXs5Y4Q== + version "26.1.1" + resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.1.tgz#b98569b8a4d4025d966b3d40c81986dd1c510f8d" + integrity sha512-Lk/357quLg5jJFyBQLnSbhycnB3FPe+e9i7ahxokyXxAYoB0q1pPmqxxRPYr4smJic1Rjcf7MXDBhZWgxlli0A== dependencies: bs-logger "0.x" buffer-from "1.x" @@ -19499,25 +19467,11 @@ yallist@^4.0.0: resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@*, yaml@^1.9.2: - version "1.9.2" - resolved "https://registry.npmjs.org/yaml/-/yaml-1.9.2.tgz#f0cfa865f003ab707663e4f04b3956957ea564ed" - integrity sha512-HPT7cGGI0DuRcsO51qC1j9O16Dh1mZ2bnXwsi0jrSpsLz0WxOLSLXfkABVl6bZO629py3CU+OMJtpNHDLB97kg== - dependencies: - "@babel/runtime" "^7.9.2" - -yaml@^1.10.0: +yaml@*, yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: version "1.10.0" resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yaml@^1.7.2: - version "1.8.3" - resolved "https://registry.npmjs.org/yaml/-/yaml-1.8.3.tgz#2f420fca58b68ce3a332d0ca64be1d191dd3f87a" - integrity sha512-X/v7VDnK+sxbQ2Imq4Jt2PRUsRsP7UcpSl3Llg6+NRRqWLIvxkMFYtH1FmvwNGYRKKPa+EPA4qDBlI9WVG1UKw== - dependencies: - "@babel/runtime" "^7.8.7" - yargs-parser@18.x, yargs-parser@^18.1.1: version "18.1.3" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"