diff --git a/backend/scaffolder/templates/ios-swift-app-template/cookiecutter.json b/backend/scaffolder/templates/ios-swift-app-template/cookiecutter.json new file mode 100644 index 0000000000..d1813da0fa --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "component_id": "", + "owner": "", + "description": "We promise to update this description /{{cookiecutter.owner}}" +} diff --git a/backend/scaffolder/templates/ios-swift-app-template/custom-fields.json b/backend/scaffolder/templates/ios-swift-app-template/custom-fields.json new file mode 100644 index 0000000000..bff05e95c7 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/custom-fields.json @@ -0,0 +1,41 @@ +{ + "schema": [ + { + "id": "component_id", + "title": "Project name", + "type": "text", + "description": "Project name used to generate Xcode files and class names. CamelCase e.g. 'MyProject'", + "validators": [ + { + "type": "required" + }, + { + "type": "regex", + "match": "^[A-Z][A-Za-z]+$", + "message": "Project name must be CamelCase" + } + ] + }, + { + "id": "owner", + "title": "owner", + "type": "text", + "description": "The owner name used to identify the owner of this component", + "validators": [ + { + "type": "required" + }, + { + "type": "string-range", + "min": 4, + "max": 33 + }, + { + "type": "regex", + "match": "^[a-z][a-z0-9]+$", + "message": "Owner names must consist only of lowercase letters" + } + ] + } + ] +} diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/README.md b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/README.md new file mode 100644 index 0000000000..20dd1dad67 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/README.md @@ -0,0 +1,7 @@ +# iOS App + +This project provides a template project to generate an iOS App written in Swift. + +## Building and Testing + +You can directly open the `xcodeproj` and build from Xcode or run `build.sh` from the command line. \ No newline at end of file diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/build.sh b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/build.sh new file mode 100755 index 0000000000..b40a44923e --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/build.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +PLATFORM="platform=iOS Simulator,OS=13.0,name=iPhone 11" +SDK="iphonesimulator" + +set -e +function trap_handler() { + echo -e "\n\nFailed to build project" + exit 255 +} +trap trap_handler INT TERM EXIT + +echo "Building {{cookiecutter.component_id}}." +xcodebuild \ + -project {{cookiecutter.component_id}}.xcodeproj \ + -scheme {{cookiecutter.component_id}} \ + -sdk "$SDK" \ + -destination "$PLATFORM" \ + build +trap - EXIT +exit 0 diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.pbxproj b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..b48e86d615 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.pbxproj @@ -0,0 +1,470 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + C5B1973423EAFA6D007C9A28 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B1973323EAFA6D007C9A28 /* AppDelegate.swift */; }; + C5B1973623EAFA6D007C9A28 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B1973523EAFA6D007C9A28 /* SceneDelegate.swift */; }; + C5B1973823EAFA6D007C9A28 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B1973723EAFA6D007C9A28 /* ViewController.swift */; }; + C5B1973B23EAFA6D007C9A28 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5B1973923EAFA6D007C9A28 /* Main.storyboard */; }; + C5B1973D23EAFA6E007C9A28 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C5B1973C23EAFA6E007C9A28 /* Assets.xcassets */; }; + C5B1974023EAFA6E007C9A28 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5B1973E23EAFA6E007C9A28 /* LaunchScreen.storyboard */; }; + C5B1974B23EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B1974A23EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + C5B1974723EAFA6E007C9A28 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C5B1972823EAFA6D007C9A28 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C5B1972F23EAFA6D007C9A28; + remoteInfo = {{cookiecutter.component_id}}; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + C5B1973023EAFA6D007C9A28 /* {{cookiecutter.component_id}}.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {{cookiecutter.component_id}}.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C5B1973323EAFA6D007C9A28 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + C5B1973523EAFA6D007C9A28 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + C5B1973723EAFA6D007C9A28 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + C5B1973A23EAFA6D007C9A28 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + C5B1973C23EAFA6E007C9A28 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C5B1973F23EAFA6E007C9A28 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + C5B1974123EAFA6E007C9A28 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C5B1974623EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = {{cookiecutter.component_id}}Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C5B1974A23EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = {{cookiecutter.component_id}}Tests.swift; sourceTree = ""; }; + C5B1974C23EAFA6E007C9A28 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + C5B1972D23EAFA6D007C9A28 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C5B1974323EAFA6E007C9A28 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C5B1972723EAFA6D007C9A28 = { + isa = PBXGroup; + children = ( + C5B1973223EAFA6D007C9A28 /* {{cookiecutter.component_id}} */, + C5B1974923EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests */, + C5B1973123EAFA6D007C9A28 /* Products */, + ); + sourceTree = ""; + }; + C5B1973123EAFA6D007C9A28 /* Products */ = { + isa = PBXGroup; + children = ( + C5B1973023EAFA6D007C9A28 /* {{cookiecutter.component_id}}.app */, + C5B1974623EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + C5B1973223EAFA6D007C9A28 /* {{cookiecutter.component_id}} */ = { + isa = PBXGroup; + children = ( + C5B1973323EAFA6D007C9A28 /* AppDelegate.swift */, + C5B1973523EAFA6D007C9A28 /* SceneDelegate.swift */, + C5B1973723EAFA6D007C9A28 /* ViewController.swift */, + C5B1973923EAFA6D007C9A28 /* Main.storyboard */, + C5B1973C23EAFA6E007C9A28 /* Assets.xcassets */, + C5B1973E23EAFA6E007C9A28 /* LaunchScreen.storyboard */, + C5B1974123EAFA6E007C9A28 /* Info.plist */, + ); + path = {{cookiecutter.component_id}}; + sourceTree = ""; + }; + C5B1974923EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests */ = { + isa = PBXGroup; + children = ( + C5B1974A23EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.swift */, + C5B1974C23EAFA6E007C9A28 /* Info.plist */, + ); + path = {{cookiecutter.component_id}}Tests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C5B1972F23EAFA6D007C9A28 /* {{cookiecutter.component_id}} */ = { + isa = PBXNativeTarget; + buildConfigurationList = C5B1974F23EAFA6E007C9A28 /* Build configuration list for PBXNativeTarget "{{cookiecutter.component_id}}" */; + buildPhases = ( + C5B1972C23EAFA6D007C9A28 /* Sources */, + C5B1972D23EAFA6D007C9A28 /* Frameworks */, + C5B1972E23EAFA6D007C9A28 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = {{cookiecutter.component_id}}; + productName = {{cookiecutter.component_id}}; + productReference = C5B1973023EAFA6D007C9A28 /* {{cookiecutter.component_id}}.app */; + productType = "com.apple.product-type.application"; + }; + C5B1974523EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C5B1975223EAFA6E007C9A28 /* Build configuration list for PBXNativeTarget "{{cookiecutter.component_id}}Tests" */; + buildPhases = ( + C5B1974223EAFA6E007C9A28 /* Sources */, + C5B1974323EAFA6E007C9A28 /* Frameworks */, + C5B1974423EAFA6E007C9A28 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + C5B1974823EAFA6E007C9A28 /* PBXTargetDependency */, + ); + name = {{cookiecutter.component_id}}Tests; + productName = {{cookiecutter.component_id}}Tests; + productReference = C5B1974623EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C5B1972823EAFA6D007C9A28 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1100; + LastUpgradeCheck = 1100; + ORGANIZATIONNAME = Organization; + TargetAttributes = { + C5B1972F23EAFA6D007C9A28 = { + CreatedOnToolsVersion = 11.0; + }; + C5B1974523EAFA6E007C9A28 = { + CreatedOnToolsVersion = 11.0; + TestTargetID = C5B1972F23EAFA6D007C9A28; + }; + }; + }; + buildConfigurationList = C5B1972B23EAFA6D007C9A28 /* Build configuration list for PBXProject "{{cookiecutter.component_id}}" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C5B1972723EAFA6D007C9A28; + productRefGroup = C5B1973123EAFA6D007C9A28 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C5B1972F23EAFA6D007C9A28 /* {{cookiecutter.component_id}} */, + C5B1974523EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C5B1972E23EAFA6D007C9A28 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C5B1974023EAFA6E007C9A28 /* LaunchScreen.storyboard in Resources */, + C5B1973D23EAFA6E007C9A28 /* Assets.xcassets in Resources */, + C5B1973B23EAFA6D007C9A28 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C5B1974423EAFA6E007C9A28 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C5B1972C23EAFA6D007C9A28 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C5B1973823EAFA6D007C9A28 /* ViewController.swift in Sources */, + C5B1973423EAFA6D007C9A28 /* AppDelegate.swift in Sources */, + C5B1973623EAFA6D007C9A28 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C5B1974223EAFA6E007C9A28 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C5B1974B23EAFA6E007C9A28 /* {{cookiecutter.component_id}}Tests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + C5B1974823EAFA6E007C9A28 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C5B1972F23EAFA6D007C9A28 /* {{cookiecutter.component_id}} */; + targetProxy = C5B1974723EAFA6E007C9A28 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + C5B1973923EAFA6D007C9A28 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + C5B1973A23EAFA6D007C9A28 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + C5B1973E23EAFA6E007C9A28 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + C5B1973F23EAFA6E007C9A28 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + C5B1974D23EAFA6E007C9A28 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C5B1974E23EAFA6E007C9A28 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C5B1975023EAFA6E007C9A28 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = {{cookiecutter.component_id}}/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.organization.{{cookiecutter.component_id}}; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C5B1975123EAFA6E007C9A28 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = {{cookiecutter.component_id}}/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.organization.{{cookiecutter.component_id}}; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + C5B1975323EAFA6E007C9A28 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = {{cookiecutter.component_id}}Tests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.organization.{{cookiecutter.component_id}}Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/{{cookiecutter.component_id}}.app/{{cookiecutter.component_id}}"; + }; + name = Debug; + }; + C5B1975423EAFA6E007C9A28 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = {{cookiecutter.component_id}}Tests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.organization.{{cookiecutter.component_id}}Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/{{cookiecutter.component_id}}.app/{{cookiecutter.component_id}}"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C5B1972B23EAFA6D007C9A28 /* Build configuration list for PBXProject "{{cookiecutter.component_id}}" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C5B1974D23EAFA6E007C9A28 /* Debug */, + C5B1974E23EAFA6E007C9A28 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C5B1974F23EAFA6E007C9A28 /* Build configuration list for PBXNativeTarget "{{cookiecutter.component_id}}" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C5B1975023EAFA6E007C9A28 /* Debug */, + C5B1975123EAFA6E007C9A28 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C5B1975223EAFA6E007C9A28 /* Build configuration list for PBXNativeTarget "{{cookiecutter.component_id}}Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C5B1975323EAFA6E007C9A28 /* Debug */, + C5B1975423EAFA6E007C9A28 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = C5B1972823EAFA6D007C9A28 /* Project object */; +} diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..fab8bef75c --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/xcuserdata/patrickb.xcuserdatad/UserInterfaceState.xcuserstate b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/xcuserdata/patrickb.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000..db2ac7d09b Binary files /dev/null and b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/project.xcworkspace/xcuserdata/patrickb.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/xcuserdata/patrickb.xcuserdatad/xcschemes/xcschememanagement.plist b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/xcuserdata/patrickb.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000000..abde0216f9 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}.xcodeproj/xcuserdata/patrickb.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + {{cookiecutter.component_id}}.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/AppDelegate.swift b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/AppDelegate.swift new file mode 100644 index 0000000000..bdcc2c1309 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// {{cookiecutter.component_id}} +// +// Copyright © 2020 Organization. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Assets.xcassets/AppIcon.appiconset/Contents.json b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d8db8d65fd --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Assets.xcassets/Contents.json b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..da4a164c91 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Base.lproj/LaunchScreen.storyboard b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..865e9329f3 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Base.lproj/Main.storyboard b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..25a763858e --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Info.plist b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Info.plist new file mode 100644 index 0000000000..2a3483c0d2 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/Info.plist @@ -0,0 +1,64 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/SceneDelegate.swift b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/SceneDelegate.swift new file mode 100644 index 0000000000..4919f3a006 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/SceneDelegate.swift @@ -0,0 +1,52 @@ +// +// SceneDelegate.swift +// {{cookiecutter.component_id}} +// +// Copyright © 2020 Organization. All rights reserved. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/ViewController.swift b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/ViewController.swift new file mode 100644 index 0000000000..9e2c5fb077 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}/ViewController.swift @@ -0,0 +1,19 @@ +// +// ViewController.swift +// {{cookiecutter.component_id}} +// +// Copyright © 2020 Organization. All rights reserved. +// + +import UIKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + } + + +} + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}Tests/Info.plist b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}Tests/Info.plist new file mode 100644 index 0000000000..64d65ca495 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}Tests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}Tests/{{cookiecutter.component_id}}Tests.swift b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}Tests/{{cookiecutter.component_id}}Tests.swift new file mode 100644 index 0000000000..3ccb2e1662 --- /dev/null +++ b/backend/scaffolder/templates/ios-swift-app-template/{{cookiecutter.component_id}}/{{cookiecutter.component_id}}Tests/{{cookiecutter.component_id}}Tests.swift @@ -0,0 +1,33 @@ +// +// {{cookiecutter.component_id}}Tests.swift +// {{cookiecutter.component_id}}Tests +// +// Copyright © 2020 Organization. All rights reserved. +// + +import XCTest +@testable import {{cookiecutter.component_id}} + +class {{cookiecutter.component_id}}Tests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/frontend/packages/app/package.json b/frontend/packages/app/package.json index 9082342597..130e7972ad 100644 --- a/frontend/packages/app/package.json +++ b/frontend/packages/app/package.json @@ -5,6 +5,7 @@ "dependencies": { "@backstage/core": "0.0.0", "@backstage/plugin-hello-world": "0.0.0", + "@backstage/plugin-home-page": "0.0.0", "@backstage/plugin-login": "0.0.0", "@react-workspaces/react-scripts": "^3.3.0-alpha-08", "@testing-library/jest-dom": "^4.2.4", diff --git a/frontend/packages/app/src/App.test.tsx b/frontend/packages/app/src/App.test.tsx index a4b304af28..3dc39e48b7 100644 --- a/frontend/packages/app/src/App.test.tsx +++ b/frontend/packages/app/src/App.test.tsx @@ -6,7 +6,5 @@ describe('App', () => { it('renders learn react link', () => { const rendered = render(); rendered.getByText('This is Backstage!'); - rendered.getByText('Backstage is an open platform for building developer portals'); - rendered.getByText('…with plugin hello-world:'); }); }); diff --git a/frontend/packages/app/src/App.tsx b/frontend/packages/app/src/App.tsx index f51cf54d0b..c172c2bd38 100644 --- a/frontend/packages/app/src/App.tsx +++ b/frontend/packages/app/src/App.tsx @@ -1,18 +1,41 @@ -import React, { FC, Fragment } from 'react'; -import helloWorld, { MyComponent } from '@backstage/plugin-hello-world'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import SideBar from './components/SideBar'; -import PageHeader from './components/PageHeader'; +import { + BackstageTheme, + createApp, + Header, + InfoCard, + Page, + theme, +} from '@backstage/core'; +//import PageHeader from './components/PageHeader'; import { LoginComponent } from '@backstage/plugin-login'; +import HomePagePlugin from '@backstage/plugin-home-page'; +import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core'; +import React, { FC } from 'react'; import { BrowserRouter as Router, - Switch, - Route, Link as RouterLink, + Route, + Switch, } from 'react-router-dom'; +import HomePageTimer from './components/HomepageTimer'; +import SideBar from './components/SideBar'; +import entities from './entities'; const useStyles = makeStyles(theme => ({ + '@global': { + html: { + height: '100%', + fontFamily: theme.typography.fontFamily, + }, + body: { + height: '100%', + fontFamily: theme.typography.fontFamily, + }, + a: { + color: 'inherit', + textDecoration: 'none', + }, + }, root: { display: 'grid', // FIXME: Don't used a fixed width here @@ -26,54 +49,21 @@ const useStyles = makeStyles(theme => ({ overflowY: 'auto', }, pageBody: { - paddingLeft: theme.spacing(2), - paddingTop: theme.spacing(2), + padding: theme.spacing(2), }, avatarButton: { padding: theme.spacing(2), }, })); -const App: FC<{}> = () => { - return ( - - - - - - - - - - - - - ); -}; - -const Home: FC<{}> = () => { - return ( - - - {' '} - …with plugin {helloWorld?.id ?? 'wat'}: - - -
- Go to Login -
-
- ); -}; - const Login: FC<{}> = () => { return ( - +
Go to Home
-
+ ); }; @@ -83,12 +73,44 @@ const AppShell: FC<{}> = ({ children }) => { return (
-
- -
{children}
-
+ +
+
+ +
+
{children}
+
+
); }; +const app = createApp(); + +app.registerEntityKind(...entities); +app.registerPlugin(HomePagePlugin); + +const AppComponent = app.build(); + +const App: FC<{}> = () => { + return ( + + + + + + + + + + + + + + + + + ); +}; + export default App; diff --git a/frontend/packages/app/src/components/HomepageTimer/HomepageTimer.tsx b/frontend/packages/app/src/components/HomepageTimer/HomepageTimer.tsx new file mode 100644 index 0000000000..54d269bf9c --- /dev/null +++ b/frontend/packages/app/src/components/HomepageTimer/HomepageTimer.tsx @@ -0,0 +1,58 @@ +import React, { FC } from 'react'; +import { HeaderLabel } from '@backstage/core'; + +const timeFormat = { hour: '2-digit', minute: '2-digit' }; +const nycOptions = { timeZone: 'America/New_York', ...timeFormat }; +const utcOptions = { timeZone: 'UTC', ...timeFormat }; +const lonOptions = { timeZone: 'Europe/London', ...timeFormat }; +const stoOptions = { timeZone: 'Europe/Stockholm', ...timeFormat }; + +const defaultTimes = { + timeNY: '', + timeUTC: '', + timeLON: '', + timeSTO: '', +}; + +function getTimes() { + const d = new Date(); + const lang = window.navigator.language; + + // Using the browser native toLocaleTimeString instead of huge moment-tz + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString + const timeNY = d.toLocaleTimeString(lang, nycOptions); + const timeUTC = d.toLocaleTimeString(lang, utcOptions); + const timeLON = d.toLocaleTimeString(lang, lonOptions); + const timeSTO = d.toLocaleTimeString(lang, stoOptions); + + return { timeNY, timeUTC, timeLON, timeSTO }; +} + +const HomePageTimer: FC<{}> = () => { + const [{ timeNY, timeUTC, timeLON, timeSTO }, setTimes] = React.useState( + defaultTimes, + ); + + React.useEffect(() => { + setTimes(getTimes()); + + const intervalId = setInterval(() => { + setTimes(getTimes()); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, []); + + return ( + <> + + + + + + ); +}; + +export default HomePageTimer; diff --git a/frontend/packages/app/src/components/HomepageTimer/index.ts b/frontend/packages/app/src/components/HomepageTimer/index.ts new file mode 100644 index 0000000000..122eef2c23 --- /dev/null +++ b/frontend/packages/app/src/components/HomepageTimer/index.ts @@ -0,0 +1 @@ +export { default } from './HomepageTimer'; diff --git a/frontend/packages/app/src/components/PageHeader/PageHeader.tsx b/frontend/packages/app/src/components/PageHeader/PageHeader.tsx deleted file mode 100644 index 38f77ddbc5..0000000000 --- a/frontend/packages/app/src/components/PageHeader/PageHeader.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import React, { FC } from 'react'; - -const useStyles = makeStyles(theme => ({ - root: { - background: 'linear-gradient(262.63deg, #19D15A 4.2%, #1CAB5B 72.01%)', - height: '120px', - paddingTop: theme.spacing(2), - paddingLeft: theme.spacing(2), - color: 'white', - }, -})); - -const SideBar: FC<{}> = () => { - const classes = useStyles(); - - return ( -
- This is Backstage! - - Backstage is an open platform for building developer portals - -
- ); -}; - -export default SideBar; diff --git a/frontend/packages/app/src/components/PageHeader/index.ts b/frontend/packages/app/src/components/PageHeader/index.ts deleted file mode 100644 index e1b036be68..0000000000 --- a/frontend/packages/app/src/components/PageHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PageHeader'; diff --git a/frontend/packages/app/src/entities/MockEntityCard.tsx b/frontend/packages/app/src/entities/MockEntityCard.tsx new file mode 100644 index 0000000000..97a3f4073d --- /dev/null +++ b/frontend/packages/app/src/entities/MockEntityCard.tsx @@ -0,0 +1,9 @@ +import React, { FC } from 'react'; +import { useEntityUri } from '@backstage/core'; + +const MockEntityPage: FC<{}> = () => { + const uri = useEntityUri(); + return Mock card for {uri}, replace with some userful plugin; +}; + +export default MockEntityPage; diff --git a/frontend/packages/app/src/entities/MockEntityPage.tsx b/frontend/packages/app/src/entities/MockEntityPage.tsx new file mode 100644 index 0000000000..091906f31a --- /dev/null +++ b/frontend/packages/app/src/entities/MockEntityPage.tsx @@ -0,0 +1,9 @@ +import React, { FC } from 'react'; +import { useEntityUri } from '@backstage/core'; + +const MockEntityPage: FC<{}> = () => { + const uri = useEntityUri(); + return Mock page for {uri}, replace with some userful plugin; +}; + +export default MockEntityPage; diff --git a/frontend/packages/app/src/entities/index.ts b/frontend/packages/app/src/entities/index.ts new file mode 100644 index 0000000000..f32f99ab06 --- /dev/null +++ b/frontend/packages/app/src/entities/index.ts @@ -0,0 +1,32 @@ +import { + createEntityKind, + createWidgetView, + createEntityView, +} from '@backstage/core'; +import ComputerIcon from '@material-ui/icons/Computer'; +import MockEntityPage from './MockEntityPage'; +import MockEntityCard from './MockEntityCard'; + +/* SERVICE */ +const serviceOverviewPage = createWidgetView() + .addComponent(MockEntityCard) + .addComponent(MockEntityCard); + +const serviceView = createEntityView() + .addPage('Overview', 'overview', serviceOverviewPage) + .addComponent('CI/CD', 'ci-cd', MockEntityPage); + +const serviceEntity = createEntityKind({ + kind: 'service', + title: 'Service', + color: { + primary: '#f00', + secondary: '#ba5', + }, + icon: ComputerIcon, + pages: { + view: serviceView, + }, +}); + +export default [serviceEntity]; diff --git a/frontend/packages/core/package.json b/frontend/packages/core/package.json index 27c8453567..d22516afe1 100644 --- a/frontend/packages/core/package.json +++ b/frontend/packages/core/package.json @@ -12,8 +12,13 @@ "@types/node": "^12.0.0", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", + "@types/react-router-dom": "^5.1.3", "react": "^16.12.0", - "react-dom": "^16.12.0" + "react-dom": "^16.12.0", + "react-helmet": "5.2.1", + "react-addons-text-content": "0.0.4", + "react-router-dom": "^5.1.2", + "recompose": "0.30.0" }, "scripts": { "lint": "web-scripts lint", diff --git a/frontend/packages/core/src/api/api.ts b/frontend/packages/core/src/api/api.ts new file mode 100644 index 0000000000..73ca5c1da3 --- /dev/null +++ b/frontend/packages/core/src/api/api.ts @@ -0,0 +1,25 @@ +import AppBuilder from './app/AppBuilder'; +import EntityKind, { EntityConfig } from './entity/EntityKind'; +import WidgetViewBuilder from './widgetView/WidgetViewBuilder'; +import EntityViewBuilder from './entityView/EntityViewPageBuilder'; +import BackstagePlugin, { PluginConfig } from './plugin/Plugin'; + +export function createApp() { + return new AppBuilder(); +} + +export function createEntityKind(config: EntityConfig) { + return new EntityKind(config); +} + +export function createWidgetView() { + return new WidgetViewBuilder(); +} + +export function createEntityView() { + return new EntityViewBuilder(); +} + +export function createPlugin(config: PluginConfig): BackstagePlugin { + return new BackstagePlugin(config); +} diff --git a/frontend/packages/core/src/api/app/AppBuilder.tsx b/frontend/packages/core/src/api/app/AppBuilder.tsx new file mode 100644 index 0000000000..349d9a7b06 --- /dev/null +++ b/frontend/packages/core/src/api/app/AppBuilder.tsx @@ -0,0 +1,115 @@ +import React, { ComponentType, FC } from 'react'; +import { Route, Switch, useParams } from 'react-router-dom'; +import { AppContextProvider } from './AppContext'; +import { App, AppComponentBuilder } from './types'; +import EntityKind, { EntityConfig } from '../entity/EntityKind'; +import { EntityContextProvider } from '../entityView/EntityContext'; +import BackstagePlugin, { registerSymbol } from '../plugin/Plugin'; + +class AppImpl implements App { + constructor(private readonly entities: Map) {} + + getEntityConfig(kind: string): EntityConfig { + const entity = this.entities.get(kind); + if (!entity) { + throw new Error('EntityKind not found'); + } + return entity.config; + } +} + +function builtComponent( + app: App, + component: ComponentType | AppComponentBuilder, +) { + if (component instanceof AppComponentBuilder) { + return component.build(app); + } + return component; +} + +export default class AppBuilder { + private readonly entities = new Map(); + private readonly plugins = new Set(); + + registerEntityKind(...entity: EntityKind[]) { + for (const e of entity) { + const { kind } = e.config; + if (this.entities.has(e.config.kind)) { + throw new Error(`EntityKind '${kind}' is already registered`); + } + this.entities.set(e.config.kind, e); + } + } + + registerPlugin(...plugin: BackstagePlugin[]) { + for (const p of plugin) { + if (this.plugins.has(p)) { + throw new Error(`Plugin '${p}' is already registered`); + } + this.plugins.add(p); + } + } + + build(): ComponentType<{}> { + const app = new AppImpl(this.entities); + + const entityRoutes = new Array(); + + for (const { config } of this.entities.values()) { + const { kind, pages } = config; + const basePath = `/entity/${kind}`; + + if (pages.list) { + const ListComponent = builtComponent(app, pages.list); + + const Component: FC<{}> = () => ( + + + + ); + + const path = basePath; + entityRoutes.push( + , + ); + } + + if (pages.view) { + const ViewComponent = builtComponent(app, pages.view); + + const Component: FC<{}> = () => { + const { entityId } = useParams<{ entityId: string }>(); + return ( + + + + ); + }; + + const path = `${basePath}/:entityId`; + entityRoutes.push( + , + ); + } + } + + const pluginRoutes = new Array(); + + for (const plugin of this.plugins.values()) { + const { routes = [] } = plugin[registerSymbol](); + pluginRoutes.push(...routes); + } + + const routes = [...pluginRoutes, ...entityRoutes]; + + return () => ( + + + {routes} + 404 Not Found} /> + + + ); + } +} diff --git a/frontend/packages/core/src/api/app/AppContext.tsx b/frontend/packages/core/src/api/app/AppContext.tsx new file mode 100644 index 0000000000..e8acb424cd --- /dev/null +++ b/frontend/packages/core/src/api/app/AppContext.tsx @@ -0,0 +1,20 @@ +import React, { createContext, useContext, FC } from 'react'; +import { App } from './types'; + +const Context = createContext(undefined); + +type Props = { + app: App; +}; + +export const AppContextProvider: FC = ({ app, children }) => ( + +); + +export const useApp = (): App => { + const app = useContext(Context); + if (!app) { + throw new Error('No app context available'); + } + return app; +}; diff --git a/frontend/packages/core/src/api/app/types.ts b/frontend/packages/core/src/api/app/types.ts new file mode 100644 index 0000000000..189ce208e1 --- /dev/null +++ b/frontend/packages/core/src/api/app/types.ts @@ -0,0 +1,12 @@ +import { ComponentType } from 'react'; +import { EntityConfig } from '../entity/EntityKind'; + +export type App = { + getEntityConfig(kind: string): EntityConfig; +}; + +export class AppComponentBuilder { + build(_app: App): ComponentType { + throw new Error('Must override build() in AppComponentBuilder'); + } +} diff --git a/frontend/packages/core/src/api/entity/EntityKind.ts b/frontend/packages/core/src/api/entity/EntityKind.ts new file mode 100644 index 0000000000..7f6447f4a6 --- /dev/null +++ b/frontend/packages/core/src/api/entity/EntityKind.ts @@ -0,0 +1,20 @@ +import { ComponentType } from 'react'; +import { AppComponentBuilder } from '../app/types'; + +export type EntityConfig = { + kind: string; + title: string; + icon: React.ComponentType<{ fontSize: number }>; + color: { + primary: string; + secondary: string; + }; + pages: { + list?: ComponentType<{}> | AppComponentBuilder; + view?: ComponentType<{}> | AppComponentBuilder; + }; +}; + +export default class EntityKind { + constructor(readonly config: EntityConfig) {} +} diff --git a/frontend/packages/core/src/api/entityView/EntityContext.tsx b/frontend/packages/core/src/api/entityView/EntityContext.tsx new file mode 100644 index 0000000000..0a229fd1b3 --- /dev/null +++ b/frontend/packages/core/src/api/entityView/EntityContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, FC } from 'react'; +import { EntityConfig } from '../entity/EntityKind'; + +type Value = { + config: EntityConfig; + id?: string; +}; + +const Context = createContext(undefined); + +type Props = { + config: EntityConfig; + id?: string; +}; + +export const EntityContextProvider: FC = ({ config, id, children }) => ( + +); + +export const useEntity = (): { kind: string; id: string } => { + const value = useContext(Context); + if (!value) { + throw new Error('No entity context available'); + } + if (!value.id) { + throw new Error('Entity context does not contain entity id'); + } + return { kind: value.config.kind, id: value.id }; +}; + +export const useEntityConfig = (): EntityConfig => { + const value = useContext(Context); + if (!value) { + throw new Error('No entity context available'); + } + return value.config; +}; + +export const useEntityUri = (): string => { + const { kind, id } = useEntity(); + return `entity:${kind}:${id}`; +}; diff --git a/frontend/packages/core/src/api/entityView/EntityViewPageBuilder.tsx b/frontend/packages/core/src/api/entityView/EntityViewPageBuilder.tsx new file mode 100644 index 0000000000..10985325fc --- /dev/null +++ b/frontend/packages/core/src/api/entityView/EntityViewPageBuilder.tsx @@ -0,0 +1,124 @@ +import React, { ComponentType, FC } from 'react'; +import { Route, Redirect, Switch } from 'react-router-dom'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import { AppComponentBuilder, App } from '../app/types'; +import { useEntity, useEntityUri, useEntityConfig } from './EntityContext'; +import EntityLink from '../../components/EntityLink/EntityLink'; + +const EntityLayout: FC<{}> = ({ children }) => { + const config = useEntityConfig(); + return ( +
{children}
+ ); +}; + +const EntitySidebar: FC<{}> = ({ children }) => { + return {children}; +}; + +const EntitySidebarItem: FC<{ title: string; path: string }> = ({ + title, + path, +}) => { + const entityUri = useEntityUri(); + + return ( + + + {title} + + + ); +}; + +type EntityViewPage = { + title: string; + path: string; + component: ComponentType; +}; + +type Props = { + pages: EntityViewPage[]; +}; + +const EntityViewComponent: FC = ({ pages }) => { + const { kind, id } = useEntity(); + const basePath = `/entity/${kind}/${id}`; + + return ( + + + {pages.map(({ title, path }) => ( + + ))} + + + {pages.map(({ path, component }) => ( + + ))} + + + + ); +}; + +type EntityViewRegistration = + | { + type: 'page'; + title: string; + path: string; + page: AppComponentBuilder; + } + | { + type: 'component'; + title: string; + path: string; + component: ComponentType; + }; + +export default class EntityViewBuilder extends AppComponentBuilder { + private readonly registrations = new Array(); + + addPage( + title: string, + path: string, + page: AppComponentBuilder, + ): EntityViewBuilder { + this.registrations.push({ type: 'page', title, path, page }); + return this; + } + + addComponent( + title: string, + path: string, + component: ComponentType, + ): EntityViewBuilder { + this.registrations.push({ type: 'component', title, path, component }); + return this; + } + + build(app: App): ComponentType { + const pages = this.registrations.map(registration => { + switch (registration.type) { + case 'page': { + const { title, path, page } = registration; + return { title, path, component: page.build(app) }; + } + case 'component': { + const { title, path, component } = registration; + return { title, path, component }; + } + default: + throw new Error(`Unknown EntityViewBuilder registration`); + } + }); + + return () => ; + } +} diff --git a/frontend/packages/core/src/api/index.ts b/frontend/packages/core/src/api/index.ts new file mode 100644 index 0000000000..7852cf8f75 --- /dev/null +++ b/frontend/packages/core/src/api/index.ts @@ -0,0 +1,8 @@ +export * from './types'; +export * from './api'; +export { useApp } from './app/AppContext'; +export { + useEntity, + useEntityConfig, + useEntityUri, +} from './entityView/EntityContext'; diff --git a/frontend/packages/core/src/api/plugin/Plugin.tsx b/frontend/packages/core/src/api/plugin/Plugin.tsx new file mode 100644 index 0000000000..13caa40475 --- /dev/null +++ b/frontend/packages/core/src/api/plugin/Plugin.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +export type PluginConfig = { + id: string; + register?(hooks: PluginHooks): void; +}; + +export type PluginHooks = { + router: Router; +}; + +export type RouteOptions = { + // Whether the route path must match exactly, defaults to true. + exact?: boolean; +}; + +export type RedirectOptions = { + // Whether the route path must match exactly, defaults to true. + exact?: boolean; +}; + +export type Router = { + registerRoute( + path: string, + Component: React.ComponentType, + options?: RouteOptions, + ): void; + registerRedirect( + path: string, + target: string, + options?: RedirectOptions, + ): void; +}; + +export type PluginRegistrationResult = { + routes?: JSX.Element[]; +}; + +export const registerSymbol = Symbol('plugin-register'); + +export default class Plugin { + private result?: PluginRegistrationResult; + + constructor(private readonly config: PluginConfig) {} + + [registerSymbol](): PluginRegistrationResult { + if (this.result) { + return this.result; + } + if (!this.config.register) { + return {}; + } + + const { id } = this.config; + + const routes = new Array(); + + this.config.register({ + router: { + registerRoute(path, component, options = {}) { + if (path.startsWith('/entity/')) { + throw new Error( + `Plugin ${id} tried to register forbidden route ${path}`, + ); + } + const { exact = true } = options; + routes.push( + , + ); + }, + registerRedirect(path, target, options = {}) { + if (path.startsWith('/entity/')) { + throw new Error( + `Plugin ${id} tried to register forbidden redirect ${path}`, + ); + } + const { exact = true } = options; + routes.push( + , + ); + }, + }, + }); + + this.result = { routes }; + return this.result; + } + + toString() { + return `plugin{${this.config.id}}`; + } +} diff --git a/frontend/packages/core/src/api/types.ts b/frontend/packages/core/src/api/types.ts new file mode 100644 index 0000000000..06f1570900 --- /dev/null +++ b/frontend/packages/core/src/api/types.ts @@ -0,0 +1,10 @@ +export type User = { + id: string; + email: string; +}; + +export type UserApi = { + isLoggedIn(): Promise; + + getUser(): Promise; +}; diff --git a/frontend/packages/core/src/api/widgetView/WidgetViewBuilder.tsx b/frontend/packages/core/src/api/widgetView/WidgetViewBuilder.tsx new file mode 100644 index 0000000000..eb9f5d1ed0 --- /dev/null +++ b/frontend/packages/core/src/api/widgetView/WidgetViewBuilder.tsx @@ -0,0 +1,50 @@ +import React, { ComponentType, FC } from 'react'; +import { App, AppComponentBuilder } from '../app/types'; + +type Props = { + app: App; + cards: ComponentType[]; +}; + +const WidgetViewComponent: FC = ({ cards }) => { + return ( +
+ {cards.map(CardComponent => ( + + ))} +
+ ); +}; + +type WidgetViewRegistration = { + type: 'component'; + component: ComponentType; +}; + +export default class WidgetViewBuilder extends AppComponentBuilder { + private readonly registrations = new Array(); + private output?: ComponentType; + + addComponent(component: ComponentType): WidgetViewBuilder { + this.registrations.push({ type: 'component', component }); + return this; + } + + build(app: App): ComponentType { + if (this.output) { + return this.output; + } + + const cards = this.registrations.map(reg => { + switch (reg.type) { + case 'component': + return reg.component; + default: + throw new Error(`Unknown WidgetViewBuilder registration`); + } + }); + + this.output = () => ; + return this.output; + } +} diff --git a/frontend/packages/core/src/components/EntityLink/EntityLink.tsx b/frontend/packages/core/src/components/EntityLink/EntityLink.tsx new file mode 100644 index 0000000000..c26972c467 --- /dev/null +++ b/frontend/packages/core/src/components/EntityLink/EntityLink.tsx @@ -0,0 +1,43 @@ +import React, { FC } from 'react'; +import { Link } from 'react-router-dom'; + +type Props = { + subPath?: string; +} & ( + | { + kind: string; + id?: string; + } + | { + uri: string; + } +); + +function buildPath(kind: string, id?: string, subPath?: string) { + if (id) { + if (subPath) { + return `/entity/${kind}/${id}/${subPath}`; + } + return `/entity/${kind}/${id}`; + } + return `/entity/${kind}`; +} + +const EntityLink: FC = ({ subPath, children, ...props }) => { + if ('kind' in props) { + const { kind, id } = props; + return {children}; + } else { + const match = props.uri.match(/entity:([^:]+)(:[^:]+)?/); + if (!match) { + throw new TypeError(`Invalid entity uri: '${props.uri}'`); + } + + const [, kind, maybeId] = match; + const id = maybeId ? maybeId.slice(1) : undefined; + + return {children}; + } +}; + +export default EntityLink; diff --git a/frontend/packages/core/src/components/EntityLink/index.ts b/frontend/packages/core/src/components/EntityLink/index.ts new file mode 100644 index 0000000000..d35b5bee26 --- /dev/null +++ b/frontend/packages/core/src/components/EntityLink/index.ts @@ -0,0 +1 @@ +export { default } from './EntityLink'; diff --git a/frontend/packages/core/src/components/Lifecycle/Lifecycle.js b/frontend/packages/core/src/components/Lifecycle/Lifecycle.js new file mode 100644 index 0000000000..5c11599f92 --- /dev/null +++ b/frontend/packages/core/src/components/Lifecycle/Lifecycle.js @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +class Lifecycle extends Component { + static propTypes = { + isShorthand: PropTypes.bool, + fontSize: PropTypes.string, + }; +} + +const styles = { + alpha: { + color: '#d00150', + fontFamily: 'serif', + fontWeight: 'normal', + fontStyle: 'italic', + }, + beta: { + color: '#4d65cc', + fontFamily: 'serif', + fontWeight: 'normal', + fontStyle: 'italic', + }, +}; + +export class AlphaLabel extends Lifecycle { + render() { + const style = fontSize => ({ ...styles.alpha, fontSize, ...this.props.style }); + return this.props.isShorthand ? ( + α + ) : ( + Alpha + ); + } +} + +export class BetaLabel extends Lifecycle { + render() { + const fontSize = this.props.fontSize ? this.props.fontSize : this.props.isShorthand ? '120%' : '100%'; + const style = { ...styles.beta, fontSize, ...this.props.style }; + + return this.props.isShorthand ? β : Beta; + } +} diff --git a/frontend/packages/core/src/components/Lifecycle/index.js b/frontend/packages/core/src/components/Lifecycle/index.js new file mode 100644 index 0000000000..a5b40c210e --- /dev/null +++ b/frontend/packages/core/src/components/Lifecycle/index.js @@ -0,0 +1 @@ +export { AlphaLabel, BetaLabel } from './Lifecycle'; diff --git a/frontend/packages/core/src/components/Status/Status.js b/frontend/packages/core/src/components/Status/Status.js new file mode 100644 index 0000000000..04fff2a552 --- /dev/null +++ b/frontend/packages/core/src/components/Status/Status.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { pure } from 'recompose'; +import { withStyles } from '@material-ui/core'; + +const styles = theme => ({ + status: { + width: 12, + height: 12, + display: 'inline-block', + marginRight: 1, + }, + ok: { + backgroundColor: theme.palette.status.ok, + borderRadius: '50%', + }, + warning: { + backgroundColor: theme.palette.status.warning, + }, + error: { + width: '0', + height: '0', + borderLeft: '7px solid transparent', + borderRight: '7px solid transparent', + borderBottom: `14px solid ${theme.palette.status.error}`, + }, + pending: { + backgroundColor: theme.palette.status.pending, + }, + failed: { + backgroundColor: 'rgba(245, 155, 35, 0.5)', + }, + running: { + animation: 'blink 0.8s step-start 0s infinite', + backgroundColor: theme.palette.status.running, + }, + '@keyframes blink': { + '50%': { + backgroundColor: theme.palette.status.background, + }, + }, +}); + +export const StatusOK = pure( + withStyles(styles)(props => ( + + )), +); + +export const StatusWarning = pure( + withStyles(styles)(props => ( + + )), +); + +export const StatusError = pure( + withStyles(styles)(props => ( + + )), +); + +export const StatusNA = pure(() => N/A); + +export const StatusPending = pure( + withStyles(styles)(props => ( + + )), +); + +export const StatusRunning = pure( + withStyles(styles)(props => ( + + )), +); + +export const StatusFailed = pure( + withStyles(styles)(props => ( + + )), +); diff --git a/frontend/packages/core/src/components/Status/index.js b/frontend/packages/core/src/components/Status/index.js new file mode 100644 index 0000000000..4b037648e3 --- /dev/null +++ b/frontend/packages/core/src/components/Status/index.js @@ -0,0 +1 @@ +export { StatusError, StatusFailed, StatusNA, StatusOK, StatusPending, StatusRunning, StatusWarning } from './Status'; diff --git a/frontend/packages/core/src/createPlugin.test.ts b/frontend/packages/core/src/createPlugin.test.ts deleted file mode 100644 index edda12f09e..0000000000 --- a/frontend/packages/core/src/createPlugin.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import createPlugin from './createPlugin'; - -describe('createPlugin', () => { - it('should create a plugin', () => { - const plugin = createPlugin({ id: 'my-plugin' }); - expect(plugin.id).toBe('my-plugin'); - }); -}); diff --git a/frontend/packages/core/src/createPlugin.ts b/frontend/packages/core/src/createPlugin.ts deleted file mode 100644 index 9b1cef8e44..0000000000 --- a/frontend/packages/core/src/createPlugin.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BackstagePlugin } from './types'; - -export type PluginConfig = { - id: string; -}; - -function createPlugin(config: PluginConfig): BackstagePlugin { - return config; -} - -export default createPlugin; diff --git a/frontend/packages/core/src/index.ts b/frontend/packages/core/src/index.ts index 0b69aa4ff3..3dc9c80d41 100644 --- a/frontend/packages/core/src/index.ts +++ b/frontend/packages/core/src/index.ts @@ -1,2 +1,9 @@ -export * from './types'; -export { default as createPlugin } from './createPlugin'; +export * from './api'; +export { default as EntityLink } from './components/EntityLink'; +export { default as Page } from '../src/layout/Page'; +export { gradients, theme } from '../src/layout/Page'; +export { default as Header } from '../src/layout/Header/Header'; +export { default as HeaderLabel } from '../src/layout/HeaderLabel'; +export { default as InfoCard } from '../src/layout/InfoCard'; +export { default as ErrorBoundary } from '../src/layout/ErrorBoundary'; +export { default as BackstageTheme } from '../src/theme/BackstageTheme'; diff --git a/frontend/packages/core/src/layout/ContentHeader/ContentHeader.js b/frontend/packages/core/src/layout/ContentHeader/ContentHeader.js new file mode 100644 index 0000000000..ed3e69d37a --- /dev/null +++ b/frontend/packages/core/src/layout/ContentHeader/ContentHeader.js @@ -0,0 +1,69 @@ +import React, { Component, Fragment } from 'react'; +import { Typography, withStyles } from '@material-ui/core'; +import Helmet from 'react-helmet'; +import FavoriteButton from 'shared/components/layout/FavoriteButton'; + +const styles = theme => ({ + container: { + width: '100%', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + }, + leftItemsBox: { + flex: '1 1 auto', + marginBottom: theme.spacing(1), + minWidth: 0, + overflow: 'visible', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + marginLeft: theme.spacing(1), + marginBottom: theme.spacing(1), + minWidth: 0, + overflow: 'visible', + }, + description: {}, + title: { + display: 'inline-flex', + }, +}); + +class ContentHeader extends Component { + static defaultProps = { + favoriteable: true, + title: 'Unknown page', + }; + + render() { + const { title, description, favoriteable, children, classes } = this.props; + + return ( + + +
+
+ + {title} + + {favoriteable && } + {description && ( + + {description} + + )} +
+
{children}
+
+
+ ); + } +} + +export default withStyles(styles)(ContentHeader); diff --git a/frontend/packages/core/src/layout/ErrorBoundary/ErrorBoundary.js b/frontend/packages/core/src/layout/ErrorBoundary/ErrorBoundary.js new file mode 100644 index 0000000000..6963083e41 --- /dev/null +++ b/frontend/packages/core/src/layout/ErrorBoundary/ErrorBoundary.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; + +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + + this.state = { + error: null, + errorInfo: null, + onError: props.onError, + }; + } + + componentDidCatch(error, errorInfo) { + console.error(`ErrorBoundary, error: ${error}, info: ${errorInfo}`); + this.setState({ error, errorInfo }); + + // Exposed for testing + if (ErrorBoundary.onError) { + ErrorBoundary.onError(error, errorInfo); + } + } + + render() { + const { slackChannel } = this.props; + const { error, errorInfo } = this.state; + + if (!errorInfo) { + return this.props.children; + } + + return ( + + ); + } +} + +// Importing Error would mean importing a lot of stuff +// will take it up in a separate PR +const Error = ({ slackChannel }) => { + return ( +
+ Something went wrong here. Please contact {slackChannel} for help. +
+ ); +}; diff --git a/frontend/packages/core/src/layout/ErrorBoundary/index.ts b/frontend/packages/core/src/layout/ErrorBoundary/index.ts new file mode 100644 index 0000000000..257f6a24f6 --- /dev/null +++ b/frontend/packages/core/src/layout/ErrorBoundary/index.ts @@ -0,0 +1 @@ +export { default } from './ErrorBoundary'; diff --git a/frontend/packages/core/src/layout/Header/Burst.js b/frontend/packages/core/src/layout/Header/Burst.js new file mode 100644 index 0000000000..f61da40956 --- /dev/null +++ b/frontend/packages/core/src/layout/Header/Burst.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + burst: { + position: 'absolute', + top: 0, + left: 0, + height: '100%', + width: '100%', + 'z-index': -1, + }, + /* base style of burst shape SVGs */ + burstShape: { + position: 'absolute', + right: 0, + height: '100%', + 'background-repeat': 'no-repeat', + 'background-size': 'cover', + opacity:0.1, + } +})); + +const Burst = ({theme}) => { + const classes = useStyles(); + + return ( +
+
+
+ ); +} + +export default Burst; diff --git a/frontend/packages/core/src/layout/Header/Header.js b/frontend/packages/core/src/layout/Header/Header.js new file mode 100644 index 0000000000..f78e43ee76 --- /dev/null +++ b/frontend/packages/core/src/layout/Header/Header.js @@ -0,0 +1,141 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Typography, withStyles, Tooltip } from '@material-ui/core'; +import { Theme } from '../Page/Page'; +// import { Link } from 'shared/components'; +import Burst from './Burst'; +import Helmet from 'react-helmet'; + +class Header extends Component { + static propTypes = { + type: PropTypes.string, + typeLink: PropTypes.string, + title: PropTypes.node.isRequired, + tooltip: PropTypes.string, + subtitle: PropTypes.node, + pageTitleOverride: PropTypes.string, + style: PropTypes.object, + component: PropTypes.object, + }; + + typeFragment() { + const { type, typeLink, classes } = this.props; + if (!type) { + return null; + } + + return typeLink ? ( + // + {type} + // + ) : ( + {type} + ); + } + + titleFragment() { + const { title, pageTitleOverride, classes, tooltip } = this.props; + const FinalTitle = ( + + {title || pageTitleOverride} + + ); + if (tooltip) { + return ( + + {FinalTitle} + + ); + } + return FinalTitle; + } + + subtitleFragment() { + const { subtitle, classes } = this.props; + if (!subtitle) { + return null; + } else if (typeof subtitle !== 'string') { + return subtitle; + } else { + return ( + + {subtitle} + + ); + } + } + + render() { + const { title, pageTitleOverride, children, style, classes } = this.props; + const pageTitle = pageTitleOverride || title; + if (typeof pageTitle !== 'string') { + console.warn('
title prop is not a string, pageTitleOverride should be provided.'); + } + + return ( + + + + {theme => ( +
+ +
+ {this.typeFragment()} + {this.titleFragment()} + {this.subtitleFragment()} +
+
{children}
+
+ )} +
+
+ ); + } +} + +const styles = theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(3), + minHeight: 118, + width: 'calc(100vw - 224px)', + boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + position: 'relative', + zIndex: 100, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + }, + leftItemsBox: { + flex: '1 1 auto', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + }, + title: { + color: theme.palette.bursts.fontColor, + lineHeight: '1.0em', + wordBreak: 'break-all', + fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + marginBottom: theme.spacing(1), + }, + subtitle: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '1.0em', + }, + type: { + textTransform: 'uppercase', + fontSize: 9, + opacity: 0.8, + marginBottom: 10, + color: theme.palette.bursts.fontColor, + }, +}); + +export default withStyles(styles)(Header); diff --git a/frontend/packages/core/src/layout/Header/Header.test.js b/frontend/packages/core/src/layout/Header/Header.test.js new file mode 100644 index 0000000000..0609cbbc17 --- /dev/null +++ b/frontend/packages/core/src/layout/Header/Header.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Header from './Header'; +import { wrapInThemedTestApp } from '../../testUtils'; + +jest.mock('react-helmet', () => { + return ({ defaultTitle }) =>
defaultTitle: {defaultTitle}
; +}); + +describe('
', () => { + it('should render with title', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Title'); + }); + + it('should set document title', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Title1'); + rendered.getByText('defaultTitle: Title1 | Backstage'); + }); + + it('should override document title', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Title1'); + rendered.getByText('defaultTitle: Title2 | Backstage'); + }); + + it('should have subtitle', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Subtitle'); + }); + + it('should have type rendered', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('service'); + }); +}); diff --git a/frontend/packages/core/src/layout/Header/index.ts b/frontend/packages/core/src/layout/Header/index.ts new file mode 100644 index 0000000000..579f1ac23f --- /dev/null +++ b/frontend/packages/core/src/layout/Header/index.ts @@ -0,0 +1 @@ +export { default } from './Header'; diff --git a/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.js b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.js new file mode 100644 index 0000000000..4fc30019a3 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.js @@ -0,0 +1,56 @@ +import React, { Fragment } from 'react'; +import { IconButton, List, ListItem, ListItemIcon, ListItemText, Popover } from '@material-ui/core'; +import { default as KebabMenuIcon } from './MenuVertical'; + +const ActionItem = ({ label, secondaryLabel, icon, disabled = false, onClick, WrapperComponent = React.Fragment }) => { + return ( + + { + if (onClick) { + onClick(event); + } + }} + > + {icon && {icon}} + + + + ); +}; + +const HeaderActionMenu = ({ actionItems }) => { + const [open, setOpen] = React.useState(false); + const anchorElRef = React.useRef(null); + + return ( + + setOpen(true)} + data-testid="header-action-menu" + ref={anchorElRef} + style={{ color: 'white', height: 56, width: 56, marginRight: -4, padding: 0 }} + > + + + setOpen(false)} + > + + {actionItems.map(actionItem => { + return ; + })} + + + + ); +}; + +export default HeaderActionMenu; diff --git a/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js new file mode 100644 index 0000000000..5212c0c2df --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { wrapInThemedTestApp, Keyboard } from '../../testUtils'; +import HeaderActionMenu from './HeaderActionMenu'; + +describe('', () => { + it('renders without any items and without exploding', () => { + render(wrapInThemedTestApp()); + }); + + it('can open the menu and click menu items', () => { + const onClickFunction = jest.fn(); + const rendered = render( + wrapInThemedTestApp(), + ); + expect(rendered.queryByText('Some label')).not.toBeInTheDocument(); + expect(onClickFunction).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(onClickFunction).not.toHaveBeenCalled(); + expect(rendered.getByTestId('header-action-item')).not.toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(rendered.queryByText('Some label')); + expect(onClickFunction).toHaveBeenCalled(); + // We do not expect the dropdown to disappear after click + expect(rendered.queryByText('Some label')).toBeInTheDocument(); + }); + + it('Disabled', async () => { + const rendered = render( + wrapInThemedTestApp(), + ); + + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(rendered.getByTestId('header-action-item')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('Test wrapper, and secondary label', () => { + const onClickFunction = jest.fn(); + const rendered = render( + wrapInThemedTestApp( + , + }, + ]} + />, + ), + ); + + expect(onClickFunction).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(onClickFunction).not.toHaveBeenCalled(); + fireEvent.click(rendered.queryByText('Secondary label')); + expect(onClickFunction).toHaveBeenCalled(); + // We do not expect the dropdown to disappear after click + expect(rendered.queryByText('Some label')).toBeInTheDocument(); + }); + + it('should close when hitting escape', async () => { + const rendered = render(wrapInThemedTestApp()); + + expect(rendered.container.getAttribute('aria-hidden')).toBeNull(); + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(rendered.container.getAttribute('aria-hidden')).toBe('true'); + await Keyboard.type(rendered, ''); + expect(rendered.container.getAttribute('aria-hidden')).toBeNull(); + }); +}); diff --git a/frontend/packages/core/src/layout/HeaderActionMenu/MenuVertical.tsx b/frontend/packages/core/src/layout/HeaderActionMenu/MenuVertical.tsx new file mode 100644 index 0000000000..fa744f861e --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderActionMenu/MenuVertical.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +const SvgMenuVertical = (props: SvgIconProps) => + React.createElement( + SvgIcon, + props, + , + ); + +export default SvgMenuVertical; diff --git a/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.js b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.js new file mode 100644 index 0000000000..72f2cc86a0 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Typography, withStyles } from '@material-ui/core'; +import { Link } from '@material-ui/core'; + +const style = theme => ({ + root: { + textAlign: 'left', + margin: theme.spacing(2), + display: 'inline-block', + }, + label: { + color: '#FFFFFF', + fontWeight: 'bold', + lineHeight: '16px', + letterSpacing: 0, + fontSize: 14, + height: '16px', + marginBottom: 2, + }, + value: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '16px', + fontSize: 14, + height: '16px', + }, +}); + +class HeaderLabel extends Component { + static propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.node, + url: PropTypes.string, + }; + + render() { + const { label, value, url, classes } = this.props; + const content = ( + {value || ''} + ); + return ( + + {label} + {url ? {content} : content} + + ); + } +} + +export default withStyles(style)(HeaderLabel); diff --git a/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js new file mode 100644 index 0000000000..5c35cc1d4e --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import HeaderLabel from './HeaderLabel'; +import { wrapInThemedTestApp } from '../../testUtils'; + +describe('', () => { + it('should have a label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Label')).toBeInTheDocument(); + }); + + it('should say unknown', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('')).toBeInTheDocument(); + }); + + it('should have value', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Value')).toBeInTheDocument(); + }); + + it('should have a link', () => { + const rendered = render(wrapInThemedTestApp()); + const anchor = rendered.container.querySelector('a'); + expect(rendered.getByText('Value')).toBeInTheDocument(); + expect(anchor.href).toBe('http://localhost/test'); + }); +}); diff --git a/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js new file mode 100644 index 0000000000..01a694e99a --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Tooltip, Link, withStyles } from '@material-ui/core'; + +import { StatusError } from '../../components/Status'; +import HeaderLabel from './HeaderLabel'; + +const style = theme => ({ + notVerified: { + color: theme.palette.status.error, + borderRadius: 4, + padding: '3px 6px', + fontSize: '8pt', + opacity: 0.8, + fontWeight: 'bold', + position: 'relative', + top: -4, + backgroundColor: 'pink', + float: 'right', + marginLeft: 14, + }, + label: { float: 'left' }, +}); + +class OwnerHeaderLabel extends Component { + static propTypes = { + owner: PropTypes.object.isRequired, + }; + + render() { + const { owner, classes } = this.props; + const isBadSquad = owner.type !== 'squad'; + + const notVerified = isBadSquad && ( + + + Squad not verified! + + + ); + const label = ( + + + {owner.name} + + + ); + return ( + <> + + {notVerified} + + ); + } +} + +export default withStyles(style)(OwnerHeaderLabel); diff --git a/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js new file mode 100644 index 0000000000..8ced9035e0 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, prettyDOM } from '@testing-library/react'; +import OwnerHeaderLabel from './OwnerHeaderLabel'; +import { wrapInThemedTestApp } from '../../testUtils'; + +const properOwner = { id: 'tools', name: 'tools', type: 'squad' }; +const badOwner = { id: 'tools-xxx', name: 'tools-xxx' }; + +describe('', () => { + it('should have a label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Owner')).toBeInTheDocument(); + expect(rendered.getByText('tools')).toBeInTheDocument(); + expect(rendered.queryByText('Squad not verified!')).not.toBeInTheDocument(); + }); + + it('should have an org link', () => { + const rendered = render(wrapInThemedTestApp()); + const anchor = rendered.container.querySelector('a'); + expect(anchor.href).toBe('http://localhost/org/tools'); + }); + + it('should have WARNING label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Squad not verified!')).toBeInTheDocument(); + }); + + it('should have status error label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByLabelText('Status error')).toBeInTheDocument(); + }); + + it('should handle empty input', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByLabelText('Status error')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/HeaderLabel/index.ts b/frontend/packages/core/src/layout/HeaderLabel/index.ts new file mode 100644 index 0000000000..913f71a783 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/index.ts @@ -0,0 +1 @@ +export { default } from './HeaderLabel'; diff --git a/frontend/packages/core/src/layout/InfoCard/BottomLink.js b/frontend/packages/core/src/layout/InfoCard/BottomLink.js new file mode 100644 index 0000000000..d248da6063 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/BottomLink.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@material-ui/core'; +import { Divider, ListItemText } from '@material-ui/core'; +import { ListItem, ListItemIcon } from '@material-ui/core'; +import ArrowIcon from '@material-ui/icons/ArrowForward'; + +export default class BottomLink extends Component { + static propTypes = { + link: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, + }; + + render() { + const { link, title, onClick } = this.props; + return ( +
+ + + + + + + {title} + + +
+ ); + } +} diff --git a/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js b/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js new file mode 100644 index 0000000000..740f9fd835 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '../../testUtils'; +import BottomLink from './BottomLink'; + +const minProps = { + title: 'A deepLink title', + link: '/mocked', +}; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('A deepLink title')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/InfoCard/InfoCard.js b/frontend/packages/core/src/layout/InfoCard/InfoCard.js new file mode 100644 index 0000000000..2a0b332a9f --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/InfoCard.js @@ -0,0 +1,197 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + Card, + CardActions, + CardContent, + CardHeader, + Divider, + withStyles, +} from '@material-ui/core'; +import ErrorBoundary from '../ErrorBoundary/ErrorBoundary'; +import BottomLink from './BottomLink'; + +import textContent from 'react-addons-text-content'; + +const BoldHeader = withStyles({ title: { fontWeight: '700' } })(CardHeader); +const CardActionsTopRight = withStyles({ + root: { + display: 'inline-block', + paddingRight: '16px', + paddingTop: '16px', + float: 'right', + }, +})(CardActions); + +const VARIANT_STYLES = { + card: { + flex: { + display: 'flex', + flexDirection: 'column', + }, + widget: { + height: 430, + }, + fullHeight: { + height: '100%', + }, + height100: { + display: 'flex', + flexDirection: 'column', + height: 'calc(100% - 10px)', // for pages without content header + marginBottom: '10px', + }, + contentheader: { + height: 'calc(100% - 40px)', // for pages with content header + }, + contentheadertabs: { + height: 'calc(100% - 97px)', // for pages with content header and tabs (Tingle) + }, + noShrink: { + flexShrink: 0, + }, + minheight300: { + minHeight: 300, + overflow: 'initial', + }, + }, + cardContent: { + widget: { + overflowY: 'auto', + height: 332, + width: '100%', + }, + fullHeight: { + height: 'calc(100% - 50px)', + }, + height100: { + height: 'calc(100% - 50px)', + }, + contentRow: { + display: 'flex', + flexDirection: 'row', + }, + }, +}; + +/** + * InfoCard is used to display a paper-styled block on the screen, similar to a panel. + * + * You can custom style an InfoCard with the 'style' (outer container) and 'cardStyle' (inner container) + * styles. + * + * The InfoCard serves as an error boundary. As a result, if you provide a 'slackChannel' property this + * specifies the channel to display in the error component that is displayed if an error occurs + * in any descendent components. + * + * By default the InfoCard has no custom layout of its children, but is treated as a block element. A + * couple common variants are provided and can be specified via the variant property: + * + * Display the card full height suitable for DataGrid: + * + * ... + * + * Variants can be combined in a whitespace delimited list like so: + * + * ... + */ +class InfoCard extends Component { + static propTypes = { + title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + subheader: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + divider: PropTypes.bool, + deepLink: PropTypes.object, + slackChannel: PropTypes.string, + variant: PropTypes.string, + }; + + render() { + const { + title, + subheader, + divider, + deepLink, + children, + actions, + actionsTopRight, + headerStyle, + headerProps, + classes, + slackChannel, + variant, + } = this.props; + + if (this.props.style) { + console.warn( + 'InfoCard: using `style` property directly, consider migrating your style to variant in InfoCard', + ); + } + if (this.props.cardStyle) { + console.warn( + 'InfoCard: using `cardStyle` property directly, consider migrating your style to variant in InfoCard', + ); + } + + /** + * If variant is specified, we build up styles for that particular variant for both + * the Card and the CardContent (since these need to be synced) + */ + let calculatedStyle = {}; + let calculatedCardStyle = {}; + + if (variant) { + let variants = variant.split(/[\s]+/g); + variants.forEach(name => { + calculatedStyle = { ...calculatedStyle, ...VARIANT_STYLES.card[name] }; + calculatedCardStyle = { + ...calculatedCardStyle, + ...VARIANT_STYLES.cardContent[name], + }; + }); + } + + // Apply the passed styles on top + const computedStyle = { ...calculatedStyle, ...this.props.style }; + const computedCardStyle = { + ...calculatedCardStyle, + ...this.props.cardStyle, + }; + + return ( + + + {title && ( + + )} + {actionsTopRight && ( + {actionsTopRight} + )} + {divider && } + + {children} + + {actions && ( + + {actions} + + )} + {deepLink && } + + + ); + } +} + +export default InfoCard; diff --git a/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js b/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js new file mode 100644 index 0000000000..ef02efa8a2 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '../../testUtils'; +import InfoCard from './InfoCard'; + +const minProps = { + title: 'Some title', + deepLink: { + title: 'A deepLink title', + link: '/mocked', + }, +}; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('Some title')).toBeInTheDocument(); + }); + + it('renders a deepLink when prop is set', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('A deepLink title')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/InfoCard/index.js b/frontend/packages/core/src/layout/InfoCard/index.js new file mode 100644 index 0000000000..6e402b0695 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/index.js @@ -0,0 +1 @@ +export { default } from './InfoCard'; diff --git a/frontend/packages/core/src/layout/Page/Page.js b/frontend/packages/core/src/layout/Page/Page.js new file mode 100644 index 0000000000..8425abeb8a --- /dev/null +++ b/frontend/packages/core/src/layout/Page/Page.js @@ -0,0 +1,37 @@ +import React, { Component } from 'react'; +import { withStyles } from '@material-ui/core'; +import { theme } from './PageThemeProvider'; + +const styles = theme => ({ + root: { + display: 'grid', + gridTemplateAreas: + "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", + gridTemplateRows: 'auto auto 1fr', + gridTemplateColumns: 'auto 1fr auto', + minHeight: '100%', + paddingBottom: theme.spacing(3), + }, +}); + +export const Theme = React.createContext({}); + +class Page extends Component { + static defaultProps = { + theme: theme.other, + }; + + render() { + const { theme, backgroundColor, classes, children, styles = {}, ...otherProps } = this.props; + + return ( + +
+ {children} +
+
+ ); + } +} + +export default withStyles(styles)(Page); diff --git a/frontend/packages/core/src/layout/Page/PageThemeProvider.js b/frontend/packages/core/src/layout/Page/PageThemeProvider.js new file mode 100644 index 0000000000..60fef3487e --- /dev/null +++ b/frontend/packages/core/src/layout/Page/PageThemeProvider.js @@ -0,0 +1,47 @@ +export const gradients = { + blue: 'linear-gradient(135deg, #2D46B9 0%, #509BF5 100%)', + darkBlue: 'linear-gradient(44deg, #1E3264 0%, #A0C3D2 100%)', + brown: 'linear-gradient(44deg, #674638 0%, #C39887 100%)', + green: 'linear-gradient(-90deg, #1DB954 0%, #006350 100%)', + orangeYellow: 'linear-gradient(37deg, #FF6437 0%, #FFC864 100%)', + redOrange: 'linear-gradient(37deg, #A72525 0%, #E6542D 100%)', + pinkOrange: 'linear-gradient(43deg, #F13DA2 0%, #FF8A48 100%)', + purpleBlue: 'linear-gradient(-137deg, #4100F4 0%, #AF2996 100%)', + tealGreen: 'linear-gradient(-137deg, #19E68C 0%, #1D7F6E 100%)', + violetPeach: 'linear-gradient(44deg, #B39AC8 0%, #FCCBD3 100%)', + violetGreen: 'linear-gradient(44deg, #4302F4 0%, #C3EFC8 100%)', + purple: 'linear-gradient(-90deg, #a186bd 0%, #7c5c92 100%)', + tpm: 'linear-gradient(-137deg, #00FFF2 0%, #035355 100%)', + royalBlue: 'linear-gradient(45deg, #000044 0%, #0000DD 61.47%, #0033DD 74%, #4B80D4 100%)', + grey: 'linear-gradient(45deg, #111111 0%, #777777 100%)', + sunset: 'linear-gradient(148deg, #cf8022 0%, #4e6ec7 100%)', + sky: 'linear-gradient(135deg, #69B9FF 0%, #ACCEEC 100%)', +}; + +export const theme = { + service: { + activeNavLinkColor: '#1D7F6E', + gradient: gradients.tealGreen, + burstShape: null, + }, + website: { + activeNavLinkColor: '#765d90', + gradient: gradients.purple, + burstShape: null, + }, + home: { + activeNavLinkColor: '#00814e', + gradient: gradients.green, + burstShape: null, + }, + org: { + activeNavLinkColor: '#6044ef', + gradient: gradients.violetGreen, + burstShape: null, + }, + documentation: { + activeNavLinkColor: '#04c2ba', + gradient: gradients.tpm, + burstShape: null, + }, +}; \ No newline at end of file diff --git a/frontend/packages/core/src/layout/Page/index.ts b/frontend/packages/core/src/layout/Page/index.ts new file mode 100644 index 0000000000..34a8b93753 --- /dev/null +++ b/frontend/packages/core/src/layout/Page/index.ts @@ -0,0 +1,2 @@ +export { default } from './Page'; +export { gradients, theme } from './PageThemeProvider'; \ No newline at end of file diff --git a/frontend/packages/core/src/testUtils/Keyboard.js b/frontend/packages/core/src/testUtils/Keyboard.js new file mode 100644 index 0000000000..6e91a6b562 --- /dev/null +++ b/frontend/packages/core/src/testUtils/Keyboard.js @@ -0,0 +1,188 @@ +import { act, fireEvent } from '@testing-library/react'; + +const codes = { + Tab: 9, + Enter: 10, + Click: 17 /* This keyboard can click, deal with it */, + Esc: 27, +}; + +export default class Keyboard { + static async type(target, input) { + await new Keyboard(target).type(input); + } + + static async typeDebug(target, input) { + await new Keyboard(target, { debug: true }).type(input); + } + + static toReadableInput(chars) { + return chars.split('').map(char => { + switch (char.charCodeAt(0)) { + case codes.Tab: + return ''; + case codes.Enter: + return ''; + case codes.Click: + return ''; + case codes.Esc: + return ''; + default: + return char; + } + }); + } + + static fromReadableInput(input) { + return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => { + if (name in codes) { + return String.fromCharCode(codes[name]); + } + throw new Error(`Unknown char name: '${name}'`); + }); + } + + constructor(target, { debug = false } = {}) { + this.debug = debug; + + if (target.ownerDocument) { + this.document = target.ownerDocument; + } else if (target.baseElement) { + this.document = target.baseElement.ownerDocument; + } else { + throw TypeError('Keyboard(target): target must be DOM node or react-testing-library render() output'); + } + } + + toString() { + return `Keyboard{document=${this.document}, debug=${this.debug}}`; + } + + _log(message, ...args) { + if (this.debug) { + console.log(`[Keyboard] ${message}`, ...args); + } + } + + _pretty(element) { + const attrs = [...element.attributes].map(attr => `${attr.name}="${attr.value}"`).join(' '); + return `<${element.nodeName.toLowerCase()} ${attrs}>`; + } + + get focused() { + return this.document.activeElement; + } + + async type(input) { + this._log(`sending sequence '${input}' with initial focus ${this._pretty(this.focused)}`); + await this.send(Keyboard.fromReadableInput(input)); + } + + async send(chars) { + for (const key of chars.split('')) { + const charCode = key.charCodeAt(0); + + if (charCode === codes.Tab) { + await this.tab(); + continue; + } + + const focused = this.focused; + if (!focused || focused === this.document.body) { + throw Error(`No element focused in document while trying to type '${Keyboard.toReadableInput(chars)}'`); + } + const nextValue = (focused.value || '') + key; + + if (charCode >= 32) { + await this._sendKey(key, charCode, () => { + this._log(`sending +${key} = '${nextValue}' to ${this._pretty(focused)}`); + fireEvent.change(focused, { + target: { value: nextValue }, + bubbles: true, + cancelable: true, + }); + }); + } else if (charCode === codes.Enter) { + await this.enter(focused.value || ''); + } else if (charCode === codes.Esc) { + await this.escape(); + } else if (charCode === codes.Click) { + await this.click(); + } else { + throw new Error(`Unsupported char code, ${charCode}`); + } + } + } + + async click() { + this._log(`clicking ${this._pretty(this.focused)}`); + await act(async () => fireEvent.click(this.focused)); + } + + async tab() { + await this._sendKey('Tab', codes.Tab, () => { + const focusable = this.document.querySelectorAll( + [ + 'a[href]', + 'area[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'iframe', + 'object', + 'embed', + '*[tabindex]', + '*[contenteditable]', + ].join(','), + ); + + const tabbable = [...focusable].filter(el => { + return el.tabIndex >= 0; + }); + + const focused = this.document.activeElement; + const focusedIndex = tabbable.indexOf(focused); + const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)]; + + this._log(`tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`); + nextFocus.focus(); + }); + } + + async enter(value) { + this._log(`submitting '${value}' via ${this._pretty(this.focused)}`); + await act(() => + this._sendKey('Enter', codes.Enter, () => { + if (this.focused.type === 'button') { + fireEvent.click(this.focused, { target: { value } }); + } else { + fireEvent.submit(this.focused, { + target: { value }, + bubbles: true, + cancelable: true, + }); + } + }), + ); + } + + async escape() { + this._log(`escape from ${this._pretty(this.focused)}`); + await act(async () => this._sendKey('Escape', codes.Esc)); + } + + async _sendKey(key, charCode, action) { + const event = { key, charCode, keyCode: charCode, which: charCode }; + const focused = this.focused; + + if (fireEvent.keyDown(focused, event)) { + if (fireEvent.keyPress(focused, event)) { + if (action) { + action(); + } + } + } + fireEvent.keyUp(focused, event); + } +} diff --git a/frontend/packages/core/src/testUtils/Keyboard.test.js b/frontend/packages/core/src/testUtils/Keyboard.test.js new file mode 100644 index 0000000000..5ea108d8dc --- /dev/null +++ b/frontend/packages/core/src/testUtils/Keyboard.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import Keyboard from './Keyboard'; +import { render } from '@testing-library/react'; + +describe('testUtils.Keyboard', () => { + it('types into some inputs with focus and submits a form', async () => { + const typed1 = []; + const typed2 = []; + const typed3 = []; + + let submitted = false; + const handleSubmit = event => { + event.preventDefault(); + submitted = true; + }; + + const rendered = render( +
+ typed1.push(value)} /> + typed2.push(value)} autoFocus /> + typed3.push(value)} /> +
, + ); + + const keyboard = new Keyboard(rendered); + await keyboard.send('xy'); + await keyboard.tab(); + await keyboard.send('abc'); + await keyboard.enter(); + + expect(typed1).toEqual([]); + expect(typed2).toEqual(['x', 'xy']); + expect(typed3).toEqual(['a', 'ab', 'abc']); + expect(submitted).toBe(true); + }); + + it('can use Keyboard.type to send readable input', async () => { + const typed1 = []; + const typed2 = []; + const typed3 = []; + + let submitted = false; + const handleSubmit = event => { + event.preventDefault(); + submitted = true; + }; + + const rendered = render( +
+ typed1.push(value)} /> + typed2.push(value)} /> + typed3.push(value)} /> +
, + ); + + await Keyboard.type(rendered, ' a b c '); + + expect(typed1).toEqual(['1a']); + expect(typed2).toEqual(['2b']); + expect(typed3).toEqual(['3c']); + expect(submitted).toBe(true); + }); + + it('should be able to navigate a radio input with click', async () => { + const selections = []; + + const rendered = render( +
selections.push(value)}> + + + +
, + ); + + await Keyboard.type(rendered, ' '); + + expect(selections).toEqual(['a', 'c']); + }); +}); diff --git a/frontend/packages/core/src/testUtils/index.js b/frontend/packages/core/src/testUtils/index.js new file mode 100644 index 0000000000..99e94c624d --- /dev/null +++ b/frontend/packages/core/src/testUtils/index.js @@ -0,0 +1,36 @@ +/** + * Helpers for testing components + */ +import React from 'react'; +import { MuiThemeProvider } from '@material-ui/core'; +import { ThemeProvider } from '@material-ui/styles'; + +import { MemoryRouter } from 'react-router'; +import { Route } from 'react-router-dom'; + +import { V1 } from '../theme/BackstageTheme'; +import ErrorBoundary from '../layout/ErrorBoundary'; + +export { default as Keyboard } from './Keyboard'; +export { default as mockBreakpoint } from './mockBreakpoint'; + +export function wrapInTestApp(Component, initialRouterEntries) { + const Wrapper = Component instanceof Function ? Component : () => Component; + + return ( + + + + + + ); +} + +export function wrapInThemedTestApp(component, initialRouterEntries) { + const themed = {component}; + return wrapInTestApp(themed, initialRouterEntries); +} + +export const wrapInTheme = (component, theme = V1) => ( + {component} +); diff --git a/frontend/packages/core/src/testUtils/mockBreakpoint.ts b/frontend/packages/core/src/testUtils/mockBreakpoint.ts new file mode 100644 index 0000000000..388c71494c --- /dev/null +++ b/frontend/packages/core/src/testUtils/mockBreakpoint.ts @@ -0,0 +1,73 @@ +import { act } from '@testing-library/react'; + +type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +const queryToBreakpoint = { + '(min-width:1920px)': 'xl', + '(min-width:1280px)': 'lg', + '(min-width:960px)': 'md', + '(min-width:600px)': 'sm', + '(min-width:0px)': 'xs', +} as Record; + +function toBreakpoint(query: string) { + const breakpoint = queryToBreakpoint[query]; + if (!breakpoint) { + throw new Error(`received unknown media query in breakpoint mock: '${query}'`); + } + return breakpoint; +} + +type Listener = (event: { matches: boolean }) => void; + +interface QueryList { + addListener(listener: Listener): void; + removeListener(listener: Listener): void; + matches: boolean; +} + +interface Query { + query: string; + queryList: QueryList; + listeners: Set; +} + +export default function mockBreakpoint(initialBreakpoint: Breakpoint = 'xl') { + let currentBreakpoint = initialBreakpoint; + const queries = Array(); + + (window as any).matchMedia = (query: string): QueryList => { + const listeners = new Set(); + + const queryList: QueryList = { + addListener(listener) { + listeners.add(listener); + }, + removeListener(listener) { + listeners.delete(listener); + }, + matches: toBreakpoint(query) === currentBreakpoint, + }; + + queries.push({ query, queryList, listeners }); + + return queryList; + }; + + return { + set(breakpoint: Breakpoint) { + currentBreakpoint = breakpoint; + + act(() => { + queries.forEach(({ query, queryList, listeners }) => { + const matches = toBreakpoint(query) === breakpoint; + queryList.matches = matches; + listeners.forEach(listener => listener({ matches })); + }); + }); + }, + remove() { + delete window.matchMedia; + }, + }; +} diff --git a/frontend/packages/core/src/theme/BackstageTheme.js b/frontend/packages/core/src/theme/BackstageTheme.js new file mode 100644 index 0000000000..1c0f4cd2b7 --- /dev/null +++ b/frontend/packages/core/src/theme/BackstageTheme.js @@ -0,0 +1,335 @@ +import { createMuiTheme } from '@material-ui/core'; +import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; +import { blue, yellow } from '@material-ui/core/colors'; + +export const COLORS = { + PAGE_BACKGROUND: '#F8F8F8', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + COMPONENT_TYPES: { + SERVICE: '#39732E', + ENDPOINT: '#B71C1C', + PROJECT: '#E657AA', + WORKFLOW: '#c15013', + }, + NAMED: { + WHITE: '#FEFEFE', + }, + STATUS: { + OK: '#1db855', + WARNING: '#f49b20', + ERROR: '#CA001B', + }, +}; + +const extendedThemeConfig = { + props: { + MuiGrid: { + spacing: 2, + }, + MuiSwitch: { + color: 'primary', + }, + }, + palette: { + background: { + default: COLORS.PAGE_BACKGROUND, + informational: '#60a3cb', + }, + status: { + ok: COLORS.STATUS.OK, + warning: COLORS.STATUS.WARNING, + error: COLORS.STATUS.ERROR, + running: '#BEBEBE', + pending: '#5BC0DE', + background: COLORS.NAMED.WHITE, + }, + bursts: { + fontColor: COLORS.NAMED.WHITE, + slackChannelText: '#ddd', + backgroundColor: { + service: COLORS.COMPONENT_TYPES.SERVICE, + endpoint: COLORS.COMPONENT_TYPES.ENDPOINT, + project: COLORS.COMPONENT_TYPES.PROJECT, + default: COLORS.DEFAULT_PAGE_THEME_COLOR, + }, + }, + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: COLORS.ERROR_BACKGROUND_COLOR, + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: COLORS.ERROR_TEXT_COLOR, + infoText: COLORS.INFO_TEXT_COLOR, + warningText: COLORS.NAMED.WHITE, + linkHover: COLORS.LINK_TEXT_HOVER, + link: COLORS.LINK_TEXT, + gold: yellow.A700, + }, + navigation: { + width: 220, + background: '#333333', + }, + typography: { + h4: { + // Page name/heading | Dialog titles + fontSize: 48, + color: '#2D2D2D', + fontWeight: 'bold', + }, + h3: { + // Page titles + fontSize: 32, + color: '#2D2D2D', + fontWeight: 'bold', + }, + h2: { + // Card titles | Sub headings in dialogs + fontSize: 24, + color: '#2D2D2D', + fontWeight: 'bold', + lineHeight: 1.2, + marginBottom: 6, + }, + header3: { + // Table headers (ALL CAPS!) + fontSize: 12, + color: '#9E9E9E', + }, + menuItem1: { + // Sidebar menu item + fontSize: 16, + color: '#828282', + fontWeight: 'bold', + }, + menuItem2: { + // Dropdown menu items | Form labels | Deep links from cards (Go to...) + fontSize: 16, + color: '#2D2D2D', + }, + text: { + // Table entries | Information/error text/messages | General copy/paragraphs + fontSize: 13, + color: '#2D2D2D', + }, + links: { + // Table entries | Information/error text/messages | General copy/paragraphs + color: '#509BF5', + }, + tabSelected: { + // Selected tab + fontSize: 18, + color: '#2D2D2D', + }, + tabUnselected: { + // Unselected tab + fontSize: 18, + color: '#9E9E9E', + }, + code: { + fontFamily: 'monospace', + whiteSpace: 'pre', + fontSize: '16px', + }, + caption: { + // Restores caption to display: block to match MUI3 behavior; this global override should + // be removed if possible. + display: 'block', + }, + }, +}; + +const createOverrides = theme => { + const overrides = { + MuiAppBar: { + root: { + zIndex: 1049, + height: 44, + }, + colorPrimary: { + backgroundColor: '#212121', + }, + }, + MuiToolbar: { + root: { + '@media (min-width:600px)': { + minHeight: 44, + }, + }, + }, + MuiTableRow: { + root: { + height: 'auto', + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.default, + }, + }, + hover: { + '&:hover': { + cursor: 'pointer', + }, + }, + head: { + height: 'auto', + '&:nth-of-type(odd)': { + backgroundColor: COLORS.NAMED.WHITE, + }, + }, + }, + MuiTableCell: { + root: { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: '8px', + borderBottom: 0, + }, + head: { + wordBreak: 'break-word', + overflow: 'hidden', + color: 'rgb(179, 179, 179)', + fontWeight: 'normal', + lineHeight: '1', + }, + }, + MuiTabs: { + root: { + minHeight: 24, + }, + }, + MuiTab: { + root: { + color: theme.palette.link, + minHeight: 24, + textTransform: 'initial', + '&:hover': { + color: darken(theme.palette.link, 0.3), + background: lighten(theme.palette.link, 0.95), + }, + [theme.breakpoints.up('md')]: { + minWidth: 120, + fontSize: theme.typography.pxToRem(14), + fontWeight: 500, + }, + }, + textColorPrimary: { + color: theme.palette.link, + }, + }, + MuiTableSortLabel: { + root: { + color: 'inherit', + '&:hover': { + color: 'inherit', + }, + '&:focus': { + color: 'inherit', + }, + }, + active: { + fontWeight: 'bold', + color: 'inherit', + }, + }, + MuiListItemText: { + dense: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, + MuiButton: { + text: { + padding: '6px 16px', + }, + }, + MuiChip: { + root: { + marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + }, + MuiDivider: { + light: { + backgroundColor: '#727272', + }, + }, + MuiDialogTitle: { + root: { + minWidth: 600, + }, + }, + MuiCardHeader: { + root: { + // Remove bottom padding on titles; there is always a CardContent below that also has padding, + // so without this fix there will be too much space below the title. + paddingBottom: '0', + // Mui 1.2.1 introduced more padding left and right with media queries. + // Question is if we should override them or just go with the defaults + '@media (min-width:600px)': { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + }, + }, + MuiCardContent: { + root: { + '&:last-child': { + // There is some odd extra whitespace at the bottom of card content, which makes it look + // bottom-heavy and uneven; set it to the same as the other sides + paddingBottom: theme.spacing(2), + }, + // Mui 1.2.1 introduced more padding left and right with media queries. + // Question is if we should override them or just go with the defaults + '@media (min-width:600px)': { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + }, + }, + MuiCardActions: { + root: { + justifyContent: 'flex-end', + }, + }, + MuiListSubheader: { + sticky: { + // Sticky subheaders need to be opaque so that they overwrite list items as you scroll down. + backgroundColor: theme.palette.background.paper, + }, + }, + Toolbar: { + toolbar: { + // Override the toolbar in dx-react-grid table + marginTop: '-64px', + }, + }, + }; + return { + overrides, + }; +}; + +const extendedTheme = createMuiTheme(extendedThemeConfig); + +// V1 theming +// https://material-ui-next.com/customization/themes/ +// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ + +const BackstageTheme = { ...extendedTheme, ...createOverrides(extendedTheme) }; + +// Temporary workaround for files incorrectly importing the theme directly +export const V1 = BackstageTheme; +export default BackstageTheme; diff --git a/frontend/packages/core/src/types.ts b/frontend/packages/core/src/types.ts deleted file mode 100644 index c47102abb6..0000000000 --- a/frontend/packages/core/src/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type BackstagePlugin = { - id: string; -}; diff --git a/frontend/packages/plugins/_template/{{ cookiecutter.plugin_name }}/src/plugin.test.ts b/frontend/packages/plugins/_template/{{ cookiecutter.plugin_name }}/src/plugin.test.ts index 869fa6f67f..670ca54e93 100644 --- a/frontend/packages/plugins/_template/{{ cookiecutter.plugin_name }}/src/plugin.test.ts +++ b/frontend/packages/plugins/_template/{{ cookiecutter.plugin_name }}/src/plugin.test.ts @@ -2,6 +2,6 @@ import plugin from './plugin'; describe('{{ cookiecutter.plugin_name }}', () => { it('should export plugin', () => { - expect(plugin.id).toBe('{{ cookiecutter.plugin_name }}'); + expect(plugin).toBeDefined(); }); }); diff --git a/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx b/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx index ec28306367..a9ad846047 100644 --- a/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx +++ b/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx @@ -1,8 +1,8 @@ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState, useEffect, Fragment } from 'react'; import Button from '@material-ui/core/Button'; import { HelloPromiseClient } from '../../proto/hello_grpc_web_pb'; import { HelloRequest } from '../../proto/hello_pb'; -import { Paper, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; const MyComponent: FC<{}> = () => { const [message, setMessage] = useState(''); @@ -25,7 +25,7 @@ const MyComponent: FC<{}> = () => { }, []); return ( - + {message} {error} @@ -37,7 +37,7 @@ const MyComponent: FC<{}> = () => { > Hello! - + ); }; diff --git a/frontend/packages/plugins/hello-world/src/plugin.test.ts b/frontend/packages/plugins/hello-world/src/plugin.test.ts index af8c878dad..6335998b14 100644 --- a/frontend/packages/plugins/hello-world/src/plugin.test.ts +++ b/frontend/packages/plugins/hello-world/src/plugin.test.ts @@ -2,6 +2,6 @@ import plugin from './plugin'; describe('plugin', () => { it('should export plugin', () => { - expect(plugin.id).toBe('hello-world'); + expect(plugin).toBeDefined(); }); }); diff --git a/frontend/packages/plugins/home-page/README.md b/frontend/packages/plugins/home-page/README.md new file mode 100644 index 0000000000..ecac8aad72 --- /dev/null +++ b/frontend/packages/plugins/home-page/README.md @@ -0,0 +1 @@ +Welcome to your home-page plugin! diff --git a/frontend/packages/plugins/home-page/jest.config.js b/frontend/packages/plugins/home-page/jest.config.js new file mode 100644 index 0000000000..6b28dacb3d --- /dev/null +++ b/frontend/packages/plugins/home-page/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + ...require('@spotify/web-scripts/config/jest.config.js'), + setupFilesAfterEnv: ['../jest.setup.ts'], +}; diff --git a/frontend/packages/plugins/home-page/jest.setup.ts b/frontend/packages/plugins/home-page/jest.setup.ts new file mode 100644 index 0000000000..666127af39 --- /dev/null +++ b/frontend/packages/plugins/home-page/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/extend-expect'; diff --git a/frontend/packages/plugins/home-page/package.json b/frontend/packages/plugins/home-page/package.json new file mode 100644 index 0000000000..bbb77a1ce6 --- /dev/null +++ b/frontend/packages/plugins/home-page/package.json @@ -0,0 +1,26 @@ +{ + "name": "@backstage/plugin-home-page", + "version": "0.0.0", + "main": "src/index.ts", + "main:src": "src/index.ts", + "devDependencies": { + "@backstage/core": "0.0.0", + "@spotify/web-scripts": "^6.0.0", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1" + }, + "scripts": { + "lint": "web-scripts lint", + "test": "web-scripts test" + }, + "license": "Apache-2.0" +} diff --git a/frontend/packages/plugins/home-page/src/components/HomePage/HomePage.test.tsx b/frontend/packages/plugins/home-page/src/components/HomePage/HomePage.test.tsx new file mode 100644 index 0000000000..383bc9a5aa --- /dev/null +++ b/frontend/packages/plugins/home-page/src/components/HomePage/HomePage.test.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import HomePage from './HomePage'; + +describe('HomePage', () => { + it('should render', () => { + const rendered = render(); + expect(rendered.baseElement).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/plugins/home-page/src/components/HomePage/HomePage.tsx b/frontend/packages/plugins/home-page/src/components/HomePage/HomePage.tsx new file mode 100644 index 0000000000..d9cb7217a6 --- /dev/null +++ b/frontend/packages/plugins/home-page/src/components/HomePage/HomePage.tsx @@ -0,0 +1,23 @@ +import React, { FC } from 'react'; +import { InfoCard, EntityLink } from '@backstage/core'; +import { Link } from 'react-router-dom'; +import { Typography } from '@material-ui/core'; + +const HomePage: FC<{}> = () => { + return ( + + Welcome to Backstage! +
+ Go to Login + + Backstage Backend + + + Backstage LB CI/CD + +
+
+ ); +}; + +export default HomePage; diff --git a/frontend/packages/plugins/home-page/src/components/HomePage/index.ts b/frontend/packages/plugins/home-page/src/components/HomePage/index.ts new file mode 100644 index 0000000000..bf97a3e7d5 --- /dev/null +++ b/frontend/packages/plugins/home-page/src/components/HomePage/index.ts @@ -0,0 +1 @@ +export { default } from './HomePage'; diff --git a/frontend/packages/plugins/home-page/src/index.ts b/frontend/packages/plugins/home-page/src/index.ts new file mode 100644 index 0000000000..b68aea57f9 --- /dev/null +++ b/frontend/packages/plugins/home-page/src/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/frontend/packages/plugins/home-page/src/plugin.test.ts b/frontend/packages/plugins/home-page/src/plugin.test.ts new file mode 100644 index 0000000000..ed32330478 --- /dev/null +++ b/frontend/packages/plugins/home-page/src/plugin.test.ts @@ -0,0 +1,7 @@ +import plugin from './plugin'; + +describe('home-page', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/frontend/packages/plugins/home-page/src/plugin.ts b/frontend/packages/plugins/home-page/src/plugin.ts new file mode 100644 index 0000000000..29d3ec1e6d --- /dev/null +++ b/frontend/packages/plugins/home-page/src/plugin.ts @@ -0,0 +1,9 @@ +import { createPlugin } from '@backstage/core'; +import HomePage from './components/HomePage'; + +export default createPlugin({ + id: 'home-page', + register({ router }) { + router.registerRoute('/', HomePage); + }, +}); diff --git a/frontend/packages/plugins/login/src/components/LoginComponent/LoginComponent.tsx b/frontend/packages/plugins/login/src/components/LoginComponent/LoginComponent.tsx index 31caa7d4db..8826eceda7 100644 --- a/frontend/packages/plugins/login/src/components/LoginComponent/LoginComponent.tsx +++ b/frontend/packages/plugins/login/src/components/LoginComponent/LoginComponent.tsx @@ -23,7 +23,7 @@ const LoginComponent: FC<{}> = () => { style={{ marginTop: '24px', marginBottom: '24px' }} >