Merge remote-tracking branch 'origin/main' into feature/content-picker

This commit is contained in:
Jesper Møller Jensen
2022-08-08 08:46:23 +02:00
41 changed files with 45079 additions and 325 deletions

View File

@@ -1,3 +1,3 @@
# Copy this to .env.local and change what you want to test.
VITE_UMBRACO_INSTALL_STATUS=true
VITE_UMBRACO_INSTALL_STATUS=running # running or must-install or must-upgrade
VITE_UMBRACO_INSTALL_PRECONFIGURED=false

View File

@@ -23,6 +23,7 @@
"plugin:@typescript-eslint/recommended",
"plugin:lit/recommended",
"plugin:lit-a11y/recommended",
"plugin:storybook/recommended",
"prettier"
],
"env": {

View File

@@ -4,6 +4,8 @@
name: Build and test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

View File

@@ -0,0 +1,56 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '33 2 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@@ -0,0 +1,8 @@
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-a11y'],
framework: '@storybook/web-components',
core: {
builder: '@storybook/builder-vite',
},
};

View File

@@ -0,0 +1,5 @@
import { addons } from '@storybook/addons';
addons.setConfig({
enableShortcuts: false,
});

View File

@@ -0,0 +1,4 @@
<script>
document.body.classList.add('uui-font');
document.body.classList.add('uui-text');
</script>

View File

@@ -0,0 +1,22 @@
<style>
body {
padding: 0px !important;
}
</style>
<script>
(function () {
window.addEventListener('load', () => {
var body = document.querySelector('body');
window.addEventListener('keydown', (e) => {
if (e.ctrlKey === true && e.key === 'g') {
if (body.hasAttribute('baseline-grid')) {
body.removeAttribute('baseline-grid');
} else {
body.setAttribute('baseline-grid', '');
}
}
});
});
})();
</script>

View File

@@ -0,0 +1,29 @@
import '@umbraco-ui/uui';
import '@umbraco-ui/uui-css/dist/uui-css.css';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { onUnhandledRequest } from '../src/mocks/browser';
import { handlers } from '../src/mocks/handlers';
// Initialize MSW
initialize({onUnhandledRequest});
// Provide the MSW addon decorator globally
export const decorators = [mswDecorator];
export const parameters = {
actions: { argTypesRegex: '^on.*' },
controls: {
expanded: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers: {
global: handlers
}
}
};

View File

@@ -14,7 +14,7 @@ As an example to show the installer instead of the login screen, set the followi
in the `.env.local` file to indicate that Umbraco has not been installed:
```bash
VITE_UMBRACO_INSTALL_STATUS=false
VITE_UMBRACO_INSTALL_STATUS=must-install
```
## Environments

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +1,82 @@
{
"name": "umbraco-cms-backoffice",
"license": "MIT",
"private": true,
"version": "0.0.0",
"repository": {
"url": "https://github.com/umbraco/Umbraco.CMS.Backoffice",
"type": "git"
},
"bugs": {
"url": "https://github.com/umbraco/Umbraco.CMS.Backoffice/issues"
},
"author": {
"name": "Umbraco A/S",
"email": "backoffice@umbraco.com",
"url": "https://umbraco.com"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview --open",
"test": "web-test-runner --coverage",
"test:watch": "web-test-runner --watch",
"lint": "eslint . --ext .ts --cache",
"lint:fix": "npm run lint -- --fix",
"format": "prettier 'src/**/*.ts'",
"format:fix": "npm run format -- --write",
"generate:api": "npx openapi-typescript schemas/**/*.yml --output schemas/generated-schema.ts"
},
"engines": {
"node": ">=16.0.0 <17",
"npm": ">=8.0.0 < 9"
},
"dependencies": {
"@umbraco-ui/uui": "^1.0.0-rc.1",
"@umbraco-ui/uui-modal": "file:umbraco-ui-uui-modal-0.0.0.tgz",
"name": "umbraco-cms-backoffice",
"license": "MIT",
"private": true,
"version": "0.0.0",
"repository": {
"url": "https://github.com/umbraco/Umbraco.CMS.Backoffice",
"type": "git"
},
"bugs": {
"url": "https://github.com/umbraco/Umbraco.CMS.Backoffice/issues"
},
"author": {
"name": "Umbraco A/S",
"email": "backoffice@umbraco.com",
"url": "https://umbraco.com"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview --open",
"test": "web-test-runner --coverage",
"test:watch": "web-test-runner --watch",
"lint": "eslint . --ext .ts --cache",
"lint:fix": "npm run lint -- --fix",
"format": "prettier 'src/**/*.ts'",
"format:fix": "npm run format -- --write",
"generate:api": "npx openapi-typescript schemas/**/*.yml --output schemas/generated-schema.ts",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"engines": {
"node": ">=16.0.0 <17",
"npm": ">=8.0.0 < 9"
},
"dependencies": {
"@umbraco-ui/uui": "^1.0.0-rc.1",
"element-internals-polyfill": "^1.1.6",
"lit": "^2.2.8",
"openapi-typescript-fetch": "^1.1.3",
"router-slot": "^1.5.5",
"rxjs": "^7.5.6",
"@umbraco-ui/uui-modal": "file:umbraco-ui-uui-modal-0.0.0.tgz",
"@umbraco-ui/uui-modal-container": "file:umbraco-ui-uui-modal-container-0.0.0.tgz",
"@umbraco-ui/uui-modal-dialog": "file:umbraco-ui-uui-modal-dialog-0.0.0.tgz",
"@umbraco-ui/uui-modal-sidebar": "file:umbraco-ui-uui-modal-sidebar-0.0.0.tgz",
"element-internals-polyfill": "^1.1.4",
"lit": "^2.2.7",
"openapi-typescript-fetch": "^1.1.3",
"router-slot": "^1.5.5",
"rxjs": "^7.5.5"
},
"devDependencies": {
"@open-wc/testing": "^3.1.6",
"@types/chai": "^4.3.1",
"@types/mocha": "^9.1.1",
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.30.0",
"@web/dev-server-esbuild": "^0.3.1",
"@web/test-runner": "^0.13.31",
"@web/test-runner-playwright": "^0.8.9",
"eslint": "^8.18.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.1.3",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-lit": "^1.6.1",
"eslint-plugin-lit-a11y": "^2.2.0",
"msw": "^0.42.3",
"prettier": "2.7.1",
"typescript": "^4.7.4",
"vite": "^2.9.13"
},
"msw": {
"workerDirectory": "public"
}
"@umbraco-ui/uui-modal-sidebar": "file:umbraco-ui-uui-modal-sidebar-0.0.0.tgz"
},
"devDependencies": {
"@babel/core": "^7.18.10",
"@open-wc/testing": "^3.1.6",
"@storybook/addon-a11y": "^6.5.9",
"@storybook/addon-actions": "^6.5.9",
"@storybook/addon-essentials": "^6.5.9",
"@storybook/addon-links": "^6.5.9",
"@storybook/builder-vite": "^0.2.2",
"@storybook/web-components": "^6.5.9",
"@types/chai": "^4.3.1",
"@types/mocha": "^9.1.1",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"@web/dev-server-esbuild": "^0.3.1",
"@web/test-runner": "^0.13.31",
"@web/test-runner-playwright": "^0.8.9",
"babel-loader": "^8.2.5",
"eslint": "^8.21.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.4.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-lit": "^1.6.1",
"eslint-plugin-lit-a11y": "^2.2.2",
"eslint-plugin-storybook": "^0.6.3",
"lit-html": "^2.2.7",
"msw": "^0.44.2",
"msw-storybook-addon": "^1.6.3",
"prettier": "2.7.1",
"typescript": "^4.7.4",
"vite": "^3.0.3"
},
"msw": {
"workerDirectory": "public"
}
}

View File

@@ -2,22 +2,21 @@
/* tslint:disable */
/**
* Mock Service Worker (0.42.3).
* Mock Service Worker (0.44.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'
const bypassHeaderName = 'x-msw-bypass'
const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995'
const activeClientIds = new Set()
self.addEventListener('install', function () {
return self.skipWaiting()
self.skipWaiting()
})
self.addEventListener('activate', async function (event) {
return self.clients.claim()
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
@@ -33,7 +32,9 @@ self.addEventListener('message', async function (event) {
return
}
const allClients = await self.clients.matchAll()
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
@@ -83,161 +84,6 @@ self.addEventListener('message', async function (event) {
}
})
// Resolve the "main" client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll()
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: serializeHeaders(clonedResponse.headers),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
async function getResponse(event, client, requestId) {
const { request } = event
const requestClone = request.clone()
const getOriginalResponse = () => fetch(requestClone)
// Bypass mocking when the request client is not active.
if (!client) {
return getOriginalResponse()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return await getOriginalResponse()
}
// Bypass requests with the explicit bypass header
if (requestClone.headers.get(bypassHeaderName) === 'true') {
const cleanRequestHeaders = serializeHeaders(requestClone.headers)
// Remove the bypass header to comply with the CORS preflight check.
delete cleanRequestHeaders[bypassHeaderName]
const originalRequest = new Request(requestClone, {
headers: new Headers(cleanRequestHeaders),
})
return fetch(originalRequest)
}
// Send the request to the client-side MSW.
const reqHeaders = serializeHeaders(request.headers)
const body = await request.text()
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: reqHeaders,
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body,
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_SUCCESS': {
return delayPromise(
() => respondWithMock(clientMessage),
clientMessage.payload.delay,
)
}
case 'MOCK_NOT_FOUND': {
return getOriginalResponse()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload
const networkError = new Error(message)
networkError.name = name
// Rejecting a request Promise emulates a network error.
throw networkError
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body)
console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url,
)
return respondWithMock(clientMessage)
}
}
return getOriginalResponse()
}
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
@@ -265,9 +111,10 @@ self.addEventListener('fetch', function (event) {
return
}
const requestId = uuidv4()
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
return event.respondWith(
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
@@ -290,14 +137,142 @@ self.addEventListener('fetch', function (event) {
)
})
function serializeHeaders(headers) {
const reqHeaders = {}
headers.forEach((value, name) => {
reqHeaders[name] = reqHeaders[name]
? [].concat(reqHeaders[name]).concat(value)
: value
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return reqHeaders
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the cilent).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
@@ -312,27 +287,17 @@ function sendToClient(client, message) {
resolve(event.data)
}
client.postMessage(JSON.stringify(message), [channel.port2])
client.postMessage(message, [channel.port2])
})
}
function delayPromise(cb, duration) {
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), duration)
setTimeout(resolve, timeMs)
})
}
function respondWithMock(clientMessage) {
return new Response(clientMessage.payload.body, {
...clientMessage.payload,
headers: clientMessage.payload.headers,
})
}
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0
const v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}

View File

@@ -89,6 +89,35 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
/upgrade/settings:
get:
operationId: GetUpgradeSettings
responses:
'200':
description: 200 response
content:
application/json:
schema:
$ref: '#/components/schemas/UpgradeSettingsResponse'
default:
description: default response
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
/upgrade/authorize:
post:
operationId: PostUpgradeAuthorize
parameters: []
responses:
'201':
description: 201 response
'400':
description: 400 response
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
/user:
get:
operationId: GetUser
@@ -339,6 +368,25 @@ components:
type: string
required:
- version
UpgradeSettingsResponse:
type: object
properties:
currentState:
type: string
newState:
type: string
newVersion:
type: string
oldVersion:
type: string
reportUrl:
type: string
required:
- currentState
- newState
- newVersion
- oldVersion
- reportUrl
UserResponse:
type: object
properties:

View File

@@ -19,6 +19,12 @@ export interface paths {
"/server/version": {
get: operations["GetVersion"];
};
"/upgrade/settings": {
get: operations["GetUpgradeSettings"];
};
"/upgrade/authorize": {
post: operations["PostUpgradeAuthorize"];
};
"/user": {
get: operations["GetUser"];
};
@@ -107,6 +113,13 @@ export interface components {
VersionResponse: {
version: string;
};
UpgradeSettingsResponse: {
currentState: string;
newState: string;
newVersion: string;
oldVersion: string;
reportUrl: string;
};
UserResponse: {
username: string;
role: string;
@@ -207,6 +220,35 @@ export interface operations {
};
};
};
GetUpgradeSettings: {
responses: {
/** 200 response */
200: {
content: {
"application/json": components["schemas"]["UpgradeSettingsResponse"];
};
};
/** default response */
default: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
PostUpgradeAuthorize: {
parameters: {};
responses: {
/** 201 response */
201: unknown;
/** 400 response */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
GetUser: {
responses: {
/** 200 response */

View File

@@ -4,13 +4,13 @@ import 'router-slot';
import { UUIIconRegistryEssential } from '@umbraco-ui/uui';
import { css, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { Guard, IRoute } from 'router-slot/model';
import { getServerStatus } from './core/api/fetcher';
import { UmbContextProviderMixin } from './core/context';
import { UmbExtensionManifest, UmbExtensionManifestCore, UmbExtensionRegistry } from './core/extension';
import { ServerStatus } from './core/models';
import { internalManifests } from './temp-internal-manifests';
import { IRoute } from 'router-slot/model';
@customElement('umb-app')
export class UmbApp extends UmbContextProviderMixin(LitElement) {
@@ -36,12 +36,12 @@ export class UmbApp extends UmbContextProviderMixin(LitElement) {
{
path: 'upgrade',
component: () => import('./upgrader/upgrader.element'),
guards: [this._isAuthorizedGuard.bind(this)],
guards: [this._isAuthorizedGuard('/upgrade')],
},
{
path: '**',
component: () => import('./backoffice/backoffice.element'),
guards: [this._isAuthorizedGuard.bind(this)],
guards: [this._isAuthorizedGuard()],
},
];
@@ -98,13 +98,21 @@ export class UmbApp extends UmbContextProviderMixin(LitElement) {
return sessionStorage.getItem('is-authenticated') === 'true';
}
private _isAuthorizedGuard(): boolean {
if (this._isAuthorized()) {
return true;
}
private _isAuthorizedGuard(redirectTo?: string): Guard {
return () => {
if (this._isAuthorized()) {
return true;
}
history.replaceState(null, '', '/login');
return false;
let returnPath = '/login';
if (redirectTo) {
returnPath += `?redirectTo=${redirectTo}`;
}
history.replaceState(null, '', returnPath);
return false;
};
}
private async _registerExtensionManifestsFromServer() {

View File

@@ -1,12 +1,13 @@
import '../auth-layout.element';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, CSSResultGroup, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { query } from 'router-slot';
import { postUserLogin } from '../../core/api/fetcher';
import '../auth-layout.element';
@customElement('umb-login')
export default class UmbLogin extends LitElement {
static styles: CSSResultGroup = [
@@ -46,7 +47,11 @@ export default class UmbLogin extends LitElement {
try {
await postUserLogin({ username, password, persist });
this._loggingIn = false;
history.pushState(null, '', '/section');
let { redirectTo } = query();
if (!redirectTo) {
redirectTo = '/section';
}
history.pushState(null, '', redirectTo);
} catch (error) {
console.log(error);
this._loggingIn = false;

View File

@@ -17,3 +17,5 @@ export const getUserSections = fetcher.path('/user/sections').method('get').crea
export const getInstallSettings = fetcher.path('/install/settings').method('get').create();
export const postInstallValidateDatabase = fetcher.path('/install/validateDatabase').method('post').create();
export const postInstallSetup = fetcher.path('/install/setup').method('post').create();
export const getUpgradeSettings = fetcher.path('/upgrade/settings').method('get').create();
export const PostUpgradeAuthorize = fetcher.path('/upgrade/authorize').method('post').create();

View File

@@ -0,0 +1,36 @@
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { UmbContextProviderMixin } from './context-provider.mixin';
@customElement('umb-context-provider')
export class UmbContextProviderElement extends UmbContextProviderMixin(LitElement) {
/**
* The value to provide to the context.
* @required
*/
@property({ type: Object })
value!: unknown;
/**
* The key to provide to the context.
* @required
*/
@property({ type: String })
key!: string;
connectedCallback() {
super.connectedCallback();
if (!this.key) {
throw new Error('The key property is required.');
}
if (!this.value) {
throw new Error('The value property is required.');
}
this.provideContext(this.key, this.value);
}
render() {
return html`<slot></slot>`;
}
}

View File

@@ -7,6 +7,7 @@ export type ProblemDetails = components['schemas']['ProblemDetails'];
export type UserResponse = components['schemas']['UserResponse'];
export type AllowedSectionsResponse = components['schemas']['AllowedSectionsResponse'];
export type UmbracoInstaller = components['schemas']['InstallSettingsResponse'];
export type UmbracoUpgrader = components['schemas']['UpgradeSettingsResponse'];
// Models
export type UmbracoPerformInstallDatabaseConfiguration = components['schemas']['InstallSetupDatabaseConfiguration'];

View File

@@ -0,0 +1,6 @@
export * from './installer-consent.element';
export * from './installer-database.element';
export * from './installer-installing.element';
export * from './installer-user.element';
export * from './installer-layout.element';
export * from './installer.element';

View File

@@ -2,6 +2,7 @@ import { css, CSSResultGroup, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { Subscription } from 'rxjs';
import { UmbContextConsumerMixin } from '../core/context';
import { TelemetryModel } from '../core/models';
import { UmbInstallerContext } from './installer-context';
@@ -48,10 +49,10 @@ export class UmbInstallerConsent extends UmbContextConsumerMixin(LitElement) {
private _telemetryLevels: TelemetryModel[] = [];
@state()
private _telemetryFormData!: TelemetryModel['level'];
private _telemetryFormData?: TelemetryModel['level'];
@state()
private _installerStore!: UmbInstallerContext;
private _installerStore?: UmbInstallerContext;
private storeDataSubscription?: Subscription;
private storeSettingsSubscription?: Subscription;
@@ -85,7 +86,7 @@ export class UmbInstallerConsent extends UmbContextConsumerMixin(LitElement) {
const value: { [key: string]: string } = {};
value[target.name] = this._telemetryLevels[parseInt(target.value) - 1].level;
this._installerStore.appendData(value);
this._installerStore?.appendData(value);
}
private _onNext() {
@@ -105,7 +106,7 @@ export class UmbInstallerConsent extends UmbContextConsumerMixin(LitElement) {
}
private _renderSlider() {
if (!this._telemetryLevels) return;
if (!this._telemetryLevels || this._telemetryLevels.length < 1) return;
return html`
<uui-slider
@@ -125,7 +126,7 @@ export class UmbInstallerConsent extends UmbContextConsumerMixin(LitElement) {
render() {
return html`
<div id="container" class="uui-text">
<h1>Consent Level</h1>
<h1>Consent for telemetry data</h1>
${this._renderSlider()}
<div id="buttons">
<uui-button label="Back" @click=${this._onBack} look="secondary"></uui-button>

View File

@@ -4,11 +4,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import { UmbContextConsumerMixin } from '../core/context';
import {
ProblemDetails,
UmbracoInstallerDatabaseModel,
UmbracoPerformInstallDatabaseConfiguration,
} from '../core/models';
import { ProblemDetails, UmbracoInstallerDatabaseModel, UmbracoPerformInstallDatabaseConfiguration } from '../core/models';
import { UmbInstallerContext } from './installer-context';
@customElement('umb-installer-database')
@@ -99,7 +95,7 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
private _preConfiguredDatabase?: UmbracoInstallerDatabaseModel;
@state()
private _installerStore!: UmbInstallerContext;
private _installerStore?: UmbInstallerContext;
private storeDataSubscription?: Subscription;
private storeSettingsSubscription?: Subscription;
@@ -142,9 +138,9 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
const value: { [key: string]: string | boolean } = {};
value[target.name] = target.checked ?? target.value; // handle boolean and text inputs
const database = { ...this._installerStore.getData().database, ...value };
const database = { ...this._installerStore?.getData().database, ...value };
this._installerStore.appendData({ database });
this._installerStore?.appendData({ database });
}
private _handleSubmit = (e: SubmitEvent) => {
@@ -167,7 +163,7 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
const useIntegratedAuthentication = formData.has('useIntegratedAuthentication');
const database = {
...this._installerStore.getData().database,
...this._installerStore?.getData().database,
id,
username,
password,
@@ -176,14 +172,15 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
useIntegratedAuthentication,
} as UmbracoPerformInstallDatabaseConfiguration;
this._installerStore.appendData({ database });
this._installerStore?.appendData({ database });
}
this._installerStore.requestInstall().then(this._handleFulfilled.bind(this), this._handleRejected.bind(this));
this._installerStore?.requestInstall().then(this._handleFulfilled.bind(this), this._handleRejected.bind(this));
this._installButton.state = 'waiting';
};
private _handleFulfilled() {
this.dispatchEvent(new CustomEvent('next', { bubbles: true, composed: true }));
this._installButton.state = undefined;
}
private _handleRejected(error: ProblemDetails) {
this._installButton.state = 'failed';
@@ -195,7 +192,7 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
}
private get selectedDatabase() {
const id = this._installerStore.getData().database?.id;
const id = this._installerStore?.getData().database?.id;
console.log('selected id', id, this._databases);
return this._databases.find((x) => x.id === id) ?? this._databases[0];
}
@@ -213,7 +210,7 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
result.push(this._renderServer());
}
result.push(this._renderDatabaseName());
result.push(this._renderDatabaseName(this.databaseFormData.name ?? this.selectedDatabase.defaultDatabaseName));
if (this.selectedDatabase.requiresCredentials) {
result.push(this._renderCredentials());
@@ -239,15 +236,15 @@ export class UmbInstallerDatabase extends UmbContextConsumerMixin(LitElement) {
</uui-form-layout-item>
`;
private _renderDatabaseName = () => html` <uui-form-layout-item>
private _renderDatabaseName = (value: string) => html` <uui-form-layout-item>
<uui-label for="database-name" slot="label" required>Database Name</uui-label>
<uui-input
type="text"
.value=${this.databaseFormData.name ?? ''}
.value=${value}
id="database-name"
name="name"
@input=${this._handleChange}
placeholder="umbraco-cms"
placeholder="umbraco"
required
required-message="Database name is required"></uui-input>
</uui-form-layout-item>`;

View File

@@ -50,17 +50,17 @@ export class UmbInstallerLayout extends LitElement {
render() {
return html`<div>
<div id="background"></div>
<div id="background" aria-hidden="true"></div>
<div id="logo">
<div id="logo" aria-hidden="true">
<img src="/umbraco_logo_white.svg" alt="Umbraco" />
</div>
<div id="container">
<main id="container">
<div id="box">
<slot></slot>
</div>
</div>
</main>
</div>`;
}
}

View File

@@ -57,10 +57,10 @@ export class UmbInstallerUser extends UmbContextConsumerMixin(LitElement) {
];
@state()
private _userFormData!: { name: string; password: string; email: string; subscribeToNewsletter: boolean };
private _userFormData?: { name: string; password: string; email: string; subscribeToNewsletter: boolean };
@state()
private _installerStore!: UmbInstallerContext;
private _installerStore?: UmbInstallerContext;
private installerStoreSubscription?: Subscription;
@@ -101,7 +101,7 @@ export class UmbInstallerUser extends UmbContextConsumerMixin(LitElement) {
const email = formData.get('email');
const subscribeToNewsletter = formData.has('subscribeToNewsletter');
this._installerStore.appendData({ user: { name, password, email, subscribeToNewsletter } });
this._installerStore?.appendData({ user: { name, password, email, subscribeToNewsletter } });
this.dispatchEvent(new CustomEvent('next', { bubbles: true, composed: true }));
};
@@ -115,7 +115,7 @@ export class UmbInstallerUser extends UmbContextConsumerMixin(LitElement) {
<uui-input
type="text"
id="name"
.value=${this._userFormData.name}
.value=${this._userFormData?.name}
name="name"
required
required-message="Name is required"></uui-input>
@@ -126,7 +126,7 @@ export class UmbInstallerUser extends UmbContextConsumerMixin(LitElement) {
<uui-input
type="email"
id="email"
.value=${this._userFormData.email}
.value=${this._userFormData?.email}
name="email"
required
required-message="Email is required"></uui-input>
@@ -137,7 +137,7 @@ export class UmbInstallerUser extends UmbContextConsumerMixin(LitElement) {
<uui-input-password
id="password"
name="password"
.value=${this._userFormData.password}
.value=${this._userFormData?.password}
required
required-message="Password is required"></uui-input-password>
</uui-form-layout-item>
@@ -146,7 +146,7 @@ export class UmbInstallerUser extends UmbContextConsumerMixin(LitElement) {
<uui-checkbox
name="subscribeToNewsletter"
label="Remember me"
.checked=${this._userFormData.subscribeToNewsletter}>
.checked=${this._userFormData?.subscribeToNewsletter || false}>
Keep me updated on Umbraco Versions, Security Bulletins and Community News
</uui-checkbox>
</uui-form-layout-item>

View File

@@ -0,0 +1,93 @@
import '../core/context/context-provider.element';
import './installer-consent.element';
import './installer-database.element';
import './installer-installing.element';
import './installer-user.element';
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import { rest } from 'msw';
import { UmbInstallerUser } from '.';
import { UmbracoInstaller } from '../core/models';
import { UmbInstallerContext } from './installer-context';
export default {
title: 'Components/Installer/Steps',
component: 'umb-installer',
id: 'installer',
decorators: [
(story) =>
html`<umb-context-provider
style="display: block;margin: 2rem 25%;padding: 1rem;border: 1px solid #ddd;"
key="umbInstallerContext"
.value=${new UmbInstallerContext()}>
${story()}
</umb-context-provider>`,
],
} as Meta;
export const Step1User: Story<UmbInstallerUser> = () => html`<umb-installer-user></umb-installer-user>`;
Step1User.storyName = 'Step 1: User';
Step1User.parameters = {
actions: {
handles: ['next'],
},
};
export const Step2Telemetry: Story = () => html`<umb-installer-consent></umb-installer-consent>`;
Step2Telemetry.storyName = 'Step 2: Telemetry data';
Step2Telemetry.parameters = {
actions: {
handles: ['previous', 'next'],
},
};
export const Step3Database: Story = () => html`<umb-installer-database></umb-installer-database>`;
Step3Database.storyName = 'Step 3: Database';
Step3Database.parameters = {
actions: {
handles: ['previous', 'next'],
},
};
export const Step3DatabasePreconfigured: Story = () => html`<umb-installer-database></umb-installer-database>`;
Step3DatabasePreconfigured.storyName = 'Step 3: Database (preconfigured)';
Step3DatabasePreconfigured.parameters = {
actions: {
handles: ['previous', 'next'],
},
msw: {
handlers: {
global: null,
others: [
rest.get('/umbraco/backoffice/install/settings', (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json<UmbracoInstaller>({
user: { consentLevels: [], minCharLength: 2, minNonAlphaNumericLength: 2 },
databases: [
{
id: '1',
sortOrder: -1,
displayName: 'SQLite',
defaultDatabaseName: 'Umbraco',
providerName: 'Microsoft.Data.SQLite',
isConfigured: true,
requiresServer: false,
serverPlaceholder: null,
requiresCredentials: false,
supportsIntegratedAuthentication: false,
requiresConnectionTest: false,
},
],
})
);
}),
],
},
},
};
export const Step4Installing: Story = () => html`<umb-installer-installing></umb-installer-installing>`;
Step4Installing.storyName = 'Step 4: Installing';

View File

@@ -0,0 +1,103 @@
import { html, fixture, expect } from '@open-wc/testing';
import { UmbInstallerConsent } from './installer-consent.element';
import { UmbInstallerDatabase } from './installer-database.element';
import { UmbInstallerInstalling } from './installer-installing.element';
import { UmbInstallerLayout } from './installer-layout.element';
import { UmbInstallerUser } from './installer-user.element';
import { UmbInstaller } from './installer.element';
describe('UmbInstaller', () => {
let element: UmbInstaller;
beforeEach(async () => {
element = await fixture(html`<umb-installer></umb-installer>`);
});
it('is defined with its own instance', async () => {
expect(element).to.be.instanceOf(UmbInstaller);
});
it('passes the a11y audit', async () => {
expect(element).shadowDom.to.be.accessible();
});
});
describe('UmbInstallerLayout', () => {
let element: UmbInstallerLayout;
beforeEach(async () => {
element = await fixture(html`<umb-installer-layout></umb-installer-layout>`);
});
it('is defined with its own instance', async () => {
expect(element).to.be.instanceOf(UmbInstallerLayout);
});
it('passes the a11y audit', async () => {
expect(element).shadowDom.to.be.accessible();
});
});
describe('UmbInstallerUser', () => {
let element: UmbInstallerUser;
beforeEach(async () => {
element = await fixture(html`<umb-installer-user></umb-installer-user>`);
});
it('is defined with its own instance', async () => {
expect(element).to.be.instanceOf(UmbInstallerUser);
});
it('passes the a11y audit', async () => {
expect(element).shadowDom.to.be.accessible();
});
});
describe('UmbInstallerConsent', () => {
let element: UmbInstallerConsent;
beforeEach(async () => {
element = await fixture(html`<umb-installer-consent></umb-installer-consent>`);
});
it('is defined with its own instance', async () => {
expect(element).to.be.instanceOf(UmbInstallerConsent);
});
it('passes the a11y audit', async () => {
expect(element).shadowDom.to.be.accessible();
});
});
describe('UmbInstallerDatabase', () => {
let element: UmbInstallerDatabase;
beforeEach(async () => {
element = await fixture(html`<umb-installer-database></umb-installer-database>`);
});
it('is defined with its own instance', async () => {
expect(element).to.be.instanceOf(UmbInstallerDatabase);
});
it('passes the a11y audit', async () => {
expect(element).shadowDom.to.be.accessible();
});
});
describe('UmbInstallerInstalling', () => {
let element: UmbInstallerInstalling;
beforeEach(async () => {
element = await fixture(html`<umb-installer-installing></umb-installer-installing>`);
});
it('is defined with its own instance', async () => {
expect(element).to.be.instanceOf(UmbInstallerInstalling);
});
it('passes the a11y audit', async () => {
expect(element).shadowDom.to.be.accessible();
});
});

View File

@@ -1,15 +1,18 @@
import { setupWorker } from 'msw';
import { MockedRequest, setupWorker } from 'msw';
import { handlers } from './handlers';
const worker = setupWorker(...handlers);
export const onUnhandledRequest = (req: MockedRequest) => {
if (req.url.pathname.startsWith('/node_modules/')) return;
if (req.url.pathname.startsWith('/src/')) return;
if (req.destination === 'image') return;
console.warn('Found an unhandled %s request to %s', req.method, req.url.href);
};
export const startMockServiceWorker = () =>
worker.start({
onUnhandledRequest: (req) => {
if (req.url.pathname.startsWith('/node_modules/')) return;
if (req.url.pathname.startsWith('/src/')) return;
if (req.destination === 'image') return;
console.warn('Found an unhandled %s request to %s', req.method, req.url.href);
},
onUnhandledRequest,
});

View File

@@ -23,7 +23,7 @@ export const handlers = [
{
level: 'Detailed',
description:
'We will send:\n <br>- Anonymized site ID, umbraco version, and packages installed.\n <br>- Number of: Root nodes, Content nodes, Macros, Media, Document Types, Templates, Languages, Domains, User Group, Users, Members, and Property Editors in use.\n <br>- System information: Webserver, server OS, server framework, server OS language, and database provider.\n <br>- Configuration settings: Modelsbuilder mode, if custom Umbraco path exists, ASP environment, and if you are in debug mode.\n <br>\n <br><i>We might change what we send on the Detailed level in the future. If so, it will be listed above.\n <br>By choosing "Detailed" you agree to current and future anonymized information being collected.</i>',
'We will send:<ul><li>Anonymized site ID, umbraco version, and packages installed.</li><li>Number of: Root nodes, Content nodes, Macros, Media, Document Types, Templates, Languages, Domains, User Group, Users, Members, and Property Editors in use.</li><li>System information: Webserver, server OS, server framework, server OS language, and database provider.</li><li>Configuration settings: Modelsbuilder mode, if custom Umbraco path exists, ASP environment, and if you are in debug mode.</li></ul><i>We might change what we send on the Detailed level in the future. If so, it will be listed above.<br>By choosing "Detailed" you agree to current and future anonymized information being collected.</i>',
},
],
},

View File

@@ -8,7 +8,7 @@ export const handlers = [
// Respond with a 200 status code
ctx.status(200),
ctx.json<StatusResponse>({
serverStatus: import.meta.env.VITE_UMBRACO_INSTALL_STATUS !== 'false' ? 'running' : 'must-install',
serverStatus: import.meta.env.VITE_UMBRACO_INSTALL_STATUS,
})
);
}),

View File

@@ -0,0 +1,28 @@
import { rest } from 'msw';
import { PostInstallRequest, UmbracoUpgrader } from '../../core/models';
export const handlers = [
rest.get('/umbraco/backoffice/upgrade/settings', (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(200),
ctx.json<UmbracoUpgrader>({
currentState: '2b20c6e7',
newState: '2b20c6e8',
oldVersion: '13.0.0',
newVersion: '13.1.0',
reportUrl: 'https://our.umbraco.com/download/releases/1000',
})
);
}),
rest.post<PostInstallRequest>('/umbraco/backoffice/upgrade/authorize', async (_req, res, ctx) => {
await new Promise((resolve) => setTimeout(resolve, (Math.random() + 1) * 1000)); // simulate a delay of 1-2 seconds
return res(
// Respond with a 200 status code
ctx.status(201)
);
}),
];

View File

@@ -2,12 +2,14 @@ import { handlers as contentHandlers } from './domains/content.handlers';
import { handlers as installHandlers } from './domains/install.handlers';
import { handlers as manifestsHandlers } from './domains/manifests.handlers';
import { handlers as serverHandlers } from './domains/server.handlers';
import { handlers as upgradeHandlers } from './domains/upgrade.handlers';
import { handlers as userHandlers } from './domains/user.handlers';
export const handlers = [
...serverHandlers,
...contentHandlers,
...installHandlers,
...upgradeHandlers,
...manifestsHandlers,
...userHandlers,
];

View File

@@ -0,0 +1,13 @@
import '../core/context/context-provider.element';
import '../installer/installer.element';
import { Meta } from '@storybook/web-components';
import { html } from 'lit-html';
export default {
title: 'Pages/Installer',
component: 'umb-installer',
id: 'installer-page',
} as Meta;
export const Installer = () => html`<umb-installer></umb-installer>`;

View File

@@ -0,0 +1,2 @@
export * from './upgrader.element';
export * from './upgrader-view.element';

View File

@@ -0,0 +1,106 @@
import { css, CSSResultGroup, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { UmbracoUpgrader } from '../core/models';
/**
* @element umb-upgrader-view
* @fires {CustomEvent<SubmitEvent>} onAuthorizeUpgrade - fires when the user clicks the continue button
*/
@customElement('umb-upgrader-view')
export class UmbUpgraderView extends LitElement {
static styles: CSSResultGroup = [
css`
.center {
display: grid;
place-items: center;
height: 100vh;
}
.error {
color: var(--uui-color-danger);
}
`,
];
@property({ type: Boolean })
fetching = false;
@property({ type: Boolean })
upgrading = false;
@property({ type: String })
errorMessage = '';
@property({ type: Object, reflect: true })
settings?: UmbracoUpgrader;
private _renderLayout() {
return html`
<h1>Upgrading Umbraco</h1>
<p>
Welcome to the Umbraco installer. You see this screen because your Umbraco installation needs a quick upgrade
of its database and files, which will ensure your website is kept as fast, secure and up to date as possible.
</p>
<p>
Detected current version <strong>${this.settings?.oldVersion}</strong> (${this.settings?.currentState}),
which needs to be upgraded to <strong>${this.settings?.newVersion}</strong> (${this.settings?.newState}).
To compare versions and read a report of changes between versions, use the View Report button below.
</p>
${
this.settings?.reportUrl
? html`
<p>
<uui-button
look="secondary"
href="${this.settings.reportUrl}"
target="_blank"
label="View Report"></uui-button>
</p>
`
: ''
}
<p>Simply click <strong>continue</strong> below to be guided through the rest of the upgrade.</p>
<form id="authorizeUpgradeForm" @submit=${this._handleSubmit}>
<p>
<uui-button
id="authorizeUpgrade"
type="submit"
look="primary"
color="positive"
label="Continue"
state=${ifDefined(this.upgrading ? 'waiting' : undefined)}></uui-button>
</p>
</form>
${this._renderError()}
</div>
`;
}
private _renderError() {
return html` ${this.errorMessage ? html`<p class="error">${this.errorMessage}</p>` : ''} `;
}
render() {
return html` ${this.fetching ? html`<div class="center"><uui-loader></uui-loader></div>` : this._renderLayout()} `;
}
_handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('onAuthorizeUpgrade', { detail: e, bubbles: true }));
};
}
export default UmbUpgraderView;
declare global {
interface HTMLElementTagNameMap {
'umb-upgrader-view': UmbUpgraderView;
}
}

View File

@@ -1,32 +1,82 @@
import { css, CSSResultGroup, html, LitElement } from 'lit';
import '../installer/installer-layout.element';
import './upgrader-view.element';
import { html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbContextProviderMixin } from '../core/context';
import { getUpgradeSettings, PostUpgradeAuthorize } from '../core/api/fetcher';
import { UmbracoUpgrader } from '../core/models';
/**
* @element umb-upgrader
*/
@customElement('umb-upgrader')
export class UmbUpgrader extends UmbContextProviderMixin(LitElement) {
static styles: CSSResultGroup = [css``];
export class UmbUpgrader extends LitElement {
@state()
private upgradeSettings?: UmbracoUpgrader;
@state()
step = 1;
private fetching = true;
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('next', () => this._handleNext());
this.addEventListener('previous', () => this._goToPreviousStep());
}
@state()
private upgrading = false;
private _handleNext() {
this.step++;
}
@state()
private errorMessage = '';
private _goToPreviousStep() {
this.step--;
constructor() {
super();
this._setup();
}
render() {
return html`<h1>Please implement me</h1>`;
return html`<umb-installer-layout>
<umb-upgrader-view
.fetching=${this.fetching}
.upgrading=${this.upgrading}
.settings=${this.upgradeSettings}
.errorMessage=${this.errorMessage}
@onAuthorizeUpgrade=${this._handleSubmit}></umb-upgrader-view>
</umb-installer-layout>`;
}
private async _setup() {
this.fetching = true;
try {
const { data } = await getUpgradeSettings({});
this.upgradeSettings = data;
} catch (e) {
if (e instanceof getUpgradeSettings.Error) {
this.errorMessage = e.message;
}
}
this.fetching = false;
}
_handleSubmit = async (e: CustomEvent<SubmitEvent>) => {
e.stopPropagation();
this.errorMessage = '';
this.upgrading = true;
try {
await PostUpgradeAuthorize({});
history.pushState(null, '', '/');
} catch (e) {
if (e instanceof PostUpgradeAuthorize.Error) {
const error = e.getActualType();
if (error.status === 400) {
this.errorMessage = error.data.detail || 'Unknown error, please try again';
}
} else {
this.errorMessage = 'Unknown error, please try again';
}
}
this.upgrading = false;
};
}
export default UmbUpgrader;

View File

@@ -0,0 +1,58 @@
import '.';
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import { UmbUpgraderView } from './upgrader-view.element';
export default {
title: 'Components/Upgrader/States',
args: {
errorMessage: '',
upgrading: false,
fetching: false,
settings: {
currentState: '2b20c6e7',
newState: '2b20c6e8',
oldVersion: '12.0.0',
newVersion: '13.0.0',
reportUrl: 'https://our.umbraco.com/download/releases/1000',
},
},
parameters: {
actions: {
handles: ['onAuthorizeUpgrade'],
},
},
decorators: [
(story) =>
html`<div
style="margin:2rem; max-width:400px;border:1px solid #ccc;border-radius:30px 0px 0px 30px;padding:var(--uui-size-layout-4) var(--uui-size-layout-4) var(--uui-size-layout-2) var(--uui-size-layout-4);">
${story()}
</div>`,
],
} as Meta<UmbUpgraderView>;
const Template: Story<UmbUpgraderView> = ({ upgrading, errorMessage, settings, fetching }) =>
html`<umb-upgrader-view
.upgrading=${upgrading}
.errorMessage=${errorMessage}
.settings=${settings}
.fetching=${fetching}></umb-upgrader-view>`;
export const Overview = Template.bind({});
export const Upgrading = Template.bind({});
Upgrading.args = {
upgrading: true,
};
export const Fetching = Template.bind({});
Fetching.args = {
fetching: true,
};
export const Error = Template.bind({});
Error.args = {
errorMessage: 'Something went wrong',
};

View File

@@ -0,0 +1,19 @@
import { expect, fixture, html } from '@open-wc/testing';
import { UmbUpgrader } from './upgrader.element';
describe('UmbUpgrader', () => {
let element: UmbUpgrader;
beforeEach(async () => {
element = await fixture(html`<umb-upgrader></umb-upgrader>`);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbUpgrader);
});
it('passes the a11y audit', () => {
expect(element).shadowDom.to.be.accessible();
});
});

View File

@@ -1,5 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
VITE_UMBRACO_INSTALL_STATUS: string;
VITE_UMBRACO_INSTALL_STATUS: 'running' | 'must-upgrade' | 'must-install';
VITE_UMBRACO_INSTALL_PRECONFIGURED: string;
}

View File

@@ -1,5 +1,6 @@
import './installer';
import './server';
import './upgrader';
import './user';
import { api } from '@airtasker/spot';

View File

@@ -0,0 +1,38 @@
import { body, defaultResponse, endpoint, request, response } from '@airtasker/spot';
import { ProblemDetails } from './models';
@endpoint({
method: 'GET',
path: '/upgrade/settings',
})
export class GetUpgradeSettings {
@response({ status: 200 })
success(@body body: UpgradeSettingsResponse) {}
@defaultResponse
default(@body body: ProblemDetails) {}
}
@endpoint({
method: 'POST',
path: '/upgrade/authorize',
})
export class PostUpgradeAuthorize {
@request
request() {}
@response({ status: 201 })
success() {}
@response({ status: 400 })
badRequest(@body body: ProblemDetails) {}
}
export interface UpgradeSettingsResponse {
currentState: string;
newState: string;
newVersion: string;
oldVersion: string;
reportUrl: string;
}