mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
fix(docs-infra): remove part aio infra (#54929)
Remove parts of the aio infra PR Close #54929
This commit is contained in:
parent
199150849b
commit
ade024407d
375 changed files with 188 additions and 41313 deletions
17
.bazelrc
17
.bazelrc
|
|
@ -56,11 +56,6 @@ build --enable_runfiles
|
|||
build:release --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=release"
|
||||
build:release --stamp
|
||||
|
||||
# Building AIO against local Angular deps requires stamping
|
||||
# versions in Angular packages due to CLI version checks.
|
||||
build:aio_local_deps --stamp
|
||||
build:aio_local_deps --workspace_status_command="yarn -s --cwd aio local-workspace-status"
|
||||
|
||||
# Snapshots should also be stamped with version control information.
|
||||
build:snapshot-build --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=snapshot"
|
||||
build:snapshot-build --stamp
|
||||
|
|
@ -74,14 +69,6 @@ build:snapshot-build --stamp
|
|||
##########################################################
|
||||
build --flag_alias=aio_build_config=//aio:flag_aio_build_config
|
||||
|
||||
####################################
|
||||
# AIO first party dep substitution #
|
||||
# Turn on with #
|
||||
# --config=aio_local_deps #
|
||||
####################################
|
||||
|
||||
build:aio_local_deps --//aio:flag_aio_local_deps
|
||||
|
||||
###############################
|
||||
# Output #
|
||||
###############################
|
||||
|
|
@ -118,10 +105,6 @@ common:remote --jobs=200
|
|||
|
||||
build:remote --google_default_credentials
|
||||
|
||||
# Limit the number of test jobs for on an AIO local deps build. The example tests running
|
||||
# concurrently pushes the circleci executor RAM usage to its limits.
|
||||
test:aio_local_deps --jobs=24
|
||||
|
||||
# Force remote exeuctions to consider the entire run as linux
|
||||
build:remote --cpu=k8
|
||||
build:remote --host_cpu=k8
|
||||
|
|
|
|||
2
.github/workflows/adev-preview-build.yml
vendored
2
.github/workflows/adev-preview-build.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build adev to ensure it continues to work
|
||||
run: yarn bazel build --config=aio_local_deps //adev:build
|
||||
run: yarn bazel build //adev:build
|
||||
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@5774b71c01a55c4c998f858ee37d3b77ae704c31
|
||||
with:
|
||||
workflow-artifact-name: 'adev-preview'
|
||||
|
|
|
|||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -5,6 +5,7 @@ on:
|
|||
branches:
|
||||
- main
|
||||
- '[0-9]+.[0-9]+.x'
|
||||
- 'remove-aio-stuff'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ jobs:
|
|||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build adev to ensure it continues to work
|
||||
run: yarn bazel test --config=aio_local_deps //adev:test
|
||||
run: yarn bazel test //adev:test
|
||||
|
||||
publish-snapshots:
|
||||
if: github.event_name == 'push'
|
||||
|
|
@ -261,7 +262,7 @@ jobs:
|
|||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build adev to ensure it continues to work
|
||||
run: yarn bazel build --config=aio_local_deps //adev:build
|
||||
run: yarn bazel build //adev:build
|
||||
- name: Move generated project files into deployment directory
|
||||
run: cp -r dist/bin/adev/build adev/build
|
||||
- name: Setup firebase context
|
||||
|
|
|
|||
25
WORKSPACE
25
WORKSPACE
|
|
@ -2,7 +2,6 @@ workspace(
|
|||
name = "angular",
|
||||
managed_directories = {
|
||||
"@npm": ["node_modules"],
|
||||
"@aio_npm": ["aio/node_modules"],
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -109,30 +108,6 @@ yarn_install(
|
|||
yarn_lock = "//:yarn.lock",
|
||||
)
|
||||
|
||||
yarn_install(
|
||||
name = "aio_npm",
|
||||
# Note that we add the postinstall scripts here so that the dependencies are re-installed
|
||||
# when the postinstall patches are modified.
|
||||
data = [
|
||||
YARN_LABEL,
|
||||
"//:.yarnrc",
|
||||
"//:tools/npm-patches/@bazel+jasmine+5.8.1.patch",
|
||||
"//aio:tools/cli-patches/bazel-architect-output.patch",
|
||||
"//aio:tools/cli-patches/patch.js",
|
||||
],
|
||||
# Currently disabled due to:
|
||||
# 1. Missing Windows support currently.
|
||||
# 2. Incompatibilites with the `ts_library` rule.
|
||||
exports_directories_only = False,
|
||||
manual_build_file_contents = npm_package_archives(),
|
||||
package_json = "//aio:package.json",
|
||||
# We prefer to symlink the `node_modules` to only maintain a single install.
|
||||
# See https://github.com/angular/dev-infra/pull/446#issuecomment-1059820287 for details.
|
||||
symlink_node_modules = True,
|
||||
yarn = YARN_LABEL,
|
||||
yarn_lock = "//aio:yarn.lock",
|
||||
)
|
||||
|
||||
yarn_install(
|
||||
name = "aio_example_deps",
|
||||
# Rename the default js_library target from "node_modules" as this obscures the
|
||||
|
|
|
|||
|
|
@ -1,218 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts"],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"tsconfig.json",
|
||||
"tools/firebase-test-utils/tsconfig.json",
|
||||
"tests/e2e/tsconfig.json"
|
||||
],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"extends": ["plugin:@angular-eslint/template/process-inline-templates"],
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"@angular-eslint",
|
||||
"eslint-plugin-jsdoc",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-jsdoc",
|
||||
"eslint-plugin-prefer-arrow"
|
||||
],
|
||||
"rules": {
|
||||
"@angular-eslint/component-class-suffix": "error",
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"prefix": "aio",
|
||||
"style": "kebab-case",
|
||||
"type": "element"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/contextual-lifecycle": "error",
|
||||
"@angular-eslint/directive-class-suffix": "error",
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"prefix": "aio",
|
||||
"style": "camelCase",
|
||||
"type": "attribute"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/no-conflicting-lifecycle": "error",
|
||||
"@angular-eslint/no-host-metadata-property": "off",
|
||||
"@angular-eslint/no-input-rename": "error",
|
||||
"@angular-eslint/no-inputs-metadata-property": "error",
|
||||
"@angular-eslint/no-output-native": "error",
|
||||
"@angular-eslint/no-output-on-prefix": "error",
|
||||
"@angular-eslint/no-output-rename": "error",
|
||||
"@angular-eslint/no-outputs-metadata-property": "error",
|
||||
"@angular-eslint/use-lifecycle-interface": "error",
|
||||
"@angular-eslint/use-pipe-transform-interface": "error",
|
||||
"@typescript-eslint/adjacent-overload-signatures": "error",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/ban-types": "error",
|
||||
"@typescript-eslint/consistent-type-assertions": "error",
|
||||
"@typescript-eslint/dot-notation": "error",
|
||||
"@typescript-eslint/explicit-member-accessibility": "off",
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/member-delimiter-style": [
|
||||
"error",
|
||||
{
|
||||
"singleline": {
|
||||
"delimiter": "comma",
|
||||
"requireLast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/member-ordering": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-inferrable-types": [
|
||||
"error",
|
||||
{
|
||||
"ignoreParameters": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-misused-new": "error",
|
||||
"@typescript-eslint/no-namespace": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "error",
|
||||
"@typescript-eslint/no-parameter-properties": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
"@typescript-eslint/no-unused-expressions": "error",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/prefer-for-of": "error",
|
||||
"@typescript-eslint/prefer-function-type": "error",
|
||||
"@typescript-eslint/prefer-namespace-keyword": "error",
|
||||
"@typescript-eslint/quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/semi": ["error", "always"],
|
||||
"@typescript-eslint/triple-slash-reference": [
|
||||
"error",
|
||||
{
|
||||
"lib": "always",
|
||||
"path": "always",
|
||||
"types": "prefer-import"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/type-annotation-spacing": "error",
|
||||
"@typescript-eslint/unified-signatures": "error",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
"comma-dangle": "off",
|
||||
"complexity": "off",
|
||||
"constructor-super": "error",
|
||||
"curly": "error",
|
||||
"eol-last": "error",
|
||||
"eqeqeq": ["error", "smart"],
|
||||
"guard-for-in": "error",
|
||||
"id-denylist": [
|
||||
"error",
|
||||
"any",
|
||||
"Number",
|
||||
"number",
|
||||
"String",
|
||||
"string",
|
||||
"Boolean",
|
||||
"boolean",
|
||||
"Undefined",
|
||||
"undefined"
|
||||
],
|
||||
"id-match": "error",
|
||||
"import/no-deprecated": "warn",
|
||||
"indent": "off",
|
||||
"jsdoc/check-alignment": "error",
|
||||
"jsdoc/no-types": "error",
|
||||
"max-classes-per-file": "off",
|
||||
"max-len": ["error", 120],
|
||||
"new-parens": "error",
|
||||
"no-bitwise": "error",
|
||||
"no-caller": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
"allow": ["log", "warn", "error"]
|
||||
}
|
||||
],
|
||||
"no-debugger": "error",
|
||||
"no-empty": "off",
|
||||
"no-empty-function": "off",
|
||||
"no-eval": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-invalid-this": "off",
|
||||
"no-multiple-empty-lines": "off",
|
||||
"no-new-wrappers": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"message": "Please import directly from 'rxjs' instead",
|
||||
"name": "rxjs/Rx"
|
||||
}
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"message": "Don't keep jasmine focus methods.",
|
||||
"selector": "CallExpression[callee.name=/^(fdescribe|fit)$/]"
|
||||
}
|
||||
],
|
||||
"no-shadow": "off",
|
||||
"no-tabs": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-labels": "error",
|
||||
"no-use-before-define": "off",
|
||||
"no-var": "error",
|
||||
"object-shorthand": "error",
|
||||
"one-var": ["error", "never"],
|
||||
"prefer-arrow/prefer-arrow-functions": "off",
|
||||
"prefer-const": "error",
|
||||
"quote-props": ["error", "as-needed"],
|
||||
"quotes": "off",
|
||||
"radix": "error",
|
||||
"sort-keys": "off",
|
||||
"space-before-function-paren": [
|
||||
"error",
|
||||
{
|
||||
"anonymous": "never",
|
||||
"asyncArrow": "always",
|
||||
"named": "never"
|
||||
}
|
||||
],
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"extends": ["plugin:@angular-eslint/template/recommended"],
|
||||
"rules": {
|
||||
"@angular-eslint/template/alt-text": "error",
|
||||
"@angular-eslint/template/elements-content": "error",
|
||||
"@angular-eslint/template/label-has-associated-control": "error",
|
||||
"@angular-eslint/template/table-scope": "error",
|
||||
"@angular-eslint/template/valid-aria": "error",
|
||||
"@angular-eslint/template/click-events-have-key-events": "error",
|
||||
"@angular-eslint/template/eqeqeq": "off",
|
||||
"@angular-eslint/template/mouse-events-have-key-events": "error",
|
||||
"@angular-eslint/template/no-autofocus": "error",
|
||||
"@angular-eslint/template/no-distracting-elements": "error",
|
||||
"@angular-eslint/template/no-positive-tabindex": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
engine-strict = true
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
load("@aio_npm//@angular-devkit/architect-cli:index.bzl", "architect", "architect_test")
|
||||
load("@npm//@angular-devkit/architect-cli:index.bzl", "architect", "architect_test")
|
||||
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin", "npm_package_bin")
|
||||
load("//tools:defaults.bzl", "nodejs_binary")
|
||||
load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory")
|
||||
load(":local_packages_util.bzl", "link_local_packages", "substitute_local_package_deps")
|
||||
load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "string_flag")
|
||||
load("//aio/scripts:local_server_test.bzl", "local_server_test")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load(":aio_targets.bzl", "aio_test")
|
||||
load("@bazel_skylib//lib:collections.bzl", "collections")
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ npm_package_bin(
|
|||
tags = [
|
||||
"requires-network",
|
||||
],
|
||||
tool = "@aio_npm//dgeni/bin:dgeni",
|
||||
tool = "@npm//dgeni/bin:dgeni",
|
||||
toolchains = [
|
||||
"@npm//@angular/build-tooling/bazel/git-toolchain:current_git_toolchain",
|
||||
],
|
||||
|
|
@ -148,29 +148,29 @@ APPLICATION_DEPS = [
|
|||
":stackblitz",
|
||||
":example-zips",
|
||||
"//aio/src/generated:ngsw-config",
|
||||
"@aio_npm//@angular-devkit/build-angular",
|
||||
"@aio_npm//@angular-eslint/builder",
|
||||
"@aio_npm//@angular/animations",
|
||||
"@aio_npm//@angular/cdk",
|
||||
"@aio_npm//@angular/cli",
|
||||
"@aio_npm//@angular/common",
|
||||
"@aio_npm//@angular/compiler",
|
||||
"@aio_npm//@angular/compiler-cli",
|
||||
"@aio_npm//@angular/core",
|
||||
"@aio_npm//@angular/elements",
|
||||
"@aio_npm//@angular/forms",
|
||||
"@aio_npm//@angular/material",
|
||||
"@aio_npm//@angular/platform-browser",
|
||||
"@aio_npm//@angular/platform-browser-dynamic",
|
||||
"@aio_npm//@angular/router",
|
||||
"@aio_npm//@angular/service-worker",
|
||||
"@aio_npm//@types/lunr",
|
||||
"@aio_npm//@types/trusted-types",
|
||||
"@aio_npm//lunr",
|
||||
"@aio_npm//rxjs",
|
||||
"@aio_npm//safevalues",
|
||||
"@aio_npm//tslib",
|
||||
"@aio_npm//zone.js",
|
||||
"@npm//@angular-devkit/build-angular",
|
||||
"@npm//@angular-eslint/builder",
|
||||
"@npm//@angular/animations",
|
||||
"@npm//@angular/cdk",
|
||||
"@npm//@angular/cli",
|
||||
"@npm//@angular/common",
|
||||
"@npm//@angular/compiler",
|
||||
"@npm//@angular/compiler-cli",
|
||||
"@npm//@angular/core",
|
||||
"@npm//@angular/elements",
|
||||
"@npm//@angular/forms",
|
||||
"@npm//@angular/material",
|
||||
"@npm//@angular/platform-browser",
|
||||
"@npm//@angular/platform-browser-dynamic",
|
||||
"@npm//@angular/router",
|
||||
"@npm//@angular/service-worker",
|
||||
"@npm//@types/lunr",
|
||||
"@npm//@types/trusted-types",
|
||||
"@npm//lunr",
|
||||
"@npm//rxjs",
|
||||
"@npm//safevalues",
|
||||
"@npm//tslib",
|
||||
"@npm//zone.js",
|
||||
]
|
||||
|
||||
# All sources, specs, and config files required to test the docs app
|
||||
|
|
@ -183,16 +183,16 @@ TEST_FILES = APPLICATION_FILES + [
|
|||
|
||||
# Dependencies required to test the docs app
|
||||
TEST_DEPS = APPLICATION_DEPS + [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@aio_npm//@types/jasmine",
|
||||
"@aio_npm//@types/node",
|
||||
"@aio_npm//assert",
|
||||
"@aio_npm//jasmine",
|
||||
"@aio_npm//jasmine-core",
|
||||
"@aio_npm//karma-chrome-launcher",
|
||||
"@aio_npm//karma-coverage",
|
||||
"@aio_npm//karma-jasmine",
|
||||
"@aio_npm//karma-jasmine-html-reporter",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@npm//@types/jasmine",
|
||||
"@npm//@types/node",
|
||||
"@npm//assert",
|
||||
"@npm//jasmine",
|
||||
"@npm//jasmine-core",
|
||||
"@npm//karma-chrome-launcher",
|
||||
"@npm//karma-coverage",
|
||||
"@npm//karma-jasmine",
|
||||
"@npm//karma-jasmine-html-reporter",
|
||||
"//aio/tools:windows-chromium-path",
|
||||
]
|
||||
|
||||
|
|
@ -202,12 +202,12 @@ E2E_FILES = APPLICATION_FILES + glob(["tests/e2e/**"])
|
|||
|
||||
# Dependencies required to run the e2e tests
|
||||
E2E_DEPS = APPLICATION_DEPS + [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@aio_npm//@types/jasmine",
|
||||
"@aio_npm//@types/node",
|
||||
"@aio_npm//jasmine-spec-reporter",
|
||||
"@aio_npm//protractor",
|
||||
"@aio_npm//ts-node",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@npm//@types/jasmine",
|
||||
"@npm//@types/node",
|
||||
"@npm//jasmine-spec-reporter",
|
||||
"@npm//protractor",
|
||||
"@npm//ts-node",
|
||||
"//aio/tools:windows-chromium-path",
|
||||
]
|
||||
|
||||
|
|
@ -322,7 +322,7 @@ architect_test(
|
|||
# (presumably after AIO migration to Bazel)
|
||||
flaky = True,
|
||||
toolchains = [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
114
aio/README.md
114
aio/README.md
|
|
@ -1,114 +0,0 @@
|
|||
# Angular documentation project (https://angular.io)
|
||||
|
||||
Everything in this folder is part of the documentation project. This includes:
|
||||
|
||||
* the web site for displaying the documentation.
|
||||
* the dgeni configuration for converting source files to rendered files that can be viewed in the web site.
|
||||
* the tooling for setting up examples for development; and generating live-example and zip files from the examples.
|
||||
|
||||
<a name="developer-tasks"></a>
|
||||
## Developer tasks
|
||||
|
||||
We use [Yarn](https://yarnpkg.com) to manage the dependencies and development tasks. Behind the scenes, [Bazel](https://bazel.build/) is used to build targets and run tests.
|
||||
You should run all these tasks from the `angular/aio` folder.
|
||||
Here are the most important tasks you might need to use:
|
||||
|
||||
* `yarn` - install all the dependencies.
|
||||
* `yarn build` - create a development build of the application.
|
||||
* `yarn build-prod` - create a production build of the application.
|
||||
* `yarn build-local` - same as `build`, but uses locally built Angular packages from source code rather than from npm.
|
||||
* `yarn start` - run a development web server that watches, rebuilds, and reloads the page when there are changes to the source code or docs.
|
||||
* `yarn start-local` - same as `start`, but uses local Angular packages.
|
||||
* `yarn test` - run all the unit tests for the doc-viewer once.
|
||||
* `yarn test-local` - similar to `test`, but tests against locally built Angular packages.
|
||||
* `yarn test-and-watch` - watch all the source files for the doc-viewer, and run all the unit tests when any change.
|
||||
* `yarn e2e` - run all the e2e tests for the doc-viewer.
|
||||
* `yarn e2e-local` - similar to `e2e`, but tests against locally built Angular packages.
|
||||
* `yarn lint` - check that the doc-viewer code follows our style rules.
|
||||
|
||||
* `yarn docs-watch` - similar to `start`, but only watches for docs changes and uses a faster, low-fidelity build ideal for quick editing.
|
||||
* `yarn docs-test` - run the unit tests for the doc generation code.
|
||||
* `yarn docs-lint` - check that the doc gen code follows our style rules.
|
||||
|
||||
* `yarn create-example` - create a new example directory containing initial source files.
|
||||
* `yarn example-playground <exampleName>` - set up a playground to manually test an example combined with its boilerplate files
|
||||
- `--local` - link locally build Angular packages as deps
|
||||
- `--watch` - update the playground when sources change
|
||||
|
||||
* `yarn example-e2e` - run all e2e tests for examples. Available options:
|
||||
- `--local`: run e2e tests against locally built Angular packages.
|
||||
- `--filter=foo`: limit e2e tests to those containing the word "foo".
|
||||
- `--exclude=bar`: exclude e2e tests containing the word "bar".
|
||||
|
||||
> **Note for Windows users**
|
||||
>
|
||||
> The underlying Bazel build requires creating [symbolic links](https://en.wikipedia.org/wiki/Symbolic_link) (see [here](./tools/examples/README.md#symlinked-node_modules) for details). On Windows, this requires to either have [Developer Mode enabled](https://blogs.windows.com/windowsdeveloper/2016/12/02/symlinks-windows-10) (supported on Windows 10 or newer) or run the setup commands as administrator.
|
||||
|
||||
## Using ServiceWorker locally
|
||||
|
||||
Running `yarn start` (even when explicitly targeting production mode) does not set up the
|
||||
ServiceWorker. If you want to test the ServiceWorker locally, you can use `yarn build` and then
|
||||
serve the files with `yarn http-server ../dist/bin/aio/build -p 4200`.
|
||||
|
||||
|
||||
## Guide to authoring
|
||||
|
||||
There are two types of content in the documentation:
|
||||
|
||||
* **API docs**: descriptions of all that make up the Angular platform, such as the modules, classes, interfaces or decorators.
|
||||
API docs are generated directly from the source code.
|
||||
The source code is contained in TypeScript files, located in the `angular/packages` folder.
|
||||
Each API item may have a preceding comment, which contains JSDoc style tags and content.
|
||||
The content is written in markdown. To generate docs, each package's files need to be explicitly included in the [packages/BUILD.bazel](../packages/BUILD.bazel) file under the `files_for_docgen` target.
|
||||
|
||||
* **Other content**: guides, tutorials, and other marketing material.
|
||||
All other content is written using markdown in text files, located in the `angular/aio/content` folder.
|
||||
More specifically, there are sub-folders that contain particular types of content: guides, tutorial and marketing.
|
||||
|
||||
* **Code examples**: code examples need to be testable to ensure their accuracy.
|
||||
Also, our examples have a specific look and feel and allow the user to copy the source code. For larger
|
||||
examples they are rendered in a tabbed interface (e.g. template, HTML, and TypeScript on separate
|
||||
tabs). Additionally, some are live examples, which provide links where the code can be edited, executed, and/or downloaded. For details on working with code examples, please read the [Code snippets](https://angular.io/guide/docs-style-guide#code-snippets), [Source code markup](https://angular.io/guide/docs-style-guide#source-code-markup), and [Live examples](https://angular.io/guide/docs-style-guide#live-examples) pages of the [Authors Style Guide](https://angular.io/guide/docs-style-guide).
|
||||
|
||||
We use the [dgeni](https://github.com/angular/dgeni) tool to convert these files into docs that can be viewed in the doc-viewer.
|
||||
|
||||
The [Authors Style Guide](https://angular.io/guide/docs-style-guide) prescribes guidelines for
|
||||
writing guide pages, explains how to use the documentation classes and components, and how to markup sample source code to produce code snippets.
|
||||
|
||||
### Generating the complete docs
|
||||
|
||||
Running the `yarn build` or `yarn start` tasks will automatically generate the docs. This will process all the source files (API and other),
|
||||
extracting the documentation and generating JSON files that can be consumed by the doc-viewer.
|
||||
|
||||
### Partial doc generation for editors
|
||||
|
||||
Full doc generation can take up to one minute. That's too slow for efficient document creation and editing.
|
||||
|
||||
You can make small changes in a smart editor that displays formatted markdown:
|
||||
>In VS Code, _Cmd-K, V_ opens markdown preview in side pane; _Cmd-B_ toggles left sidebar
|
||||
|
||||
You also want to see those changes displayed properly in the doc viewer
|
||||
with a quick, edit/view cycle time.
|
||||
|
||||
For this purpose, use the `yarn docs-watch` task, which watches for changes to source files and only
|
||||
re-processes the files necessary to generate the docs that are related to the file that has changed.
|
||||
Since this task takes shortcuts, it is much faster (often less than 1 second) but it won't produce full
|
||||
fidelity content. For example, links to other docs and code examples may not render correctly. This is
|
||||
most particularly noticed in links to other docs and in the embedded examples, which may not always render
|
||||
correctly.
|
||||
|
||||
The general setup is as follows:
|
||||
|
||||
* Open a terminal, ensure the dependencies are installed, then start the doc-viewer:
|
||||
|
||||
```bash
|
||||
yarn docs-watch
|
||||
```
|
||||
|
||||
* A browser will open automatically at https://localhost:4200/. Navigate to the document on which you want to work.
|
||||
|
||||
* Make changes to the page's associated doc or example files. Every time a file is saved, the doc will
|
||||
be regenerated, the app will rebuild and the page will reload.
|
||||
|
||||
* If you get a build error complaining about examples or any other odd behavior, be sure to consult
|
||||
the [Authors Style Guide](https://angular.io/guide/docs-style-guide).
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# Update the angular.io app
|
||||
|
||||
The dependencies of the angular.io app (including Angular, Angular Material and Angular CLI) are automatically updated using [Renovate](https://renovatebot.com/).
|
||||
|
||||
However, it is useful to periodically also manually update the app to more closely match (in file layout, configs, etc.) what a new Angular CLI app would look like.
|
||||
This is typically only needed once for each new major Angular version.
|
||||
|
||||
Since angular.io is an Angular CLI app, we can take advantage of `ng update` to apply migrations.
|
||||
|
||||
Follow these steps to align the angular.io app with new CLI apps.
|
||||
|
||||
> **Note:**
|
||||
> The following steps assume that the related Angular dependencies have already been updated in [aio/package.json](./package.json) (for example, automatically by Renovate).
|
||||
|
||||
> **Note:**
|
||||
> All commands shown below are expected to be executed from inside the [aio/](./) directory (unless specified otherwise).
|
||||
|
||||
- Determine (for example, by examining git history) what is the last versions for which this process was performed.
|
||||
These will be referred to as `<FROM_VERSION_*>`.
|
||||
If you can't determine these, use arbitrary versions, such as the previous major version.
|
||||
|
||||
- Run the following commands to automatically apply any available migrations to the project:
|
||||
```sh
|
||||
# Ensure dependencies are installed.
|
||||
yarn install
|
||||
|
||||
# Migrate project to new versions.
|
||||
yarn ng update @angular/cli --allow-dirty --migrate-only --from=<FROM_VERSION_CLI>
|
||||
yarn ng update @angular/core --allow-dirty --migrate-only --from=<FROM_VERSION_ANGULAR>
|
||||
yarn ng update @angular/material --allow-dirty --migrate-only --from=<FROM_VERSION_MATERIAL>
|
||||
```
|
||||
|
||||
> **Note:**
|
||||
> Depending on the number of changes generated from each `ng update` command, it might make sense to create a separate commit for each update.
|
||||
|
||||
- Inspect [package.json](./package.json) to determine what is the current version of Angular CLI (i.e. `@angular/cli`) used in the app.
|
||||
This will be referred to as `<TO_VERSION_CLI>`.
|
||||
|
||||
- Use the [angular-cli-diff](https://github.com/cexbrayat/angular-cli-diff) repository to discover more changes (which are not automatically applied via `ng update` migrations) between Angular CLI apps of different versions.
|
||||
Visit https://github.com/cexbrayat/angular-cli-diff/compare/<FROM_VERSION_CLI>...<TO_VERSION_CLI>, inspect the changes between the two versions and apply the ones that make sense to the angular.io source code.
|
||||
|
||||
- Commit all changes and [submit a pull request](../CONTRIBUTING.md#submit-pr).
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
load("@aio_npm//@angular-devkit/architect-cli:index.bzl", "architect_test")
|
||||
load("@npm//@angular-devkit/architect-cli:index.bzl", "architect_test")
|
||||
|
||||
def aio_test(name, data, args, **kwargs):
|
||||
architect_test(
|
||||
|
|
@ -10,7 +10,7 @@ def aio_test(name, data, args, **kwargs):
|
|||
"CHROME_BIN": "../$(CHROMIUM)",
|
||||
},
|
||||
toolchains = [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
],
|
||||
**kwargs
|
||||
)
|
||||
|
|
|
|||
264
aio/angular.json
264
aio/angular.json
|
|
@ -1,264 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "yarn",
|
||||
"warnings": {
|
||||
"typescriptMismatch": false
|
||||
},
|
||||
"analytics": false,
|
||||
"cache": {
|
||||
// Disable build caching as the cache folder will just be dropped
|
||||
// when run under Bazel sandboxed execution.
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"site": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:application": {
|
||||
"strict": true
|
||||
},
|
||||
"@schematics/angular:component": {
|
||||
"inlineStyle": true,
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "aio",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"ngswConfigPath": "src/generated/ngsw-config.json",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||
"optimization": {
|
||||
"fonts": {
|
||||
"inline": false
|
||||
},
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"inlineCritical": false,
|
||||
"minify": true
|
||||
}
|
||||
},
|
||||
"outputHashing": "all",
|
||||
"sourceMap": true,
|
||||
"namedChunks": true,
|
||||
"assets": [
|
||||
// Architect on Windows has difficulty service assets files within a
|
||||
// Bazel runfiles symlink forest. Using "followSymlinks" seems to
|
||||
// fix this. Note that the assets below don't need this workaround as
|
||||
// symlinked tree artifacts pointing to the output tree don't appear
|
||||
// to have the same issue.
|
||||
{
|
||||
"followSymlinks": true,
|
||||
"input": "src/assets",
|
||||
"glob": "**/*",
|
||||
"output": "/assets/"
|
||||
},
|
||||
{
|
||||
"followSymlinks": true,
|
||||
"input": "src/",
|
||||
"glob": "**/pwa-manifest.json",
|
||||
"output": "/"
|
||||
},
|
||||
{
|
||||
"followSymlinks": true,
|
||||
"input": "src/",
|
||||
"glob": "**/google385281288605d160.html",
|
||||
"output": "/"
|
||||
},
|
||||
{
|
||||
"input": "stackblitz/generated",
|
||||
"output": "generated",
|
||||
"glob": "**"
|
||||
},
|
||||
{
|
||||
"input": "example-zips/generated",
|
||||
"output": "generated",
|
||||
"glob": "**"
|
||||
},
|
||||
{
|
||||
"input": "dgeni/generated",
|
||||
"output": "generated",
|
||||
"glob": "**"
|
||||
},
|
||||
{
|
||||
"input": "dgeni-fast/generated",
|
||||
"output": "generated",
|
||||
"glob": "**"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles/main.scss",
|
||||
{
|
||||
"inject": false,
|
||||
"input": "src/styles/custom-themes/dark-theme.scss",
|
||||
"bundleName": "dark-theme"
|
||||
},
|
||||
{
|
||||
"inject": false,
|
||||
"input": "src/styles/custom-themes/light-theme.scss",
|
||||
"bundleName": "light-theme"
|
||||
}
|
||||
],
|
||||
"scripts": [],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "850kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"next": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.next.ts"
|
||||
}
|
||||
],
|
||||
"serviceWorker": true
|
||||
},
|
||||
"rc": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.rc.ts"
|
||||
}
|
||||
],
|
||||
"serviceWorker": true
|
||||
},
|
||||
"stable": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.stable.ts"
|
||||
}
|
||||
],
|
||||
"serviceWorker": true
|
||||
},
|
||||
"archive": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.archive.ts"
|
||||
}
|
||||
],
|
||||
"serviceWorker": true
|
||||
},
|
||||
"ci": {
|
||||
"progress": false
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"outputHashing": "none",
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "stable"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"headers": {
|
||||
// Keep in sync with the `firebase.json` file.
|
||||
// Note: Unlike in `firebase.json` we omit the `report-uri` for development.
|
||||
"Content-Security-Policy": "require-trusted-types-for 'script'; trusted-types angular angular#bundler angular#unsafe-bypass aio#analytics google#safe goog#html"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"next": {
|
||||
"browserTarget": "site:build:next"
|
||||
},
|
||||
"rc": {
|
||||
"browserTarget": "site:build:rc"
|
||||
},
|
||||
"stable": {
|
||||
"browserTarget": "site:build:stable"
|
||||
},
|
||||
"archive": {
|
||||
"browserTarget": "site:build:archive"
|
||||
},
|
||||
"ci": {
|
||||
"browserTarget": "site:build:ci"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "site:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "site:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": ["zone.js", "zone.js/testing"],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"assets": ["src/assets", "src/pwa-manifest.json", "src/google385281288605d160.html"],
|
||||
"styles": [
|
||||
"src/styles/main.scss",
|
||||
{
|
||||
"inject": false,
|
||||
"input": "src/styles/custom-themes/dark-theme.scss",
|
||||
"bundleName": "dark-theme"
|
||||
},
|
||||
{
|
||||
"inject": false,
|
||||
"input": "src/styles/custom-themes/light-theme.scss",
|
||||
"bundleName": "light-theme"
|
||||
}
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/!(generated)/**/*.ts",
|
||||
"src/!(generated)/**/*.html",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "tests/e2e/protractor.conf.js",
|
||||
"devServerTarget": "site:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "site:serve:ci"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ def docs_example(name, test = True, test_tags = [], test_exec_properties = {}, f
|
|||
data = [
|
||||
":%s" % name,
|
||||
YARN_LABEL,
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"//aio/tools/examples:run-example-e2e",
|
||||
"//aio/tools:windows-chromium-path",
|
||||
# We install the whole node modules for runtime deps of e2e tests
|
||||
|
|
@ -229,7 +229,7 @@ def docs_example(name, test = True, test_tags = [], test_exec_properties = {}, f
|
|||
"CHROMEDRIVER_BIN": "$(CHROMEDRIVER)",
|
||||
},
|
||||
toolchains = [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
],
|
||||
exec_properties = test_exec_properties,
|
||||
flaky = flaky,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("//aio/content/examples:examples.bzl", "docs_example")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"rules": {
|
||||
".read": false,
|
||||
".write": false,
|
||||
|
||||
"events": {
|
||||
".read": true,
|
||||
".write": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
{
|
||||
// Docs on Firebase hosting configuration: https://firebase.google.com/docs/hosting/full-config
|
||||
"hosting": {
|
||||
"target": "aio",
|
||||
"public": "dist",
|
||||
"cleanUrls": true,
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ADDING REDIRECTS
|
||||
//
|
||||
// In order to permanently redirect a URL to a new address, add an new entry in the `redirects`
|
||||
// list:
|
||||
// ```json
|
||||
// {"type": 301, "source": "/old/url", "destination": "/new/url"}
|
||||
// ```
|
||||
//
|
||||
// This will cause the server to redirect any clients that visit `/old/url` to `/new/url`. At
|
||||
// the same time, the ServiceWorker will be automatically configured to let requests for
|
||||
// `/old/url` pass through to the server (instead of serving them locally with the cached
|
||||
// `index.html`).
|
||||
//
|
||||
// In addition, you should also add a corresponding testcase in `URLS_TO_REDIRECT.txt` (inside
|
||||
// `tests/deployment/shared/`) to ensure that the new redirect behaves as expected.
|
||||
//
|
||||
// NOTE 1:
|
||||
// On clients that still have an old ServiceWorker version installed the next time they visit a
|
||||
// recently redirected URL, the ServiceWorker will not yet know to let the request pass through
|
||||
// to the server (and get redirected). In such cases, those clients will get a "Page not found"
|
||||
// error, as the app tries to load `generated/docs/old/url.json` (which no longer exists). Such
|
||||
// errors would typically go away after reloading the page (since the ServiceWorker will have
|
||||
// updated to the latest version in the background).
|
||||
//
|
||||
// This temporary error is usually not a concern, because it only affects clients with an old
|
||||
// ServiceWorker version at the time of the first visit and it goes away on reload. If, however,
|
||||
// it is important to avoid this error for a specific redirect, you can add an additional
|
||||
// redirect entry for the requested JSON file:
|
||||
// ```json
|
||||
// {"type": 302, "source": "/generated/docs/old/url.json", "destination": "/generated/docs/new/url.json"}
|
||||
// ```
|
||||
//
|
||||
// In order to avoid bloating the redirects config, it is recommended to remove this additional
|
||||
// entry after some time has passed, at which point the large majority of existing clients will
|
||||
// have switched to an updated ServiceWorker version.
|
||||
//
|
||||
// NOTE 2:
|
||||
// If you decide to add the additional entry described above, keep in mind that the JSON file
|
||||
// URL is derived from the page URL by prepending `/generated/docs`, replacing any upper-case
|
||||
// letters with the corresponding lower-case ones followed by an underscore (`_`) and appending
|
||||
// `.json`.
|
||||
//
|
||||
// For example, the page URL `/api/forms/FormBuilder` would result in requesting the JSON file
|
||||
// `/generated/docs/api/forms/f_ormb_uilder.json`.
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
"redirects": [
|
||||
// A random bad indexed page that used `api/api`
|
||||
{"type": 301, "source": "/api/api/:rest*", "destination": "/api/:rest*"},
|
||||
|
||||
// Guide renames/removals
|
||||
{"type": 301, "source": "/docs/*/latest/cli-quickstart.html", "destination": "/start"},
|
||||
{"type": 301, "source": "/docs/*/latest/glossary.html", "destination": "/guide/glossary"},
|
||||
{"type": 301, "source": "/docs/*/latest/quickstart.html", "destination": "/start"},
|
||||
{"type": 301, "source": "/docs/*/latest/guide/server-communication.html", "destination": "/guide/understanding-communicating-with-http"},
|
||||
{"type": 301, "source": "/docs/*/latest/guide/style-guide.html", "destination": "/guide/styleguide"},
|
||||
{"type": 301, "source": "/guide/bazel", "destination": "https://github.com/angular/angular/blob/main/packages/bazel/docs/BAZEL_SCHEMATICS.md"},
|
||||
{"type": 301, "source": "/guide", "destination": "/docs"},
|
||||
{"type": 301, "source": "/guide/cli-quickstart", "destination": "/start"},
|
||||
{"type": 301, "source": "/guide/i18n", "destination": "/guide/i18n-overview"},
|
||||
{"type": 301, "source": "/guide/service-worker-getstart", "destination": "/guide/service-worker-getting-started"},
|
||||
{"type": 301, "source": "/guide/service-worker-comm", "destination": "/guide/service-worker-communications"},
|
||||
{"type": 301, "source": "/guide/service-worker-configref", "destination": "/guide/service-worker-config"},
|
||||
{"type": 301, "source": "/guide/webpack", "destination": "https://v5.angular.io/guide/webpack"},
|
||||
{"type": 301, "source": "/guide/http", "destination": "/guide/understanding-communicating-with-http"},
|
||||
{"type": 301, "source": "/guide/setup", "destination": "/guide/setup-local"},
|
||||
{"type": 301, "source": "/guide/setup-systemjs-anatomy", "destination": "/guide/file-structure"},
|
||||
{"type": 301, "source": "/guide/change-log", "destination": "https://github.com/angular/angular/blob/main/CHANGELOG.md"},
|
||||
{"type": 301, "source": "/guide/quickstart", "destination": "/start"},
|
||||
{"type": 301, "source": "/getting-started", "destination": "/start"},
|
||||
{"type": 301, "source": "/getting-started/:rest*", "destination": "/start/:rest*"},
|
||||
{"type": 301, "source": "/guide/displaying-data", "destination": "/start#template-syntax"},
|
||||
{"type": 301, "source": "/guide/ivy", "destination": "https://v12.angular.io/guide/ivy"},
|
||||
{"type": 301, "source": "/guide/updating-to-version-10", "destination": "https://v10.angular.io/guide/updating-to-version-10"},
|
||||
{"type": 301, "source": "/guide/updating-to-version-11", "destination": "https://v11.angular.io/guide/updating-to-version-11"},
|
||||
{"type": 301, "source": "/guide/updating-to-version-12", "destination": "https://v12.angular.io/guide/updating-to-version-12"},
|
||||
{"type": 301, "source": "/guide/updating-to-version-13", "destination": "https://v13.angular.io/guide/update-to-latest-version"},
|
||||
{"type": 301, "source": "/guide/set-document-title", "destination": "/guide/router#setting-the-page-title"},
|
||||
|
||||
// Update Angular guide refactor to name individual update topics.
|
||||
// This redirects the generic link to the current version's topic.
|
||||
// The destination value must be updated with each new major version.
|
||||
// When changing this value, be sure to update or add the corresponding
|
||||
// test in angular/aio/tests/deployment/shared/URLS_TO_REDIRECT.txt
|
||||
{"type": 301, "source": "/guide/update-to-latest-version", "destination": "/guide/update-to-version-17"},
|
||||
|
||||
// Renaming of Getting Started topics
|
||||
{"type": 301, "source": "/start/data", "destination": "/start/start-data"},
|
||||
{"type": 301, "source": "/start/deployment", "destination": "/start/start-deployment"},
|
||||
{"type": 301, "source": "/start/forms", "destination": "/start/start-forms"},
|
||||
{"type": 301, "source": "/start/routing", "destination": "/start/start-routing"},
|
||||
|
||||
// Tutorials moved to subdirectories
|
||||
{"type": 301, "source": "/tutorial/index", "destination": "/tutorial/tour-of-heroes/index"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt0", "destination": "/tutorial/tour-of-heroes/toh-pt0"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt1", "destination": "/tutorial/tour-of-heroes/toh-pt1"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt2", "destination": "/tutorial/tour-of-heroes/toh-pt2"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt3", "destination": "/tutorial/tour-of-heroes/toh-pt3"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt4", "destination": "/tutorial/tour-of-heroes/toh-pt4"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt5", "destination": "/tutorial/tour-of-heroes/toh-pt5"},
|
||||
{"type": 301, "source": "/tutorial/toh-pt6", "destination": "/tutorial/tour-of-heroes/toh-pt6"},
|
||||
|
||||
// some top level guide pages on old site were moved below the guide folder
|
||||
{"type": 301, "source": "/styleguide", "destination": "/guide/styleguide"},
|
||||
{"type": 301, "source": "/docs/styleguide", "destination": "/guide/styleguide"},
|
||||
|
||||
// news is now blog
|
||||
{"type": 301, "source": "/news*", "destination": "https://blog.angular.io/"},
|
||||
|
||||
// cookbook guides were moved (and sometime renamed or removed)
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook", "destination": "/docs"},
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook/", "destination": "/docs"},
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook/index.html", "destination": "/docs"},
|
||||
{"type": 301, "source": "/**/cookbook/ts-to-js*", "destination": "https://v2.angular.io/docs/ts/latest/cookbook/ts-to-js.html"},
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook/a1-a2-quick-reference.html", "destination": "/guide/ajs-quick-reference"},
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook/component-communication.html", "destination": "/guide/component-interaction"},
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook/dependency-injection.html", "destination": "/guide/dependency-injection-in-action"},
|
||||
{"type": 301, "source": "/docs/*/latest/cookbook/:cookbook.html", "destination": "/guide/:cookbook"},
|
||||
|
||||
// Forms related code was moved from the `common` to `forms` package (and NgFor was renamed to NgForOf)
|
||||
{"type": 301, "source": "/**/NgFor-*", "destination": "/api/common/NgForOf"},
|
||||
{"type": 301, "source": "/**/api/common/index/MaxLengthValidator-*", "destination": "/api/forms/MaxLengthValidator"},
|
||||
{"type": 301, "source": "/**/api/common/ControlGroup*", "destination": "/api/forms/FormGroup"},
|
||||
{"type": 301, "source": "/**/api/common/Control*", "destination": "/api/forms/FormControl"},
|
||||
{"type": 301, "source": "/**/api/common/SelectControlValueAccessor-*", "destination": "/api/forms/SelectControlValueAccessor"},
|
||||
{"type": 301, "source": "/**/api/common/NgModel", "destination": "/api/forms/NgModel"},
|
||||
|
||||
// `@angular/http` package was removed, and new `HttpClient` APIs are available under `@angular/common/http` package
|
||||
{"type": 301, "source": "/api/http", "destination": "/guide/deprecations#http"},
|
||||
{"type": 301, "source": "/api/http/**", "destination": "/guide/deprecations#http"},
|
||||
|
||||
// Animations moves, renames and removals
|
||||
{"type": 301, "source": "/api/animate/:rest*", "destination": "/api/animations/:rest*"},
|
||||
// AnimationStateDeclarationMetadata was removed
|
||||
{"type": 301, "source": "/**/AnimationStateDeclarationMetadata*", "destination": "/api/animations"},
|
||||
// `AnimationDriver` was moved to the `animations/browser` package
|
||||
{"type": 301, "source": "/api/platform-browser/AnimationDriver", "destination": "/api/animations/browser/AnimationDriver"},
|
||||
|
||||
// The `testing` package was renamed to `core/testing`
|
||||
{"type": 301, "source": "/api/testing/:api-*", "destination": "/api/core/testing/:api"},
|
||||
|
||||
// CORE_DIRECTIVES & PLATFORM_PIPES were removed and are now in the CommonModule
|
||||
{"type": 301, "source": "/**/CORE_DIRECTIVES*", "destination": "/api/common/CommonModule"},
|
||||
{"type": 301, "source": "/**/PLATFORM_PIPES*", "destination": "/api/common/CommonModule"},
|
||||
|
||||
// DirectiveMetadata is now covered by the Directive decorator
|
||||
{"type": 301, "source": "/**/DirectiveMetadata-*", "destination": "/api/core/Directive"},
|
||||
|
||||
// OptionalMetadata is now covered by the Optional decorator
|
||||
{"type": 301, "source": "/**/OptionalMetadata-*", "destination": "/api/core/Optional"},
|
||||
|
||||
// HTTP_PROVIDERS was removed and is now provided in HttpModule
|
||||
{"type": 301, "source": "/**/HTTP_PROVIDERS*", "destination": "/api/http/HttpModule"},
|
||||
|
||||
// URLs that use the old scheme of adding the type to the end (e.g. `SomeClass-class`)
|
||||
// (Exclude disambiguated URLs that might be suffixed with `-\d+` (e.g. `SomeClass-1`))
|
||||
{"type": 301, "source": "/api/:package/:api-@(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)", "destination": "/api/:package/:api"},
|
||||
{"type": 301, "source": "/api/:package/testing/index/:api", "destination": "/api/:package/testing/:api"},
|
||||
{"type": 301, "source": "/api/:package/testing/:api-@(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)", "destination": "/api/:package/testing/:api"},
|
||||
{"type": 301, "source": "/api/upgrade/:package/index/:api", "destination": "/api/upgrade/:package/:api"},
|
||||
{"type": 301, "source": "/api/upgrade/:package/:api-@(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)", "destination": "/api/upgrade/:package/:api"},
|
||||
|
||||
// URLs that use the old scheme before we moved the docs to the angular/angular repo
|
||||
{"type": 301, "source": "/docs/*/latest", "destination": "/docs"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/", "destination": "/api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/testing/:api-*", "destination": "/api/core/testing/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/:package/:api-*", "destination": "/api/:package/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/:package/index/:api-*", "destination": "/api/:package/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/:package/testing", "destination": "/api/:package/testing"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/:package/testing/index/:api-*", "destination": "/api/:package/testing/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/platform-browser/animations/index/:api-*", "destination": "/api/platform-browser/animations/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/upgrade/:package/:api-*", "destination": "/api/upgrade/:package/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/api/upgrade/:package/index/:api-*", "destination": "/api/upgrade/:package/:api"},
|
||||
{"type": 301, "source": "/docs/*/latest/glossary", "destination": "/guide/glossary"},
|
||||
{"type": 301, "source": "/docs/*/latest/guide/", "destination": "/docs"},
|
||||
{"type": 301, "source": "/docs/*/latest/guide/lifecycle-hooks", "destination": "/guide/lifecycle-hooks"},
|
||||
{"type": 301, "source": "/docs/*/latest/:rest*", "destination": "/:rest*"},
|
||||
{"type": 301, "source": "/docs/latest/:rest*", "destination": "/:rest*"},
|
||||
{"type": 301, "source": "/docs/styleguide*", "destination": "/guide/styleguide"},
|
||||
{"type": 301, "source": "/guide/metadata", "destination": "/guide/aot-compiler"},
|
||||
{"type": 301, "source": "/guide/ngmodule", "destination": "/guide/ngmodules"},
|
||||
{"type": 301, "source": "/guide/learning-angular*", "destination": "/start"},
|
||||
{"type": 301, "source": "/testing", "destination": "/guide/testing"},
|
||||
{"type": 301, "source": "/testing/**", "destination": "/guide/testing"},
|
||||
|
||||
// Work around Firebase hosting bug with `/index.html?<query>` leading to infinite redirects.
|
||||
// See https://github.com/angular/angular/issues/42518#issuecomment-858545483 for details.
|
||||
{"type": 301, "source": "/index.html:query*", "destination": "/:query*"},
|
||||
|
||||
// Strip off the `.html` extension, because Firebase will not do this automatically any more
|
||||
// (unless the new URL points to an existing file, which is not necessarily the case here).
|
||||
{"type": 301, "source": "/:topLevelFile.html", "destination": "/:topLevelFile"},
|
||||
{"type": 301, "source": "/:somePath*/:file.html", "destination": "/:somePath*/:file"},
|
||||
|
||||
// The below paths are referenced in users projects generated by the CLI
|
||||
{"type": 301, "source": "/config/tsconfig", "destination": "/guide/typescript-configuration"},
|
||||
{"type": 301, "source": "/config/solution-tsconfig", "destination": "https://devblogs.microsoft.com/typescript/announcing-typescript-3-9/#solution-style-tsconfig"},
|
||||
{"type": 301, "source": "/strict", "destination": "/guide/strict-mode"},
|
||||
|
||||
// Use discoverable link for Angular DevTools and redirect to the guide page
|
||||
{"type": 301, "source": "/devtools", "destination": "/guide/devtools"},
|
||||
|
||||
// Use discoverable link for Angular Package Format and redirect to the guide page
|
||||
{"type": 301, "source": "/apf", "destination": "/guide/angular-package-format"},
|
||||
|
||||
// Temporarily serve the guide at `/devtools` as well as `/guide/devtools` until users have
|
||||
// their SW updated to a version that knows about the `/devtools` redirect.
|
||||
{"type": 302, "source": "/generated/docs/devtools.json", "destination": "/generated/docs/guide/devtools.json"},
|
||||
|
||||
// Analytics pages that have been deleted in v15
|
||||
{"type": 301, "source": "/cli/usage-analytics-gathering", "destination": "/cli/analytics"},
|
||||
{"type": 301, "source": "/analytics", "destination": "/cli/analytics"},
|
||||
|
||||
// Deprecated error messages
|
||||
{"type": 301, "source": "/errors/NG6999", "destination": "https://v13.angular.io/errors/NG6999"},
|
||||
|
||||
// Universal has been renamed to ssr
|
||||
{"type": 301, "source": "/guide/universal", "destination": "/guide/ssr"}
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**/!(*.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
// All URLs
|
||||
"source": "**",
|
||||
"headers": [
|
||||
// Report Trusted Types violations
|
||||
{
|
||||
"key": "Content-Security-Policy-Report-Only",
|
||||
|
||||
// The following Trusted Types policies are allowed:
|
||||
// - angular: Angular's main internal policy. Defined in the `@angular/core` package.
|
||||
// - angular#bundler: Used by Angular's bundler. Defined in the `webpack` package, enabled by `@angular-devkit/build-angular`.
|
||||
// - angular#unsafe-bypass: For bypassSecurityTrust* usage. Defined in the `@angular/core` package.
|
||||
// - aio#analytics: For the Legacy Google Analytics snippet. Defined in `index.html`.
|
||||
// - google#safe: Used by the safevalues library. Defined in the `safevalues` package.
|
||||
// - goog#html: Used by the Global Site Tag (`gtag.js`).
|
||||
|
||||
// csp.withgoogle.com is Google's CSP report collecting
|
||||
// infrastructure.
|
||||
// NOTE: Keep in sync with the header in `angular.json`.
|
||||
"value": "require-trusted-types-for 'script'; trusted-types angular angular#bundler angular#unsafe-bypass aio#analytics google#safe goog#html; report-uri https://csp.withgoogle.com/csp/angular.io"
|
||||
},
|
||||
{
|
||||
"key": "X-Frame-Options",
|
||||
"value": "DENY"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
// All paths (URLs without a file extension).
|
||||
"source": "**/!(*.*)",
|
||||
"headers": [
|
||||
{"key": "Cache-Control", "value": "no-cache"},
|
||||
{"key": "Link", "value": "</generated/navigation.json>;rel=preload;as=fetch,</generated/docs/index.json>;rel=preload;as=fetch"}
|
||||
]
|
||||
},
|
||||
{
|
||||
// Images, fonts, (non-hashed) CSS/JS files.
|
||||
"source": "**/*.@(gif|jpg|jpeg|png|svg|webp|js|css|eot|otf|ttf|ttc|woff|woff2)",
|
||||
"headers": [
|
||||
{"key": "Cache-Control", "value": "max-age=86400"} // 1 day
|
||||
]
|
||||
},
|
||||
{
|
||||
// Hashed CSS/JS files...
|
||||
"source": "**/*.+([0-9a-f]).@(css|js)",
|
||||
"headers": [
|
||||
{"key": "Cache-Control", "value": "max-age=2592000"} // 30 days
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"database": {
|
||||
"rules": "database.rules.json"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
const {getAdjustedChromeBinPathForWindows} = require('./tools/windows-chromium-path');
|
||||
|
||||
process.env.CHROME_BIN = getAdjustedChromeBinPathForWindows();
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma'),
|
||||
{'reporter:jasmine-seed': ['type', JasmineSeedReporter]},
|
||||
],
|
||||
proxies: {
|
||||
'/dummy/image': 'src/assets/images/logos/angular/angular.png',
|
||||
},
|
||||
client: {
|
||||
clearContext: false, // leave Jasmine Spec Runner output visible in browser
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
random: true,
|
||||
seed: '',
|
||||
},
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/site'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
],
|
||||
},
|
||||
reporters: ['progress', 'kjhtml', 'jasmine-seed'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
customLaunchers: {
|
||||
ChromeHeadlessNoSandbox: {
|
||||
base: 'ChromeHeadless',
|
||||
// See /integration/README.md#browser-tests for more info on these args
|
||||
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars', '--mute-audio'],
|
||||
},
|
||||
},
|
||||
browsers: ['ChromeHeadlessNoSandbox'],
|
||||
browserNoActivityTimeout: 60000,
|
||||
singleRun: false,
|
||||
restartOnFileChange: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Helpers
|
||||
function JasmineSeedReporter(baseReporterDecorator) {
|
||||
baseReporterDecorator(this);
|
||||
|
||||
this.onBrowserComplete = (browser, result) => {
|
||||
const seed = result.order && result.order.random && result.order.seed;
|
||||
if (seed) this.write(`${browser}: Randomized with seed ${seed}.\n`);
|
||||
};
|
||||
|
||||
this.onRunComplete = () => undefined;
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ def link_local_packages(all_aio_deps):
|
|||
|
||||
# Special case deps that must be testonly
|
||||
testonly_deps = [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
]
|
||||
|
||||
# Stamp a corresponding target for each AIO dep that filters out transitive
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app-shell",
|
||||
"installMode": "prefetch",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/index.html",
|
||||
"/pwa-manifest.json",
|
||||
"/assets/images/favicons/favicon.ico",
|
||||
"/assets/js/*.js",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
],
|
||||
"urls": [
|
||||
"https://fonts.googleapis.com/**",
|
||||
"https://fonts.gstatic.com/s/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets-eager",
|
||||
"installMode": "prefetch",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/images/**",
|
||||
"/generated/images/marketing/**",
|
||||
"!/assets/images/favicons/**",
|
||||
"!/**/_unused/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets-lazy",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/images/favicons/**",
|
||||
"!/**/_unused/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "docs-index",
|
||||
"installMode": "prefetch",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/generated/*.json",
|
||||
"/generated/docs/*.json",
|
||||
"/generated/docs/api/api-list.json",
|
||||
"/generated/docs/app/search-data.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "docs-lazy",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "lazy",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/generated/docs/**/*.json",
|
||||
"/generated/images/**",
|
||||
"!/**/_unused/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"navigationUrls": [
|
||||
"/**",
|
||||
"!/**/*.*",
|
||||
"!/**/*__*",
|
||||
"!/**/*__*/**",
|
||||
"!/**/stackblitz/{0,1}",
|
||||
"!/**/AnimationStateDeclarationMetadata*",
|
||||
"!/**/CORE_DIRECTIVES*",
|
||||
"!/**/DirectiveMetadata-*",
|
||||
"!/**/HTTP_PROVIDERS*",
|
||||
"!/**/NgFor-*",
|
||||
"!/**/OptionalMetadata-*",
|
||||
"!/**/PLATFORM_PIPES*",
|
||||
"!/**/api/common/Control*",
|
||||
"!/**/api/common/ControlGroup*",
|
||||
"!/**/api/common/NgModel/{0,1}",
|
||||
"!/**/api/common/SelectControlValueAccessor-*",
|
||||
"!/**/api/common/index/MaxLengthValidator-*",
|
||||
"!/**/cookbook/ts-to-js*",
|
||||
"!/analytics/{0,1}",
|
||||
"!/apf/{0,1}",
|
||||
"!/api/*/*-(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)",
|
||||
"!/api/*/testing/*-(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)",
|
||||
"!/api/*/testing/index/*",
|
||||
"!/api/animate/**",
|
||||
"!/api/api/**",
|
||||
"!/api/http/**",
|
||||
"!/api/http/{0,1}",
|
||||
"!/api/platform-browser/AnimationDriver/{0,1}",
|
||||
"!/api/testing/*-*",
|
||||
"!/api/upgrade/*/*-(class|decorator|directive|function|interface|let|pipe|type|type-alias|var)",
|
||||
"!/api/upgrade/*/index/*",
|
||||
"!/cli/usage-analytics-gathering/{0,1}",
|
||||
"!/config/solution-tsconfig/{0,1}",
|
||||
"!/config/tsconfig/{0,1}",
|
||||
"!/devtools/{0,1}",
|
||||
"!/docs/*/latest/**",
|
||||
"!/docs/*/latest/{0,1}",
|
||||
"!/docs/latest/**",
|
||||
"!/docs/styleguide*",
|
||||
"!/docs/styleguide/{0,1}",
|
||||
"!/getting-started/**",
|
||||
"!/getting-started/{0,1}",
|
||||
"!/guide/bazel/{0,1}",
|
||||
"!/guide/change-log/{0,1}",
|
||||
"!/guide/cli-quickstart/{0,1}",
|
||||
"!/guide/displaying-data/{0,1}",
|
||||
"!/guide/i18n/{0,1}",
|
||||
"!/guide/ivy/{0,1}",
|
||||
"!/guide/learning-angular*",
|
||||
"!/guide/metadata/{0,1}",
|
||||
"!/guide/ngmodule/{0,1}",
|
||||
"!/guide/quickstart/{0,1}",
|
||||
"!/guide/service-worker-comm/{0,1}",
|
||||
"!/guide/service-worker-configref/{0,1}",
|
||||
"!/guide/service-worker-getstart/{0,1}",
|
||||
"!/guide/set-document-title/{0,1}",
|
||||
"!/guide/setup-systemjs-anatomy/{0,1}",
|
||||
"!/guide/setup/{0,1}",
|
||||
"!/guide/update-to-latest-version/{0,1}",
|
||||
"!/guide/updating-to-version-10/{0,1}",
|
||||
"!/guide/updating-to-version-11/{0,1}",
|
||||
"!/guide/updating-to-version-12/{0,1}",
|
||||
"!/guide/updating-to-version-13/{0,1}",
|
||||
"!/guide/webpack/{0,1}",
|
||||
"!/guide/{0,1}",
|
||||
"!/news*",
|
||||
"!/start/data/{0,1}",
|
||||
"!/start/deployment/{0,1}",
|
||||
"!/start/forms/{0,1}",
|
||||
"!/start/routing/{0,1}",
|
||||
"!/strict/{0,1}",
|
||||
"!/styleguide/{0,1}",
|
||||
"!/testing/**",
|
||||
"!/testing/{0,1}"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
"name": "app-shell",
|
||||
"installMode": "prefetch",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/index.html",
|
||||
"/pwa-manifest.json",
|
||||
"/assets/images/favicons/favicon.ico",
|
||||
"/assets/js/*.js",
|
||||
"/*.css",
|
||||
"/*.js"
|
||||
],
|
||||
"urls": [
|
||||
"https://fonts.googleapis.com/**",
|
||||
"https://fonts.gstatic.com/s/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets-eager",
|
||||
"installMode": "prefetch",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/images/**",
|
||||
"/generated/images/marketing/**",
|
||||
"!/assets/images/favicons/**",
|
||||
"!/**/_unused/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assets-lazy",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/assets/images/favicons/**",
|
||||
"!/**/_unused/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "docs-index",
|
||||
"installMode": "prefetch",
|
||||
"updateMode": "prefetch",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/generated/*.json",
|
||||
"/generated/docs/*.json",
|
||||
"/generated/docs/api/api-list.json",
|
||||
"/generated/docs/app/search-data.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "docs-lazy",
|
||||
"installMode": "lazy",
|
||||
"updateMode": "lazy",
|
||||
"resources": {
|
||||
"files": [
|
||||
"/generated/docs/**/*.json",
|
||||
"/generated/images/**",
|
||||
"!/**/_unused/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"navigationUrls": [
|
||||
"/**",
|
||||
"!/**/*.*",
|
||||
"!/**/*__*",
|
||||
"!/**/*__*/**",
|
||||
"!/**/stackblitz/{0,1}"
|
||||
]
|
||||
}
|
||||
171
aio/package.json
171
aio/package.json
|
|
@ -1,171 +0,0 @@
|
|||
{
|
||||
"name": "angular.io",
|
||||
"version": "0.0.0",
|
||||
"main": "index.js",
|
||||
"repository": "git@github.com:angular/angular.git",
|
||||
"author": "Angular",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"postinstall": "node tools/cli-patches/patch.js && patch-package --patch-dir ../tools/npm-patches/",
|
||||
"prebuild": "yarn setup",
|
||||
"build": "bazel build //aio:build",
|
||||
"build-prod": "yarn build --config=release",
|
||||
"build-local": "yarn build --config=aio_local_deps",
|
||||
"lint": "yarn check-env && yarn docs-lint && ng lint && yarn example-lint && yarn tools-lint && yarn security-lint",
|
||||
"test": "bazel test //aio:test --test_output=streamed --test_tag_filters=-broken",
|
||||
"test-and-watch": "ibazel test //aio:test-and-watch --test_output=streamed --test_tag_filters=-broken",
|
||||
"test-local": "yarn test --config=aio_local_deps",
|
||||
"test:ci": "bazel test //aio/... --test_tag_filters=-broken",
|
||||
"test-local:ci": "yarn test:ci --config=aio_local_deps",
|
||||
"e2e": "bazel test //aio:e2e",
|
||||
"e2e-local": "yarn e2e --config=aio_local_deps",
|
||||
"setup": "yarn --cwd .. install && yarn install --frozen-lockfile && yarn ~~check-env",
|
||||
"set-opensearch-url": "node --eval \"const sh = require('shelljs'); sh.set('-e'); sh.sed('-i', /PLACEHOLDER_URL/g, process.argv[1], '../dist/bin/aio/build/assets/opensearch.xml');\"",
|
||||
"smoke-tests": "protractor tests/deployment/e2e/protractor.conf.js --suite smoke --baseUrl",
|
||||
"test-a11y-score": "bazel run //aio/scripts:test-aio-a11y",
|
||||
"test-a11y-score-localhost": "bazel test //aio:test-a11y-score-localhost",
|
||||
"test-pwa-score": "sh -c 'bazel run //aio/scripts:audit-web-app ${0} all:0,pwa:${1}'",
|
||||
"test-pwa-score-localhost": "bazel test //aio:test-pwa-score-localhost",
|
||||
"test-production-url": "bazel test //aio/tests/deployment/e2e",
|
||||
"example-e2e": "node --experimental-import-meta-resolve tools/examples/run-filtered-example-e2es.mjs",
|
||||
"example-list-overrides": "bazel run //aio/tools/examples:example-boilerplate list-overrides",
|
||||
"example-lint": "eslint content/examples",
|
||||
"example-playground": "node ./tools/examples/create-example-playground-wrapper.mjs",
|
||||
"deploy-production": "node ./scripts/deploy-to-firebase/index.mjs",
|
||||
"check-env": "yarn ~~check-env",
|
||||
"payload-size": "scripts/payload.sh",
|
||||
"predocs": "node scripts/contributors/validate-data && bazel build -- //aio:stackblitz //aio:example-zips",
|
||||
"docs-watch": "bazel run //aio:docs-watch --config=release",
|
||||
"docs": "bazel build //aio:dgeni --config=release",
|
||||
"docs-lint": "eslint --ignore-path=\"tools/transforms/.eslintignore\" tools/transforms",
|
||||
"docs-test": "bazel test //aio/tools/transforms/...",
|
||||
"redirects-test": "bazel test //aio/tests/deployment/unit:test",
|
||||
"firebase-utils-test": "bazel test //aio/tools/firebase-test-utils:test",
|
||||
"tools-lint": "eslint --ext=.mjs scripts/deploy-to-firebase && eslint tools/firebase-test-utils",
|
||||
"tools-test": "bazel test -- //aio/tools/... //aio/scripts/deploy-to-firebase:test",
|
||||
"start": "ibazel run //aio:serve --config=release",
|
||||
"start-local": "yarn start --config=aio_local_deps",
|
||||
"boilerplate:test": "bazel test //aio/tools/examples:example-boilerplate-test",
|
||||
"create-example": "bazel run //aio/tools/examples:create-example",
|
||||
"security-lint": "tsec -p tsconfig.app.json",
|
||||
"~~audit-web-app": "node scripts/audit-web-app.mjs",
|
||||
"~~check-env": "node scripts/check-environment",
|
||||
"~~light-server": "light-server --bind=localhost --historyindex=/index.html --no-reload",
|
||||
"local-workspace-status": "node scripts/local-workspace-status.mjs"
|
||||
},
|
||||
"//engines-comment": "If applicable, also update /package.json and /aio/tools/examples/shared/package.json",
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"yarn": ">=1.22.4 <2",
|
||||
"npm": "Please use yarn instead of NPM to install dependencies"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "17.3.0-rc.0",
|
||||
"@angular/cdk": "17.3.0-rc.0",
|
||||
"@angular/common": "17.3.0-rc.0",
|
||||
"@angular/compiler": "17.3.0-rc.0",
|
||||
"@angular/core": "17.3.0-rc.0",
|
||||
"@angular/elements": "17.3.0-rc.0",
|
||||
"@angular/forms": "17.3.0-rc.0",
|
||||
"@angular/material": "17.3.0-rc.0",
|
||||
"@angular/platform-browser": "17.3.0-rc.0",
|
||||
"@angular/platform-browser-dynamic": "17.3.0-rc.0",
|
||||
"@angular/router": "17.3.0-rc.0",
|
||||
"@angular/service-worker": "17.3.0-rc.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"safevalues": "^0.5.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/architect-cli": "0.1702.0-rc.0",
|
||||
"@angular-devkit/build-angular": "17.2.0-rc.0",
|
||||
"@angular-eslint/builder": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
||||
"@angular-eslint/template-parser": "^17.0.0",
|
||||
"@angular/build-tooling": "https://github.com/angular/dev-infra-private-build-tooling-builds.git#7c4cf003cb4ac849986beaa243d7e85a893612f2",
|
||||
"@angular/cli": "17.2.0-rc.0",
|
||||
"@angular/compiler-cli": "17.2.0-rc.1",
|
||||
"@bazel/bazelisk": "^1.7.5",
|
||||
"@bazel/buildozer": "^6.0.0",
|
||||
"@bazel/ibazel": "^0.16.2",
|
||||
"@bazel/jasmine": "^5.4.1",
|
||||
"@bazel/runfiles": "5.8.1",
|
||||
"@bazel/typescript": "5.8.1",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/lunr": "^2.3.3",
|
||||
"@types/node": "^12.7.9",
|
||||
"@types/trusted-types": "^2.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "7.4.0",
|
||||
"@typescript-eslint/parser": "7.4.0",
|
||||
"archiver": "^7.0.0",
|
||||
"assert": "^2.0.0",
|
||||
"canonical-path": "1.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"cjson": "^0.5.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"css-selector-parser": "^3.0.0",
|
||||
"dgeni": "^0.4.14",
|
||||
"dgeni-packages": "^0.30.0",
|
||||
"entities": "^4.0.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jasmine": "4.1.3",
|
||||
"eslint-plugin-jsdoc": "48.2.1",
|
||||
"eslint-plugin-prefer-arrow": "1.2.3",
|
||||
"find-free-port": "^2.0.0",
|
||||
"firebase-tools": "^13.0.0",
|
||||
"fs-extra": "^11.0.0",
|
||||
"get-port": "^7.0.0",
|
||||
"globby": "^14.0.0",
|
||||
"hast-util-has-property": "^1.0.4",
|
||||
"hast-util-is-element": "^1.1.0",
|
||||
"hast-util-to-string": "^1.0.4",
|
||||
"html": "^1.0.0",
|
||||
"ignore": "^5.1.8",
|
||||
"image-size": "^1.0.0",
|
||||
"jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"jasmine-spec-reporter": "~7.0.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"json5": "^2.2.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"light-server": "^2.9.1",
|
||||
"lighthouse": "^11.0.0",
|
||||
"lighthouse-logger": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lunr": "^2.3.9",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"patch-package": "^7.0.0",
|
||||
"protractor": "~7.0.0",
|
||||
"puppeteer-core": "22.6.1",
|
||||
"rehype-slug": "^4.0.1",
|
||||
"remark": "^12.0.0",
|
||||
"remark-html": "^13.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"shelljs": "^0.8.5",
|
||||
"source-map-support": "0.5.21",
|
||||
"stemmer": "^2.0.0",
|
||||
"tree-kill": "^1.1.0",
|
||||
"ts-node": "^10.8.1",
|
||||
"tsec": "^0.2.2",
|
||||
"tslint": "6.1.3",
|
||||
"typescript": "~5.0.2",
|
||||
"uglify-js": "^3.13.3",
|
||||
"unist-util-filter": "^2.0.3",
|
||||
"unist-util-source": "^3.0.0",
|
||||
"unist-util-visit": "^2.0.3",
|
||||
"unist-util-visit-parents": "^3.1.1",
|
||||
"watchr": "^3.0.1",
|
||||
"xregexp": "^5.0.2",
|
||||
"yargs": "^17.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
|
||||
load("@aio_npm//@angular/build-tooling/bazel:filter_outputs.bzl", "filter_first_output")
|
||||
load("@npm//@angular/build-tooling/bazel:filter_outputs.bzl", "filter_first_output")
|
||||
load("//tools:defaults.bzl", "nodejs_binary", "nodejs_test")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
|
@ -13,8 +13,8 @@ nodejs_binary(
|
|||
data = [
|
||||
"//aio:firebase.json",
|
||||
"//aio:ngsw-config.template.json",
|
||||
"@aio_npm//canonical-path",
|
||||
"@aio_npm//json5",
|
||||
"@npm//canonical-path",
|
||||
"@npm//json5",
|
||||
],
|
||||
entry_point = "build-ngsw-config.js",
|
||||
)
|
||||
|
|
@ -26,7 +26,7 @@ js_library(
|
|||
],
|
||||
deps = [
|
||||
"//aio/tools/transforms/authors-package:watchdocs",
|
||||
"@aio_npm//@angular-devkit/architect-cli",
|
||||
"@npm//@angular-devkit/architect-cli",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -40,17 +40,17 @@ nodejs_binary(
|
|||
testonly = True,
|
||||
data = [
|
||||
"//aio/tools:windows-chromium-path",
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@aio_npm//lighthouse",
|
||||
"@aio_npm//lighthouse-logger",
|
||||
"@aio_npm//puppeteer-core",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@npm//lighthouse",
|
||||
"@npm//lighthouse-logger",
|
||||
"@npm//puppeteer-core",
|
||||
],
|
||||
entry_point = "audit-web-app.mjs",
|
||||
env = {
|
||||
"CHROME_BIN": "$(CHROMIUM)",
|
||||
},
|
||||
toolchains = [
|
||||
"@aio_npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ nodejs_test(
|
|||
data = [
|
||||
":audit-web-app",
|
||||
":audit-web-app-script",
|
||||
"@aio_npm//shelljs",
|
||||
"@npm//shelljs",
|
||||
],
|
||||
entry_point = "test-aio-a11y.mjs",
|
||||
env = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
load("@aio_npm//@bazel/jasmine:index.bzl", "jasmine_node_test")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test")
|
||||
load("@npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
|
||||
DEPLOY_TO_FIREBASE_SOURCES = glob(
|
||||
["**/*.mjs"],
|
||||
|
|
@ -7,7 +7,7 @@ DEPLOY_TO_FIREBASE_SOURCES = glob(
|
|||
)
|
||||
|
||||
DEPLOY_TO_FIREBASE_DEPS = [
|
||||
"@aio_npm//shelljs",
|
||||
"@npm//shelljs",
|
||||
"//aio:build",
|
||||
"//:package.json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -16,16 +16,16 @@ def local_server_test(name, entry_point, serve_target, data = [], args = [], **k
|
|||
name = name,
|
||||
testonly = True,
|
||||
args = [
|
||||
"$(rootpath @aio_npm//light-server/bin:light-server)",
|
||||
"$(rootpath @npm//light-server/bin:light-server)",
|
||||
"$(rootpath %s)" % serve_target,
|
||||
"$(rootpath %s)" % entry_point,
|
||||
] + args,
|
||||
data = [
|
||||
"//aio/scripts:run-with-local-server.mjs",
|
||||
"@aio_npm//get-port",
|
||||
"@aio_npm//shelljs",
|
||||
"@aio_npm//tree-kill",
|
||||
"@aio_npm//light-server/bin:light-server",
|
||||
"@npm//get-port",
|
||||
"@npm//shelljs",
|
||||
"@npm//tree-kill",
|
||||
"@npm//light-server/bin:light-server",
|
||||
serve_target,
|
||||
entry_point,
|
||||
] + data,
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"ban-reviewed-conversions": [
|
||||
"src/app/custom-elements/code/code.component.ts",
|
||||
"src/app/custom-elements/code/pretty-printer.service.ts",
|
||||
"src/app/documents/document.service.ts",
|
||||
"src/app/shared/security.ts"
|
||||
],
|
||||
"ban-worker-calls": [
|
||||
"src/app/search/search.service.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<!--
|
||||
This content replaces the `<body>` content of `index.html` to generate our custom `404.html` page.
|
||||
The content must visually and structurally resemble the resulting HTML of the main app for not
|
||||
found pages (e.g. https://angular.io/not/exist.ing).
|
||||
-->
|
||||
|
||||
<style>
|
||||
.mat-toolbar-row{display:flex;box-sizing:border-box;padding:0 16px;width:100%;flex-direction:row;align-items:center;white-space:nowrap}
|
||||
.mat-toolbar-row{height:64px}
|
||||
@media (max-width:600px){.mat-toolbar-row{height:56px}}
|
||||
.nav-link.home{margin-left:24px}
|
||||
</style>
|
||||
|
||||
<aio-shell class="mode-stable page-file-not-found folder-file-not-found view- aio-notification-hide">
|
||||
|
||||
<mat-toolbar class="app-toolbar no-print mat-toolbar mat-primary">
|
||||
<mat-toolbar-row class="mat-toolbar-row">
|
||||
<a class="nav-link home" href="/">
|
||||
<img alt="Home" height="40" src="assets/images/logos/angular/logo-nav@2x.png" title="Home" width="150">
|
||||
</a>
|
||||
</mat-toolbar-row>
|
||||
</mat-toolbar>
|
||||
|
||||
<mat-sidenav-container class="sidenav-container mat-drawer-container mat-sidenav-container" role="main">
|
||||
<mat-sidenav-content class="mat-drawer-content mat-sidenav-content">
|
||||
<section class="sidenav-content" role="article">
|
||||
<aio-doc-viewer>
|
||||
<div class="content">
|
||||
<div class="nf-container l-flex-wrap flex-center">
|
||||
<img src="assets/images/support/angular-404.svg" width="300" height="300" alt="not found"/>
|
||||
<div class="nf-response l-flex-wrap center">
|
||||
<h1 class="no-toc" id="page-not-found">Resource Not Found</h1>
|
||||
<p>We're sorry. The resource you are looking for cannot be found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aio-doc-viewer>
|
||||
</section>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
|
||||
<footer class="no-print">
|
||||
<aio-footer>
|
||||
<p>
|
||||
Powered by Google ©2010-2022.
|
||||
Code licensed under an <a href="license" title="License text">MIT-style License</a>.
|
||||
Documentation licensed under <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.
|
||||
</p>
|
||||
</aio-footer>
|
||||
</footer>
|
||||
|
||||
</aio-shell>
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<div id="top-of-page"></div>
|
||||
|
||||
<aio-cookies-popup></aio-cookies-popup>
|
||||
|
||||
<a class="skip-to-content-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<div *ngIf="isFetching" class="progress-bar-container">
|
||||
<mat-progress-bar mode="indeterminate" color="warn"></mat-progress-bar>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<mat-toolbar #appToolbar color="primary" class="app-toolbar no-print" [class.transitioning]="isTransitioning">
|
||||
<mat-toolbar-row class="notification-container">
|
||||
<aio-notification notificationId="state-of-js-2023" expirationDate="2023-12-12" [dismissOnContentClick]="true" (dismissed)="notificationDismissed()">
|
||||
<a href="https://survey.devographics.com/survey/state-of-js/2023?source=angular_homepage">
|
||||
<mat-icon class="icon" svgIcon="insert_comment" aria-label="Announcement"></mat-icon>
|
||||
<span class="message">Share your experience with Angular in <b>The State of JavaScript</b></span>
|
||||
<span class="action-button">Go to survey</span>
|
||||
</a>
|
||||
</aio-notification>
|
||||
</mat-toolbar-row>
|
||||
<mat-toolbar-row>
|
||||
<button mat-button class="hamburger" [class.no-animations]="disableAnimations" (click)="sidenav.toggle()" title="Docs menu">
|
||||
<mat-icon svgIcon="menu"></mat-icon>
|
||||
</button>
|
||||
<a class="nav-link home" href="/" [ngSwitch]="showTopMenu">
|
||||
<img *ngSwitchCase="true" src="assets/images/logos/angular/logo-nav@2x.png" width="150" height="40" title="Home" alt="Home">
|
||||
<img *ngSwitchDefault src="assets/images/logos/angular/shield-large.svg" width="37" height="40" title="Home" alt="Home">
|
||||
</a>
|
||||
<aio-top-menu *ngIf="showTopMenu" [nodes]="topMenuNodes" [currentNode]="currentNodes.TopBar"></aio-top-menu>
|
||||
<aio-search-box class="search-container" #searchBox (onSearch)="doSearch($event)" (onFocus)="doSearch($event, true)"></aio-search-box>
|
||||
<aio-theme-toggle #themeToggle></aio-theme-toggle>
|
||||
<div #externalIcons class="toolbar-external-icons-container">
|
||||
<a mat-icon-button href="https://twitter.com/angular" title="Twitter" aria-label="Angular on Twitter">
|
||||
<mat-icon svgIcon="logos:twitter"></mat-icon>
|
||||
</a>
|
||||
<a mat-icon-button href="https://github.com/angular/angular" title="GitHub" aria-label="Angular on GitHub">
|
||||
<mat-icon svgIcon="logos:github"></mat-icon>
|
||||
</a>
|
||||
<a mat-icon-button href="https://youtube.com/angular" title="YouTube" aria-label="Angular on YouTube">
|
||||
<mat-icon svgIcon="logos:youtube"></mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</mat-toolbar-row>
|
||||
</mat-toolbar>
|
||||
</header>
|
||||
|
||||
<aio-search-results *ngIf="showSearchResults"
|
||||
#searchResultsView
|
||||
[searchResults]="searchResults | async"
|
||||
(resultSelected)="hideSearchResults()"
|
||||
(closeButtonClick)="hideSearchResults()">
|
||||
</aio-search-results>
|
||||
|
||||
<mat-sidenav-container class="sidenav-container" [class.no-animations]="disableAnimations" [class.has-floating-toc]="hasFloatingToc">
|
||||
|
||||
<mat-sidenav [class.collapsed]="!dockSideNav" #sidenav class="sidenav" [mode]="mode" [opened]="isOpened" (openedChange)="updateHostClasses()">
|
||||
<aio-nav-menu *ngIf="!showTopMenu" [nodes]="topMenuNarrowNodes" [currentNode]="currentNodes.TopBarNarrow" [isWide]="dockSideNav" navLabel="primary"></aio-nav-menu>
|
||||
<aio-nav-menu [nodes]="sideNavNodes" [currentNode]="currentNodes.SideNav" [isWide]="dockSideNav" navLabel="guides and docs"></aio-nav-menu>
|
||||
|
||||
<div class="doc-version">
|
||||
<aio-nav-menu [nodes]="docVersions" [isWide]="true" [currentNode]="currentDocsVersionNode" navLabel="docs versions"></aio-nav-menu>
|
||||
</div>
|
||||
</mat-sidenav>
|
||||
|
||||
<section class="sidenav-content-container">
|
||||
|
||||
<main class="sidenav-content" [id]="pageId">
|
||||
<div id="main-content" tabindex="-1"></div>
|
||||
<aio-mode-banner *ngIf="versionInfo" [mode]="deployment.mode" [version]="versionInfo"></aio-mode-banner>
|
||||
<aio-doc-viewer [class.no-animations]="disableAnimations" [doc]="currentDocument" (docReady)="onDocReady()" (docRemoved)="onDocRemoved()" (docInserted)="onDocInserted()" (docRendered)="onDocRendered()">
|
||||
</aio-doc-viewer>
|
||||
<aio-dt *ngIf="dtOn" [(doc)]="currentDocument"></aio-dt>
|
||||
</main>
|
||||
|
||||
<div *ngIf="hasFloatingToc" class="toc-container no-print" [style.max-height.px]="tocMaxHeight" (wheel)="restrainScrolling($event)">
|
||||
<aio-lazy-ce selector="aio-toc"></aio-lazy-ce>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<footer class="no-print">
|
||||
<aio-footer [nodes]="footerNodes" [versionInfo]="versionInfo"></aio-footer>
|
||||
</footer>
|
||||
|
||||
</mat-sidenav-container>
|
||||
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,480 +0,0 @@
|
|||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
OnInit,
|
||||
QueryList,
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
} from '@angular/core';
|
||||
import { MatSidenav } from '@angular/material/sidenav';
|
||||
import { DocumentContents, DocumentService } from 'app/documents/document.service';
|
||||
import { NotificationComponent } from 'app/layout/notification/notification.component';
|
||||
import { CurrentNodes, NavigationNode, NavigationService, VersionInfo } from 'app/navigation/navigation.service';
|
||||
import { SearchResults } from 'app/search/interfaces';
|
||||
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
|
||||
import { SearchService } from 'app/search/search.service';
|
||||
import { Deployment } from 'app/shared/deployment.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { ScrollService } from 'app/shared/scroll.service';
|
||||
import { TocService } from 'app/shared/toc.service';
|
||||
import { SwUpdatesService } from 'app/sw-updates/sw-updates.service';
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
|
||||
const sideNavView = 'SideNav';
|
||||
export const showTopMenuWidth = 1150;
|
||||
export const dockSideNavWidth = 992;
|
||||
export const showFloatingTocWidth = 800;
|
||||
|
||||
@Component({
|
||||
selector: 'aio-shell',
|
||||
templateUrl: './app.component.html',
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
static reducedMotion = window.matchMedia('(prefers-reduced-motion)').matches;
|
||||
|
||||
// Disable all Angular animations if the user prefers reduced motion or for the initial render.
|
||||
@HostBinding('@.disabled')
|
||||
get disableAnimations(): boolean { return AppComponent.reducedMotion || this.isStarting; }
|
||||
|
||||
currentDocument: DocumentContents;
|
||||
currentDocVersion: NavigationNode;
|
||||
currentNodes: CurrentNodes = {};
|
||||
currentPath: string;
|
||||
docVersions: NavigationNode[];
|
||||
dtOn = false;
|
||||
footerNodes: NavigationNode[];
|
||||
|
||||
/**
|
||||
* An HTML friendly identifier for the currently displayed page.
|
||||
* This is computed from the `currentDocument.id` by replacing `/` with `-`
|
||||
*/
|
||||
pageId: string;
|
||||
/**
|
||||
* An HTML friendly identifier for the "folder" of the currently displayed page.
|
||||
* This is computed by taking everything up to the first `/` in the `currentDocument.id`
|
||||
*/
|
||||
folderId: string;
|
||||
/**
|
||||
* These CSS classes are computed from the current state of the application
|
||||
* (e.g. what document is being viewed) to allow for fine grain control over
|
||||
* the styling of individual pages.
|
||||
* You will get three classes:
|
||||
*
|
||||
* * `page-...`: computed from the current document id (e.g. events, guide-security, tutorial-toh-pt2)
|
||||
* * `folder-...`: computed from the top level folder for an id (e.g. guide, tutorial, etc)
|
||||
* * `view-...`: computed from the navigation view (e.g. SideNav, TopBar, etc)
|
||||
*/
|
||||
@HostBinding('class')
|
||||
hostClasses = '';
|
||||
|
||||
private isStarting = true;
|
||||
isTransitioning = true;
|
||||
isFetching = false;
|
||||
showTopMenu = false;
|
||||
dockSideNav = false;
|
||||
private isFetchingTimeout: any;
|
||||
private isSideNavDoc = false;
|
||||
|
||||
sideNavNodes: NavigationNode[];
|
||||
topMenuNodes: NavigationNode[];
|
||||
topMenuNarrowNodes: NavigationNode[];
|
||||
|
||||
hasFloatingToc = false;
|
||||
private showFloatingToc = new BehaviorSubject(false);
|
||||
tocMaxHeight: string;
|
||||
private tocMaxHeightOffset = 0;
|
||||
|
||||
currentDocsVersionNode?: NavigationNode;
|
||||
|
||||
versionInfo: VersionInfo | undefined;
|
||||
|
||||
get isOpened() { return this.dockSideNav && this.isSideNavDoc; }
|
||||
get mode() { return this.isOpened ? 'side' : 'over'; }
|
||||
|
||||
// Search related properties
|
||||
showSearchResults = false;
|
||||
searchResults: Observable<SearchResults>;
|
||||
@ViewChildren('searchBox, searchResultsView', { read: ElementRef })
|
||||
searchElements: QueryList<ElementRef>;
|
||||
@ViewChild(SearchBoxComponent, { static: true })
|
||||
searchBox: SearchBoxComponent;
|
||||
@ViewChild('searchResultsView', { read: ElementRef })
|
||||
searchResultsView: ElementRef;
|
||||
|
||||
@ViewChild(MatSidenav, { static: true })
|
||||
sidenav: MatSidenav;
|
||||
|
||||
@ViewChild(NotificationComponent, { static: true })
|
||||
notification: NotificationComponent;
|
||||
notificationAnimating = false;
|
||||
|
||||
@ViewChild('appToolbar', { read: ElementRef }) toolbar: ElementRef;
|
||||
|
||||
@ViewChildren('themeToggle, externalIcons', { read: ElementRef }) toolbarIcons: QueryList<ElementRef>;
|
||||
|
||||
constructor(
|
||||
public deployment: Deployment,
|
||||
private documentService: DocumentService,
|
||||
private hostElement: ElementRef,
|
||||
private locationService: LocationService,
|
||||
private navigationService: NavigationService,
|
||||
private scrollService: ScrollService,
|
||||
private searchService: SearchService,
|
||||
private swUpdatesService: SwUpdatesService,
|
||||
private tocService: TocService
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
// Do not initialize the search on browsers that lack web worker support
|
||||
if ('Worker' in window) {
|
||||
// Delay initialization by up to 2 seconds
|
||||
this.searchService.initWorker(2000);
|
||||
}
|
||||
|
||||
this.onResize(window.innerWidth);
|
||||
|
||||
/* No need to unsubscribe because this root component never dies */
|
||||
|
||||
this.documentService.currentDocument.subscribe(doc => this.currentDocument = doc);
|
||||
|
||||
this.locationService.currentPath.subscribe(path => {
|
||||
if (path === this.currentPath) {
|
||||
// scroll only if on same page (most likely a change to the hash)
|
||||
this.scrollService.scroll();
|
||||
} else {
|
||||
// don't scroll; leave that to `onDocRendered`
|
||||
this.currentPath = path;
|
||||
|
||||
// Start progress bar if doc not rendered within brief time
|
||||
clearTimeout(this.isFetchingTimeout);
|
||||
this.isFetchingTimeout = setTimeout(() => this.isFetching = true, 200);
|
||||
}
|
||||
});
|
||||
|
||||
this.navigationService.currentNodes.subscribe(currentNodes => {
|
||||
this.currentNodes = currentNodes;
|
||||
|
||||
// Redirect to docs if we are in archive mode and are not hitting a docs page
|
||||
// (i.e. we have arrived at a marketing page)
|
||||
if (this.deployment.mode === 'archive' && !currentNodes[sideNavView]) {
|
||||
this.locationService.replace('docs');
|
||||
}
|
||||
});
|
||||
|
||||
// Compute the version picker list from the current version and the versions in the navigation map
|
||||
combineLatest([
|
||||
this.navigationService.versionInfo,
|
||||
this.navigationService.navigationViews.pipe(map(views => views.docVersions)),
|
||||
this.locationService.currentUrl,
|
||||
]).subscribe(([versionInfo, versions, currentUrl]) => {
|
||||
// TODO(pbd): consider whether we can lookup the stable and next versions from the internet
|
||||
const computedVersions: NavigationNode[] = [
|
||||
{ title: 'next', url: 'https://next.angular.io/' },
|
||||
{ title: 'rc', url: 'https://rc.angular.io/' },
|
||||
{ title: 'stable', url: 'https://angular.io/' },
|
||||
];
|
||||
if (this.deployment.mode === 'archive') {
|
||||
computedVersions.push({ title: `v${versionInfo.major}` });
|
||||
}
|
||||
const allDocsVersionNodes = [...computedVersions, ...versions].map(version => ({
|
||||
...version,
|
||||
// Update the urls so that they point to the same page the user is currently at
|
||||
url: `${version.url}${(version.url?.endsWith('/') ? '' : '/' )}${currentUrl}`,
|
||||
}));
|
||||
// Find the current version - either title matches the current deployment mode
|
||||
// or its title matches the major version of the current version info
|
||||
this.currentDocsVersionNode = allDocsVersionNodes.find(
|
||||
version => version.title === this.deployment.mode || version.title === `v${versionInfo.major}`
|
||||
);
|
||||
this.docVersions = [
|
||||
{
|
||||
title: 'Docs Versions',
|
||||
children : allDocsVersionNodes
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
this.navigationService.navigationViews.subscribe(views => {
|
||||
this.footerNodes = views.Footer || [];
|
||||
this.sideNavNodes = views.SideNav || [];
|
||||
this.topMenuNodes = views.TopBar || [];
|
||||
this.topMenuNarrowNodes = views.TopBarNarrow || this.topMenuNodes;
|
||||
});
|
||||
|
||||
this.navigationService.versionInfo.subscribe(vi => this.versionInfo = vi);
|
||||
|
||||
const hasNonEmptyToc = this.tocService.tocList.pipe(map(tocList => tocList.length > 0));
|
||||
combineLatest([hasNonEmptyToc, this.showFloatingToc])
|
||||
.subscribe(([hasToc, showFloatingToc]) => this.hasFloatingToc = hasToc && showFloatingToc);
|
||||
|
||||
// Generally, we want to delay updating the shell (e.g. host classes, sidenav state) for the new
|
||||
// document, until after the leaving document has been removed (to avoid having the styles for
|
||||
// the new document applied prematurely).
|
||||
// For the first document, though, (when we know there is no previous document), we want to
|
||||
// ensure the styles are applied as soon as possible to avoid flicker.
|
||||
combineLatest([
|
||||
this.documentService.currentDocument, // ...needed to determine host classes
|
||||
this.navigationService.currentNodes, // ...needed to determine `sidenav` state
|
||||
]).pipe(first())
|
||||
.subscribe(() => this.updateShell());
|
||||
|
||||
// Start listening for SW version update events.
|
||||
this.swUpdatesService.enable();
|
||||
}
|
||||
|
||||
onDocReady() {
|
||||
// About to transition to new view.
|
||||
this.isTransitioning = true;
|
||||
|
||||
// Stop fetching timeout (which, when render is fast, means progress bar never shown)
|
||||
clearTimeout(this.isFetchingTimeout);
|
||||
|
||||
// If progress bar has been shown, keep it for at least 500ms (to avoid flashing).
|
||||
setTimeout(() => this.isFetching = false, 500);
|
||||
}
|
||||
|
||||
onDocRemoved() {
|
||||
this.scrollService.removeStoredScrollInfo();
|
||||
}
|
||||
|
||||
onDocInserted() {
|
||||
// Update the shell (host classes, sidenav state) to match the new document.
|
||||
// This may be called as a result of actions initiated by view updates.
|
||||
// In order to avoid errors (e.g. `ExpressionChangedAfterItHasBeenChecked`), updating the view
|
||||
// (e.g. sidenav, host classes) needs to happen asynchronously.
|
||||
setTimeout(() => this.updateShell());
|
||||
|
||||
// Scroll the good position depending on the context
|
||||
this.scrollService.scrollAfterRender(500);
|
||||
}
|
||||
|
||||
onDocRendered() {
|
||||
if (this.isStarting) {
|
||||
// In order to ensure that the initial sidenav-content left margin
|
||||
// adjustment happens without animation, we need to ensure that
|
||||
// `isStarting` remains `true` until the margin change is triggered.
|
||||
// (Apparently, this happens with a slight delay.)
|
||||
setTimeout(() => this.isStarting = false, 100);
|
||||
}
|
||||
|
||||
this.isTransitioning = false;
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event.target.innerWidth'])
|
||||
onResize(width: number) {
|
||||
this.showTopMenu = width >= showTopMenuWidth;
|
||||
this.dockSideNav = width >= dockSideNavWidth;
|
||||
this.showFloatingToc.next(width > showFloatingTocWidth);
|
||||
|
||||
if (this.showTopMenu && !this.isSideNavDoc) {
|
||||
// If this is a non-sidenav doc and the screen is wide enough so that we can display menu
|
||||
// items in the top-bar, ensure the sidenav is closed.
|
||||
// (This condition can only be met when the resize event changes the value of `showTopMenu`
|
||||
// from `false` to `true` while on a non-sidenav doc.)
|
||||
this.sidenav.toggle(false);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('focusin', ['$event.target'])
|
||||
onFocus(eventTarget: HTMLElement) {
|
||||
// Implement a focus trap starting at the input search and ending after the search results
|
||||
if (this.showSearchResults) {
|
||||
const insideFocusLoop = [
|
||||
...this.toolbarIcons,
|
||||
...this.searchElements
|
||||
].some(element => element.nativeElement.contains(eventTarget));
|
||||
const insideToolbar = this.toolbar.nativeElement.contains(eventTarget);
|
||||
if (!insideFocusLoop) {
|
||||
if (!insideToolbar) {
|
||||
// the user is focusing forward at the last search result element,
|
||||
// loop it back to the search input
|
||||
this.focusSearchBox();
|
||||
} else {
|
||||
// the user is focusing backward from the search input,
|
||||
// loop it back to the results' close button
|
||||
const closeBtn: HTMLButtonElement =
|
||||
this.searchResultsView.nativeElement.querySelector('button.close-button');
|
||||
closeBtn.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
||||
onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean {
|
||||
// Hide the search results if we clicked outside both the "search box" and the "search results"
|
||||
if (
|
||||
this.showSearchResults &&
|
||||
!this.searchElements.some(element => element.nativeElement.contains(eventTarget))
|
||||
) {
|
||||
this.hideSearchResults();
|
||||
}
|
||||
|
||||
// Show developer source view if the footer is clicked while holding the meta and alt keys
|
||||
if (eventTarget.tagName === 'FOOTER' && metaKey && altKey) {
|
||||
this.dtOn = !this.dtOn;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deal with anchor clicks; climb DOM tree until anchor found (or null)
|
||||
let target: HTMLElement | null = eventTarget;
|
||||
while (target && !(target instanceof HTMLAnchorElement)) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
if (target instanceof HTMLAnchorElement) {
|
||||
return this.locationService.handleAnchorClick(target, button, ctrlKey, metaKey);
|
||||
}
|
||||
|
||||
// Allow the click to pass through
|
||||
return true;
|
||||
}
|
||||
|
||||
setPageId(id: string) {
|
||||
// Special case the home page
|
||||
this.pageId = (id === 'index') ? 'home' : id.replace('/', '-');
|
||||
}
|
||||
|
||||
setFolderId(id: string) {
|
||||
// Special case the home page
|
||||
this.folderId = (id === 'index') ? 'home' : id.split('/', 1)[0];
|
||||
}
|
||||
|
||||
notificationDismissed() {
|
||||
this.notificationAnimating = true;
|
||||
// this should be kept in sync with the animation durations in:
|
||||
// - aio/src/styles/2-modules/_notification.scss
|
||||
// - aio/src/app/layout/notification/notification.component.ts
|
||||
setTimeout(() => this.notificationAnimating = false, 250);
|
||||
this.updateHostClasses();
|
||||
}
|
||||
|
||||
updateHostClasses() {
|
||||
const mode = `mode-${this.deployment.mode}`;
|
||||
const sideNavOpen = `sidenav-${this.sidenav.opened ? 'open' : 'closed'}`;
|
||||
const pageClass = `page-${this.pageId}`;
|
||||
const folderClass = `folder-${this.folderId}`;
|
||||
const viewClasses = Object.keys(this.currentNodes).map(view => `view-${view}`).join(' ');
|
||||
const notificationClass = `aio-notification-${this.notification.showNotification}`;
|
||||
const notificationAnimatingClass = this.notificationAnimating ? 'aio-notification-animating' : '';
|
||||
|
||||
this.hostClasses = [
|
||||
mode,
|
||||
sideNavOpen,
|
||||
pageClass,
|
||||
folderClass,
|
||||
viewClasses,
|
||||
notificationClass,
|
||||
notificationAnimatingClass
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
updateShell() {
|
||||
// Update the SideNav state (if necessary).
|
||||
this.updateSideNav();
|
||||
|
||||
// Update the host classes.
|
||||
this.setPageId(this.currentDocument.id);
|
||||
this.setFolderId(this.currentDocument.id);
|
||||
this.updateHostClasses();
|
||||
}
|
||||
|
||||
updateSideNav() {
|
||||
// Preserve current sidenav open state by default.
|
||||
let openSideNav = this.sidenav.opened;
|
||||
const isSideNavDoc = !!this.currentNodes[sideNavView];
|
||||
|
||||
if (this.isSideNavDoc !== isSideNavDoc) {
|
||||
// View type changed. Is it now a sidenav view (e.g, guide or tutorial)?
|
||||
// Open if changed to a sidenav doc; close if changed to a marketing doc.
|
||||
openSideNav = this.isSideNavDoc = isSideNavDoc;
|
||||
}
|
||||
|
||||
// May be open or closed when wide; always closed when narrow.
|
||||
this.sidenav.toggle(this.dockSideNav && openSideNav);
|
||||
}
|
||||
|
||||
// Dynamically change height of table of contents container
|
||||
@HostListener('window:scroll')
|
||||
onScroll() {
|
||||
if (!this.tocMaxHeightOffset) {
|
||||
// Must wait until `mat-toolbar` is measurable.
|
||||
const el = this.hostElement.nativeElement as Element;
|
||||
const headerEl = el.querySelector('.app-toolbar');
|
||||
const footerEl = el.querySelector('footer');
|
||||
|
||||
if (headerEl && footerEl) {
|
||||
this.tocMaxHeightOffset =
|
||||
headerEl.clientHeight +
|
||||
footerEl.clientHeight +
|
||||
24; // fudge margin
|
||||
}
|
||||
}
|
||||
|
||||
this.tocMaxHeight = (document.body.scrollHeight - window.scrollY - this.tocMaxHeightOffset).toFixed(2);
|
||||
}
|
||||
|
||||
// Restrain scrolling inside an element, when the cursor is over it
|
||||
restrainScrolling(evt: WheelEvent) {
|
||||
const elem = evt.currentTarget as Element;
|
||||
const scrollTop = elem.scrollTop;
|
||||
|
||||
if (evt.deltaY < 0) {
|
||||
// Trying to scroll up: Prevent scrolling if already at the top.
|
||||
if (scrollTop < 1) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
} else {
|
||||
// Trying to scroll down: Prevent scrolling if already at the bottom.
|
||||
const maxScrollTop = elem.scrollHeight - elem.clientHeight;
|
||||
if (maxScrollTop - scrollTop < 1) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Search related methods and handlers
|
||||
|
||||
hideSearchResults() {
|
||||
this.showSearchResults = false;
|
||||
const oldSearch = this.locationService.search();
|
||||
if (oldSearch.search !== undefined) {
|
||||
this.locationService.setSearch('', { ...oldSearch, search: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
focusSearchBox() {
|
||||
if (this.searchBox) {
|
||||
this.searchBox.focus();
|
||||
}
|
||||
}
|
||||
|
||||
doSearch(query: string, fromFocus = false) {
|
||||
if (this.showSearchResults && fromFocus) {
|
||||
// the results where already being displayed so there is no
|
||||
// need to perform the search until the input actually changes
|
||||
return;
|
||||
}
|
||||
this.searchResults = this.searchService.search(query);
|
||||
this.showSearchResults = !!query;
|
||||
}
|
||||
|
||||
@HostListener('document:keyup', ['$event.key', '$event.which'])
|
||||
onKeyUp(key: string, keyCode: number) {
|
||||
// forward slash "/"
|
||||
if (key === '/' || keyCode === 191) {
|
||||
this.focusSearchBox();
|
||||
}
|
||||
if (key === 'Escape' || keyCode === 27) {
|
||||
// escape key
|
||||
if (this.showSearchResults) {
|
||||
this.hideSearchResults();
|
||||
this.focusSearchBox();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ErrorHandler, NgModule } from '@angular/core';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
|
||||
import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
|
||||
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconRegistry } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
|
||||
import { svg } from 'app/shared/security';
|
||||
import { trustedResourceUrl, unwrapResourceUrl } from 'safevalues';
|
||||
|
||||
import { AppComponent } from 'app/app.component';
|
||||
import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry';
|
||||
import { Deployment } from 'app/shared/deployment.service';
|
||||
import { CookiesPopupComponent } from 'app/layout/cookies-popup/cookies-popup.component';
|
||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||
import { DtComponent } from 'app/layout/doc-viewer/dt.component';
|
||||
import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component';
|
||||
import { AnalyticsService } from 'app/shared/analytics.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { STORAGE_PROVIDERS } from 'app/shared/storage.service';
|
||||
import { NavigationService } from 'app/navigation/navigation.service';
|
||||
import { DocumentService } from 'app/documents/document.service';
|
||||
import { SearchService } from 'app/search/search.service';
|
||||
import { TopMenuComponent } from 'app/layout/top-menu/top-menu.component';
|
||||
import { FooterComponent } from 'app/layout/footer/footer.component';
|
||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
||||
import { ReportingErrorHandler } from 'app/shared/reporting-error-handler';
|
||||
import { ScrollService } from 'app/shared/scroll.service';
|
||||
import { ScrollSpyService } from 'app/shared/scroll-spy.service';
|
||||
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
|
||||
import { NotificationComponent } from 'app/layout/notification/notification.component';
|
||||
import { TocService } from 'app/shared/toc.service';
|
||||
import { CurrentDateToken, currentDateProvider } from 'app/shared/current-date';
|
||||
import { WindowToken, windowProvider } from 'app/shared/window';
|
||||
|
||||
import { CustomElementsModule } from 'app/custom-elements/custom-elements.module';
|
||||
import { SharedModule } from 'app/shared/shared.module';
|
||||
import { ThemeToggleComponent } from 'app/shared/theme-picker/theme-toggle.component';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
// These are the hardcoded inline svg sources to be used by the `<mat-icon>` component.
|
||||
/* eslint-disable max-len */
|
||||
export const svgIconProviders = [
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
name: 'close',
|
||||
svgSource: svg`<svg focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
name: 'insert_comment',
|
||||
svgSource: svg`<svg focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z" />
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
name: 'keyboard_arrow_right',
|
||||
svgSource: svg`<svg focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.59 16.34l4.58-4.59-4.58-4.59L10 5.75l6 6-6 6z" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
name: 'menu',
|
||||
svgSource: svg`<svg focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
|
||||
// Namespace: logos
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
namespace: 'logos',
|
||||
name: 'github',
|
||||
svgSource:
|
||||
svg`<svg focusable="false" viewBox="0 0 51.8 50.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.9,0.2C11.8,0.2,0.3,11.7,0.3,25.8c0,11.3,7.3,20.9,17.5,24.3c1.3,0.2,1.7-0.6,1.7-1.2c0-0.6,0-2.6,0-4.8
|
||||
c-7.1,1.5-8.6-3-8.6-3c-1.2-3-2.8-3.7-2.8-3.7c-2.3-1.6,0.2-1.6,0.2-1.6c2.6,0.2,3.9,2.6,3.9,2.6c2.3,3.9,6,2.8,7.5,2.1
|
||||
c0.2-1.7,0.9-2.8,1.6-3.4c-5.7-0.6-11.7-2.8-11.7-12.7c0-2.8,1-5.1,2.6-6.9c-0.3-0.7-1.1-3.3,0.3-6.8c0,0,2.1-0.7,7,2.6
|
||||
c2-0.6,4.2-0.9,6.4-0.9c2.2,0,4.4,0.3,6.4,0.9c4.9-3.3,7-2.6,7-2.6c1.4,3.5,0.5,6.1,0.3,6.8c1.6,1.8,2.6,4.1,2.6,6.9
|
||||
c0,9.8-6,12-11.7,12.6c0.9,0.8,1.7,2.4,1.7,4.7c0,3.4,0,6.2,0,7c0,0.7,0.5,1.5,1.8,1.2c10.2-3.4,17.5-13,17.5-24.3
|
||||
C51.5,11.7,40.1,0.2,25.9,0.2z" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
namespace: 'logos',
|
||||
name: 'twitter',
|
||||
svgSource: svg`<svg focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: SVG_ICONS,
|
||||
useValue: {
|
||||
namespace: 'logos',
|
||||
name: 'youtube',
|
||||
svgSource: svg`<svg focusable="false" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.58 7.19c-.23-.86-.91-1.54-1.77-1.77C18.25 5 12 5 12 5s-6.25 0-7.81.42c-.86.23-1.54.91-1.77 1.77
|
||||
C2 8.75 2 12 2 12s0 3.25.42 4.81c.23.86.91 1.54 1.77 1.77C5.75 19 12 19 12 19s6.25 0 7.81-.42
|
||||
c.86-.23 1.54-.91 1.77-1.77C22 15.25 22 12 22 12s0-3.25-.42-4.81zM10 15V9l5.2 3-5.2 3z" />
|
||||
</svg>`,
|
||||
},
|
||||
multi: true,
|
||||
},
|
||||
];
|
||||
/* eslint-enable max-len */
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule.withConfig({disableAnimations: AppComponent.reducedMotion}),
|
||||
CustomElementsModule,
|
||||
HttpClientModule,
|
||||
MatButtonModule,
|
||||
MatProgressBarModule,
|
||||
MatSidenavModule,
|
||||
MatToolbarModule,
|
||||
SharedModule,
|
||||
ServiceWorkerModule.register(
|
||||
// Make sure service worker is loaded with a TrustedScriptURL
|
||||
unwrapResourceUrl(trustedResourceUrl`/ngsw-worker.js`) as string,
|
||||
{enabled: environment.production}),
|
||||
],
|
||||
declarations: [
|
||||
AppComponent,
|
||||
CookiesPopupComponent,
|
||||
DocViewerComponent,
|
||||
DtComponent,
|
||||
FooterComponent,
|
||||
ModeBannerComponent,
|
||||
NavMenuComponent,
|
||||
NavItemComponent,
|
||||
SearchBoxComponent,
|
||||
NotificationComponent,
|
||||
TopMenuComponent,
|
||||
ThemeToggleComponent,
|
||||
],
|
||||
providers: [
|
||||
AnalyticsService,
|
||||
Deployment,
|
||||
DocumentService,
|
||||
{ provide: ErrorHandler, useClass: ReportingErrorHandler },
|
||||
Logger,
|
||||
Location,
|
||||
{ provide: LocationStrategy, useClass: PathLocationStrategy },
|
||||
LocationService,
|
||||
{ provide: MatIconRegistry, useClass: CustomIconRegistry },
|
||||
NavigationService,
|
||||
ScrollService,
|
||||
ScrollSpyService,
|
||||
SearchService,
|
||||
STORAGE_PROVIDERS,
|
||||
svgIconProviders,
|
||||
TocService,
|
||||
{ provide: CurrentDateToken, useFactory: currentDateProvider },
|
||||
{ provide: WindowToken, useFactory: windowProvider },
|
||||
],
|
||||
bootstrap: [ AppComponent ]
|
||||
})
|
||||
export class AppModule { }
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
import { AnnouncementBarComponent } from './announcement-bar.component';
|
||||
|
||||
const today = new Date();
|
||||
const lastWeek = changeDays(today, -7);
|
||||
const yesterday = changeDays(today, -1);
|
||||
const tomorrow = changeDays(today, 1);
|
||||
const nextWeek = changeDays(today, 7);
|
||||
|
||||
describe('AnnouncementBarComponent', () => {
|
||||
|
||||
let element: HTMLElement;
|
||||
let fixture: ComponentFixture<AnnouncementBarComponent>;
|
||||
let component: AnnouncementBarComponent;
|
||||
let httpMock: HttpTestingController;
|
||||
let mockLogger: MockLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
declarations: [AnnouncementBarComponent],
|
||||
providers: [{ provide: Logger, useClass: MockLogger }]
|
||||
});
|
||||
|
||||
httpMock = injector.inject(HttpTestingController);
|
||||
mockLogger = injector.inject(Logger) as any;
|
||||
fixture = TestBed.createComponent(AnnouncementBarComponent);
|
||||
component = fixture.componentInstance;
|
||||
element = fixture.nativeElement;
|
||||
});
|
||||
|
||||
it('should have no announcement when first created', () => {
|
||||
expect(component.announcement).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('ngOnInit', () => {
|
||||
it('should make a single request to the server', () => {
|
||||
component.ngOnInit();
|
||||
httpMock.expectOne('generated/announcements.json');
|
||||
expect().nothing(); // Prevent jasmine from complaining about no expectations.
|
||||
});
|
||||
|
||||
it('should set the announcement to the first "live" one in the list loaded from `announcements.json`', () => {
|
||||
component.ngOnInit();
|
||||
const request = httpMock.expectOne('generated/announcements.json');
|
||||
request.flush([
|
||||
{ startDate: lastWeek, endDate: yesterday, message: 'Test Announcement 0' },
|
||||
{ startDate: tomorrow, endDate: nextWeek, message: 'Test Announcement 1' },
|
||||
{ startDate: yesterday, endDate: tomorrow, message: 'Test Announcement 2' },
|
||||
{ startDate: yesterday, endDate: tomorrow, message: 'Test Announcement 3' }
|
||||
]);
|
||||
expect(component.announcement.message).toEqual('Test Announcement 2');
|
||||
});
|
||||
|
||||
it('should set the announcement to `undefined` if there are no announcements in `announcements.json`', () => {
|
||||
component.ngOnInit();
|
||||
const request = httpMock.expectOne('generated/announcements.json');
|
||||
request.flush([]);
|
||||
expect(component.announcement).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle invalid data in `announcements.json`', () => {
|
||||
component.ngOnInit();
|
||||
const request = httpMock.expectOne('generated/announcements.json');
|
||||
request.flush('some random response');
|
||||
expect(component.announcement).toBeUndefined();
|
||||
expect(mockLogger.output.error).toEqual([
|
||||
[jasmine.any(Error)]
|
||||
]);
|
||||
expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/announcements\.json contains invalid data:/);
|
||||
});
|
||||
|
||||
it('should handle a failed request for `announcements.json`', () => {
|
||||
component.ngOnInit();
|
||||
const request = httpMock.expectOne('generated/announcements.json');
|
||||
request.error(new ProgressEvent('404'));
|
||||
expect(component.announcement).toBeUndefined();
|
||||
expect(mockLogger.output.error).toEqual([
|
||||
[jasmine.any(Error)]
|
||||
]);
|
||||
expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/announcements\.json request failed:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
beforeEach(() => {
|
||||
component.announcement = {
|
||||
imageUrl: 'dummy/image',
|
||||
linkUrl: 'link/to/website',
|
||||
message: 'this is an <b>important</b> message',
|
||||
endDate: '2018-03-01',
|
||||
startDate: '2018-02-01'
|
||||
};
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the message as HTML', () => {
|
||||
expect(element.innerHTML).toContain('this is an <b>important</b> message');
|
||||
});
|
||||
|
||||
it('should display an image', () => {
|
||||
expect(element.querySelector('img')?.src).toContain('dummy/image');
|
||||
});
|
||||
|
||||
it('should display a link', () => {
|
||||
expect(element.querySelector('a')?.href).toContain('link/to/website');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function changeDays(initial: Date, days: number) {
|
||||
return (new Date(initial.valueOf()).setDate(initial.getDate() + days));
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { CONTENT_URL_PREFIX } from 'app/documents/document.service';
|
||||
const announcementsPath = CONTENT_URL_PREFIX + 'announcements.json';
|
||||
|
||||
export interface Announcement {
|
||||
imageUrl: string;
|
||||
message: string;
|
||||
linkUrl: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the latest live announcement. This is used on the homepage.
|
||||
*
|
||||
* The data for the announcements is kept in `aio/content/marketing/announcements.json`.
|
||||
*
|
||||
* The format for that data file looks like:
|
||||
*
|
||||
* ```
|
||||
* [
|
||||
* {
|
||||
* "startDate": "2018-02-01",
|
||||
* "endDate": "2018-03-01",
|
||||
* "message": "This is an <b>important</b> announcement",
|
||||
* "imageUrl": "url/to/image",
|
||||
* "linkUrl": "url/to/website"
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* Only one announcement will be shown at any time. This is determined as the first "live"
|
||||
* announcement in the file, where "live" means that its start date is before today, and its
|
||||
* end date is after today.
|
||||
*
|
||||
* **Security Note:**
|
||||
* The `message` field can contain unsanitized HTML but this field should only updated by
|
||||
* verified members of the Angular team.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'aio-announcement-bar',
|
||||
template: `
|
||||
<div class="homepage-container" *ngIf="announcement">
|
||||
<div class="announcement-bar">
|
||||
<img [src]="announcement.imageUrl" alt="">
|
||||
<p [innerHTML]="announcement.message"></p>
|
||||
<a class="button" [href]="announcement.linkUrl">Learn More</a>
|
||||
</div>
|
||||
</div>`
|
||||
})
|
||||
export class AnnouncementBarComponent implements OnInit {
|
||||
announcement: Announcement;
|
||||
|
||||
constructor(private http: HttpClient, private logger: Logger) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.http.get<Announcement[]>(announcementsPath)
|
||||
.pipe(
|
||||
catchError(error => {
|
||||
this.logger.error(new Error(`${announcementsPath} request failed: ${error.message}`));
|
||||
return [];
|
||||
}),
|
||||
map(announcements => this.findCurrentAnnouncement(announcements)),
|
||||
catchError(error => {
|
||||
this.logger.error(new Error(`${announcementsPath} contains invalid data: ${error.message}`));
|
||||
return [];
|
||||
}),
|
||||
)
|
||||
.subscribe(announcement => this.announcement = announcement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first date in the list that is "live" now
|
||||
*/
|
||||
private findCurrentAnnouncement(announcements: Announcement[]) {
|
||||
return announcements
|
||||
.filter(announcement => new Date(announcement.startDate).valueOf() < Date.now())
|
||||
.filter(announcement => new Date(announcement.endDate).valueOf() > Date.now())
|
||||
[0];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { AnnouncementBarComponent } from './announcement-bar.component';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, SharedModule, HttpClientModule ],
|
||||
declarations: [ AnnouncementBarComponent ]
|
||||
})
|
||||
export class AnnouncementBarModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = AnnouncementBarComponent;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<div class="l-flex-wrap api-filter">
|
||||
|
||||
<aio-select (change)="setType($event.option)"
|
||||
[options]="types"
|
||||
[selected]="type"
|
||||
[showSymbol]="true"
|
||||
label="Type:">
|
||||
</aio-select>
|
||||
|
||||
<aio-select (change)="setStatus($event.option)"
|
||||
[options]="statuses"
|
||||
[selected]="status"
|
||||
[disabled]="type.value === 'package'"
|
||||
label="Status:">
|
||||
</aio-select>
|
||||
|
||||
<div class="form-search">
|
||||
<input #filter placeholder="Filter" (input)="setQuery($any($event.target).value)" aria-label="Filter Search">
|
||||
<em class="material-icons">search</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="api-list-container">
|
||||
<div *ngFor="let section of filteredSections | async" >
|
||||
<h2 *ngIf="section.items"><a [href]="section.path" [class.deprecated-api-item]="section.deprecated">{{section.title}}</a></h2>
|
||||
<ul class="api-list" *ngIf="section.items && section.items.length">
|
||||
<ng-container *ngFor="let item of section.items">
|
||||
<li class="api-item">
|
||||
<a [href]="item.path" [class.deprecated-api-item]="item.stability === 'deprecated'">
|
||||
<span class="symbol {{item.docType}}"></span>
|
||||
{{item.title}}
|
||||
</a>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ApiListComponent } from './api-list.component';
|
||||
import { ApiItem, ApiSection, ApiService } from './api.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
import { ApiListModule } from './api-list.module';
|
||||
|
||||
describe('ApiListComponent', () => {
|
||||
let component: ApiListComponent;
|
||||
let fixture: ComponentFixture<ApiListComponent>;
|
||||
let sections: ApiSection[];
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ ApiListModule ],
|
||||
providers: [
|
||||
{ provide: ApiService, useClass: TestApiService },
|
||||
{ provide: Logger, useClass: MockLogger },
|
||||
{ provide: LocationService, useClass: TestLocationService }
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(ApiListComponent);
|
||||
component = fixture.componentInstance;
|
||||
sections = getApiSections();
|
||||
});
|
||||
|
||||
/**
|
||||
* Expectation Utility: Assert that filteredSections has the expected result for this test.
|
||||
*
|
||||
* @param itemTest - return true if the item passes the match test
|
||||
*
|
||||
* Subscribes to `filteredSections` and performs expectation within subscription callback.
|
||||
*/
|
||||
function expectFilteredResult(label: string, itemTest: (item: ApiItem) => boolean) {
|
||||
component.filteredSections.subscribe(filtered => {
|
||||
filtered = filtered.filter(section => section.items);
|
||||
expect(filtered.length).withContext('expected something').toBeGreaterThan(0);
|
||||
expect(filtered.every(section => section.items?.every(itemTest))).withContext(label).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
describe('#filteredSections', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should return all complete sections when no criteria', () => {
|
||||
let filtered: ApiSection[]|undefined;
|
||||
component.filteredSections.subscribe(f => filtered = f);
|
||||
expect(filtered).toEqual(sections);
|
||||
});
|
||||
|
||||
it('item.show should be true for all queried items', () => {
|
||||
component.setQuery('class');
|
||||
expectFilteredResult('query: class', item => /class/.test(item.name));
|
||||
});
|
||||
|
||||
it('items should be an array for every item in section when query matches section name', () => {
|
||||
component.setQuery('core');
|
||||
component.filteredSections.subscribe(filtered => {
|
||||
filtered = filtered.filter(section => Array.isArray(section.items));
|
||||
expect(filtered.length).withContext('only one section').toBe(1);
|
||||
expect(filtered[0].name).toBe('core');
|
||||
expect(filtered[0].items).toEqual(sections.find(section => section.name === 'core')?.items as ApiItem[]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section.items', () => {
|
||||
it('should null if there are no matching items and the section itself does not match', () => {
|
||||
component.setQuery('core');
|
||||
component.filteredSections.subscribe(filtered => {
|
||||
const commonSection = filtered.find(section => section.name === 'common');
|
||||
expect(commonSection?.items).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be visible if they have the selected stability status', () => {
|
||||
component.setStatus({value: 'stable', title: 'Stable'});
|
||||
expectFilteredResult('status: stable', item => item.stability === 'stable');
|
||||
});
|
||||
|
||||
it('should be visible if they have the selected security status', () => {
|
||||
component.setStatus({value: 'security-risk', title: 'Security Risk'});
|
||||
expectFilteredResult('status: security-risk', item => item.securityRisk);
|
||||
});
|
||||
|
||||
it('should be visible if they have the selected developer preview status', () => {
|
||||
component.setStatus({value: 'developer-preview', title: 'Developer Preview'});
|
||||
expectFilteredResult('status: developer-preview', item => item.developerPreview);
|
||||
});
|
||||
|
||||
it('should be visible if they match the selected API type', () => {
|
||||
component.setType({value: 'class', title: 'Class'});
|
||||
expectFilteredResult('type: class', item => item.docType === 'class');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have no sections and no items visible when there is no match', () => {
|
||||
component.setQuery('fizbuzz');
|
||||
component.filteredSections.subscribe(filtered => {
|
||||
expect(filtered.some(section => !!section.items)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial criteria from location', () => {
|
||||
let locationService: TestLocationService;
|
||||
|
||||
beforeEach(() => {
|
||||
locationService = fixture.componentRef.injector.get<any>(LocationService);
|
||||
});
|
||||
|
||||
function expectOneItem(name: string, section: string, type: string, stability: string) {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.filteredSections.subscribe(filtered => {
|
||||
filtered = filtered.filter(s => s.items);
|
||||
expect(filtered.length).withContext('sections').toBe(1);
|
||||
expect(filtered[0].name).withContext('section name').toBe(section);
|
||||
const items = filtered[0].items as ApiItem[];
|
||||
expect(items.length).withContext('items').toBe(1);
|
||||
|
||||
const item = items[0];
|
||||
const badItem = 'Wrong item: ' + JSON.stringify(item, null, 2);
|
||||
|
||||
expect(item.docType).withContext(badItem).toBe(type);
|
||||
expect(item.stability).withContext(badItem).toBe(stability);
|
||||
expect(item.name).withContext(badItem).toBe(name);
|
||||
});
|
||||
}
|
||||
|
||||
it('should filter as expected for ?query', () => {
|
||||
locationService.query = {query: '_3'};
|
||||
expectOneItem('class_3', 'core', 'class', 'experimental');
|
||||
});
|
||||
|
||||
it('should filter as expected for ?status', () => {
|
||||
locationService.query = {status: 'deprecated'};
|
||||
expectOneItem('function_1', 'core', 'function', 'deprecated');
|
||||
});
|
||||
|
||||
it('should filter as expected when status is security-risk', () => {
|
||||
locationService.query = {status: 'security-risk'};
|
||||
fixture.detectChanges();
|
||||
expectFilteredResult('security-risk', item => item.securityRisk);
|
||||
});
|
||||
|
||||
it('should filter as expected when status is developer-preview', () => {
|
||||
locationService.query = {status: 'developer-preview'};
|
||||
fixture.detectChanges();
|
||||
expectFilteredResult('developer-preview', item => item.developerPreview);
|
||||
});
|
||||
|
||||
it('should filter as expected for ?type', () => {
|
||||
locationService.query = {type: 'pipe'};
|
||||
expectOneItem('pipe_1', 'common', 'pipe', 'stable');
|
||||
});
|
||||
|
||||
it('should filter as expected for ?query&status&type', () => {
|
||||
locationService.query = {
|
||||
query: 's_1',
|
||||
status: 'experimental',
|
||||
type: 'class'
|
||||
};
|
||||
fixture.detectChanges();
|
||||
expectOneItem('class_1', 'common', 'class', 'experimental');
|
||||
});
|
||||
|
||||
it('should ignore case for ?query&status&type', () => {
|
||||
locationService.query = {
|
||||
query: 'S_1',
|
||||
status: 'ExperiMental',
|
||||
type: 'CLASS'
|
||||
};
|
||||
fixture.detectChanges();
|
||||
expectOneItem('class_1', 'common', 'class', 'experimental');
|
||||
});
|
||||
});
|
||||
|
||||
describe('location path after criteria change', () => {
|
||||
let locationService: TestLocationService;
|
||||
|
||||
beforeEach(() => {
|
||||
locationService = fixture.componentRef.injector.get<any>(LocationService);
|
||||
});
|
||||
|
||||
it('should have query', () => {
|
||||
component.setQuery('foo');
|
||||
|
||||
// `setSearch` 2nd param is a query/search params object
|
||||
const search = locationService.setSearch.calls.mostRecent().args[1];
|
||||
expect(search.query).toBe('foo');
|
||||
});
|
||||
|
||||
it('should keep last of multiple query settings (in lowercase)', () => {
|
||||
component.setQuery('foo');
|
||||
component.setQuery('fooBar');
|
||||
|
||||
const search = locationService.setSearch.calls.mostRecent().args[1];
|
||||
expect(search.query).toBe('foobar');
|
||||
});
|
||||
|
||||
it('should have query, status, and type', () => {
|
||||
component.setQuery('foo');
|
||||
component.setStatus({value: 'stable', title: 'Stable'});
|
||||
component.setType({value: 'class', title: 'Class'});
|
||||
|
||||
const search = locationService.setSearch.calls.mostRecent().args[1];
|
||||
expect(search.query).toBe('foo');
|
||||
expect(search.status).toBe('stable');
|
||||
expect(search.type).toBe('class');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
////// Helpers ////////
|
||||
|
||||
class TestLocationService {
|
||||
query: {[index: string]: string } = {};
|
||||
setSearch = jasmine.createSpy('setSearch');
|
||||
search() { return this.query; }
|
||||
}
|
||||
|
||||
class TestApiService {
|
||||
sectionsSubject = new BehaviorSubject(getApiSections());
|
||||
sections = this.sectionsSubject.asObservable();
|
||||
}
|
||||
|
||||
const apiSections: ApiSection[] = [
|
||||
{
|
||||
name: 'common',
|
||||
title: 'common',
|
||||
path: 'api/common',
|
||||
deprecated: false,
|
||||
items: [
|
||||
{
|
||||
name: 'class_1',
|
||||
title: 'Class 1',
|
||||
path: 'api/common/class_1',
|
||||
docType: 'class',
|
||||
stability: 'experimental',
|
||||
securityRisk: false,
|
||||
developerPreview: false
|
||||
},
|
||||
{
|
||||
name: 'class_2',
|
||||
title: 'Class 2',
|
||||
path: 'api/common/class_2',
|
||||
docType: 'class',
|
||||
stability: 'stable',
|
||||
securityRisk: false,
|
||||
developerPreview: false
|
||||
},
|
||||
{
|
||||
name: 'directive_1',
|
||||
title: 'Directive 1',
|
||||
path: 'api/common/directive_1',
|
||||
docType: 'directive',
|
||||
stability: 'stable',
|
||||
securityRisk: true,
|
||||
developerPreview: false
|
||||
},
|
||||
{
|
||||
name: 'pipe_1',
|
||||
title: 'Pipe 1',
|
||||
path: 'api/common/pipe_1',
|
||||
docType: 'pipe',
|
||||
stability: 'stable',
|
||||
securityRisk: true,
|
||||
developerPreview: false
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'core',
|
||||
title: 'core',
|
||||
path: 'api/core',
|
||||
deprecated: false,
|
||||
items: [
|
||||
{
|
||||
name: 'class_3',
|
||||
title: 'Class 3',
|
||||
path: 'api/core/class_3',
|
||||
docType: 'class',
|
||||
stability: 'experimental',
|
||||
securityRisk: false,
|
||||
developerPreview: false
|
||||
},
|
||||
{
|
||||
name: 'function_1',
|
||||
title: 'Function 1',
|
||||
path: 'api/core/function 1',
|
||||
docType: 'function',
|
||||
stability: 'deprecated',
|
||||
securityRisk: true,
|
||||
developerPreview: false
|
||||
},
|
||||
{
|
||||
name: 'const_1',
|
||||
title: 'Const 1',
|
||||
path: 'api/core/const_1',
|
||||
docType: 'const',
|
||||
stability: 'stable',
|
||||
securityRisk: false,
|
||||
developerPreview: true
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function getApiSections() { return apiSections; }
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
/*
|
||||
* API List & Filter Component
|
||||
*
|
||||
* A page that displays a formatted list of the public Angular API entities.
|
||||
* Clicking on a list item triggers navigation to the corresponding API entity document.
|
||||
* Can add/remove API entity links based on filter settings.
|
||||
*/
|
||||
|
||||
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||
|
||||
import { combineLatest, Observable, ReplaySubject } from 'rxjs';
|
||||
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { ApiItem, ApiSection, ApiService } from './api.service';
|
||||
|
||||
import { Option } from 'app/shared/select/select.component';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
class SearchCriteria {
|
||||
query = '';
|
||||
status = 'all';
|
||||
type = 'all';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'aio-api-list',
|
||||
templateUrl: './api-list.component.html',
|
||||
})
|
||||
export class ApiListComponent implements OnInit {
|
||||
|
||||
filteredSections: Observable<ApiSection[]>;
|
||||
|
||||
showStatusMenu = false;
|
||||
showTypeMenu = false;
|
||||
|
||||
private criteriaSubject = new ReplaySubject<SearchCriteria>(1);
|
||||
private searchCriteria = new SearchCriteria();
|
||||
|
||||
status: Option;
|
||||
type: Option;
|
||||
|
||||
// API types
|
||||
types: Option[] = [
|
||||
{ value: 'all', title: 'All' },
|
||||
{ value: 'class', title: 'Class' },
|
||||
{ value: 'const', title: 'Const'},
|
||||
{ value: 'decorator', title: 'Decorator' },
|
||||
{ value: 'directive', title: 'Directive' },
|
||||
{ value: 'element', title: 'Element'},
|
||||
{ value: 'block', title: 'Block'},
|
||||
{ value: 'enum', title: 'Enum' },
|
||||
{ value: 'function', title: 'Function' },
|
||||
{ value: 'interface', title: 'Interface' },
|
||||
{ value: 'package', title: 'Package'},
|
||||
{ value: 'pipe', title: 'Pipe'},
|
||||
{ value: 'ngmodule', title: 'NgModule'},
|
||||
{ value: 'type-alias', title: 'Type alias' },
|
||||
];
|
||||
|
||||
statuses: Option[] = [
|
||||
{ value: 'all', title: 'All' },
|
||||
{ value: 'stable', title: 'Stable'},
|
||||
{ value: 'developer-preview', title: 'Developer Preview'},
|
||||
{ value: 'deprecated', title: 'Deprecated' },
|
||||
{ value: 'security-risk', title: 'Security Risk' }
|
||||
];
|
||||
|
||||
@ViewChild('filter', { static: true }) queryEl: ElementRef;
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private locationService: LocationService) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.filteredSections =
|
||||
combineLatest([
|
||||
this.apiService.sections,
|
||||
this.criteriaSubject,
|
||||
]).pipe(
|
||||
map( results => ({ sections: results[0], criteria: results[1]})),
|
||||
map( results => (
|
||||
results.sections
|
||||
.map(section => ({ ...section, items: this.filterSection(section, results.criteria) }))
|
||||
))
|
||||
);
|
||||
|
||||
this.initializeSearchCriteria();
|
||||
}
|
||||
|
||||
// TODO: may need to debounce as the original did
|
||||
// although there shouldn't be any perf consequences if we don't
|
||||
setQuery(query: string) {
|
||||
this.setSearchCriteria({query: (query || '').toLowerCase().trim() });
|
||||
}
|
||||
|
||||
setStatus(status: Option) {
|
||||
this.toggleStatusMenu();
|
||||
this.status = status;
|
||||
this.setSearchCriteria({status: status.value});
|
||||
}
|
||||
|
||||
setType(type: Option) {
|
||||
this.toggleTypeMenu();
|
||||
this.type = type;
|
||||
this.setSearchCriteria({type: type.value});
|
||||
}
|
||||
|
||||
toggleStatusMenu() {
|
||||
this.showStatusMenu = !this.showStatusMenu;
|
||||
}
|
||||
|
||||
toggleTypeMenu() {
|
||||
this.showTypeMenu = !this.showTypeMenu;
|
||||
}
|
||||
|
||||
//////// Private //////////
|
||||
|
||||
private filterSection(section: ApiSection, { query, status, type }: SearchCriteria) {
|
||||
const sectionNameMatches = !query || section.name.indexOf(query) !== -1;
|
||||
|
||||
const matchesQuery = (item: ApiItem) =>
|
||||
sectionNameMatches || item.name.indexOf(query) !== -1;
|
||||
const matchesStatus = (item: ApiItem) =>
|
||||
status === 'all' ||
|
||||
status === item.stability ||
|
||||
(status === 'security-risk' && item.securityRisk) ||
|
||||
(status === 'developer-preview' && item.developerPreview);
|
||||
const matchesType = (item: ApiItem) =>
|
||||
type === 'all' || type === item.docType;
|
||||
|
||||
const items: ApiItem[] = (section.items || []).filter(item =>
|
||||
matchesType(item) && matchesStatus(item) && matchesQuery(item));
|
||||
|
||||
// If there are no items we still return an empty array if the section name matches and the type is 'package'
|
||||
return items.length ? items : (sectionNameMatches && type === 'package') ? [] : null;
|
||||
}
|
||||
|
||||
// Get initial search criteria from URL search params
|
||||
private initializeSearchCriteria() {
|
||||
const {query, status, type} = this.locationService.search();
|
||||
|
||||
const q = (query || '').toLowerCase();
|
||||
// Hack: can't bind to query because input cursor always forced to end-of-line.
|
||||
this.queryEl.nativeElement.value = q;
|
||||
|
||||
this.status = this.statuses.find(x => x.value === status) || this.statuses[0];
|
||||
this.type = this.types.find(x => x.value === type) || this.types[0];
|
||||
|
||||
this.searchCriteria = {
|
||||
query: q,
|
||||
status: this.status.value,
|
||||
type: this.type.value
|
||||
};
|
||||
|
||||
this.criteriaSubject.next(this.searchCriteria);
|
||||
}
|
||||
|
||||
private setLocationSearch() {
|
||||
const {query, status, type} = this.searchCriteria;
|
||||
const params = {
|
||||
query: query ? query : undefined,
|
||||
status: status !== 'all' ? status : undefined,
|
||||
type: type !== 'all' ? type : undefined
|
||||
};
|
||||
|
||||
this.locationService.setSearch('API Search', params);
|
||||
}
|
||||
|
||||
private setSearchCriteria(criteria: Partial<SearchCriteria>) {
|
||||
this.criteriaSubject.next(Object.assign(this.searchCriteria, criteria));
|
||||
this.setLocationSearch();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { ApiListComponent } from './api-list.component';
|
||||
import { ApiService } from './api.service';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, SharedModule, HttpClientModule ],
|
||||
declarations: [ ApiListComponent ],
|
||||
providers: [ ApiService ]
|
||||
})
|
||||
export class ApiListModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = ApiListComponent;
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Injector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
describe('ApiService', () => {
|
||||
|
||||
let injector: Injector;
|
||||
let service: ApiService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
ApiService,
|
||||
{ provide: Logger, useClass: TestLogger }
|
||||
]
|
||||
});
|
||||
|
||||
service = injector.get<ApiService>(ApiService);
|
||||
httpMock = injector.get(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should not immediately connect to the server', () => {
|
||||
httpMock.expectNone({});
|
||||
expect().nothing(); // Prevent jasmine from complaining about no expectations.
|
||||
});
|
||||
|
||||
it('subscribers should be completed/unsubscribed when service destroyed', () => {
|
||||
let completed = false;
|
||||
|
||||
service.sections.subscribe({complete: () => completed = true});
|
||||
|
||||
service.ngOnDestroy();
|
||||
expect(completed).toBe(true);
|
||||
|
||||
// Stop `httpMock.verify()` from complaining.
|
||||
httpMock.expectOne({});
|
||||
});
|
||||
|
||||
describe('#sections', () => {
|
||||
|
||||
it('first subscriber should fetch sections', done => {
|
||||
const data = [
|
||||
{name: 'a', title: 'A', path: '', items: [], deprecated: false},
|
||||
{name: 'b', title: 'B', path: '', items: [], deprecated: false},
|
||||
];
|
||||
|
||||
service.sections.subscribe(sections => {
|
||||
expect(sections).toEqual(data);
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne({}).flush(data);
|
||||
});
|
||||
|
||||
it('second subscriber should get previous sections and NOT trigger refetch', done => {
|
||||
const data = [
|
||||
{name: 'a', title: 'A', path: '', items: [], deprecated: false},
|
||||
{name: 'b', title: 'B', path: '', items: [], deprecated: false},
|
||||
];
|
||||
let subscriptions = 0;
|
||||
|
||||
service.sections.subscribe(sections => {
|
||||
subscriptions++;
|
||||
expect(sections).toEqual(data);
|
||||
});
|
||||
|
||||
service.sections.subscribe(sections => {
|
||||
subscriptions++;
|
||||
expect(sections).toEqual(data);
|
||||
expect(subscriptions).toBe(2);
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne({}).flush(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#fetchSections', () => {
|
||||
|
||||
it('should connect to the server w/ expected URL', () => {
|
||||
service.fetchSections();
|
||||
httpMock.expectOne('generated/docs/api/api-list.json');
|
||||
expect().nothing(); // Prevent jasmine from complaining about no expectations.
|
||||
});
|
||||
|
||||
it('should refresh the #sections observable w/ new content on second call', () => {
|
||||
|
||||
let call = 0;
|
||||
|
||||
let data = [
|
||||
{name: 'a', title: 'A', path: '', items: [], deprecated: false},
|
||||
{name: 'b', title: 'B', path: '', items: [], deprecated: false},
|
||||
];
|
||||
|
||||
service.sections.subscribe(sections => {
|
||||
// called twice during this test
|
||||
// (1) during subscribe
|
||||
// (2) after refresh
|
||||
expect(sections).withContext('call ' + call++).toEqual(data);
|
||||
});
|
||||
|
||||
httpMock.expectOne({}).flush(data);
|
||||
|
||||
// refresh/refetch
|
||||
data = [{name: 'c', title: 'C', path: '', items: [], deprecated: false}];
|
||||
service.fetchSections();
|
||||
httpMock.expectOne({}).flush(data);
|
||||
|
||||
expect(call).withContext('should be called twice').toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestLogger {
|
||||
log = jasmine.createSpy('log');
|
||||
error = jasmine.createSpy('error');
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
import { ReplaySubject, Subject } from 'rxjs';
|
||||
import { takeUntil, tap } from 'rxjs/operators';
|
||||
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { DOC_CONTENT_URL_PREFIX } from 'app/documents/document.service';
|
||||
|
||||
export interface ApiItem {
|
||||
name: string;
|
||||
title: string;
|
||||
path: string;
|
||||
docType: string;
|
||||
stability: string;
|
||||
securityRisk: boolean;
|
||||
developerPreview: boolean;
|
||||
}
|
||||
|
||||
export interface ApiSection {
|
||||
path: string;
|
||||
name: string;
|
||||
title: string;
|
||||
deprecated: boolean;
|
||||
items: ApiItem[]|null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiService implements OnDestroy {
|
||||
|
||||
private apiBase = DOC_CONTENT_URL_PREFIX + 'api/';
|
||||
private apiListJsonDefault = 'api-list.json';
|
||||
private firstTime = true;
|
||||
private onDestroy = new Subject<void>();
|
||||
private sectionsSubject = new ReplaySubject<ApiSection[]>(1);
|
||||
private _sections = this.sectionsSubject.pipe(takeUntil(this.onDestroy));
|
||||
|
||||
/**
|
||||
* Return a cached observable of API sections from a JSON file.
|
||||
* API sections is an array of Angular top modules and metadata about their API documents (items).
|
||||
*/
|
||||
get sections() {
|
||||
|
||||
if (this.firstTime) {
|
||||
this.firstTime = false;
|
||||
this.fetchSections(); // TODO: get URL for fetchSections by configuration?
|
||||
|
||||
// makes sectionsSubject hot; subscribe ensures stays alive (always refCount > 0);
|
||||
this._sections.subscribe(sections => this.logger.log(`ApiService got API ${sections.length} section(s)`));
|
||||
}
|
||||
|
||||
return this._sections.pipe(tap(sections => {
|
||||
sections.forEach(section => {
|
||||
section.deprecated = !!section.items &&
|
||||
section.items.every(item => item.stability === 'deprecated');
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
constructor(private http: HttpClient, private logger: Logger) { }
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch API sections from a JSON file.
|
||||
* API sections is an array of Angular top modules and metadata about their API documents (items).
|
||||
* Updates `sections` observable
|
||||
*
|
||||
* @param [src] - Name of the api list JSON file
|
||||
*/
|
||||
fetchSections(src?: string) {
|
||||
// TODO: get URL by configuration?
|
||||
const url = this.apiBase + (src || this.apiListJsonDefault);
|
||||
this.http.get<ApiSection[]>(url)
|
||||
.pipe(
|
||||
takeUntil(this.onDestroy),
|
||||
tap(() => this.logger.log(`Got API sections from ${url}`)),
|
||||
)
|
||||
.subscribe({
|
||||
next: sections => this.sectionsSubject.next(sections),
|
||||
error: (err: HttpErrorResponse) => {
|
||||
// TODO: handle error
|
||||
this.logger.error(err);
|
||||
throw err; // rethrow for now.
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { Component, ViewChild } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CodeExampleComponent } from './code-example.component';
|
||||
import { CodeExampleModule } from './code-example.module';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
import { MockPrettyPrinter } from 'testing/pretty-printer.service';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
|
||||
describe('CodeExampleComponent', () => {
|
||||
let hostComponent: HostComponent;
|
||||
let codeExampleComponent: CodeExampleComponent;
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ CodeExampleModule ],
|
||||
declarations: [
|
||||
HostComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: Logger, useClass: MockLogger },
|
||||
{ provide: PrettyPrinter, useClass: MockPrettyPrinter },
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent = fixture.componentInstance;
|
||||
codeExampleComponent = hostComponent.codeExampleComponent;
|
||||
});
|
||||
|
||||
it('should be able to capture the code snippet provided in content', () => {
|
||||
expect(codeExampleComponent.aioCode.code.toString().trim()).toBe('const foo = "bar";');
|
||||
});
|
||||
|
||||
it('should clean-up the projected code snippet once captured', () => {
|
||||
expect(codeExampleComponent.content.nativeElement.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should change aio-code classes based on header presence', () => {
|
||||
expect(codeExampleComponent.header).toBe('Great Example');
|
||||
expect(fixture.nativeElement.querySelector('header')).toBeTruthy();
|
||||
|
||||
const aioCodeEl = fixture.nativeElement.querySelector('aio-code');
|
||||
|
||||
expect(aioCodeEl).toHaveClass('headed-code');
|
||||
expect(aioCodeEl).not.toHaveClass('simple-code');
|
||||
|
||||
codeExampleComponent.header = '';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(codeExampleComponent.header).toBe('');
|
||||
expect(fixture.nativeElement.querySelector('header')).toBeFalsy();
|
||||
expect(aioCodeEl).not.toHaveClass('headed-code');
|
||||
expect(aioCodeEl).toHaveClass('simple-code');
|
||||
});
|
||||
|
||||
it('should set avoidFile class if path has .avoid.', () => {
|
||||
const codeExampleComponentElement: HTMLElement =
|
||||
fixture.nativeElement.querySelector('code-example');
|
||||
|
||||
expect(codeExampleComponent.path).toBe('code-path');
|
||||
expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(true);
|
||||
|
||||
codeExampleComponent.path = 'code-path.avoid.';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(false);
|
||||
});
|
||||
|
||||
it('should coerce hidecopy', () => {
|
||||
expect(codeExampleComponent.hidecopy).toBe(false);
|
||||
|
||||
hostComponent.hidecopy = true;
|
||||
fixture.detectChanges();
|
||||
expect(codeExampleComponent.hidecopy).toBe(true);
|
||||
|
||||
hostComponent.hidecopy = 'false';
|
||||
fixture.detectChanges();
|
||||
expect(codeExampleComponent.hidecopy).toBe(false);
|
||||
|
||||
hostComponent.hidecopy = 'true';
|
||||
fixture.detectChanges();
|
||||
expect(codeExampleComponent.hidecopy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<code-example [header]="header" [path]="path" [hidecopy]="hidecopy">
|
||||
{{code}}
|
||||
</code-example>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
code = 'const foo = "bar";';
|
||||
header = 'Great Example';
|
||||
path = 'code-path';
|
||||
hidecopy: boolean | string = false;
|
||||
|
||||
@ViewChild(CodeExampleComponent, {static: true}) codeExampleComponent: CodeExampleComponent;
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/* eslint-disable @angular-eslint/component-selector */
|
||||
import { Component, HostBinding, ElementRef, ViewChild, Input, AfterViewInit } from '@angular/core';
|
||||
import { fromInnerHTML } from 'app/shared/security';
|
||||
import { CodeComponent } from './code.component';
|
||||
|
||||
/**
|
||||
* An embeddable code block that displays nicely formatted code.
|
||||
* Example usage:
|
||||
*
|
||||
* ```
|
||||
* <code-example language="ts" linenums="2" class="special" header="Do Stuff">
|
||||
* // a code block
|
||||
* console.log('do stuff');
|
||||
* </code-example>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'code-example',
|
||||
template: `
|
||||
<!-- Content projection is used to get the content HTML provided to this component -->
|
||||
<div #content style="display: none"><ng-content></ng-content></div>
|
||||
|
||||
<header *ngIf="header">{{header}}</header>
|
||||
|
||||
<aio-code [class.headed-code]="!!this.header"
|
||||
[class.simple-code]="!this.header"
|
||||
[language]="language"
|
||||
[linenums]="linenums"
|
||||
[path]="path"
|
||||
[region]="region"
|
||||
[hideCopy]="hidecopy"
|
||||
[header]="header">
|
||||
</aio-code>
|
||||
`,
|
||||
})
|
||||
export class CodeExampleComponent implements AfterViewInit {
|
||||
classes: { 'headed-code': boolean, 'simple-code': boolean };
|
||||
|
||||
@Input() language: string;
|
||||
|
||||
@Input() linenums: string;
|
||||
|
||||
@Input() region: string;
|
||||
|
||||
@Input() header: string;
|
||||
|
||||
@Input()
|
||||
set path(path: string) {
|
||||
this._path = path;
|
||||
this.isAvoid = this.path.indexOf('.avoid.') !== -1;
|
||||
}
|
||||
get path(): string { return this._path; }
|
||||
private _path = '';
|
||||
|
||||
@Input()
|
||||
set hidecopy(hidecopy: boolean) {
|
||||
// Coerce the boolean value.
|
||||
this._hidecopy = hidecopy != null && `${hidecopy}` !== 'false';
|
||||
}
|
||||
get hidecopy(): boolean { return this._hidecopy; }
|
||||
private _hidecopy: boolean;
|
||||
|
||||
/* eslint-disable-next-line @angular-eslint/no-input-rename */
|
||||
@Input('hide-copy')
|
||||
set hyphenatedHideCopy(hidecopy: boolean) {
|
||||
this.hidecopy = hidecopy;
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @angular-eslint/no-input-rename */
|
||||
@Input('hideCopy')
|
||||
set capitalizedHideCopy(hidecopy: boolean) {
|
||||
this.hidecopy = hidecopy;
|
||||
}
|
||||
|
||||
@HostBinding('class.avoidFile') isAvoid = false;
|
||||
|
||||
@ViewChild('content', { static: true }) content: ElementRef<HTMLDivElement>;
|
||||
|
||||
@ViewChild(CodeComponent, { static: true }) aioCode: CodeComponent;
|
||||
|
||||
ngAfterViewInit() {
|
||||
const contentElem = this.content.nativeElement;
|
||||
this.aioCode.code = fromInnerHTML(contentElem);
|
||||
contentElem.textContent = ''; // Remove DOM nodes that are no longer needed.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CodeExampleComponent } from './code-example.component';
|
||||
import { CodeModule } from './code.module';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, CodeModule ],
|
||||
declarations: [ CodeExampleComponent ],
|
||||
exports: [ CodeExampleComponent ]
|
||||
})
|
||||
export class CodeExampleModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = CodeExampleComponent;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Component, ViewChild, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
import { MockPrettyPrinter } from 'testing/pretty-printer.service';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
import { CodeTabsComponent } from './code-tabs.component';
|
||||
import { CodeTabsModule } from './code-tabs.module';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
|
||||
describe('CodeTabsComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let hostComponent: HostComponent;
|
||||
let codeTabsComponent: CodeTabsComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ HostComponent ],
|
||||
imports: [ CodeTabsModule, NoopAnimationsModule ],
|
||||
schemas: [ NO_ERRORS_SCHEMA ],
|
||||
providers: [
|
||||
{ provide: Logger, useClass: MockLogger },
|
||||
{ provide: PrettyPrinter, useClass: MockPrettyPrinter },
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent = fixture.componentInstance;
|
||||
codeTabsComponent = hostComponent.codeTabsComponent;
|
||||
});
|
||||
|
||||
it('should get correct tab info', () => {
|
||||
const tabs = codeTabsComponent.tabs;
|
||||
expect(tabs.length).toBe(2);
|
||||
|
||||
// First code pane expectations
|
||||
expect(tabs[0].class).toBe('class-A');
|
||||
expect(tabs[0].language).toBe('language-A');
|
||||
expect(tabs[0].linenums).toBe('linenums-A');
|
||||
expect(tabs[0].path).toBe('path-A');
|
||||
expect(tabs[0].region).toBe('region-A');
|
||||
expect(tabs[0].header).toBe('header-A');
|
||||
expect(tabs[0].code.toString().trim()).toBe('Code example 1');
|
||||
|
||||
// Second code pane expectations
|
||||
expect(tabs[1].class).toBe('class-B');
|
||||
expect(tabs[1].language).toBe('language-B');
|
||||
expect(tabs[1].linenums).withContext('Default linenums should have been used')
|
||||
.toBe('default-linenums');
|
||||
expect(tabs[1].path).toBe('path-B');
|
||||
expect(tabs[1].region).toBe('region-B');
|
||||
expect(tabs[1].header).toBe('header-B');
|
||||
expect(tabs[1].code.toString().trim()).toBe('Code example 2');
|
||||
});
|
||||
|
||||
it('should create the right number of tabs with the right labels and classes', () => {
|
||||
const matTabs = fixture.nativeElement.querySelectorAll('.mdc-tab__text-label');
|
||||
expect(matTabs.length).toBe(2);
|
||||
|
||||
expect(matTabs[0].textContent.trim()).toBe('header-A');
|
||||
expect(matTabs[0].querySelector('.class-A')).toBeTruthy();
|
||||
|
||||
expect(matTabs[1].textContent.trim()).toBe('header-B');
|
||||
expect(matTabs[1].querySelector('.class-B')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show the first tab with the right code', () => {
|
||||
const codeContent = fixture.nativeElement.querySelector('aio-code').textContent;
|
||||
expect(codeContent.indexOf('Code example 1') !== -1).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should clean-up the projected tabs content once captured', () => {
|
||||
expect(codeTabsComponent.content.nativeElement.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<code-tabs linenums="default-linenums">
|
||||
<code-pane class="class-A"
|
||||
language="language-A"
|
||||
linenums="linenums-A"
|
||||
path="path-A"
|
||||
region="region-A"
|
||||
header="header-A">
|
||||
Code example 1
|
||||
</code-pane>
|
||||
<code-pane class="class-B"
|
||||
language="language-B"
|
||||
path="path-B"
|
||||
region="region-B"
|
||||
header="header-B">
|
||||
Code example 2
|
||||
</code-pane>
|
||||
</code-tabs>
|
||||
`
|
||||
})
|
||||
class HostComponent {
|
||||
@ViewChild(CodeTabsComponent, {static: true}) codeTabsComponent: CodeTabsComponent;
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/* eslint-disable @angular-eslint/component-selector */
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
|
||||
import { fromInnerHTML } from 'app/shared/security';
|
||||
import { CodeComponent } from './code.component';
|
||||
|
||||
export interface TabInfo {
|
||||
class: string;
|
||||
code: TrustedHTML;
|
||||
path: string;
|
||||
region: string;
|
||||
|
||||
header?: string;
|
||||
language?: string;
|
||||
linenums?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a set of tab group of code snippets.
|
||||
*
|
||||
* The innerHTML of the `<code-tabs>` component should contain `<code-pane>` elements.
|
||||
* Each `<code-pane>` has the same interface as the embedded `<code-example>` component.
|
||||
* The optional `linenums` attribute is the default `linenums` for each code pane.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'code-tabs',
|
||||
template: `
|
||||
<!-- Use content projection so that the provided HTML's code-panes can be split into tabs -->
|
||||
<div #content style="display: none"><ng-content></ng-content></div>
|
||||
|
||||
<mat-card>
|
||||
<mat-tab-group class="code-tab-group" [disableRipple]="true">
|
||||
<mat-tab style="overflow-y: hidden;" *ngFor="let tab of tabs">
|
||||
<ng-template mat-tab-label>
|
||||
<span class="{{ tab.class }}">{{ tab.header }}</span>
|
||||
</ng-template>
|
||||
<aio-code class="{{ tab.class }}"
|
||||
[language]="tab.language"
|
||||
[linenums]="tab.linenums"
|
||||
[path]="tab.path"
|
||||
[region]="tab.region"
|
||||
[header]="tab.header">
|
||||
</aio-code>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card>
|
||||
`,
|
||||
})
|
||||
export class CodeTabsComponent implements OnInit, AfterViewInit {
|
||||
tabs: TabInfo[];
|
||||
|
||||
@Input() linenums: string | undefined;
|
||||
|
||||
@ViewChild('content', { static: true }) content: ElementRef<HTMLDivElement>;
|
||||
|
||||
@ViewChildren(CodeComponent) codeComponents: QueryList<CodeComponent>;
|
||||
|
||||
ngOnInit() {
|
||||
this.tabs = [];
|
||||
const contentElem = this.content.nativeElement;
|
||||
const codeExamples = Array.from(contentElem.querySelectorAll('code-pane'));
|
||||
|
||||
// Remove DOM nodes that are no longer needed.
|
||||
contentElem.textContent = '';
|
||||
|
||||
for (const tabContent of codeExamples) {
|
||||
this.tabs.push(this.getTabInfo(tabContent));
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.codeComponents.toArray().forEach((codeComponent, i) => {
|
||||
codeComponent.code = this.tabs[i].code;
|
||||
});
|
||||
}
|
||||
|
||||
/** Gets the extracted TabInfo data from the provided code-pane element. */
|
||||
private getTabInfo(tabContent: Element): TabInfo {
|
||||
return {
|
||||
class: tabContent.getAttribute('class') || '',
|
||||
code: fromInnerHTML(tabContent),
|
||||
path: tabContent.getAttribute('path') || '',
|
||||
region: tabContent.getAttribute('region') || '',
|
||||
|
||||
header: tabContent.getAttribute('header') || undefined,
|
||||
language: tabContent.getAttribute('language') || undefined,
|
||||
linenums: tabContent.getAttribute('linenums') || this.linenums,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CodeTabsComponent } from './code-tabs.component';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { CodeModule } from './code.module';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, MatCardModule, MatTabsModule, CodeModule ],
|
||||
declarations: [ CodeTabsComponent ],
|
||||
exports: [ CodeTabsComponent ]
|
||||
})
|
||||
export class CodeTabsModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = CodeTabsComponent;
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
import { Component, ViewChild, AfterViewInit } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
|
||||
import { CodeComponent } from './code.component';
|
||||
import { CodeModule } from './code.module';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { htmlEscape } from 'safevalues';
|
||||
import { MockPrettyPrinter } from 'testing/pretty-printer.service';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
|
||||
const oneLineCode = 'const foo = "bar";';
|
||||
|
||||
const smallMultiLineCode = `<hero-details>
|
||||
<h2>Bah Dah Bing</h2>
|
||||
<hero-team>
|
||||
<h3>NYC Team</h3>
|
||||
</hero-team>
|
||||
</hero-details>`;
|
||||
|
||||
const bigMultiLineCode = `${smallMultiLineCode}\n${smallMultiLineCode}\n${smallMultiLineCode}`;
|
||||
|
||||
describe('CodeComponent', () => {
|
||||
let hostComponent: HostComponent;
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ NoopAnimationsModule, CodeModule ],
|
||||
declarations: [ HostComponent ],
|
||||
providers: [
|
||||
{ provide: Logger, useClass: TestLogger },
|
||||
{ provide: PrettyPrinter, useClass: MockPrettyPrinter },
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
hostComponent = fixture.componentInstance;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('pretty printing', () => {
|
||||
const getFormattedCode = () => fixture.nativeElement.querySelector('code').innerHTML;
|
||||
|
||||
it('should format a one-line code sample without linenums by default', () => {
|
||||
hostComponent.setCode(oneLineCode);
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: false): ${oneLineCode}`);
|
||||
});
|
||||
|
||||
it('should add line numbers to one-line code sample when linenums is `true`', () => {
|
||||
hostComponent.setCode(oneLineCode);
|
||||
hostComponent.linenums = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: true): ${oneLineCode}`);
|
||||
});
|
||||
|
||||
it('should add line numbers to one-line code sample when linenums is `\'true\'`', () => {
|
||||
hostComponent.setCode(oneLineCode);
|
||||
hostComponent.linenums = 'true';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: true): ${oneLineCode}`);
|
||||
});
|
||||
|
||||
it('should format a small multi-line code sample without linenums by default', () => {
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: false): ${htmlEscape(smallMultiLineCode)}`);
|
||||
});
|
||||
|
||||
it('should add line numbers to a small multi-line code sample when linenums is `true`', () => {
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
hostComponent.linenums = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: true): ${htmlEscape(smallMultiLineCode)}`);
|
||||
});
|
||||
|
||||
it('should add line numbers to a small multi-line code sample when linenums is `\'true\'`', () => {
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
hostComponent.linenums = 'true';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: true): ${htmlEscape(smallMultiLineCode)}`);
|
||||
});
|
||||
|
||||
it('should format a big multi-line code without linenums by default', () => {
|
||||
hostComponent.setCode(bigMultiLineCode);
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: false): ${htmlEscape(bigMultiLineCode)}`);
|
||||
});
|
||||
|
||||
it('should add line numbers to a big multi-line code sample when linenums is `true`', () => {
|
||||
hostComponent.setCode(bigMultiLineCode);
|
||||
hostComponent.linenums = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: true): ${htmlEscape(bigMultiLineCode)}`);
|
||||
});
|
||||
|
||||
it('should add line numbers to a big multi-line code sample when linenums is `\'true\'`', () => {
|
||||
hostComponent.setCode(bigMultiLineCode);
|
||||
hostComponent.linenums = 'true';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(
|
||||
`Formatted code (language: auto, linenums: true): ${htmlEscape(bigMultiLineCode)}`);
|
||||
});
|
||||
|
||||
it('should skip prettify if language is `\'none\'`', () => {
|
||||
hostComponent.setCode(bigMultiLineCode);
|
||||
hostComponent.language = 'none';
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getFormattedCode()).toBe(htmlEscape(bigMultiLineCode).toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('should remove common indentation from the code before rendering', () => {
|
||||
hostComponent.linenums = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(`
|
||||
abc
|
||||
let x = text.split('\\n');
|
||||
ghi
|
||||
|
||||
jkl
|
||||
`);
|
||||
const codeContent = fixture.nativeElement.querySelector('code').textContent;
|
||||
expect(codeContent).toEqual(
|
||||
'Formatted code (language: auto, linenums: false): abc\n let x = text.split(\'\\n\');\nghi\n\njkl');
|
||||
});
|
||||
|
||||
it('should trim whitespace from the code before rendering', () => {
|
||||
hostComponent.linenums = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode('\n\n\n' + smallMultiLineCode + '\n\n\n');
|
||||
const codeContent = fixture.nativeElement.querySelector('code').textContent;
|
||||
expect(codeContent).toEqual(codeContent.trim());
|
||||
});
|
||||
|
||||
it('should trim whitespace from code before computing whether to format linenums', () => {
|
||||
hostComponent.setCode('\n\n\n' + oneLineCode + '\n\n\n');
|
||||
|
||||
// `<li>`s are a tell-tale for line numbers
|
||||
const lis = fixture.nativeElement.querySelectorAll('li');
|
||||
expect(lis.length).withContext('should be no linenums').toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message', () => {
|
||||
|
||||
function getErrorMessage() {
|
||||
const missing: HTMLElement = fixture.nativeElement.querySelector('.code-missing');
|
||||
return missing ? missing.textContent : null;
|
||||
}
|
||||
|
||||
it('should not display "code-missing" class when there is some code', () => {
|
||||
expect(getErrorMessage()).withContext('should not have element with "code-missing" class').toBeNull();
|
||||
});
|
||||
|
||||
it('should display error message when there is no code (after trimming)', () => {
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toContain('missing');
|
||||
});
|
||||
|
||||
it('should show path and region in missing-code error message', () => {
|
||||
hostComponent.path = 'fizz/buzz/foo.html';
|
||||
hostComponent.region = 'something';
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html#something$/);
|
||||
});
|
||||
|
||||
it('should show path only in missing-code error message when no region', () => {
|
||||
hostComponent.path = 'fizz/buzz/foo.html';
|
||||
fixture.detectChanges();
|
||||
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html$/);
|
||||
});
|
||||
|
||||
it('should show simple missing-code error message when no path/region', () => {
|
||||
hostComponent.setCode(' \n ');
|
||||
expect(getErrorMessage()).toMatch(/missing.$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy button', () => {
|
||||
|
||||
function getButton() {
|
||||
const btnDe = fixture.debugElement.query(By.css('button'));
|
||||
return btnDe ? btnDe.nativeElement : null;
|
||||
}
|
||||
|
||||
it('should be hidden if the `hideCopy` input is true', () => {
|
||||
hostComponent.hideCopy = true;
|
||||
fixture.detectChanges();
|
||||
expect(getButton()).toBe(null);
|
||||
});
|
||||
|
||||
it('should have title', () => {
|
||||
expect(getButton().title).toBe('Copy code snippet');
|
||||
});
|
||||
|
||||
it('should have no aria-label by default', () => {
|
||||
expect(getButton().getAttribute('aria-label')).toBe('');
|
||||
});
|
||||
|
||||
it('should have aria-label explaining what is being copied when header passed in', () => {
|
||||
hostComponent.header = 'a/b/c/foo.ts';
|
||||
fixture.detectChanges();
|
||||
expect(getButton().getAttribute('aria-label')).toContain(hostComponent.header);
|
||||
});
|
||||
|
||||
it('should call copier service when clicked', () => {
|
||||
const clipboard = TestBed.inject(Clipboard);
|
||||
const spy = spyOn(clipboard, 'copy');
|
||||
expect(spy.calls.count()).withContext('before click').toBe(0);
|
||||
getButton().click();
|
||||
expect(spy.calls.count()).withContext('after click').toBe(1);
|
||||
});
|
||||
|
||||
it('should copy code text when clicked', () => {
|
||||
const clipboard = TestBed.inject(Clipboard);
|
||||
const spy = spyOn(clipboard, 'copy');
|
||||
getButton().click();
|
||||
expect(spy.calls.argsFor(0)[0]).withContext('after click').toBe(oneLineCode);
|
||||
});
|
||||
|
||||
it('should preserve newlines in the copied code', () => {
|
||||
const clipboard = TestBed.inject(Clipboard);
|
||||
const spy = spyOn(clipboard, 'copy');
|
||||
const expectedCode = smallMultiLineCode.trim();
|
||||
let actualCode;
|
||||
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
|
||||
[false, true, 42].forEach(linenums => {
|
||||
hostComponent.linenums = linenums;
|
||||
fixture.detectChanges();
|
||||
getButton().click();
|
||||
actualCode = spy.calls.mostRecent().args[0];
|
||||
|
||||
expect(actualCode).withContext(`when linenums=${linenums}`).toBe(expectedCode);
|
||||
expect(actualCode.match(/\r?\n/g)?.length).toBe(5);
|
||||
|
||||
spy.calls.reset();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a message when copy succeeds', () => {
|
||||
const snackBar: MatSnackBar = TestBed.inject(MatSnackBar);
|
||||
const clipboard = TestBed.inject(Clipboard);
|
||||
spyOn(snackBar, 'open');
|
||||
spyOn(clipboard, 'copy').and.returnValue(true);
|
||||
getButton().click();
|
||||
expect(snackBar.open).toHaveBeenCalledWith('Code Copied', '', { duration: 800 });
|
||||
});
|
||||
|
||||
it('should display an error when copy fails', () => {
|
||||
const snackBar: MatSnackBar = TestBed.inject(MatSnackBar);
|
||||
const clipboard = TestBed.inject(Clipboard);
|
||||
const logger = TestBed.inject(Logger) as unknown as TestLogger;
|
||||
spyOn(snackBar, 'open');
|
||||
spyOn(clipboard, 'copy').and.returnValue(false);
|
||||
getButton().click();
|
||||
expect(snackBar.open).toHaveBeenCalledWith('Copy failed. Please try again!', '', { duration: 800 });
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(jasmine.any(Error));
|
||||
expect(logger.error.calls.mostRecent().args[0].message).toMatch(/^ERROR copying code to clipboard:/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
//// Test helpers ////
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<aio-code [language]="language"
|
||||
[linenums]="linenums" [path]="path" [region]="region"
|
||||
[hideCopy]="hideCopy" [header]="header"></aio-code>
|
||||
`
|
||||
})
|
||||
class HostComponent implements AfterViewInit {
|
||||
hideCopy: boolean;
|
||||
language: string;
|
||||
linenums: boolean | number | string;
|
||||
path: string;
|
||||
region: string;
|
||||
header: string;
|
||||
|
||||
@ViewChild(CodeComponent, {static: false}) codeComponent: CodeComponent;
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.setCode(oneLineCode);
|
||||
}
|
||||
|
||||
/** Changes the displayed code on the code component. */
|
||||
setCode(code: string) {
|
||||
this.codeComponent.code = htmlEscape(code);
|
||||
}
|
||||
}
|
||||
|
||||
class TestLogger {
|
||||
log = jasmine.createSpy('log');
|
||||
error = jasmine.createSpy('error');
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { unwrapHtml } from 'safevalues';
|
||||
import { htmlSafeByReview } from 'safevalues/restricted/reviewed';
|
||||
import { fromOuterHTML } from 'app/shared/security';
|
||||
|
||||
/**
|
||||
* Formatted Code Block
|
||||
*
|
||||
* Pretty renders a code block, used in the docs and API reference by the code-example and
|
||||
* code-tabs embedded components.
|
||||
* It includes a "copy" button that will send the content to the clipboard when clicked
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* ```
|
||||
* <aio-code
|
||||
* [language]="ts"
|
||||
* [linenums]="true"
|
||||
* [path]="router/src/app/app.module.ts"
|
||||
* [region]="animations-module">
|
||||
* </aio-code>
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* Renders code provided through the `updateCode` method.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'aio-code',
|
||||
template: `
|
||||
<pre class="prettyprint lang-{{language}}">
|
||||
<button *ngIf="!hideCopy" class="material-icons copy-button no-print"
|
||||
title="Copy code snippet"
|
||||
[attr.aria-label]="ariaLabel"
|
||||
(click)="doCopy()">
|
||||
<span aria-hidden="true">content_copy</span>
|
||||
</button>
|
||||
<code class="animated fadeIn" #codeContainer></code>
|
||||
</pre>
|
||||
`
|
||||
})
|
||||
export class CodeComponent implements OnChanges {
|
||||
ariaLabel = '';
|
||||
|
||||
/** The code to be copied when clicking the copy button, this should not be HTML encoded */
|
||||
private codeText: string;
|
||||
|
||||
/** Code that should be formatted with current inputs and displayed in the view. */
|
||||
set code(code: TrustedHTML) {
|
||||
this._code = code;
|
||||
|
||||
if (!this._code.toString().trim()) {
|
||||
this.showMissingCodeMessage();
|
||||
} else {
|
||||
this.formatDisplayedCode();
|
||||
}
|
||||
}
|
||||
get code(): TrustedHTML {
|
||||
return this._code;
|
||||
}
|
||||
_code: TrustedHTML;
|
||||
|
||||
/** Whether the copy button should be shown. */
|
||||
@Input() hideCopy: boolean;
|
||||
|
||||
/** Language to render the code (e.g. javascript, typescript). */
|
||||
@Input() language: string | undefined;
|
||||
|
||||
/**
|
||||
* Whether to display line numbers:
|
||||
* - If false: hide
|
||||
* - If true: show
|
||||
* - If number: show but start at that number
|
||||
*/
|
||||
@Input() linenums: boolean | number | string | undefined;
|
||||
|
||||
/** Path to the source of the code. */
|
||||
@Input() path: string;
|
||||
|
||||
/** Region of the source of the code being displayed. */
|
||||
@Input() region: string;
|
||||
|
||||
/** Optional header to be displayed above the code. */
|
||||
@Input()
|
||||
set header(header: string | undefined) {
|
||||
this._header = header;
|
||||
this.ariaLabel = this.header ? `Copy code snippet from ${this.header}` : '';
|
||||
}
|
||||
get header(): string|undefined { return this._header; }
|
||||
private _header: string | undefined;
|
||||
|
||||
@Output() codeFormatted = new EventEmitter<void>();
|
||||
|
||||
/** The element in the template that will display the formatted code. */
|
||||
@ViewChild('codeContainer', { static: true }) codeContainer: ElementRef;
|
||||
|
||||
constructor(
|
||||
private snackbar: MatSnackBar,
|
||||
private pretty: PrettyPrinter,
|
||||
private clipboard: Clipboard,
|
||||
private logger: Logger) {}
|
||||
|
||||
ngOnChanges() {
|
||||
// If some inputs have changed and there is code displayed, update the view with the latest
|
||||
// formatted code.
|
||||
if (this.code) {
|
||||
this.formatDisplayedCode();
|
||||
}
|
||||
}
|
||||
|
||||
private formatDisplayedCode() {
|
||||
const linenums = this.getLinenums();
|
||||
const leftAlignedCode = leftAlign(this.code);
|
||||
this.setCodeHtml(leftAlignedCode); // start with unformatted code
|
||||
this.codeText = this.getCodeText(); // store the unformatted code as text (for copying)
|
||||
|
||||
const skipPrettify = of(undefined);
|
||||
const prettifyCode = this.pretty
|
||||
.formatCode(leftAlignedCode, this.language, linenums)
|
||||
.pipe(tap(formattedCode => this.setCodeHtml(formattedCode)));
|
||||
|
||||
if (linenums !== false && this.language === 'none') {
|
||||
this.logger.warn("Using 'linenums' with 'language: none' is currently not supported.");
|
||||
}
|
||||
|
||||
((this.language === 'none' ? skipPrettify : prettifyCode) as Observable<unknown>)
|
||||
.subscribe({
|
||||
next: () => this.codeFormatted.emit(),
|
||||
error: () => { /* ignore failure to format */ },
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets the message showing that the code could not be found. */
|
||||
private showMissingCodeMessage() {
|
||||
const src = this.path ? this.path + (this.region ? '#' + this.region : '') : '';
|
||||
const msg = `The code sample is missing${src ? ` for\n${src}` : '.'}`;
|
||||
const el = document.createElement('p');
|
||||
el.className = 'code-missing';
|
||||
el.textContent = msg;
|
||||
this.setCodeHtml(fromOuterHTML(el));
|
||||
}
|
||||
|
||||
/** Sets the innerHTML of the code container to the provided code string. */
|
||||
private setCodeHtml(formattedCode: TrustedHTML) {
|
||||
// **Security:** Code example content is provided by docs authors and as such its considered to
|
||||
// be safe for innerHTML purposes.
|
||||
this.codeContainer.nativeElement.innerHTML = unwrapHtml(formattedCode);
|
||||
}
|
||||
|
||||
/** Gets the textContent of the displayed code element. */
|
||||
private getCodeText() {
|
||||
// `prettify` may remove newlines, e.g. when `linenums` are on. Retrieve the content of the
|
||||
// container as text, before prettifying it.
|
||||
// We take the textContent because we don't want it to be HTML encoded.
|
||||
return this.codeContainer.nativeElement.textContent;
|
||||
}
|
||||
|
||||
/** Copies the code snippet to the user's clipboard. */
|
||||
doCopy() {
|
||||
const code = this.codeText;
|
||||
const successfullyCopied = this.clipboard.copy(code);
|
||||
|
||||
if (successfullyCopied) {
|
||||
this.logger.log('Copied code to clipboard:', code);
|
||||
this.snackbar.open('Code Copied', '', { duration: 800 });
|
||||
} else {
|
||||
this.logger.error(new Error(`ERROR copying code to clipboard: "${code}"`));
|
||||
this.snackbar.open('Copy failed. Please try again!', '', { duration: 800 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Gets the calculated value of linenums (boolean/number). */
|
||||
getLinenums() {
|
||||
const linenums =
|
||||
typeof this.linenums === 'boolean' ? this.linenums :
|
||||
this.linenums === 'true' ? true :
|
||||
this.linenums === 'false' ? false :
|
||||
typeof this.linenums === 'string' ? parseInt(this.linenums, 10) :
|
||||
this.linenums;
|
||||
|
||||
return (linenums != null) && !isNaN(linenums as number) && linenums;
|
||||
}
|
||||
}
|
||||
|
||||
function leftAlign(text: TrustedHTML): TrustedHTML {
|
||||
let indent = Number.MAX_VALUE;
|
||||
|
||||
const lines = text.toString().split('\n');
|
||||
lines.forEach(line => {
|
||||
const lineIndent = line.search(/\S/);
|
||||
if (lineIndent !== -1) {
|
||||
indent = Math.min(lineIndent, indent);
|
||||
}
|
||||
});
|
||||
|
||||
return htmlSafeByReview(
|
||||
lines.map(line => line.slice(indent)).join('\n').trim(),
|
||||
'safe manipulation of existing trusted HTML');
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CodeComponent } from './code.component';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { PrettyPrinter } from './pretty-printer.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, MatSnackBarModule ],
|
||||
declarations: [ CodeComponent ],
|
||||
exports: [ CodeComponent ],
|
||||
providers: [ PrettyPrinter ]
|
||||
})
|
||||
export class CodeModule { }
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { htmlSafeByReview } from 'safevalues/restricted/reviewed';
|
||||
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { first, map, share } from 'rxjs/operators';
|
||||
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
|
||||
type PrettyPrintOne = (code: TrustedHTML, language?: string, linenums?: number|boolean) => string;
|
||||
|
||||
/**
|
||||
* Wrapper around the prettify.js library
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrettyPrinter {
|
||||
|
||||
private prettyPrintOne: Observable<PrettyPrintOne>;
|
||||
|
||||
constructor(private logger: Logger) {
|
||||
this.prettyPrintOne = from(this.getPrettyPrintOne()).pipe(share());
|
||||
}
|
||||
|
||||
private getPrettyPrintOne(): Promise<PrettyPrintOne> {
|
||||
const ppo = (window as any).prettyPrintOne;
|
||||
return ppo ? Promise.resolve(ppo) :
|
||||
// `prettyPrintOne` is not on `window`, which means `prettify.js` has not been loaded yet.
|
||||
// Import it; ad a side-effect it will add `prettyPrintOne` on `window`.
|
||||
import('assets/js/prettify.js' as any)
|
||||
.then(
|
||||
() => (window as any).prettyPrintOne,
|
||||
err => {
|
||||
const msg = `Cannot get prettify.js from server: ${err.message}`;
|
||||
this.logger.error(new Error(msg));
|
||||
// return a pretty print fn that always fails.
|
||||
return () => { throw new Error(msg); };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format code snippet as HTML.
|
||||
*
|
||||
* @param code - the code snippet to format; should already be HTML encoded
|
||||
* @param [language] - The language of the code to render (could be javascript, html, typescript, etc)
|
||||
* @param [linenums] - Whether to display line numbers:
|
||||
* - false: don't display
|
||||
* - true: do display
|
||||
* - number: do display but start at the given number
|
||||
* @returns Observable<string> - Observable of formatted code
|
||||
*/
|
||||
formatCode(code: TrustedHTML, language?: string, linenums?: number|boolean) {
|
||||
return this.prettyPrintOne.pipe(
|
||||
map(ppo => {
|
||||
try {
|
||||
return htmlSafeByReview(
|
||||
ppo(code, language, linenums), 'prettify.js modifies already trusted HTML inline');
|
||||
} catch (err) {
|
||||
const msg = `Could not format code that begins '${code.toString().slice(0, 50)}...'.`;
|
||||
console.error(msg, err);
|
||||
throw new Error(msg);
|
||||
}
|
||||
}),
|
||||
first(), // complete immediately
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { Injector } from '@angular/core';
|
||||
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ContributorGroup } from './contributors.model';
|
||||
import { ContributorListComponent } from './contributor-list.component';
|
||||
import { ContributorService } from './contributor.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
|
||||
// Testing the component class behaviors, independent of its template
|
||||
// Let e2e tests verify how it displays.
|
||||
describe('ContributorListComponent', () => {
|
||||
|
||||
let component: ContributorListComponent;
|
||||
let injector: Injector;
|
||||
let contributorService: TestContributorService;
|
||||
let locationService: TestLocationService;
|
||||
let contributorGroups: ContributorGroup[];
|
||||
|
||||
beforeEach(() => {
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
{provide: ContributorListComponent, deps: [ContributorService, LocationService] },
|
||||
{provide: ContributorService, useClass: TestContributorService, deps: [] },
|
||||
{provide: LocationService, useClass: TestLocationService, deps: [] }
|
||||
]
|
||||
});
|
||||
|
||||
locationService = injector.get(LocationService) as unknown as TestLocationService;
|
||||
contributorService = injector.get(ContributorService) as unknown as TestContributorService;
|
||||
contributorGroups = contributorService.testContributors;
|
||||
});
|
||||
|
||||
it('should select the first group when no query string', () => {
|
||||
component = getComponent();
|
||||
expect(component.selectedGroup).toBe(contributorGroups[0]);
|
||||
});
|
||||
|
||||
it('should select the first group when query string w/o "group" property', () => {
|
||||
locationService.searchResult = { foo: 'GDE' };
|
||||
component = getComponent();
|
||||
expect(component.selectedGroup).toBe(contributorGroups[0]);
|
||||
});
|
||||
|
||||
it('should select the first group when query group not found', () => {
|
||||
locationService.searchResult = { group: 'foo' };
|
||||
component = getComponent();
|
||||
expect(component.selectedGroup).toBe(contributorGroups[0]);
|
||||
});
|
||||
|
||||
it('should select the GDE group when query group is "GDE"', () => {
|
||||
locationService.searchResult = { group: 'GDE' };
|
||||
component = getComponent();
|
||||
expect(component.selectedGroup).toBe(contributorGroups[1]);
|
||||
});
|
||||
|
||||
it('should select the GDE group when query group is "gde" (case insensitive)', () => {
|
||||
locationService.searchResult = { group: 'gde' };
|
||||
component = getComponent();
|
||||
expect(component.selectedGroup).toBe(contributorGroups[1]);
|
||||
});
|
||||
|
||||
it('should set the query to the "GDE" group when user selects "GDE"', () => {
|
||||
component = getComponent();
|
||||
component.selectGroup('GDE');
|
||||
expect(locationService.searchResult.group).toBe('GDE');
|
||||
});
|
||||
|
||||
it('should set the query to the first group when user selects unknown name', () => {
|
||||
component = getComponent();
|
||||
component.selectGroup('GDE'); // a legit group that isn't the first
|
||||
|
||||
component.selectGroup('foo'); // not a legit group name
|
||||
expect(locationService.searchResult.group).toBe('Angular');
|
||||
});
|
||||
|
||||
//// Test Helpers ////
|
||||
function getComponent(): ContributorListComponent {
|
||||
const comp = injector.get(ContributorListComponent);
|
||||
comp.ngOnInit();
|
||||
return comp;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
[index: string]: string;
|
||||
}
|
||||
|
||||
class TestLocationService {
|
||||
searchResult: SearchResult = {};
|
||||
search = jasmine.createSpy('search').and.callFake(() => this.searchResult);
|
||||
setSearch = jasmine.createSpy('setSearch')
|
||||
.and.callFake((_label: string, result: SearchResult) => {
|
||||
this.searchResult = result;
|
||||
});
|
||||
}
|
||||
|
||||
class TestContributorService {
|
||||
testContributors = getTestData();
|
||||
contributors = of(this.testContributors);
|
||||
}
|
||||
|
||||
function getTestData(): ContributorGroup[] {
|
||||
return [
|
||||
// Not interested in the contributors data in these tests
|
||||
{ name: 'Angular', order: 0, contributors: [] },
|
||||
{ name: 'GDE', order: 1, contributors: [] },
|
||||
];
|
||||
}
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { ContributorGroup } from './contributors.model';
|
||||
import { ContributorService } from './contributor.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-contributor-list',
|
||||
template: `
|
||||
<div class="flex-center group-buttons">
|
||||
<button *ngFor="let name of groupNames"
|
||||
class="button mat-button filter-button"
|
||||
[class.selected]="name === selectedGroup.name"
|
||||
(click)="selectGroup(name)">{{name}}</button>
|
||||
</div>
|
||||
<section *ngIf="selectedGroup" class="grid-fluid">
|
||||
<div class="contributor-group">
|
||||
<aio-contributor *ngFor="let person of selectedGroup.contributors"
|
||||
[person]="person"></aio-contributor>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ContributorListComponent implements OnInit {
|
||||
private groups: ContributorGroup[];
|
||||
groupNames: string[];
|
||||
selectedGroup: ContributorGroup;
|
||||
|
||||
constructor(
|
||||
private contributorService: ContributorService,
|
||||
private locationService: LocationService) { }
|
||||
|
||||
ngOnInit() {
|
||||
const groupName = this.locationService.search().group || '';
|
||||
// no need to unsubscribe because `contributors` completes
|
||||
this.contributorService.contributors
|
||||
.subscribe(grps => {
|
||||
this.groups = grps;
|
||||
this.groupNames = grps.map(g => g.name);
|
||||
this.selectGroup(groupName);
|
||||
});
|
||||
}
|
||||
|
||||
selectGroup(name: string) {
|
||||
name = name.toLowerCase();
|
||||
this.selectedGroup = this.groups.find(g => g.name.toLowerCase() === name) || this.groups[0];
|
||||
this.locationService.setSearch('', {group: this.selectedGroup.name});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ContributorListComponent } from './contributor-list.component';
|
||||
import { ContributorService } from './contributor.service';
|
||||
import { ContributorComponent } from './contributor.component';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule, MatIconModule ],
|
||||
declarations: [ ContributorListComponent, ContributorComponent ],
|
||||
providers: [ ContributorService ]
|
||||
})
|
||||
export class ContributorListModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = ContributorListComponent;
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { Contributor } from './contributors.model';
|
||||
import { CONTENT_URL_PREFIX } from 'app/documents/document.service';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-contributor',
|
||||
template: `
|
||||
<section class="contributor-card" [class.no-image]="!person.picture"
|
||||
attr.aria-labelledby="{{person.name}}-section-heading">
|
||||
<div *ngIf="person.picture" class="contributor-image-wrapper">
|
||||
<img [src]="pictureBase+person.picture" alt="{{person.name}}" class="contributor-image">
|
||||
</div>
|
||||
<h3 id="{{person.name}}-section-heading" class="contributor-title">{{person.name}}</h3>
|
||||
<p class="contributor-bio">{{person.bio}}</p>
|
||||
<div class="contributor-social-links">
|
||||
<a *ngIf="person.twitter" mat-icon-button class="info-item icon contributor-social"
|
||||
attr.aria-label="twitter of {{person.name}}"
|
||||
href="https://twitter.com/{{person.twitter}}"
|
||||
target="_blank" (click)="$event.stopPropagation()">
|
||||
<mat-icon svgIcon="logos:twitter"></mat-icon>
|
||||
</a>
|
||||
<a *ngIf="person.website" mat-icon-button class="info-item icon"
|
||||
attr.aria-label="website of {{person.name}}"
|
||||
href="{{person.website}}" target="_blank" (click)="$event.stopPropagation()">
|
||||
<mat-icon class="link-icon">link</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class ContributorComponent {
|
||||
@Input() person: Contributor;
|
||||
pictureBase = CONTENT_URL_PREFIX + 'images/bios/';
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Injector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ContributorService } from './contributor.service';
|
||||
import { ContributorGroup } from './contributors.model';
|
||||
|
||||
describe('ContributorService', () => {
|
||||
|
||||
let injector: Injector;
|
||||
let contribService: ContributorService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
ContributorService
|
||||
]
|
||||
});
|
||||
|
||||
contribService = injector.get<ContributorService>(ContributorService);
|
||||
httpMock = injector.get(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should make a single connection to the server', () => {
|
||||
const req = httpMock.expectOne({});
|
||||
expect(req.request.url).toBe('generated/contributors.json');
|
||||
});
|
||||
|
||||
describe('#contributors', () => {
|
||||
|
||||
let contribs: ContributorGroup[];
|
||||
let testData: any;
|
||||
|
||||
beforeEach(() => {
|
||||
testData = getTestContribs();
|
||||
httpMock.expectOne({}).flush(testData);
|
||||
contribService.contributors.subscribe(results => contribs = results);
|
||||
});
|
||||
|
||||
it('contributors observable should complete', () => {
|
||||
let completed = false;
|
||||
contribService.contributors.subscribe({complete: () => completed = true});
|
||||
expect(completed).withContext('observable completed').toBe(true);
|
||||
});
|
||||
|
||||
it('should reshape the contributor json to expected result', () => {
|
||||
const groupNames = contribs.map(g => g.name).join(',');
|
||||
expect(groupNames).toEqual('Angular,Collaborators,GDE');
|
||||
});
|
||||
|
||||
it('should have expected "GDE" contribs in order', () => {
|
||||
const gde = contribs[2];
|
||||
const actualAngularNames = gde.contributors.map(l => l.name).join(',');
|
||||
const expectedAngularNames = [testData.gkalpak, testData.kapunahelewong].map(l => l.name).join(',');
|
||||
expect(actualAngularNames).toEqual(expectedAngularNames);
|
||||
});
|
||||
|
||||
it('should support including a contributor in multiple groups', () => {
|
||||
const contributor = testData.gkalpak;
|
||||
const matchedGroups = contribs
|
||||
.filter(group => group.contributors.includes(contributor))
|
||||
.map(group => group.name);
|
||||
|
||||
expect(matchedGroups).toEqual(['Collaborators', 'GDE']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should do WHAT(?) if the request fails');
|
||||
});
|
||||
|
||||
function getTestContribs() {
|
||||
return {
|
||||
kapunahelewong: {
|
||||
name: 'Kapunahele Wong',
|
||||
picture: 'kapunahelewong.jpg',
|
||||
website: 'https://github.com/kapunahelewong',
|
||||
twitter: 'kapunahele',
|
||||
bio: 'Kapunahele is a front-end developer and contributor to angular.io',
|
||||
groups: ['GDE']
|
||||
},
|
||||
misko: {
|
||||
name: 'Miško Hevery',
|
||||
picture: 'misko.jpg',
|
||||
twitter: 'mhevery',
|
||||
website: 'http://misko.hevery.com',
|
||||
bio: 'Miško Hevery is the creator of AngularJS framework.',
|
||||
groups: ['Angular']
|
||||
},
|
||||
igor: {
|
||||
name: 'Igor Minar',
|
||||
picture: 'igor-minar.jpg',
|
||||
twitter: 'IgorMinar',
|
||||
website: 'https://google.com/+IgorMinar',
|
||||
bio: 'Igor is a software engineer at Angular.',
|
||||
groups: ['Angular']
|
||||
},
|
||||
kara: {
|
||||
name: 'Kara Erickson',
|
||||
picture: 'kara-erickson.jpg',
|
||||
twitter: 'karaforthewin',
|
||||
website: 'https://github.com/kara',
|
||||
bio: 'Kara is a software engineer on the Angular team at Angular and a co-organizer of the Angular-SF Meetup. ',
|
||||
groups: ['Angular']
|
||||
},
|
||||
jeffcross: {
|
||||
name: 'Jeff Cross',
|
||||
picture: 'jeff-cross.jpg',
|
||||
twitter: 'jeffbcross',
|
||||
website: 'https://twitter.com/jeffbcross',
|
||||
bio: 'Jeff was one of the earliest core team members on AngularJS.',
|
||||
groups: ['Collaborators']
|
||||
},
|
||||
naomi: {
|
||||
name: 'Naomi Black',
|
||||
picture: 'naomi.jpg',
|
||||
twitter: 'naomitraveller',
|
||||
website: 'http://google.com/+NaomiBlack',
|
||||
bio: 'Naomi is Angular\'s TPM generalist and jack-of-all-trades.',
|
||||
groups: ['Angular']
|
||||
},
|
||||
gkalpak: {
|
||||
name: 'George Kalpakas',
|
||||
picture: 'gkalpak.jpg',
|
||||
twitter: 'gkalpakas',
|
||||
bio: 'George wrote this test, so he gets to have his name included here.',
|
||||
groups: ['GDE', 'Collaborators'],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
|
||||
import {AsyncSubject, connectable, Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
import {Contributor, ContributorGroup} from './contributors.model';
|
||||
|
||||
// TODO(andrewjs): Look into changing this so that we don't import the service just to get the const
|
||||
import {CONTENT_URL_PREFIX} from 'app/documents/document.service';
|
||||
|
||||
const contributorsPath = CONTENT_URL_PREFIX + 'contributors.json';
|
||||
const knownGroups = ['Angular', 'Collaborators', 'GDE'];
|
||||
|
||||
@Injectable()
|
||||
export class ContributorService {
|
||||
contributors: Observable<ContributorGroup[]>;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.contributors = this.getContributors();
|
||||
}
|
||||
|
||||
private getContributors() {
|
||||
const contributors = this.http.get<{[key: string]: Contributor}>(contributorsPath).pipe(
|
||||
// Create group map
|
||||
map((contribs) => {
|
||||
const contribMap: {[name: string]: Contributor[]} = {};
|
||||
Object.keys(contribs).forEach((key) => {
|
||||
const contributor = contribs[key];
|
||||
contributor.groups.forEach((group) => {
|
||||
const contribGroup = contribMap[group] || (contribMap[group] = []);
|
||||
contribGroup.push(contributor);
|
||||
});
|
||||
});
|
||||
|
||||
return contribMap;
|
||||
}),
|
||||
|
||||
// Flatten group map into sorted group array of sorted contributors
|
||||
map((cmap) =>
|
||||
Object.keys(cmap)
|
||||
.map((key) => {
|
||||
const order = knownGroups.indexOf(key);
|
||||
return {
|
||||
name: key,
|
||||
order: order === -1 ? knownGroups.length : order,
|
||||
contributors: cmap[key].sort(compareContributors),
|
||||
};
|
||||
})
|
||||
.sort(compareGroups)
|
||||
)
|
||||
);
|
||||
|
||||
const connectableContributors = connectable(contributors, {
|
||||
connector: () => new AsyncSubject(),
|
||||
resetOnDisconnect: false,
|
||||
});
|
||||
connectableContributors.connect();
|
||||
return connectableContributors;
|
||||
}
|
||||
}
|
||||
|
||||
function compareContributors(l: Contributor, r: Contributor) {
|
||||
return l.name.toUpperCase() > r.name.toUpperCase() ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareGroups(l: ContributorGroup, r: ContributorGroup) {
|
||||
return l.order === r.order ? (l.name > r.name ? 1 : -1) : l.order > r.order ? 1 : -1;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
export interface ContributorGroup {
|
||||
name: string;
|
||||
order: number;
|
||||
contributors: Contributor[];
|
||||
}
|
||||
|
||||
export interface Contributor {
|
||||
groups: string[];
|
||||
name: string;
|
||||
picture?: string;
|
||||
website?: string;
|
||||
twitter?: string;
|
||||
bio?: string;
|
||||
isFlipped?: boolean;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { ROUTES} from '@angular/router';
|
||||
import { ElementsLoader } from './elements-loader';
|
||||
import {
|
||||
ELEMENT_MODULE_LOAD_CALLBACKS,
|
||||
ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES,
|
||||
ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN
|
||||
} from './element-registry';
|
||||
import { LazyCustomElementComponent } from './lazy-custom-element.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ LazyCustomElementComponent ],
|
||||
exports: [ LazyCustomElementComponent ],
|
||||
providers: [
|
||||
ElementsLoader,
|
||||
{ provide: ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, useValue: ELEMENT_MODULE_LOAD_CALLBACKS },
|
||||
|
||||
// Providing these routes as a signal to the build system that these modules should be
|
||||
// registered as lazy-loadable.
|
||||
// TODO(andrewjs): Provide first-class support for providing this.
|
||||
{ provide: ROUTES, useValue: ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES, multi: true },
|
||||
],
|
||||
})
|
||||
export class CustomElementsModule { }
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { VERSION } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from 'environments/environment';
|
||||
import { DistTagComponent } from './dist-tag.component';
|
||||
|
||||
describe('DistTagComponent', () => {
|
||||
let actualMode: string;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ declarations: [DistTagComponent] });
|
||||
actualMode = environment.mode;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(environment.mode as any) = actualMode;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should display nothing (for stable versions)', () => {
|
||||
environment.mode = 'stable';
|
||||
const fixture = TestBed.createComponent(DistTagComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toEqual('');
|
||||
});
|
||||
|
||||
it('should display the current major version (for archive versions)', () => {
|
||||
environment.mode = 'archive';
|
||||
const fixture = TestBed.createComponent(DistTagComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toEqual('@' + VERSION.major);
|
||||
});
|
||||
|
||||
it('should display "@next" (for rc versions)', () => {
|
||||
environment.mode = 'rc';
|
||||
const fixture = TestBed.createComponent(DistTagComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toEqual('@next');
|
||||
});
|
||||
|
||||
it('should display "@next" (for next versions)', () => {
|
||||
environment.mode = 'next';
|
||||
const fixture = TestBed.createComponent(DistTagComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toEqual('@next');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { Component, VERSION } from '@angular/core';
|
||||
import { environment } from 'environments/environment';
|
||||
|
||||
/**
|
||||
* Display the dist-tag of Angular for installing from npm at the point these docs are generated.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'aio-angular-dist-tag',
|
||||
template: '{{tag}}',
|
||||
})
|
||||
export class DistTagComponent {
|
||||
tag: string;
|
||||
|
||||
constructor() {
|
||||
switch (environment.mode) {
|
||||
case 'stable':
|
||||
this.tag = '';
|
||||
break;
|
||||
case 'next':
|
||||
case 'rc':
|
||||
this.tag = '@next';
|
||||
break;
|
||||
default:
|
||||
this.tag = `@${VERSION.major}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
import { DistTagComponent } from './dist-tag.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [ DistTagComponent ],
|
||||
})
|
||||
export class DistTagModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = DistTagComponent;
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { InjectionToken, Type } from '@angular/core';
|
||||
import { LoadChildrenCallback } from '@angular/router';
|
||||
|
||||
// Modules containing custom elements must be set up as lazy-loaded routes (loadChildren)
|
||||
// TODO(andrewjs): This is a hack, Angular should have first-class support for preparing a module
|
||||
// that contains custom elements.
|
||||
export const ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES = [
|
||||
{
|
||||
selector: 'aio-announcement-bar',
|
||||
loadChildren: () => import('./announcement-bar/announcement-bar.module').then(m => m.AnnouncementBarModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-api-list',
|
||||
loadChildren: () => import('./api/api-list.module').then(m => m.ApiListModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-contributor-list',
|
||||
loadChildren: () => import('./contributor/contributor-list.module').then(m => m.ContributorListModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-file-not-found-search',
|
||||
loadChildren: () => import('./search/file-not-found-search.module').then(m => m.FileNotFoundSearchModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-angular-dist-tag',
|
||||
loadChildren: () => import('./dist-tag/dist-tag.module').then(m => m.DistTagModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-resource-list',
|
||||
loadChildren: () => import('./resource/resource-list.module').then(m => m.ResourceListModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-toc',
|
||||
loadChildren: () => import('./toc/toc.module').then(m => m.TocModule)
|
||||
},
|
||||
{
|
||||
selector: 'code-example',
|
||||
loadChildren: () => import('./code/code-example.module').then(m => m.CodeExampleModule)
|
||||
},
|
||||
{
|
||||
selector: 'code-tabs',
|
||||
loadChildren: () => import('./code/code-tabs.module').then(m => m.CodeTabsModule)
|
||||
},
|
||||
{
|
||||
selector: 'live-example',
|
||||
loadChildren: () => import('./live-example/live-example.module').then(m => m.LiveExampleModule)
|
||||
},
|
||||
{
|
||||
selector: 'aio-events',
|
||||
loadChildren: () => import('./events/events.module').then(m => m.EventsModule)
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Interface expected to be implemented by all modules that declare a component that can be used as
|
||||
* a custom element.
|
||||
*/
|
||||
export interface WithCustomElementComponent {
|
||||
customElementComponent: Type<any>;
|
||||
}
|
||||
|
||||
/** Injection token to provide the element path modules. */
|
||||
export const ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN = new InjectionToken<
|
||||
Map<string, LoadChildrenCallback>
|
||||
>('aio/elements-map');
|
||||
|
||||
/** Map of possible custom element selectors to their lazy-loadable module paths. */
|
||||
export const ELEMENT_MODULE_LOAD_CALLBACKS = new Map<string, LoadChildrenCallback>();
|
||||
ELEMENT_MODULE_LOAD_CALLBACKS_AS_ROUTES.forEach(route => {
|
||||
ELEMENT_MODULE_LOAD_CALLBACKS.set(route.selector, route.loadChildren);
|
||||
});
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
import { Component, NgModule, Type } from '@angular/core';
|
||||
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||
|
||||
import { ElementsLoader } from './elements-loader';
|
||||
import { ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, WithCustomElementComponent } from './element-registry';
|
||||
|
||||
|
||||
interface Deferred {
|
||||
resolve(): void;
|
||||
reject(err: any): void;
|
||||
}
|
||||
|
||||
describe('ElementsLoader', () => {
|
||||
let elementsLoader: ElementsLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ElementsLoader,
|
||||
{
|
||||
provide: ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN,
|
||||
useValue: new Map<string, () => Promise<Type<WithCustomElementComponent>>>([
|
||||
['element-a-selector', async () => createFakeCustomElementModule('element-a-module')],
|
||||
['element-b-selector', async () => createFakeCustomElementModule('element-b-module')],
|
||||
['element-c-selector', async () => createFakeCustomElementModule('element-c-module')],
|
||||
]),
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
elementsLoader = injector.inject(ElementsLoader);
|
||||
});
|
||||
|
||||
describe('loadContainedCustomElements()', () => {
|
||||
let loadCustomElementSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => loadCustomElementSpy = spyOn(elementsLoader, 'loadCustomElement'));
|
||||
|
||||
it('should attempt to load and register all contained elements', fakeAsync(() => {
|
||||
expect(loadCustomElementSpy).not.toHaveBeenCalled();
|
||||
|
||||
const hostEl = document.createElement('div');
|
||||
hostEl.innerHTML = `
|
||||
<element-a-selector></element-a-selector>
|
||||
<element-b-selector></element-b-selector>
|
||||
`;
|
||||
|
||||
elementsLoader.loadContainedCustomElements(hostEl);
|
||||
flushMicrotasks();
|
||||
|
||||
expect(loadCustomElementSpy).toHaveBeenCalledTimes(2);
|
||||
expect(loadCustomElementSpy).toHaveBeenCalledWith('element-a-selector');
|
||||
expect(loadCustomElementSpy).toHaveBeenCalledWith('element-b-selector');
|
||||
}));
|
||||
|
||||
it('should attempt to load and register only contained elements', fakeAsync(() => {
|
||||
expect(loadCustomElementSpy).not.toHaveBeenCalled();
|
||||
|
||||
const hostEl = document.createElement('div');
|
||||
hostEl.innerHTML = `
|
||||
<element-b-selector></element-b-selector>
|
||||
`;
|
||||
|
||||
elementsLoader.loadContainedCustomElements(hostEl);
|
||||
flushMicrotasks();
|
||||
|
||||
expect(loadCustomElementSpy).toHaveBeenCalledTimes(1);
|
||||
expect(loadCustomElementSpy).toHaveBeenCalledWith('element-b-selector');
|
||||
}));
|
||||
|
||||
it('should wait for all contained elements to load and register', fakeAsync(() => {
|
||||
const deferreds = returnPromisesFromSpy(loadCustomElementSpy);
|
||||
|
||||
const hostEl = document.createElement('div');
|
||||
hostEl.innerHTML = `
|
||||
<element-a-selector></element-a-selector>
|
||||
<element-b-selector></element-b-selector>
|
||||
`;
|
||||
|
||||
const log: any[] = [];
|
||||
elementsLoader.loadContainedCustomElements(hostEl).subscribe({
|
||||
next: v => log.push(`emitted: ${v}`),
|
||||
error: e => log.push(`errored: ${e}`),
|
||||
complete: () => log.push('completed'),
|
||||
});
|
||||
|
||||
flushMicrotasks();
|
||||
expect(log).toEqual([]);
|
||||
|
||||
deferreds[0].resolve();
|
||||
flushMicrotasks();
|
||||
expect(log).toEqual([]);
|
||||
|
||||
deferreds[1].resolve();
|
||||
flushMicrotasks();
|
||||
expect(log).toEqual(['emitted: undefined', 'completed']);
|
||||
}));
|
||||
|
||||
it('should fail if any of the contained elements fails to load and register', fakeAsync(() => {
|
||||
const deferreds = returnPromisesFromSpy(loadCustomElementSpy);
|
||||
|
||||
const hostEl = document.createElement('div');
|
||||
hostEl.innerHTML = `
|
||||
<element-a-selector></element-a-selector>
|
||||
<element-b-selector></element-b-selector>
|
||||
`;
|
||||
|
||||
const log: any[] = [];
|
||||
elementsLoader.loadContainedCustomElements(hostEl).subscribe({
|
||||
next: v => log.push(`emitted: ${v}`),
|
||||
error: e => log.push(`errored: ${e}`),
|
||||
complete: () => log.push('completed'),
|
||||
});
|
||||
|
||||
flushMicrotasks();
|
||||
expect(log).toEqual([]);
|
||||
|
||||
deferreds[0].resolve();
|
||||
flushMicrotasks();
|
||||
expect(log).toEqual([]);
|
||||
|
||||
deferreds[1].reject('foo');
|
||||
flushMicrotasks();
|
||||
expect(log).toEqual(['errored: foo']);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('loadCustomElement()', () => {
|
||||
let definedSpy: jasmine.Spy;
|
||||
let whenDefinedSpy: jasmine.Spy;
|
||||
let whenDefinedDeferreds: Deferred[];
|
||||
|
||||
beforeEach(() => {
|
||||
// `loadCustomElement()` uses the `window.customElements` API. Provide mocks for these tests.
|
||||
definedSpy = spyOn(window.customElements, 'define');
|
||||
whenDefinedSpy = spyOn(window.customElements, 'whenDefined');
|
||||
whenDefinedDeferreds = returnPromisesFromSpy(whenDefinedSpy);
|
||||
});
|
||||
|
||||
it('should be able to load and register an element', fakeAsync(() => {
|
||||
elementsLoader.loadCustomElement('element-a-selector');
|
||||
flushMicrotasks();
|
||||
|
||||
expect(definedSpy).toHaveBeenCalledTimes(1);
|
||||
expect(definedSpy).toHaveBeenCalledWith('element-a-selector', jasmine.any(Function));
|
||||
|
||||
// Verify the right component was loaded/registered.
|
||||
const Ctor = definedSpy.calls.argsFor(0)[1];
|
||||
expect(Ctor.observedAttributes).toEqual(['element-a-module']);
|
||||
}));
|
||||
|
||||
it('should wait until the element is defined', fakeAsync(() => {
|
||||
let state = 'pending';
|
||||
elementsLoader.loadCustomElement('element-b-selector').then(() => state = 'resolved');
|
||||
flushMicrotasks();
|
||||
|
||||
expect(state).toBe('pending');
|
||||
expect(whenDefinedSpy).toHaveBeenCalledTimes(1);
|
||||
expect(whenDefinedSpy).toHaveBeenCalledWith('element-b-selector');
|
||||
|
||||
whenDefinedDeferreds[0].resolve();
|
||||
flushMicrotasks();
|
||||
expect(state).toBe('resolved');
|
||||
}));
|
||||
|
||||
it('should not load and register the same element more than once', fakeAsync(() => {
|
||||
elementsLoader.loadCustomElement('element-a-selector');
|
||||
flushMicrotasks();
|
||||
expect(definedSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
definedSpy.calls.reset();
|
||||
|
||||
// While loading/registering is still in progress:
|
||||
elementsLoader.loadCustomElement('element-a-selector');
|
||||
flushMicrotasks();
|
||||
expect(definedSpy).not.toHaveBeenCalled();
|
||||
|
||||
definedSpy.calls.reset();
|
||||
whenDefinedDeferreds[0].resolve();
|
||||
|
||||
// Once loading/registering is already completed:
|
||||
let state = 'pending';
|
||||
elementsLoader.loadCustomElement('element-a-selector').then(() => state = 'resolved');
|
||||
flushMicrotasks();
|
||||
expect(state).toBe('resolved');
|
||||
expect(definedSpy).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should fail if defining the custom element fails', fakeAsync(() => {
|
||||
let state = 'pending';
|
||||
elementsLoader.loadCustomElement('element-b-selector').catch(e => state = `rejected: ${e}`);
|
||||
flushMicrotasks();
|
||||
expect(state).toBe('pending');
|
||||
|
||||
whenDefinedDeferreds[0].reject('foo');
|
||||
flushMicrotasks();
|
||||
expect(state).toBe('rejected: foo');
|
||||
}));
|
||||
|
||||
it('should be able to load and register an element again if previous attempt failed',
|
||||
fakeAsync(() => {
|
||||
elementsLoader.loadCustomElement('element-a-selector');
|
||||
flushMicrotasks();
|
||||
expect(definedSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
definedSpy.calls.reset();
|
||||
|
||||
// While loading/registering is still in progress:
|
||||
elementsLoader.loadCustomElement('element-a-selector').catch(() => undefined);
|
||||
flushMicrotasks();
|
||||
expect(definedSpy).not.toHaveBeenCalled();
|
||||
|
||||
whenDefinedDeferreds[0].reject('foo');
|
||||
flushMicrotasks();
|
||||
expect(definedSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Once loading/registering has already failed:
|
||||
elementsLoader.loadCustomElement('element-a-selector');
|
||||
flushMicrotasks();
|
||||
expect(definedSpy).toHaveBeenCalledTimes(1);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// TEST CLASSES/HELPERS
|
||||
|
||||
class FakeCustomElementModule implements WithCustomElementComponent {
|
||||
static readonly modulePath: string;
|
||||
customElementComponent: Type<any>;
|
||||
}
|
||||
|
||||
function createFakeComponent(inputName: string): Type<any> {
|
||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
||||
@Component({inputs: [inputName]})
|
||||
class FakeComponent {}
|
||||
|
||||
return FakeComponent;
|
||||
}
|
||||
|
||||
function createFakeCustomElementModule(modulePath: string): typeof FakeCustomElementModule {
|
||||
@NgModule({})
|
||||
class FakeModule extends FakeCustomElementModule {
|
||||
static override readonly modulePath = modulePath;
|
||||
override customElementComponent = createFakeComponent(modulePath);
|
||||
}
|
||||
|
||||
return FakeModule;
|
||||
}
|
||||
|
||||
function returnPromisesFromSpy(spy: jasmine.Spy): Deferred[] {
|
||||
const deferreds: Deferred[] = [];
|
||||
spy.and.callFake(() => new Promise<void>((resolve, reject) => deferreds.push({resolve, reject})));
|
||||
return deferreds;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { createNgModule, Inject, Injectable, NgModuleRef, Type } from '@angular/core';
|
||||
import { ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN, WithCustomElementComponent } from './element-registry';
|
||||
import { from, Observable, of } from 'rxjs';
|
||||
import { createCustomElement } from '@angular/elements';
|
||||
import { LoadChildrenCallback } from '@angular/router';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class ElementsLoader {
|
||||
/** Map of unregistered custom elements and their respective module paths to load. */
|
||||
private elementsToLoad: Map<string, LoadChildrenCallback>;
|
||||
/** Map of custom elements that are in the process of being loaded and registered. */
|
||||
private elementsLoading = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(private moduleRef: NgModuleRef<any>,
|
||||
@Inject(ELEMENT_MODULE_LOAD_CALLBACKS_TOKEN) elementModulePaths: Map<string, LoadChildrenCallback>) {
|
||||
this.elementsToLoad = new Map(elementModulePaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the provided element for any custom elements that have not yet been registered with
|
||||
* the browser. Custom elements that are registered will be removed from the list of unregistered
|
||||
* elements so that they will not be queried in subsequent calls.
|
||||
*/
|
||||
loadContainedCustomElements(element: HTMLElement): Observable<void> {
|
||||
const unregisteredSelectors = Array.from(this.elementsToLoad.keys())
|
||||
.filter(s => element.querySelector(s));
|
||||
|
||||
if (!unregisteredSelectors.length) { return of(undefined); }
|
||||
|
||||
// Returns observable that completes when all discovered elements have been registered.
|
||||
const allRegistered = Promise.all(unregisteredSelectors.map(s => this.loadCustomElement(s)));
|
||||
return from(allRegistered.then(() => undefined));
|
||||
}
|
||||
|
||||
/** Loads and registers the custom element defined on the `WithCustomElement` module factory. */
|
||||
loadCustomElement(selector: string): Promise<void> {
|
||||
if (this.elementsLoading.has(selector)) {
|
||||
// The custom element is in the process of being loaded and registered.
|
||||
return this.elementsLoading.get(selector) as Promise<void>;
|
||||
}
|
||||
|
||||
if (this.elementsToLoad.has(selector)) {
|
||||
// Load and register the custom element (for the first time).
|
||||
const modulePathLoader = this.elementsToLoad.get(selector) as LoadChildrenCallback;
|
||||
const loadedAndRegistered =
|
||||
(modulePathLoader() as Promise<Type<WithCustomElementComponent>>)
|
||||
.then(elementModule => {
|
||||
const elementModuleRef = createNgModule(elementModule, this.moduleRef.injector);
|
||||
const injector = elementModuleRef.injector;
|
||||
const CustomElementComponent = elementModuleRef.instance.customElementComponent;
|
||||
const CustomElement = createCustomElement(CustomElementComponent, {injector});
|
||||
|
||||
customElements.define(selector, CustomElement);
|
||||
return customElements.whenDefined(selector);
|
||||
})
|
||||
.then(() => {
|
||||
// The custom element has been successfully loaded and registered.
|
||||
// Remove from `elementsLoading` and `elementsToLoad`.
|
||||
this.elementsLoading.delete(selector);
|
||||
this.elementsToLoad.delete(selector);
|
||||
})
|
||||
.catch(err => {
|
||||
// The custom element has failed to load and register.
|
||||
// Remove from `elementsLoading`.
|
||||
// (Do not remove from `elementsToLoad` in case it was a temporary error.)
|
||||
this.elementsLoading.delete(selector);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
this.elementsLoading.set(selector, loadedAndRegistered);
|
||||
return loadedAndRegistered;
|
||||
}
|
||||
|
||||
// The custom element has already been loaded and registered.
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<h2>Where we'll be presenting:</h2>
|
||||
<ng-container [ngSwitch]="!!upcomingEvents.length">
|
||||
<div *ngSwitchCase="false">
|
||||
<p>We don't have any upcoming speaking engagements at the moment.</p>
|
||||
<p>
|
||||
Until something comes up, make sure you check our <a href="https://www.youtube.com/angular">YouTube channel</a>
|
||||
and follow us on <a href="https://twitter.com/angular">social media</a>.
|
||||
</p>
|
||||
<p>
|
||||
If you want us to be part of your event reach out on <a href="mailto:devrel@angular.io">devrel@angular.io</a>!
|
||||
</p>
|
||||
</div>
|
||||
<ng-container *ngSwitchDefault>
|
||||
<ng-container *ngTemplateOutlet="eventsTable; context: {$implicit: upcomingEvents}"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<br />
|
||||
|
||||
<h2>Where we already presented:</h2>
|
||||
<ng-container *ngTemplateOutlet="eventsTable; context: {$implicit: pastEvents}"></ng-container>
|
||||
|
||||
<ng-template #eventsTable let-events>
|
||||
<table class="is-full-width">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Start date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let event of events">
|
||||
<th [ngSwitch]="!!event.linkUrl">
|
||||
<span *ngSwitchCase="false">{{ event.name }}</span>
|
||||
<a *ngSwitchDefault href="{{ event.linkUrl }}">{{ event.name }}</a>
|
||||
</th>
|
||||
<td>{{ event.date.start }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-template>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Injector } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { AngularEvent, EventsComponent } from './events.component';
|
||||
import { EventsService } from './events.service';
|
||||
|
||||
describe('EventsComponent', () => {
|
||||
let component: EventsComponent;
|
||||
let injector: Injector;
|
||||
let eventsService: TestEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
{ provide: EventsComponent, deps: [EventsService] } ,
|
||||
{ provide: EventsService, useClass: TestEventsService, deps: [] },
|
||||
]
|
||||
});
|
||||
eventsService = injector.get(EventsService) as unknown as TestEventsService;
|
||||
component = injector.get(EventsComponent) as unknown as EventsComponent;
|
||||
});
|
||||
|
||||
it('should have no pastEvents when first created', () => {
|
||||
expect(component.pastEvents.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should have no upcoming when first created', () => {
|
||||
expect(component.upcomingEvents.length).toEqual(0);
|
||||
});
|
||||
|
||||
describe('ngOnInit()', () => {
|
||||
beforeEach(() => {
|
||||
jasmine.clock().install();
|
||||
// End of day on June 15
|
||||
jasmine.clock().mockDate(new Date(Date.parse('2020-06-16') - 1));
|
||||
component.ngOnInit();
|
||||
});
|
||||
|
||||
afterEach(() => jasmine.clock().uninstall());
|
||||
|
||||
it('should separate past and upcoming events', () => {
|
||||
eventsService.events.next([
|
||||
createMockEvent('Upcoming event 1', {start: '2020-06-16'}),
|
||||
createMockEvent('Upcoming event 3', {start: '2222-01-01'}),
|
||||
createMockEvent('Past event 2', {start: '2020-06-13'}),
|
||||
createMockEvent('Upcoming event 2', {start: '2020-06-17'}),
|
||||
createMockEvent('Past event 1', {start: '2020-05-30'}),
|
||||
createMockEvent('Past event 3', {start: '2020-06-14'}),
|
||||
]);
|
||||
|
||||
expect(component.pastEvents.map(evt => evt.name)).toEqual(jasmine.arrayWithExactContents(
|
||||
['Past event 1', 'Past event 2', 'Past event 3']));
|
||||
|
||||
expect(component.upcomingEvents.map(evt => evt.name)).toEqual(jasmine.arrayWithExactContents(
|
||||
['Upcoming event 1', 'Upcoming event 2', 'Upcoming event 3']));
|
||||
});
|
||||
|
||||
it('should order past events in reverse chronological order', () => {
|
||||
eventsService.events.next([
|
||||
createMockEvent('Past event 2', {start: '1999-12-13'}),
|
||||
createMockEvent('Past event 4', {start: '2020-01-16'}),
|
||||
createMockEvent('Past event 3', {start: '2020-01-15'}),
|
||||
createMockEvent('Past event 1', {start: '1999-12-12'}),
|
||||
]);
|
||||
|
||||
expect(component.pastEvents.map(evt => evt.name)).toEqual(
|
||||
['Past event 4', 'Past event 3', 'Past event 2', 'Past event 1']);
|
||||
});
|
||||
|
||||
it('should order upcoming events in chronological order', () => {
|
||||
eventsService.events.next([
|
||||
createMockEvent('Upcoming event 2', {start: '2020-12-13'}),
|
||||
createMockEvent('Upcoming event 4', {start: '2021-01-16'}),
|
||||
createMockEvent('Upcoming event 3', {start: '2021-01-15'}),
|
||||
createMockEvent('Upcoming event 1', {start: '2020-12-12'}),
|
||||
]);
|
||||
|
||||
expect(component.upcomingEvents.map(evt => evt.name)).toEqual(
|
||||
['Upcoming event 1', 'Upcoming event 2', 'Upcoming event 3', 'Upcoming event 4']);
|
||||
});
|
||||
|
||||
it('should treat ongoing events as upcoming', () => {
|
||||
eventsService.events.next([
|
||||
createMockEvent('Ongoing event 1', {start: '2020-06-15'}),
|
||||
]);
|
||||
|
||||
expect(component.pastEvents).toEqual([]);
|
||||
expect(component.upcomingEvents.map(evt => evt.name)).toEqual(jasmine.arrayWithExactContents(
|
||||
['Ongoing event 1']));
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers
|
||||
class TestEventsService {
|
||||
events = new Subject<AngularEvent[]>();
|
||||
}
|
||||
|
||||
function createMockEvent(name: string, date: AngularEvent['date']): AngularEvent {
|
||||
return {
|
||||
name,
|
||||
linkUrl: '',
|
||||
date,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { EventsService } from './events.service';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface AngularEvent {
|
||||
name: string;
|
||||
linkUrl?: string;
|
||||
date: {
|
||||
start: `${number}-${number}-${number}`; // Date string in the format: `YYYY-MM-DD`
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'aio-events',
|
||||
templateUrl: 'events.component.html'
|
||||
})
|
||||
export class EventsComponent implements OnInit {
|
||||
|
||||
pastEvents: AngularEvent[] = [];
|
||||
upcomingEvents: AngularEvent[] = [];
|
||||
|
||||
constructor(private eventsService: EventsService) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.eventsService.events.subscribe(events => {
|
||||
this.pastEvents = events
|
||||
.filter(event => isInThePast(event))
|
||||
.sort((l: AngularEvent, r: AngularEvent) => (l.date.start < r.date.start) ? 1 : -1);
|
||||
|
||||
this.upcomingEvents = events
|
||||
.filter(event => !isInThePast(event))
|
||||
.sort((l: AngularEvent, r: AngularEvent) => (l.date.start < r.date.start) ? -1 : 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isInThePast(event: AngularEvent): boolean {
|
||||
return new Date(event.date.start).getTime() < Date.now() - DAY;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventsComponent } from './events.component';
|
||||
import { EventsService } from './events.service';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule ],
|
||||
declarations: [ EventsComponent ],
|
||||
providers: [ EventsService ],
|
||||
})
|
||||
export class EventsModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = EventsComponent;
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Injector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { EventsService } from './events.service';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
|
||||
describe('EventsService', () => {
|
||||
|
||||
let injector: Injector;
|
||||
let eventsService: EventsService;
|
||||
let httpMock: HttpTestingController;
|
||||
let mockLogger: MockLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
EventsService,
|
||||
{ provide: Logger, useClass: MockLogger }
|
||||
]
|
||||
});
|
||||
|
||||
eventsService = injector.get<EventsService>(EventsService);
|
||||
mockLogger = injector.get(Logger) as any;
|
||||
httpMock = injector.get(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should make a single connection to the server', () => {
|
||||
eventsService.events.subscribe();
|
||||
eventsService.events.subscribe();
|
||||
httpMock.expectOne('generated/events.json');
|
||||
expect().nothing(); // Prevent jasmine from complaining about no expectations.
|
||||
});
|
||||
|
||||
it('should handle a failed request for `events.json`', () => {
|
||||
const request = httpMock.expectOne('generated/events.json');
|
||||
request.error(new ProgressEvent('404'));
|
||||
expect(mockLogger.output.error).toEqual([
|
||||
[jasmine.any(Error)]
|
||||
]);
|
||||
expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/events\.json request failed:/);
|
||||
});
|
||||
|
||||
it('should return an empty array on a failed request for `events.json`', done => {
|
||||
const request = httpMock.expectOne('generated/events.json');
|
||||
request.error(new ProgressEvent('404'));
|
||||
eventsService.events.subscribe(results => {
|
||||
expect(results).toEqual([]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
|
||||
import {AsyncSubject, connectable, Observable, of} from 'rxjs';
|
||||
import {catchError} from 'rxjs/operators';
|
||||
|
||||
import {AngularEvent} from './events.component';
|
||||
import {CONTENT_URL_PREFIX} from 'app/documents/document.service';
|
||||
import {Logger} from 'app/shared/logger.service';
|
||||
|
||||
const eventsPath = CONTENT_URL_PREFIX + 'events.json';
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
events: Observable<AngularEvent[]>;
|
||||
|
||||
constructor(private http: HttpClient, private logger: Logger) {
|
||||
this.events = this.getEvents();
|
||||
}
|
||||
|
||||
private getEvents() {
|
||||
const eventsSource = this.http.get<any>(eventsPath).pipe(
|
||||
catchError((error) => {
|
||||
this.logger.error(new Error(`${eventsPath} request failed: ${error.message}`));
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
const events = connectable(eventsSource, {
|
||||
connector: () => new AsyncSubject(),
|
||||
resetOnDisconnect: false,
|
||||
});
|
||||
events.connect();
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { MockLogger } from 'testing/logger.service';
|
||||
import { LazyCustomElementComponent } from './lazy-custom-element.component';
|
||||
import { ElementsLoader } from './elements-loader';
|
||||
|
||||
describe('LazyCustomElementComponent', () => {
|
||||
let mockElementsLoader: jasmine.SpyObj<ElementsLoader>;
|
||||
let mockLogger: MockLogger;
|
||||
let fixture: ComponentFixture<LazyCustomElementComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockElementsLoader = jasmine.createSpyObj<ElementsLoader>('ElementsLoader', [
|
||||
'loadContainedCustomElements',
|
||||
'loadCustomElement',
|
||||
]);
|
||||
|
||||
const injector = TestBed.configureTestingModule({
|
||||
declarations: [ LazyCustomElementComponent ],
|
||||
providers: [
|
||||
{ provide: ElementsLoader, useValue: mockElementsLoader },
|
||||
{ provide: Logger, useClass: MockLogger },
|
||||
],
|
||||
});
|
||||
|
||||
mockLogger = injector.inject(Logger) as any;
|
||||
fixture = TestBed.createComponent(LazyCustomElementComponent);
|
||||
});
|
||||
|
||||
it('should set the HTML content based on the selector', () => {
|
||||
const elem = fixture.nativeElement;
|
||||
|
||||
expect(elem.innerHTML).toBe('');
|
||||
|
||||
fixture.componentInstance.selector = 'foo-bar';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(elem.innerHTML).toBe('<foo-bar></foo-bar>');
|
||||
});
|
||||
|
||||
it('should load the specified custom element', () => {
|
||||
expect(mockElementsLoader.loadCustomElement).not.toHaveBeenCalled();
|
||||
|
||||
fixture.componentInstance.selector = 'foo-bar';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockElementsLoader.loadCustomElement).toHaveBeenCalledWith('foo-bar');
|
||||
});
|
||||
|
||||
it('should log an error (and abort) if the selector is empty', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockElementsLoader.loadCustomElement).not.toHaveBeenCalled();
|
||||
expect(mockLogger.output.error).toEqual([[jasmine.any(Error)]]);
|
||||
expect(mockLogger.output.error[0][0].message).toBe('Invalid selector for \'aio-lazy-ce\': ');
|
||||
});
|
||||
|
||||
it('should log an error (and abort) if the selector is invalid', () => {
|
||||
fixture.componentInstance.selector = 'foo-bar><script></script><foo-bar';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockElementsLoader.loadCustomElement).not.toHaveBeenCalled();
|
||||
expect(mockLogger.output.error).toEqual([[jasmine.any(Error)]]);
|
||||
expect(mockLogger.output.error[0][0].message).toBe(
|
||||
'Invalid selector for \'aio-lazy-ce\': foo-bar><script></script><foo-bar');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { Component, ElementRef, Input, OnInit } from '@angular/core';
|
||||
import { Logger } from 'app/shared/logger.service';
|
||||
import { ElementsLoader } from './elements-loader';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-lazy-ce',
|
||||
template: '',
|
||||
})
|
||||
export class LazyCustomElementComponent implements OnInit {
|
||||
@Input() selector = '';
|
||||
|
||||
constructor(
|
||||
private elementRef: ElementRef,
|
||||
private elementsLoader: ElementsLoader,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (!this.selector || /[^\w-]/.test(this.selector)) {
|
||||
this.logger.error(new Error(`Invalid selector for 'aio-lazy-ce': ${this.selector}`));
|
||||
return;
|
||||
}
|
||||
|
||||
this.elementRef.nativeElement.textContent = '';
|
||||
this.elementRef.nativeElement.appendChild(document.createElement(this.selector));
|
||||
this.elementsLoader.loadCustomElement(this.selector);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<!-- Content projection is used to get the content HTML provided to the component. -->
|
||||
<span #content style="display: none"><ng-content></ng-content></span>
|
||||
|
||||
<span [ngSwitch]="mode">
|
||||
<span *ngSwitchCase="'embedded'">
|
||||
<div title="{{title}}">
|
||||
<aio-embedded-stackblitz [src]="stackblitz"></aio-embedded-stackblitz>
|
||||
</div>
|
||||
<p *ngIf="enableDownload">
|
||||
You can also <a [href]="zip" download title="Download example">download this example</a>.
|
||||
</p>
|
||||
</span>
|
||||
<span *ngSwitchCase="'downloadOnly'">
|
||||
<a [href]="zip" download title="{{title}}">{{title}}</a>
|
||||
</span>
|
||||
<span *ngSwitchDefault>
|
||||
<a [href]="stackblitz" target="_blank" title="{{title}}">{{title}}</a>
|
||||
<span *ngIf="enableDownload">
|
||||
/ <a [href]="zip" download title="Download example">download example</a>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Component, DebugElement } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
|
||||
import { LiveExampleComponent, EmbeddedStackblitzComponent } from './live-example.component';
|
||||
|
||||
const defaultTestPath = '/test';
|
||||
|
||||
describe('LiveExampleComponent', () => {
|
||||
let liveExampleDe: DebugElement;
|
||||
let liveExampleComponent: LiveExampleComponent;
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let testPath: string;
|
||||
|
||||
//////// test helpers ////////
|
||||
|
||||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: '<live-example></live-example>'
|
||||
})
|
||||
class HostComponent { }
|
||||
|
||||
class TestLocation {
|
||||
path() { return testPath; }
|
||||
}
|
||||
|
||||
function getAnchors() {
|
||||
return liveExampleDe.queryAll(By.css('a')).map(de => de.nativeElement as HTMLAnchorElement);
|
||||
}
|
||||
|
||||
function getHrefs() { return getAnchors().map(a => a.href); }
|
||||
|
||||
function setHostTemplate(template: string) {
|
||||
TestBed.overrideComponent(HostComponent, {set: {template}});
|
||||
}
|
||||
|
||||
function testComponent(testFn: () => void) {
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
liveExampleDe = fixture.debugElement.children[0];
|
||||
liveExampleComponent = liveExampleDe.componentInstance;
|
||||
|
||||
// Trigger `ngAfterContentInit()`.
|
||||
fixture.detectChanges();
|
||||
|
||||
testFn();
|
||||
}
|
||||
|
||||
//////// tests ////////
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ HostComponent, LiveExampleComponent, EmbeddedStackblitzComponent ],
|
||||
providers: [
|
||||
{ provide: Location, useClass: TestLocation }
|
||||
]
|
||||
})
|
||||
// Disable the <iframe> within the EmbeddedStackblitzComponent
|
||||
.overrideComponent(EmbeddedStackblitzComponent, {set: {template: 'NO IFRAME'}});
|
||||
|
||||
testPath = defaultTestPath;
|
||||
});
|
||||
|
||||
describe('when not embedded', () => {
|
||||
function getLiveExampleAnchor() { return getAnchors()[0]; }
|
||||
|
||||
it('should create LiveExampleComponent', () => {
|
||||
testComponent(() => {
|
||||
expect(liveExampleComponent).withContext('LiveExampleComponent').toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expected stackblitz & download hrefs', () => {
|
||||
testPath = '/tutorial/tour-of-heroes/toh-pt1';
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/toh-pt1/stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/toh-pt1/toh-pt1.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expected stackblitz & download hrefs even when path has # frag', () => {
|
||||
testPath = '/tutorial/tour-of-heroes/toh-pt1#somewhere';
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/toh-pt1/stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/toh-pt1/toh-pt1.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expected stackblitz & download hrefs even when path has ? params', () => {
|
||||
testPath = '/tutorial/tour-of-heroes/toh-pt1?foo=1&bar="bar"';
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/toh-pt1/stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/toh-pt1/toh-pt1.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expected stackblitz & download hrefs when has example directory (name)', () => {
|
||||
testPath = '/guide/somewhere';
|
||||
setHostTemplate('<live-example name="toh-pt1"></live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/toh-pt1/stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/toh-pt1/toh-pt1.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expected stackblitz & download hrefs when has `stackblitz`', () => {
|
||||
testPath = '/testing';
|
||||
setHostTemplate('<live-example stackblitz="app-specs"></live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/testing/app-specs.stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/testing/app-specs.testing.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have expected stackblitz & download hrefs when has `name` & `stackblitz`', () => {
|
||||
testPath = '/guide/somewhere';
|
||||
setHostTemplate('<live-example name="testing" stackblitz="app-specs"></live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/testing/app-specs.stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/testing/app-specs.testing.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should be embedded style by default', () => {
|
||||
setHostTemplate('<live-example></live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain(defaultTestPath + '/stackblitz.html');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have a download link when `noDownload` attr present', () => {
|
||||
setHostTemplate('<live-example noDownload></live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs.length).withContext('only the stackblitz live-example anchor').toBe(1);
|
||||
expect(hrefs[0]).toContain('stackblitz.html');
|
||||
});
|
||||
});
|
||||
|
||||
it('should only have a download link when `downloadOnly` attr present', () => {
|
||||
setHostTemplate('<live-example downloadOnly>download this</live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs.length).withContext('only the zip anchor').toBe(1);
|
||||
expect(hrefs[0]).toContain('.zip'); });
|
||||
});
|
||||
|
||||
it('should have default title when no title attribute or content', () => {
|
||||
setHostTemplate('<live-example></live-example>');
|
||||
testComponent(() => {
|
||||
const expectedTitle = 'live example';
|
||||
const anchor = getLiveExampleAnchor();
|
||||
expect(anchor.textContent).withContext('anchor content').toBe(expectedTitle);
|
||||
expect(anchor.getAttribute('title')).withContext('title').toBe(expectedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add title when set `title` attribute', () => {
|
||||
const expectedTitle = 'Great Example';
|
||||
setHostTemplate(`<live-example title="${expectedTitle}"></live-example>`);
|
||||
testComponent(() => {
|
||||
const anchor = getLiveExampleAnchor();
|
||||
expect(anchor.textContent).withContext('anchor content').toBe(expectedTitle);
|
||||
expect(anchor.getAttribute('title')).withContext('title').toBe(expectedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add title from <live-example> body', () => {
|
||||
const expectedTitle = 'The Greatest Example';
|
||||
setHostTemplate(`<live-example title="ignore this title">${expectedTitle}</live-example>`);
|
||||
testComponent(() => {
|
||||
const anchor = getLiveExampleAnchor();
|
||||
expect(anchor.textContent).withContext('anchor content').toBe(expectedTitle);
|
||||
expect(anchor.getAttribute('title')).withContext('title').toBe(expectedTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not duplicate the exampleDir on a zip when there is a / on the name', () => {
|
||||
setHostTemplate('<live-example name="testing/ts"></live-example>');
|
||||
testComponent(() => {
|
||||
const hrefs = getHrefs();
|
||||
expect(hrefs[0]).toContain('/testing/ts/stackblitz.html');
|
||||
expect(hrefs[1]).toContain('/testing/ts/testing.zip');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when embedded', () => {
|
||||
|
||||
function getDownloadAnchor() {
|
||||
const anchor = liveExampleDe.query(By.css('p > a'));
|
||||
return anchor && anchor.nativeElement as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
function getEmbeddedStackblitzComponent() {
|
||||
const compDe = liveExampleDe.query(By.directive(EmbeddedStackblitzComponent));
|
||||
return compDe && compDe.componentInstance as EmbeddedStackblitzComponent;
|
||||
}
|
||||
|
||||
it('should have hidden, embedded stackblitz', () => {
|
||||
setHostTemplate('<live-example embedded></live-example>');
|
||||
testComponent(() => {
|
||||
expect(liveExampleComponent.mode).withContext('component is embedded').toBe('embedded');
|
||||
expect(getEmbeddedStackblitzComponent()).withContext('EmbeddedStackblitzComponent').toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have download paragraph with expected anchor href', () => {
|
||||
testPath = '/tutorial/tour-of-heroes/toh-pt1';
|
||||
setHostTemplate('<live-example embedded></live-example>');
|
||||
testComponent(() => {
|
||||
expect(getDownloadAnchor().href).toContain('/toh-pt1/toh-pt1.zip');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not have download paragraph when has `nodownload`', () => {
|
||||
testPath = '/tutorial/tour-of-heroes/toh-pt1';
|
||||
setHostTemplate('<live-example embedded nodownload></live-example>');
|
||||
testComponent(() => {
|
||||
expect(getDownloadAnchor()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
/* eslint-disable @angular-eslint/component-selector */
|
||||
import { AfterContentInit, AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { CONTENT_URL_PREFIX } from 'app/documents/document.service';
|
||||
import { AttrMap, boolFromValue, getAttrs, getAttrValue } from 'app/shared/attribute-utils';
|
||||
|
||||
|
||||
const LIVE_EXAMPLE_BASE = CONTENT_URL_PREFIX + 'live-examples/';
|
||||
const ZIP_BASE = CONTENT_URL_PREFIX + 'zips/';
|
||||
|
||||
/**
|
||||
* Angular.io Live Example Embedded Component
|
||||
*
|
||||
* Renders a link to a live/host example of the doc page.
|
||||
*
|
||||
* All attributes and the text content are optional
|
||||
*
|
||||
* Usage:
|
||||
* <live-example
|
||||
* [name="..."] // name of the example directory
|
||||
* [stackblitz="...""] // name of the stackblitz file (becomes part of zip file name as well)
|
||||
* [embedded] // embed the stackblitz in the doc page, else display in new browser tab (default)
|
||||
* [noDownload] // no downloadable zip option
|
||||
* [downloadOnly] // just the zip
|
||||
* [title="..."]> // text for live example link and tooltip
|
||||
* text // higher precedence way to specify text for live example link and tooltip
|
||||
* </live-example>
|
||||
* Example:
|
||||
* <p>Run <live-example>Try the live example</live-example></p>.
|
||||
* // ~/resources/live-examples/{page}/stackblitz.json
|
||||
*
|
||||
* <p>Run <live-example name="toh-pt1">this example</live-example></p>.
|
||||
* // ~/resources/live-examples/toh-pt1/stackblitz.json
|
||||
*
|
||||
* // Link to the default stackblitz in the toh-pt1 sample
|
||||
* // The title overrides default ("live example") with "Tour of Heroes - Part 1"
|
||||
* <p>Run <live-example name="toh-pt1" title="Tour of Heroes - Part 1"></live-example></p>.
|
||||
* // ~/resources/live-examples/toh-pt1/stackblitz.json
|
||||
*
|
||||
* <p>Run <live-example stackblitz="minimal"></live-example></p>.
|
||||
* // ~/resources/live-examples/{page}/minimal.stackblitz.json
|
||||
*
|
||||
* // Embed the current page's default stackblitz
|
||||
* // Text within tag is "live example"
|
||||
* // No title (no tooltip)
|
||||
* <live-example embedded title=""></live-example>
|
||||
* // ~/resources/live-examples/{page}/stackblitz.json
|
||||
*
|
||||
* // Displays within the document page as an embedded style stackblitz editor
|
||||
* <live-example name="toh-pt1" embedded stackblitz="minimal">Tour of Heroes - Part 1</live-example>
|
||||
* // ~/resources/live-examples/toh-pt1/minimal.stackblitz.json
|
||||
*/
|
||||
@Component({
|
||||
selector: 'live-example',
|
||||
templateUrl: 'live-example.component.html'
|
||||
})
|
||||
export class LiveExampleComponent implements AfterContentInit {
|
||||
|
||||
readonly mode: 'default' | 'embedded' | 'downloadOnly';
|
||||
readonly enableDownload: boolean;
|
||||
readonly stackblitz: string;
|
||||
readonly zip: string;
|
||||
title: string;
|
||||
|
||||
@ViewChild('content', { static: true })
|
||||
private content: ElementRef;
|
||||
|
||||
constructor(elementRef: ElementRef, location: Location) {
|
||||
const attrs = getAttrs(elementRef);
|
||||
const exampleDir = this.getExampleDir(attrs, location.path(false));
|
||||
const stackblitzName = this.getStackblitzName(attrs);
|
||||
|
||||
this.mode = this.getMode(attrs);
|
||||
this.enableDownload = this.getEnableDownload(attrs);
|
||||
this.stackblitz = this.getStackblitz(exampleDir, stackblitzName, this.mode === 'embedded');
|
||||
this.zip = this.getZip(exampleDir, stackblitzName);
|
||||
this.title = this.getTitle(attrs);
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
// Angular will sanitize this title when displayed, so it should be plain text.
|
||||
const textContent = this.content.nativeElement.textContent.trim();
|
||||
if (textContent) {
|
||||
this.title = textContent;
|
||||
}
|
||||
}
|
||||
|
||||
private getEnableDownload(attrs: AttrMap) {
|
||||
const downloadDisabled = boolFromValue(getAttrValue(attrs, 'noDownload'));
|
||||
return !downloadDisabled;
|
||||
}
|
||||
|
||||
private getExampleDir(attrs: AttrMap, path: string) {
|
||||
let exampleDir = getAttrValue(attrs, 'name');
|
||||
if (!exampleDir) {
|
||||
// Take the last path segment, excluding query params and hash fragment.
|
||||
const match = path.match(/[^/?#]+(?=\/?(?:\?|#|$))/);
|
||||
exampleDir = match ? match[0] : 'index';
|
||||
}
|
||||
return exampleDir.trim();
|
||||
}
|
||||
|
||||
private getMode(this: LiveExampleComponent, attrs: AttrMap): typeof this.mode {
|
||||
const downloadOnly = boolFromValue(getAttrValue(attrs, 'downloadOnly'));
|
||||
const isEmbedded = boolFromValue(getAttrValue(attrs, 'embedded'));
|
||||
|
||||
return downloadOnly ? 'downloadOnly'
|
||||
: isEmbedded ? 'embedded' :
|
||||
'default';
|
||||
}
|
||||
|
||||
private getStackblitz(exampleDir: string, stackblitzName: string, isEmbedded: boolean) {
|
||||
const urlQuery = isEmbedded ? '?ctl=1' : '';
|
||||
return `${LIVE_EXAMPLE_BASE}${exampleDir}/${stackblitzName}stackblitz.html${urlQuery}`;
|
||||
}
|
||||
|
||||
private getStackblitzName(attrs: AttrMap) {
|
||||
const attrValue = (getAttrValue(attrs, 'stackblitz') || '').trim();
|
||||
return attrValue && `${attrValue}.`;
|
||||
}
|
||||
|
||||
private getTitle(attrs: AttrMap) {
|
||||
return (getAttrValue(attrs, 'title') || 'live example').trim();
|
||||
}
|
||||
|
||||
private getZip(exampleDir: string, stackblitzName: string) {
|
||||
const zipName = exampleDir.split('/')[0];
|
||||
return `${ZIP_BASE}${exampleDir}/${stackblitzName}${zipName}.zip`;
|
||||
}
|
||||
}
|
||||
|
||||
///// EmbeddedStackblitzComponent ///
|
||||
/**
|
||||
* Hides the <iframe> so we can test LiveExampleComponent without actually triggering
|
||||
* a call to stackblitz to load the iframe
|
||||
*/
|
||||
@Component({
|
||||
selector: 'aio-embedded-stackblitz',
|
||||
template: '<iframe #iframe style="border: 0" width="100%" height="100%" title="live example"></iframe>',
|
||||
styles: [ 'iframe { min-height: 400px; }' ]
|
||||
})
|
||||
export class EmbeddedStackblitzComponent implements AfterViewInit {
|
||||
@Input() src: string;
|
||||
|
||||
@ViewChild('iframe', { static: true }) iframe: ElementRef;
|
||||
|
||||
ngAfterViewInit() {
|
||||
// DEVELOPMENT TESTING ONLY
|
||||
// this.src = 'https://angular.io/resources/live-examples/quickstart/ts/stackblitz.json';
|
||||
|
||||
if (this.iframe) {
|
||||
// security: the `src` is always authored by the documentation team
|
||||
// and is considered to be safe
|
||||
this.iframe.nativeElement.src = this.src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EmbeddedStackblitzComponent, LiveExampleComponent } from './live-example.component';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule ],
|
||||
declarations: [ LiveExampleComponent, EmbeddedStackblitzComponent ]
|
||||
})
|
||||
export class LiveExampleModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = LiveExampleComponent;
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<div class="center-layout">
|
||||
<div class="flex-center group-buttons">
|
||||
<button *ngFor="let category of categories"
|
||||
class="button mat-button filter-button"
|
||||
[class.selected]="category.id == selectedCategory.id"
|
||||
(click)="selectCategory(category.id)">{{category.title}}</button>
|
||||
</div>
|
||||
<div class="showcase">
|
||||
<div *ngFor="let subCategory of selectedCategory?.subCategories">
|
||||
<h2 class="subcategory-title" id="{{subCategory.id}}">{{subCategory.title}}</h2>
|
||||
<div *ngFor="let resource of subCategory.resources">
|
||||
<div class="resource-item">
|
||||
<a class="resource-row-link" rel="noopener" target="_blank" [href]="resource.url">
|
||||
<h3 class="resource-name">{{resource.title}}</h3>
|
||||
<p class="resource-description">{{resource.desc || 'No Description'}}</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { Injector } from '@angular/core';
|
||||
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ResourceListComponent } from './resource-list.component';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
import { Category } from './resource.model';
|
||||
|
||||
// Testing the component class behaviors, independent of its template
|
||||
// Let e2e tests verify how it displays.
|
||||
describe('ResourceListComponent', () => {
|
||||
|
||||
let component: ResourceListComponent;
|
||||
let injector: Injector;
|
||||
let resourceService: TestResourceService;
|
||||
let locationService: TestLocationService;
|
||||
let categories: Category[];
|
||||
|
||||
beforeEach(() => {
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
{provide: ResourceListComponent, deps: [ResourceService, LocationService] },
|
||||
{provide: ResourceService, useClass: TestResourceService, deps: [] },
|
||||
{provide: LocationService, useClass: TestLocationService, deps: [] }
|
||||
]
|
||||
});
|
||||
|
||||
locationService = injector.get(LocationService) as unknown as TestLocationService;
|
||||
resourceService = injector.get(ResourceService) as unknown as TestResourceService;
|
||||
categories = resourceService.testCategories;
|
||||
});
|
||||
|
||||
it('should select the first category when no query string', () => {
|
||||
component = getComponent();
|
||||
expect(component.selectedCategory).toBe(categories[0]);
|
||||
});
|
||||
|
||||
it('should select the first category when query string w/o "category" property', () => {
|
||||
locationService.searchResult = { foo: 'development' };
|
||||
component = getComponent();
|
||||
expect(component.selectedCategory).toBe(categories[0]);
|
||||
});
|
||||
|
||||
it('should select the first category when query category not found', () => {
|
||||
locationService.searchResult = { category: 'foo' };
|
||||
component = getComponent();
|
||||
expect(component.selectedCategory).toBe(categories[0]);
|
||||
});
|
||||
|
||||
it('should select the education category when query category is "education"', () => {
|
||||
locationService.searchResult = { category: 'education' };
|
||||
component = getComponent();
|
||||
expect(component.selectedCategory).toBe(categories[1]);
|
||||
});
|
||||
|
||||
it('should select the education category when query category is "EDUCATION" (case insensitive)', () => {
|
||||
locationService.searchResult = { category: 'EDUCATION' };
|
||||
component = getComponent();
|
||||
expect(component.selectedCategory).toBe(categories[1]);
|
||||
});
|
||||
|
||||
it('should set the query to the "education" category when user selects "education"', () => {
|
||||
component = getComponent();
|
||||
component.selectCategory('education');
|
||||
expect(locationService.searchResult.category).toBe('education');
|
||||
});
|
||||
|
||||
it('should set the query to the first category when user selects unknown name', () => {
|
||||
component = getComponent();
|
||||
component.selectCategory('education'); // a legit group that isn't the first
|
||||
|
||||
component.selectCategory('foo'); // not a legit group name
|
||||
expect(locationService.searchResult.category).toBe('development');
|
||||
});
|
||||
|
||||
//// Test Helpers ////
|
||||
function getComponent(): ResourceListComponent {
|
||||
const comp = injector.get(ResourceListComponent);
|
||||
comp.ngOnInit();
|
||||
return comp;
|
||||
}
|
||||
|
||||
class TestResourceService {
|
||||
testCategories = getTestData();
|
||||
categories = of(this.testCategories);
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
[index: string]: string;
|
||||
}
|
||||
|
||||
class TestLocationService {
|
||||
searchResult: SearchResult = {};
|
||||
search = jasmine.createSpy('search').and.callFake(() => this.searchResult);
|
||||
setSearch = jasmine.createSpy('setSearch')
|
||||
.and.callFake((_label: string, result: SearchResult) => {
|
||||
this.searchResult = result;
|
||||
});
|
||||
}
|
||||
|
||||
function getTestData(): Category[] {
|
||||
return [
|
||||
// Not interested in the sub-categories data in these tests
|
||||
{
|
||||
id: 'development',
|
||||
title: 'Development',
|
||||
order: 0,
|
||||
subCategories: []
|
||||
},
|
||||
{
|
||||
id: 'education',
|
||||
title: 'Education',
|
||||
order: 1,
|
||||
subCategories: []
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { Category } from './resource.model';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { LocationService } from 'app/shared/location.service';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-resource-list',
|
||||
templateUrl: 'resource-list.component.html'
|
||||
})
|
||||
export class ResourceListComponent implements OnInit {
|
||||
|
||||
categories: Category[];
|
||||
selectedCategory: Category;
|
||||
|
||||
constructor(
|
||||
private resourceService: ResourceService,
|
||||
private locationService: LocationService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const category = this.locationService.search().category || '';
|
||||
// Not using async pipe because cats appear twice in template
|
||||
// No need to unsubscribe because categories observable completes.
|
||||
this.resourceService.categories.subscribe(cats => {
|
||||
this.categories = cats;
|
||||
this.selectCategory(category);
|
||||
});
|
||||
}
|
||||
|
||||
selectCategory(id: string) {
|
||||
id = id.toLowerCase();
|
||||
this.selectedCategory =
|
||||
this.categories.find(category => category.id.toLowerCase() === id) || this.categories[0];
|
||||
this.locationService.setSearch('', {category: this.selectedCategory.id});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { NgModule, Type } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ResourceListComponent } from './resource-list.component';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { WithCustomElementComponent } from '../element-registry';
|
||||
|
||||
@NgModule({
|
||||
imports: [ CommonModule ],
|
||||
declarations: [ ResourceListComponent ],
|
||||
providers: [ ResourceService ]
|
||||
})
|
||||
export class ResourceListModule implements WithCustomElementComponent {
|
||||
customElementComponent: Type<any> = ResourceListComponent;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
export interface Category {
|
||||
id: string; // "education"
|
||||
title: string; // "Education"
|
||||
order: number; // 2
|
||||
subCategories: SubCategory[];
|
||||
}
|
||||
|
||||
export interface SubCategory {
|
||||
id: string; // "books"
|
||||
title: string; // "Books"
|
||||
order: number; // 1
|
||||
resources: Resource[];
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
category: string; // "Education"
|
||||
subCategory: string; // "Books"
|
||||
id: string; // "-KLI8vJ0ZkvWhqPembZ7"
|
||||
desc: string; // "This books shows all the steps necessary for the development of SPA"
|
||||
title: string; // "Practical Angular 2",
|
||||
url: string; // "https://leanpub.com/practical-angular-2"
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Injector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ResourceService } from './resource.service';
|
||||
import { Category } from './resource.model';
|
||||
|
||||
describe('ResourceService', () => {
|
||||
|
||||
let injector: Injector;
|
||||
let resourceService: ResourceService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
ResourceService
|
||||
]
|
||||
});
|
||||
|
||||
resourceService = injector.get(ResourceService);
|
||||
httpMock = injector.get(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should make a single connection to the server', () => {
|
||||
const req = httpMock.expectOne({});
|
||||
expect(req.request.url).toBe('generated/resources.json');
|
||||
});
|
||||
|
||||
describe('#categories', () => {
|
||||
|
||||
let categories: Category[];
|
||||
let testData: any;
|
||||
|
||||
beforeEach(() => {
|
||||
testData = getTestResources();
|
||||
httpMock.expectOne({}).flush(testData);
|
||||
resourceService.categories.subscribe(results => categories = results);
|
||||
});
|
||||
|
||||
it('categories observable should complete', () => {
|
||||
let completed = false;
|
||||
resourceService.categories.subscribe({complete: () => completed = true});
|
||||
expect(completed).withContext('observable completed').toBe(true);
|
||||
});
|
||||
|
||||
it('should reshape contributors.json to sorted category array', () => {
|
||||
const actualIds = categories.map(c => c.id).join(',');
|
||||
expect(actualIds).toBe('cat-1,cat-3');
|
||||
});
|
||||
|
||||
it('should convert ids to canonical form', () => {
|
||||
// canonical form is lowercase with dashes for spaces
|
||||
const cat = categories[1];
|
||||
const sub = cat.subCategories[0];
|
||||
const res = sub.resources[0];
|
||||
|
||||
expect(cat.id).withContext('category id').toBe('cat-3');
|
||||
expect(sub.id).withContext('subcat id').toBe('cat3-subcat2');
|
||||
expect(res.id).withContext('resources id').toBe('cat3-subcat2-res1');
|
||||
});
|
||||
|
||||
it('resource knows its category and sub-category titles', () => {
|
||||
const cat = categories[1];
|
||||
const sub = cat.subCategories[0];
|
||||
const res = sub.resources[0];
|
||||
expect(res.category).withContext('category title').toBe(cat.title);
|
||||
expect(res.subCategory).withContext('subcategory title').toBe(sub.title);
|
||||
});
|
||||
|
||||
it('should have expected SubCategories of "Cat 3"', () => {
|
||||
const actualIds = categories[1].subCategories.map(s => s.id).join(',');
|
||||
expect(actualIds).toBe('cat3-subcat2,cat3-subcat1');
|
||||
});
|
||||
|
||||
it('should have expected sorted resources of "Cat 1:SubCat1"', () => {
|
||||
const actualIds = categories[0].subCategories[0].resources.map(r => r.id).join(',');
|
||||
expect(actualIds).toBe('a-a-a,s-s-s,z-z-z');
|
||||
});
|
||||
});
|
||||
|
||||
it('should do WHAT(?) if the request fails');
|
||||
});
|
||||
|
||||
function getTestResources() {
|
||||
return {
|
||||
'Cat 3': {
|
||||
order: 3,
|
||||
subCategories: {
|
||||
'Cat3 SubCat1': {
|
||||
order: 2,
|
||||
resources: {
|
||||
'Cat3 SubCat1 Res1': {
|
||||
desc: 'Meetup in Barcelona, Spain. ',
|
||||
title: 'Angular Beers',
|
||||
url: 'http://www.meetup.com/AngularJS-Beers/',
|
||||
},
|
||||
'Cat3 SubCat1 Res2': {
|
||||
desc: 'Angular Camps in Barcelona, Spain.',
|
||||
title: 'Angular Camp',
|
||||
url: 'http://angularcamp.org/',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Cat3 SubCat2': {
|
||||
order: 1,
|
||||
resources: {
|
||||
'Cat3 SubCat2 Res1': {
|
||||
desc: 'A community index of components and libraries',
|
||||
title: 'Catalog of Angular Components & Libraries',
|
||||
url: 'https://a/b/c',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Cat 1': {
|
||||
order: 1,
|
||||
subCategories: {
|
||||
'Cat1 SubCat1': {
|
||||
order: 1,
|
||||
resources: {
|
||||
'S S S': {
|
||||
desc: 'SSS',
|
||||
title: 'Sssss',
|
||||
url: 'http://s/s/s',
|
||||
},
|
||||
'A A A': {
|
||||
desc: 'AAA',
|
||||
title: 'Aaaa',
|
||||
url: 'http://a/a/a',
|
||||
},
|
||||
'Z Z Z': {
|
||||
desc: 'ZZZ',
|
||||
title: 'Zzzzz',
|
||||
url: 'http://z/z/z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import {Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
|
||||
import {AsyncSubject, connectable, Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
import {Category, Resource, SubCategory} from './resource.model';
|
||||
import {CONTENT_URL_PREFIX} from 'app/documents/document.service';
|
||||
|
||||
const resourcesPath = CONTENT_URL_PREFIX + 'resources.json';
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService {
|
||||
categories: Observable<Category[]>;
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.categories = this.getCategories();
|
||||
}
|
||||
|
||||
private getCategories(): Observable<Category[]> {
|
||||
const categoriesSrc = this.http.get<any>(resourcesPath).pipe(map((data) => mkCategories(data)));
|
||||
const categories = connectable(categoriesSrc, {
|
||||
connector: () => new AsyncSubject(),
|
||||
resetOnDisconnect: false,
|
||||
});
|
||||
categories.connect();
|
||||
return categories;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract sorted Category[] from resource JSON data
|
||||
function mkCategories(categoryJson: any): Category[] {
|
||||
return Object.keys(categoryJson).map(catKey => {
|
||||
const cat = categoryJson[catKey];
|
||||
return {
|
||||
id: makeId(catKey),
|
||||
title: catKey,
|
||||
order: cat.order,
|
||||
subCategories: mkSubCategories(cat.subCategories, catKey)
|
||||
} as Category;
|
||||
})
|
||||
.sort(compareCats);
|
||||
}
|
||||
|
||||
// Extract sorted SubCategory[] from JSON category data
|
||||
function mkSubCategories(subCategoryJson: any, catKey: string): SubCategory[] {
|
||||
return Object.keys(subCategoryJson).map(subKey => {
|
||||
const sub = subCategoryJson[subKey];
|
||||
return {
|
||||
id: makeId(subKey),
|
||||
title: subKey,
|
||||
order: sub.order,
|
||||
resources: mkResources(sub.resources, subKey, catKey)
|
||||
} as SubCategory;
|
||||
})
|
||||
.sort(compareCats);
|
||||
}
|
||||
|
||||
// Extract sorted Resource[] from JSON subcategory data
|
||||
function mkResources(resourceJson: any, subKey: string, catKey: string): Resource[] {
|
||||
return Object.keys(resourceJson).map(resKey => {
|
||||
const res = resourceJson[resKey];
|
||||
res.category = catKey;
|
||||
res.subCategory = subKey;
|
||||
res.id = makeId(resKey);
|
||||
return res as Resource;
|
||||
})
|
||||
.sort(compareTitles);
|
||||
}
|
||||
|
||||
function compareCats(l: Category | SubCategory, r: Category | SubCategory) {
|
||||
return l.order === r.order ? compareTitles(l, r) : l.order > r.order ? 1 : -1;
|
||||
}
|
||||
|
||||
function compareTitles(l: {title: string}, r: {title: string}) {
|
||||
return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1;
|
||||
}
|
||||
|
||||
function makeId(title: string) {
|
||||
return title.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue