Merge submodule contents for src/Umbraco.Web.UI.Client/main

This commit is contained in:
Jacob Overgaard
2024-11-09 09:55:57 +01:00
6083 changed files with 314515 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = tab
indent_size = 2
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.json]
indent_size = 2
[*.{html,js,md}]
block_comment_start = /**
block_comment = *
block_comment_end = */

View File

@@ -0,0 +1,6 @@
# Copy this to .env.local and change what you want to test.
VITE_UMBRACO_USE_MSW=on # on = turns on MSW, off = disables all mock handlers
VITE_UMBRACO_API_URL=https://localhost:44339
VITE_UMBRACO_INSTALL_STATUS=running # running or must-install or must-upgrade
VITE_MSW_QUIET=off # on = turns off MSW console logs, off = turns on MSW console logs
VITE_UMBRACO_EXTENSION_MOCKS=off # on = turns on extension mocks, off = turns off extension mocks

View File

@@ -0,0 +1,3 @@
# Copy this to .env.local and change what you want to test.
VITE_UMBRACO_USE_MSW=off # Playwright handles the mocking itself (using msw but it starts it up manually)
VITE_UMBRACO_API_URL=

View File

@@ -0,0 +1,2 @@
VITE_UMBRACO_USE_MSW=off
VITE_UMBRACO_API_URL=

View File

@@ -0,0 +1,2 @@
VITE_UMBRACO_INSTALL_STATUS=running # running or must-install or must-upgrade
VITE_UMBRACO_USE_MSW=on

View File

@@ -0,0 +1,239 @@
# Contribution Guidelines
## Thoughts, links, and questions
In the high probability that you are porting something from angular JS then here are a few helpful tips for using Lit:
Here is the LIT documentation and playground: [https://lit.dev](https://lit.dev)
### How best to find what needs converting from the old backoffice?
1. Navigate to [https://github.com/umbraco/Umbraco-CMS](https://github.com/umbraco/Umbraco-CMS)
2. Make sure you are on the `v14/dev` branch
### What is the process of contribution?
- Read the [README](README.md) to learn how to get the project up and running
- Find an issue marked as [community/up-for-grabs](https://github.com/umbraco/Umbraco.CMS.Backoffice/issues?q=is%3Aissue+is%3Aopen+label%3Acommunity%2Fup-for-grabs) - note that some are also marked [good first issue](https://github.com/umbraco/Umbraco.CMS.Backoffice/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) which indicates they are simple to get started on
- Umbraco HQ owns the Management API on the backend, so features can be worked on in the frontend only when there is an API, or otherwise if no API is required
- A contribution should be made in a fork of the repository
- Once a contribution is ready, a pull request should be made to this repository and HQ will assign a reviewer
- A pull request should always indicate what part of a feature it tries to solve, i.e. does it close the targeted issue (if any) or does the developer expect Umbraco HQ to take over
## Contributing in general terms
A lot of the UI has already been migrated to the new backoffice. Generally speaking, one would find a feature on the projects board, locate the UI in the old backoffice (v11 is fine), convert it to Lit components using the UI library, put the business logic into a store/service, write tests, and make a pull request.
We are also very keen to receive contributions towards **documentation, unit testing, package development, accessibility, and just general testing of the UI.**
## The Management API
The management API is the colloquial term used to describe the new backoffice API. It is built as a .NET Web API, has a Swagger endpoint (/umbraco/swagger), and outputs an OpenAPI v3 schema, that the frontend consumes.
The frontend has an API formatter that takes the OpenAPI schema file and converts it into a set of TypeScript classes and interfaces.
### Caveats
1. The backoffice can be run and tested against a real Umbraco instance by cloning down the `v14/dev` branch, but there are no guarantees about how well it works yet.
**Current schema for API:**
[https://raw.githubusercontent.com/umbraco/Umbraco-CMS/v13/dev/src/Umbraco.Cms.Api.Management/OpenApi.json](https://raw.githubusercontent.com/umbraco/Umbraco-CMS/v14/dev/src/Umbraco.Cms.Api.Management/OpenApi.json)
**How to convert it:**
- Run `npm run generate:api`
## A contribution example
### Example: Published Cache Status Dashboard
![Published Status Dashboard](/.github/images/contributing/published-cache-status-dashboard.png)
### Boilerplate (example using Lit)
Links for Lit examples and documentation:
- [https://lit.dev](https://lit.dev)
- [https://lit.dev/docs/](https://lit.dev/docs/)
- [https://lit.dev/playground/](https://lit.dev/playground/)
### Functionality
**HTML**
The simplest approach is to copy over the HTML from the old backoffice into a new Lit element (check existing elements in the repository, e.g. if you are working with a dashboard, then check other dashboards, etc.). Once the HTML is inside the `render` method, it is often enough to simply replace `<umb-***>` elements with `<uui-***>` and replace a few of the attributes. In general, we try to build as much UI with Umbraco UI Library as possible.
**Controller**
The old AngularJS controllers will have to be converted into modern TypeScript and will have to use our new services and stores. We try to abstract as much away as possible, and mostly you will have to make API calls and let the rest of the system handle things like error handling and so on. In the case of this dashboard, we only have a few GET and POST requests. Looking at the new Management API, we find the PublishedCacheService, which is the new API controller to serve data to the dashboard.
To make the first button work, which simply just requests a new status from the server, we must make a call to `PublishedCacheService.getPublishedCacheStatus()`. An additional thing here is to wrap that in a friendly function called `tryExecuteAndNotify`, which is something we make available to developers to automatically handle the responses coming from the server and additionally use the Notifications to notify of any errors:
```typescript
import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources';
import { PublishedCacheService } from '@umbraco-cms/backoffice/external/backend-api';
private _getStatus() {
const { data: status } = await tryExecuteAndNotify(this, PublishedCacheService.getPublishedCacheStatus());
if (status) {
// we now have the status
console.log(status);
}
}
```
### State (buttons, etc)
It is a good idea to make buttons indicate a loading state when awaiting an API call. All `<uui-button>` support the `.state` property, which you can set around API calls:
```typescript
@state()
private _buttonState: UUIButtonState = undefined;
private _getStatus() {
this._buttonState = 'waiting';
[...await...]
this._buttonState = 'success';
}
```
## Making the dashboard visible
### Add to internal manifests
All items are declared in a `manifests.ts` file, which is located in each section directory.
To declare the Published Cache Status Dashboard as a new manifest, we need to add the section as a new json object that would look like this:
```typescript
{
type: 'dashboard',
alias: 'Umb.Dashboard.PublishedStatus',
name: 'Published Status Dashboard',
elementName: 'umb-dashboard-published-status',
element: () => import('./published-status/dashboard-published-status.element.js'),
weight: 200,
meta: {
label: 'Published Status',
pathname: 'published-status',
},
conditions: [
{
alias: 'Umb.Condition.SectionAlias',
match: 'Umb.Section.Settings',
},
],
},
```
Lets go through each of these properties…
- Type: can be one of the following:
- section - examples include: `Content`, `Media`
- dashboard - a view within a section. Examples include: the welcome dashboard
- propertyEditorUi
- editorView
- propertyAction
- tree
- editor
- treeItemAction
- Alias: is the unique key used to identify this item.
- Name: is the human-readable name for this item.
- ElementName: this is the customElementName declared on the element at the top of the file i.e
```typescript
@customElement('umb-dashboard-published-status')
```
- Js: references a function call to import the file that the element is declared within
- Weight: allows us to specify the order in which the dashboard will be displayed within the tabs bar
- Meta: allows us to reference additional data - in our case, we can specify the label that is shown in the tabs bar and the pathname that will be displayed in the URL
- Conditions: allows us to specify the conditions that must be met for the dashboard to be displayed. In our case, we are specifying that the dashboard will only be displayed within the Settings section
## API mock handlers
Running the app with `npm run dev`, you will quickly notice the API requests turn into 404 errors. To hit the API, we need to add a mock handler to define the endpoints that our dashboard will call. In the case of the Published Cache Status section, we have several calls to work through. Lets start by looking at the call to retrieve the current status of the cache:
![Published Status Dashboard](/.github/images/contributing/status-of-cache.png)
From the existing functionality, we can see that this is a string message that is received as part of a `GET` request from the server.
So to define this, we must first add a handler for the Published Status called `published-status.handlers.ts` within the mocks/domains folder. In this file we will have code that looks like the following:
```typescript
const { rest } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
rest.get(umbracoPath('/published-cache/status'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(200),
ctx.json<string>(
'Database cache is ok. ContentStore contains 1 item and has 1 generation and 0 snapshot. MediaStore contains 5 items and has 1 generation and 0 snapshot.',
),
);
}),
];
```
This is defining the `GET` path that we will call through the resource: `/published-cache/status`
It returns a `200 OK` response and a string value with the current “status” of the published cache for us to use within the element
An example `POST` is similar. Lets take the “Refresh status” button as an example:
![Published Status Dashboard](/.github/images/contributing/refresh-status.png)
From our existing functionality, we can see that this makes a `POST` call to the server to prompt a reload of the published cache. So we would add a new endpoint to the mock handler that would look like:
```typescript
rest.post(umbracoPath('/published-cache/reload'), async (_req, res, ctx) => {
return res(
// Simulate a 1 second delay for the benefit of the UI
ctx.delay(1000)
// Respond with a 201 status code
ctx.status(201)
);
})
```
Which is defining a new `POST` endpoint that we can add to the core API fetcher using the path `/published-cache/reload`.
This call returns a simple `OK` status code and no other object.
## Storybook stories
We try to make good Storybook stories for new components, which is a nice way to work with a component in an isolated state. Imagine you are working with a dialog on page 3 and have to navigate back to that every time you make a change - this is now eliminated with Storybook as you can just make a story that displays that step. Storybook can only show one component at a time, so it also helps us to isolate view logic into more and smaller components, which in turn are more testable.
In-depth: [https://storybook.js.org/docs/web-components/get-started/introduction](https://storybook.js.org/docs/web-components/get-started/introduction)
Reference: [https://ambitious-stone-0033b3603.1.azurestaticapps.net/](https://ambitious-stone-0033b3603.1.azurestaticapps.net/)
- Locally: `npm run storybook`
For Umbraco UI stories, please navigate to [https://uui.umbraco.com/](https://uui.umbraco.com/)
## Testing
There are two testing tools on the backoffice: unit testing and end-to-end testing.
### Unit testing
We are using a tool called Web Test Runner which spins up a bunch of browsers using Playwright with the well-known jasmine/chai syntax. It is expected that any new component/element has a test file named “&lt;component>.test.ts”. It will automatically be picked up and there are a set of standard tests we apply to all components, which checks that they are registered correctly and they pass accessibility testing through Axe.
Working with playwright: [https://playwright.dev/docs/intro](https://playwright.dev/docs/intro)
## Putting it all together
When we are finished with the dashboard we will hopefully have something akin to this [real-world example of the actual dashboard that was migrated](https://github.com/umbraco/Umbraco.CMS.Backoffice/tree/main/src/backoffice/settings/dashboards/published-status).

View File

@@ -0,0 +1,32 @@
---
name: "\U0001F41B Bug report"
about: Create a report to help us improve
title: "[BUG]: "
labels: type/bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: "✨ Feature request"
about: Suggest an idea for this project
title: ''
labels: type/feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 💡 Features and ideas
url: https://github.com/umbraco/Umbraco.CMS.Backoffice/discussions/new?category=ideas
about: Start a new discussion when you have ideas or feature requests, eventually discussions can turn into plans.
- name: ❓ Support Question
url: https://our.umbraco.com
about: This issue tracker is NOT meant for support questions. If you have a question, please join us on the discussions board or Discord.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Umbraco HQ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,76 @@
# Umbraco.CMS.Backoffice (Bellissima)
This is the working repository of the upcoming new Backoffice to Umbraco CMS.
[![Build and test](https://github.com/umbraco/Umbraco.CMS.Backoffice/actions/workflows/build_test.yml/badge.svg)](https://github.com/umbraco/Umbraco.CMS.Backoffice/actions/workflows/build_test.yml)
[![Storybook](https://github.com/umbraco/Umbraco.CMS.Backoffice/actions/workflows/azure-static-web-apps-ambitious-stone-0033b3603.yml/badge.svg)](https://github.com/umbraco/Umbraco.CMS.Backoffice/actions/workflows/azure-static-web-apps-ambitious-stone-0033b3603.yml)
[![SonarCloud](https://sonarcloud.io/api/project_badges/measure?project=umbraco_Umbraco.CMS.Backoffice&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=umbraco_Umbraco.CMS.Backoffice)
## Installation instructions
1. Run `npm install`
2. Run `npm run dev` to launch Vite in dev mode
### Environment variables
The development environment supports `.env` files, so in order to set your own make a copy
of `.env` and name it `.env.local` and set the variables you need.
As an example to show the installer instead of the login screen, set the following
in the `.env.local` file to indicate that Umbraco has not been installed:
```bash
VITE_UMBRACO_INSTALL_STATUS=must-install
```
## Environments
### Development
The development environment is the default environment and is used when running `npm run dev`. All API calls are mocked and the Umbraco backoffice is served from the `src` folder.
### Run against a local Umbraco instance
If you have a local Umbraco instance running, you can use the development environment to run against it by overriding the API URL and bypassing the mock-service-worker in the frontend client.
Create a `.env.local` file and set the following variables:
```bash
VITE_UMBRACO_API_URL=https://localhost:44339 # This will be the URL to your Umbraco instance
VITE_UMBRACO_USE_MSW=off # Indicate that you want all API calls to bypass MSW (mock-service-worker)
```
Open this file in an editor: `src/Umbraco.Web.UI/appsettings.Development.json` and add this to the `Umbraco:CMS:Security` section to override the backoffice host:
```json
"Umbraco": {
"CMS": {
"Security":{
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error",
},
},
}
```
Now start the vite server: `npm run dev:server` in your backoffice folder and open the http://localhost:5173 URL in your browser.
### Storybook
Storybook is also being built and deployed automatically on the Main branch, including a preview URL on each pull request. See it in action on this [Azure Static Web App](https://ambitious-stone-0033b3603.1.azurestaticapps.net/).
You can test the Storybook locally by running `npm run storybook`. This will start the Storybook server and open a browser window with the Storybook UI.
Storybook is an excellent tool to test out UI components in isolation and to document them. It is also a great way to test the responsiveness and accessibility of the components.
## Contributing
We accept contributions to this project. However be aware that we are mainly working on a private backlog, so not everything will be immediately obvious. If you want to get started on contributing, please read the [contributing guidelines](./CONTRIBUTING.md).
A list of issues can be found on the [Umbraco-CMS Issue Tracker](https://github.com/umbraco/Umbraco-CMS/issues). Many of them are marked as `community/up-for-grabs` which means they are up for grabs for anyone to work on.
## Documentation
The documentation can be found on [Umbraco Docs](https://docs.umbraco.com/umbraco-cms).

View File

@@ -0,0 +1,31 @@
# Bellissima release instructions
## Build
> _See internal documentation on the build/release workflow._
## GitHub Release Notes
To generate release notes on GitHub.
- Go to the [**Releases** area](https://github.com/umbraco/Umbraco.CMS.Backoffice/releases)
- Press the [**"Draft a new release"** button](https://github.com/umbraco/Umbraco.CMS.Backoffice/releases/new)
- In the combobox for "Choose a tag", expand then select or enter the next version number, e.g. `v14.2.0`
- If the tag does not already exist, an option labelled "Create new tag: v14.2.0 on publish" will appear, select that option
- In the combobox for "Target: main", expand then select the release branch for the next version, e.g. `release/14.2`
- In the combobox for "Previous tag: auto":
- If the next release is an RC, then you can leave as `auto`
- Otherwise, select the previous stable version, e.g. `v14.1.1`
- Press the **"Generate release notes"** button, this will populate the main textarea
- Check the details, view in the "Preview" tab
- What type of release is this?
- If it's an RC, then check "Set as a pre-release"
- If it's stable, then check "Set as the latest release"
- Once you're happy with the contents and ready to save...
- If you need more time to review, press the **"Save draft"** button and you can come back to it later
- If you are ready to make the release notes public, then press **"Publish release"** button! :tada:
> If you're curious about how the content is generated, take a look at the `release.yml` configuration:
> https://github.com/umbraco/Umbraco.CMS.Backoffice/blob/main/.github/release.yml

View File

@@ -0,0 +1,14 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Maintain dependencies for npm
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,240 @@
# Help us with Localization!
Localization of the New Backoffice is in full swing!
This is a work in process and here you can find the overview of all the sections that needs to be localized. We are also looking forward to see any contributions towards localization of the new Backoffice.
You may tick a section/subsection in the same PR as your changes, if it completes said section.
Before you start:
- Make sure you have read the [README](https://github.com/umbraco/Umbraco.CMS.Backoffice/blob/main/.github/README.md) and [Contributing Guidelines](https://github.com/umbraco/Umbraco.CMS.Backoffice/blob/main/.github/CONTRIBUTING.md).
- Please note some sections may already be partly or fully localized without it being reflected in the overview just yet.
- Get an understanding of how we do localization in the new Backoffice. The explanations can be found in the stories under **Localization** by running `npm run storybook`. Alternatively see the raw story file [localization.mdx](https://github.com/umbraco/Umbraco.CMS.Backoffice/blob/main/src/packages/core/localization/stories/localization.mdx)
# Overview
- [Sections that needs to be localized](#sections)
- [Keys that needs to be localized](#keys)
## Sections
- [ ] [Header App](#header-app)
- [ ] [Content](#content)
- [ ] [Media](#media)
- [ ] [Settings](#settings)
- [ ] [Members](#members)
- [ ] [Packages](#packages)
- [ ] [Dictionary](#dictionary)
- [ ] [Users](#users)
- [ ] [Property Editors](#property-editor-ui-and-their-input)
- [ ] [Modals](#modals)
- [ ] [Misc](#misc)
### Subsections
#### Header App
- [ ] Ensure all sections are localized
- [ ] Search
- [ ] Current user (Modal)
- [ ] Change password
#### Content
- [ ] Dashboards
- [ ] Welcome
- [ ] Redirect Management
- [ ] Content / Document
- [ ] Section: Content
- [ ] Section: Info
- [ ] Section: Actions
#### Media
- [ ] (To be continued)
#### Settings
- [ ] Dashboards
- [x] Welcome / Settings
- [ ] Examine Management
- [ ] Models Builder
- [ ] Published Status
- [ ] Health Check
- [x] Profiling
- [x] Telemetry Data
- [ ] Document Type
- [ ] Section: Design
- [ ] Section: Structure
- [ ] Section: Settings
- [ ] Section: Templates
- [ ] Media Type
- [ ] Member Type
- [ ] Data Type
- [ ] Section: Details
- [ ] Section: Info
- [ ] Relation Types
- [ ] Log Viewer
- [x] Document Blueprints
- [ ] Languages
- [ ] Extensions
- [ ] Templates
- [ ] Partial Views
- [ ] Stylesheets
- [ ] Section: Rich Text Editor
- [ ] Section: Code
- [ ] Scripts
#### Members
- [ ] Member Groups
- [ ] Members
#### Packages
- [ ] Section: Installed
- [ ] Section: Created
- [ ] Package builder: "Create Package"
#### Dictionary
- [ ] Everything within Dictionary
#### Users
- [ ] Users
- [ ] User Groups
- [ ] Create user
- [ ] User Profiles
#### Property Editor UI (and their inputs)
Ensure all property editors are properly localized.
(Some may be missing in this list / more to be added)
- [ ] Block Grid
- [ ] Block List
- [x] Checkbox List
- [ ] Collection View
- [ ] Color Picker
- [ ] Date Picker
- [x] Dropdown
- [ ] Eye Dropper
- [x] Icon Picker
- [ ] Image Cropper
- [ ] Image Crops Configuration
- [x] Label
- [ ] Markdown Editor
- [ ] Media Picker
- [ ] Member Group Picker
- [ ] Member Picker
- [ ] Multi URL Picker
- [ ] Multiple Text String
- [ ] Number (missing label)
- [ ] Number Range
- [ ] Order Direction
- [x] Radio Button List
- [ ] Slider (label)
- [ ] TextBox (label)
- [ ] TextArea
- [ ] TinyMCE
- [ ] Toggle
- [ ] Tree Picker
- [ ] StartNode
- [x] DynamicRoot
- [ ] Upload Field
- [ ] User Picker
- [ ] Value Type
#### Modals
Ensure all modals are properly localized.
(Some may be missing in this list / more to be added)
- [ ] Code Editor
- [ ] Confirm
- [ ] Embedded Media
- [ ] Folder
- [ ] Icon Picker
- [ ] Link Picker
- [ ] Property Settings
- [ ] Section Picker
- [ ] Template
- [ ] Tree Picker
- [ ] Debug
Rest of modals can be found:
- [ ] Umb***ModalName***ModalElement
#### Misc
- [ ] Tree
- [ ] Tree Actions
- [ ] Recycle Bin
- [ ] Validator messages
## Keys
Do you speak any of the following languages?
Then we need your help! With Bellissima we added new localization keys, and we still need them available in all our supported languages.
- `bs-BS` - Bosnian (Bosnia and Herzegovina)
- `cs-CZ` - Czech (Czech Republic)
- `cy-GB` - Welsh (United Kingdom)
- `da-DK` - Danish (Denmark)
- `de-DE` - German (Germany)
- `en-GB` - English (United Kingdom)
- `es-ES` - Spanish (Spain)
- `fr-FR` - French (France)
- `he-IL` - Hebrew (Israel)
- `hr-HR` - Croatian (Croatia)
- `it-IT` - Italian (Italy)
- `ja-JP` - Japanese (Japan)
- `ko-KR` - Korean (Korea)
- `nb-NO` - Norwegian Bokmål (Norway)
- `nl-NL` - Dutch (Netherlands)
- `pl-PL` - Polish (Poland)
- `pt-BR` - Portuguese (Brazil)
- `ro-RO` - Romanian (Romania)
- `ru-RU` - Russian (Russia)
- `sv-SE` - Swedish (Sweden)
- `tr-TR` - Turkish (Turkey)
- `ua-UA` - Ukrainian (Ukraine)
- `zh-CN` - Chinese (China)
- `zh-TW` - Chinese (Taiwan)
#### settingsDashboard
- documentationHeader
- documentationDescription
- communityHeader
- communityDescription
- trainingHeader
- trainingDescription
- supportHeader
- supportDescription
- videosHeader
- videosDescription
- getHelp
- getCertified
- goForum
- chatWithCommunity
- watchVideos
- [ ] `bs-BS` - Bosnian (Bosnia and Herzegovina)
- [ ] `cs-CZ` - Czech (Czech Republic)
- [ ] `cy-GB` - Welsh (United Kingdom)
- [x] `da-DK` - Danish (Denmark)
- [ ] `de-DE` - German (Germany)
- [ ] `en-GB` - English (United Kingdom)
- [ ] `es-ES` - Spanish (Spain)
- [ ] `fr-FR` - French (France)
- [ ] `he-IL` - Hebrew (Israel)
- [ ] `hr-HR` - Croatian (Croatia)
- [ ] `it-IT` - Italian (Italy)
- [ ] `ja-JP` - Japanese (Japan)
- [ ] `ko-KR` - Korean (Korea)
- [ ] `nb-NO` - Norwegian Bokmål (Norway)
- [ ] `nl-NL` - Dutch (Netherlands)
- [ ] `pl-PL` - Polish (Poland)
- [ ] `pt-BR` - Portuguese (Brazil)
- [ ] `ro-RO` - Romanian (Romania)
- [ ] `ru-RU` - Russian (Russia)
- [ ] `sv-SE` - Swedish (Sweden)
- [ ] `tr-TR` - Turkish (Turkey)
- [ ] `ua-UA` - Ukrainian (Ukraine)
- [ ] `zh-CN` - Chinese (China)
- [ ] `zh-TW` - Chinese (Taiwan)

View File

@@ -0,0 +1,30 @@
<!--- Provide a general summary of your changes in the Title above -->
## Description
<!--- Describe the changes in detail -->
## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Chore (minor updates related to the tooling or maintenance of the repository, does not impact compiled assets)
## Motivation and context
<!--- Why is this change required? What problem does it solve? -->
## How to test?
## Screenshots (if appropriate)
## Checklist
<!--- Go over all the following points, and put an `x` in all the boxes that apply. If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] If my change requires a change to the documentation, I have updated the documentation in this pull request.
- [ ] I have read the **[CONTRIBUTING](<(https://github.com/umbraco/Umbraco.CMS.Backoffice/blob/main/.github/CONTRIBUTING.md)>)** document.
- [ ] I have added tests to cover my changes.

View File

@@ -0,0 +1,40 @@
# .github/release.yml
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- wontfix
categories:
- title: 🙌 Notable Changes
labels:
- category/notable
- title: 💥 Breaking Changes
labels:
- category/breaking
- title: 🚀 New Features
labels:
- type/feature
- category/feature
- type/enhancement
- category/enhancement
- title: 🐛 Bug Fixes
labels:
- type/bug
- category/bug
- title: 📄 Documentation
labels:
- documentation
- title: 🏠 Internal
labels:
- internal
- title: 📦 Dependencies
labels:
- dependencies
- title: 🌈 A11Y
labels:
- accessibility
- title: Other Changes
labels:
- '*'

View File

@@ -0,0 +1,82 @@
name: Storybook CI/CD
on:
push:
branches:
- main
- release/*
- v*/dev
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
- release/*
- v*/dev
workflow_dispatch:
inputs:
issue_number:
type: number
description: 'Issue/PR Number to comment on'
required: false
env:
NODE_OPTIONS: --max_old_space_size=16384
jobs:
build_and_deploy_job:
if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'storybook'))
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Build And Deploy
id: builddeploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_AMBITIOUS_STONE_0033B3603 }}
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
action: 'upload'
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: '/' # App source code path
app_build_command: 'npm run build-storybook'
api_location: '' # Api source code path - optional
output_location: '/storybook-static' # Built app content directory - optional
###### End of Repository/Build Configurations ######
- name: Comment on PR
# azure/static-web-apps-deploy doesn't support workflow_dispatch, so we need to manually comment on the PR
if: github.event_name == 'workflow_dispatch' && inputs.issue_number != null
uses: actions/github-script@v7
env:
ISSUE_NUMBER: ${{ inputs.issue_number }}
SITE_URL: ${{ steps.builddeploy.outputs.static_web_app_url }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.addLabels({
issue_number: process.env.ISSUE_NUMBER,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['storybook']
})
github.rest.issues.createComment({
issue_number: process.env.ISSUE_NUMBER,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Storybook is available at: ${process.env.SITE_URL}`
})
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'storybook')
runs-on: ubuntu-latest
name: Close Pull Request Job
steps:
- name: Close Pull Request
id: closepullrequest
uses: Azure/static-web-apps-deploy@v1
with:
app_location: '/'
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_AMBITIOUS_STONE_0033B3603 }}
action: 'close'

View File

@@ -0,0 +1,63 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Build and test
on:
push:
branches:
- main
- release/*
- v*/dev
pull_request:
branches:
- main
- release/*
- v*/dev
# Allows GitHub to use this workflow to validate the merge queue
merge_group:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
env:
NODE_OPTIONS: --max_old_space_size=16384
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: ./package-lock.json
- run: npm ci --no-audit --no-fund --prefer-offline
- run: npm run lint:errors
- run: npm run build:for:cms
- run: npm run check:paths
- run: npm run generate:jsonschema:dist
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: ./package-lock.json
- run: npm ci --no-audit --no-fund --prefer-offline
- run: npx playwright install --with-deps
- run: npm test
- name: Upload Code Coverage reports
uses: actions/upload-artifact@v4
if: always()
with:
name: code-coverage
path: coverage/
retention-days: 30

View File

@@ -0,0 +1,61 @@
# 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
- release/*
- v*/dev
pull_request:
branches:
- main
- release/*
- v*/dev
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@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
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@v3

View File

@@ -0,0 +1,20 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v4
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4

View File

@@ -0,0 +1,82 @@
# This workflow will publish the @umbraco-cms/backoffice package to npmjs.com
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages
# The @umbraco-cms scope is owned by Umbraco HQ
name: Node.js Package
on:
push:
branches: [main]
paths:
- 'src/**'
- 'devops/publish/**'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- 'staticwebapp.config.json'
- 'README.md'
workflow_dispatch:
inputs:
ref:
description: Branch or tag or SHA to publish
required: false
version:
description: 'Version to publish'
required: false
tag:
description: 'Tag to publish'
required: false
type: choice
options:
- 'next'
- 'latest'
env:
NODE_OPTIONS: --max-old-space-size=16384
jobs:
build_publish:
name: Build and publish
runs-on: ubuntu-latest
concurrency:
group: npm-publish
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
registry-url: https://registry.npmjs.org/
scope: '@umbraco-cms'
- run: npm ci
- run: npm run build
- name: Calculate version
run: |
if [ -z "${{inputs.version}}" ]; then
echo "No version input, calculating version from package.json"
SHA_SHORT=$(echo $GITHUB_SHA | cut -c1-8)
VERSION=$(node -p "require('./package.json').version")-$SHA_SHORT
echo "Version: $VERSION"
echo "BACKOFFICE_VERSION=$VERSION" >> "$GITHUB_ENV"
else
echo "Version input found, using ${{inputs.version}}"
echo "BACKOFFICE_VERSION=${{inputs.version}}" >> "$GITHUB_ENV"
fi
- name: Publish
run: |
npm whoami
npm version $BACKOFFICE_VERSION --allow-same-version --no-git-tag-version
npm publish --tag $BACKOFFICE_NPM_TAG --access public
echo "### Published new version of @umbraco-cms/backoffice to npm! :rocket:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Version: $BACKOFFICE_VERSION" >> $GITHUB_STEP_SUMMARY
echo "- Tag: $BACKOFFICE_NPM_TAG" >> $GITHUB_STEP_SUMMARY
echo "- Commit: $GITHUB_SHA" >> $GITHUB_STEP_SUMMARY
echo "- Commit message: $GITHUB_SHA_MESSAGE" >> $GITHUB_STEP_SUMMARY
echo "- Commit date: $GITHUB_SHA_DATE" >> $GITHUB_STEP_SUMMARY
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
BACKOFFICE_NPM_TAG: ${{ inputs.tag || 'next' }}

View File

@@ -0,0 +1,48 @@
name: pr-first-response
on:
pull_request_target:
types: [opened]
jobs:
send-response:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Fetch random comment 🗣️ and add it to the PR
uses: actions/github-script@v7
with:
script: |
const response = await fetch('https://collaboratorsv2.euwest01.umbraco.io/umbraco/api/comments/PostComment', {
method: 'post',
body: JSON.stringify({
repo: '${{ github.repository }}',
number: '${{ github.event.number }}',
actor: '${{ github.actor }}',
commentType: 'opened-pr-first-comment'
}),
headers: {
'Authorization': 'Bearer ${{ secrets.OUR_BOT_API_TOKEN }}',
'Content-Type': 'application/json'
}
});
try {
const data = await response.text();
if(response.status === 200 && data !== '') {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: data
});
} else {
console.log("Status code did not indicate success:", response.status);
console.log("Returned data:", data);
}
} catch(error) {
console.log(error);
}

56
src/Umbraco.Web.UI.Client/.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-cms
dist-ssr
/types
*.tsbuildinfo
*.local
*.tgz
## testing
coverage/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/*.code-snippets
!.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# eslint
.eslintcache
test-results/
playwright-report/
playwright/.cache/
# storybook
storybook-static/
# API Docs
ui-api/
custom-elements.json
# JSON for HTML Custom Data
# https://github.com/runem/web-component-analyzer#vscode
# https://github.com/microsoft/vscode-custom-data
vscode-html-custom-data.json
public/tinymce/*
# Vite runtime files
vite.config.ts.timestamp-*.mjs

View File

@@ -0,0 +1,9 @@
{
"tsConfig": "tsconfig.json",
"detectiveOptions": {
"ts": {
"skipTypeImports": true,
"skipAsyncImports": true
}
}
}

View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -0,0 +1 @@
20

View File

@@ -0,0 +1,4 @@
# Ignore auto-generated backend-api files
src/external/backend-api/src
src/packages/core/icon-registry/icons.ts
src/packages/core/icon-registry/icons

View File

@@ -0,0 +1,8 @@
{
"printWidth": 120,
"singleQuote": true,
"semi": true,
"bracketSpacing": true,
"bracketSameLine": true,
"useTabs": true
}

View File

@@ -0,0 +1,4 @@
{
"sonarCloudOrganization": "umbraco",
"projectKey": "umbraco_Umbraco.CMS.Backoffice"
}

View File

@@ -0,0 +1,57 @@
import { dirname, join } from 'path';
import { StorybookConfig } from '@storybook/web-components-vite';
import remarkGfm from 'remark-gfm';
const config: StorybookConfig = {
stories: ['../@(src|libs|apps|storybook)/**/*.mdx', '../@(src|libs|apps|storybook)/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
getAbsolutePath('@storybook/addon-links'),
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-a11y'),
{
name: '@storybook/addon-docs',
options: {
mdxPluginOptions: {
mdxCompileOptions: {
remarkPlugins: [remarkGfm],
},
},
},
},
],
framework: {
name: getAbsolutePath('@storybook/web-components-vite'),
options: {},
},
staticDirs: [
'../public-assets',
'../public',
'../src/assets',
{
from: '../src/packages/core/icon-registry/icons',
to: 'assets/icons',
},
],
typescript: {
check: true,
},
docs: {},
managerHead(head, { configType }) {
const base = process.env.VITE_BASE_PATH || '/';
const injections = [
`<base href="${base}" />`, // This decide how storybook's main frame visit stories
];
return configType === 'PRODUCTION' ? `${injections.join('')}${head}` : head;
},
refs: {
uui: {
title: 'Umbraco UI Library',
url: 'https://62189360eeb21b003ab2f4ad-vfnpsanjps.chromatic.com/',
},
},
};
export default config;
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')));
}

View File

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

View File

@@ -0,0 +1,3 @@
{
"type": "commonjs"
}

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,43 @@
<style>
/* Make stories able to use 100% height */
html {
height: 100vh;
}
/* Make stories able to use 100% height */
body,
#root,
#root-inner {
height: 100%;
}
body {
padding: 0px !important;
}
pre {
font-family: monospace;
font-size: 14px;
background-color: var(--uui-color-background);
padding: 1.5em;
border-radius: 3px;
line-height: 1.3em;
}
</style>
<script src="umbraco/backoffice/msw/index.js"></script>
<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,148 @@
import '@umbraco-ui/uui-css/dist/uui-css.css';
import '../src/css/umb-css.css';
import 'element-internals-polyfill';
import '@umbraco-ui/uui';
import { html } from 'lit';
import { setCustomElements } from '@storybook/web-components';
import { startMockServiceWorker } from '../src/mocks';
import '../src/libs/controller-api/controller-host-provider.element';
import { UmbModalManagerContext } from '../src/packages/core/modal';
import { UmbDataTypeTreeStore } from '../src/packages/data-type/tree/data-type-tree.store';
import { UmbDocumentDetailStore } from '../src/packages/documents/documents/repository/detail/document-detail.store';
import { UmbDocumentTreeStore } from '../src/packages/documents/documents/tree/document-tree.store';
import { UmbCurrentUserStore } from '../src/packages/user/current-user/repository/current-user.store';
import { umbExtensionsRegistry } from '../src/packages/core/extension-registry';
import { UmbIconRegistry } from '../src/packages/core/icon-registry/icon.registry';
import { UmbLitElement } from '../src/packages/core/lit-element';
import { umbLocalizationRegistry } from '../src/packages/core/localization';
import customElementManifests from '../dist-cms/custom-elements.json';
import icons from '../src/packages/core/icon-registry/icons';
import '../src/libs/context-api/provide/context-provider.element';
import '../src/packages/core/components';
import { manifests as documentManifests } from '../src/packages/documents/manifests';
import { manifests as localizationManifests } from '../src/packages/core/localization/manifests';
import { UmbNotificationContext } from '../src/packages/core/notification';
// MSW
startMockServiceWorker({ serviceWorker: { url: (import.meta.env.VITE_BASE_PATH ?? '/') + 'mockServiceWorker.js' } });
class UmbStoryBookElement extends UmbLitElement {
_umbIconRegistry = new UmbIconRegistry();
constructor() {
super();
this._umbIconRegistry.setIcons(icons);
this._umbIconRegistry.attach(this);
this._registerExtensions(documentManifests);
new UmbModalManagerContext(this);
new UmbCurrentUserStore(this);
new UmbNotificationContext(this);
this._registerExtensions(localizationManifests);
umbLocalizationRegistry.loadLanguage('en-us'); // register default language
}
_registerExtensions(manifests) {
manifests.forEach((manifest) => {
if (umbExtensionsRegistry.isRegistered(manifest.alias)) return;
umbExtensionsRegistry.register(manifest);
});
}
render() {
return html`<slot></slot>
<umb-backoffice-modal-container></umb-backoffice-modal-container>
<umb-backoffice-notification-container></umb-backoffice-notification-container>`;
}
}
customElements.define('umb-storybook', UmbStoryBookElement);
const storybookProvider = (story) => html` <umb-storybook>${story()}</umb-storybook> `;
const dataTypeStoreProvider = (story) => html`
<umb-controller-host-provider .create=${(host) => new UmbDataTypeTreeStore(host)}
>${story()}</umb-controller-host-provider
>
`;
const documentStoreProvider = (story) => html`
<umb-controller-host-provider .create=${(host) => new UmbDocumentDetailStore(host)}
>${story()}</umb-controller-host-provider
>
`;
const documentTreeStoreProvider = (story) => html`
<umb-controller-host-provider .create=${(host) => new UmbDocumentTreeStore(host)}
>${story()}</umb-controller-host-provider
>
`;
// Provide the MSW addon decorator globally
export const decorators = [documentStoreProvider, documentTreeStoreProvider, dataTypeStoreProvider, storybookProvider];
export const parameters = {
docs: {
source: {
excludeDecorators: true,
format: 'html', // see storybook docs for more info on this format https://storybook.js.org/docs/api/doc-blocks/doc-block-source#format
},
},
options: {
storySort: {
method: 'alphabetical',
includeNames: true,
order: [
'Guides',
[
'Getting started',
'Extending the Backoffice',
[
'Intro',
'Registration',
'Header Apps',
'Sections',
['Intro', 'Sidebar', 'Views', '*'],
'Entity Actions',
'Workspaces',
['Intro', 'Views', 'Actions', '*'],
'Property Editors',
'Repositories',
'*',
],
'*',
],
'*',
],
},
},
controls: {
expanded: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: 'Greyish',
values: [
{
name: 'Greyish',
value: '#F3F3F5',
},
{
name: 'White',
value: '#ffffff',
},
],
},
};
setCustomElements(customElementManifests);
export const tags = ['autodocs'];

View File

@@ -0,0 +1,14 @@
{
"recommendations": [
"gruntfuggly.todo-tree",
"formulahendry.auto-rename-tag",
"mikestead.dotenv",
"dbaeumer.vscode-eslint",
"runem.lit-plugin",
"esbenp.prettier-vscode",
"hbenl.vscode-test-explorer",
"vunguyentuan.vscode-css-variables",
"unifiedjs.vscode-mdx",
"editorconfig.editorconfig"
]
}

View File

@@ -0,0 +1,30 @@
{
"UmbNewLitElement": {
"prefix": "new umb element",
"scope": "typescript",
"body": [
"import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';",
"import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';",
"import { UmbTextStyles } from '@umbraco-cms/backoffice/style';",
"",
"@customElement('umb-${TM_FILENAME_BASE/(.*)\\..+$/$1/}')",
"export class Umb${TM_FILENAME_BASE/(.*)$/${1:/pascalcase}/}Element extends UmbLitElement {",
"\toverride render() {",
"\t\treturn html`$0`;",
"\t}",
"",
"\tstatic override readonly styles = [UmbTextStyles, css``];",
"}",
"",
"export { Umb${TM_FILENAME_BASE/(.*)$/${1:/pascalcase}/}Element as element };",
"",
"declare global {",
"\tinterface HTMLElementTagNameMap {",
"\t\t'umb-${TM_FILENAME_BASE/(.*)\\..+$/$1/}': Umb${TM_FILENAME_BASE/(.*)$/${1:/pascalcase}/}Element;",
"\t}",
"}",
"",
],
"description": "Create a new Umbraco Lit element",
},
}

View File

@@ -0,0 +1,40 @@
{
"cssVariables.lookupFiles": ["node_modules/@umbraco-ui/uui-css/dist/custom-properties.css"],
"cSpell.words": [
"backoffice",
"Backoffice",
"combobox",
"ctrls",
"devs",
"Dropcursor",
"Elementable",
"Gapcursor",
"iframes",
"invariantable",
"lucide",
"Niels",
"pickable",
"popovertarget",
"Registrator",
"Routable",
"stylesheet",
"svgs",
"templating",
"tinymce",
"tiptap",
"umbraco",
"Uncategorized",
"uninitialize",
"unprovide",
"unpublishing",
"variantable"
],
"exportall.config.folderListener": [],
"exportall.config.relExclusion": [],
"conventionalCommits.scopes": ["partial views"],
"typescript.tsdk": "node_modules/typescript/lib",
"sonarlint.connectedMode.project": {
"connectionId": "umbraco",
"projectKey": "umbraco_Umbraco.CMS.Backoffice"
}
}

View File

@@ -0,0 +1,44 @@
MIT License
Copyright (c) 2024 Umbraco HQ
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Third-party licenses
---
Lucide License
ISC License <https://lucide.dev/license>
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
Simple Icons
CC0 1.0 Universal license <https://creativecommons.org/publicdomain/zero/1.0/>
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.

View File

@@ -0,0 +1,172 @@
# @umbraco-cms/backoffice
This package contains the types for the Umbraco Backoffice.
## Installation
```bash
npm install -D @umbraco-cms/backoffice
```
## Usage
### Vanilla JavaScript
Create an umbraco-package.json file in the root of your package.
```json
{
"name": "My.Package",
"version": "0.1.0",
"extensions": [
{
"type": "dashboard",
"alias": "my.custom.dashboard",
"name": "My Dashboard",
"js": "/App_Plugins/MyPackage/dashboard.js",
"weight": -1,
"meta": {
"label": "My Dashboard",
"pathname": "my-dashboard"
},
"conditions": [
{
"alias": "Umb.Condition.SectionAlias",
"match": "Umb.Section.Content"
}
]
}
]
}
```
Then create a dashboard.js file the same folder.
```javascript
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
padding: 20px;
display: block;
box-sizing: border-box;
}
</style>
<uui-box>
<h1>Welcome to my dashboard</h1>
<p>Example of vanilla JS code</p>
<uui-button label="Click me" id="clickMe" look="secondary"></uui-button>
</uui-box>
`;
export default class MyDashboardElement extends UmbElementMixin(HTMLElement) {
/** @type {import('@umbraco-cms/backoffice/notification').UmbNotificationContext} */
#notificationContext;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.shadowRoot.getElementById('clickMe').addEventListener('click', this.onClick.bind(this));
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (_instance) => {
this.#notificationContext = _instance;
});
}
onClick = () => {
this.#notificationContext?.peek('positive', { data: { headline: 'Hello' } });
};
}
customElements.define('my-custom-dashboard', MyDashboardElement);
```
### TypeScript with Lit
First install Lit and Vite. This command will create a new folder called `my-package` which will have the Vite tooling and Lit for WebComponent development setup.
```bash
npm create vite@latest -- --template lit-ts my-package
```
Go to the new folder and install the backoffice package.
```bash
cd my-package
npm install -D @umbraco-cms/backoffice
```
Then go to the element located in `src/my-element.ts` and replace it with the following code.
```typescript
// src/my-element.ts
import { LitElement, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import { UmbNotificationContext, UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
@customElement('my-element')
export default class MyElement extends UmbElementMixin(LitElement) {
private _notificationContext?: UmbNotificationContext;
constructor() {
super();
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (_instance) => {
this._notificationContext = _instance;
});
}
onClick() {
this._notificationContext?.peek('positive', { data: { message: '#h5yr' } });
}
override render() {
return html`
<uui-box headline="Welcome">
<p>A TypeScript Lit Dashboard</p>
<uui-button look="primary" label="Click me" @click=${() => this.onClick()}></uui-button>
</uui-box>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'my-element': MyElement;
}
}
```
Finally add an umbraco-package.json file in the root of your package folder `my-package`.
```json
{
"name": "My.Package",
"version": "0.1.0",
"extensions": [
{
"type": "dashboard",
"alias": "my.custom.dashboard",
"name": "My Dashboard",
"js": "/App_Plugins/MyPackage/dist/my-package.js",
"weight": -1,
"meta": {
"label": "My Dashboard",
"pathname": "my-dashboard"
},
"conditions": [
{
"alias": "Umb.Condition.SectionAlias",
"match": "Umb.Section.Content"
}
]
}
]
}
```

View File

@@ -0,0 +1,80 @@
import { readdirSync, statSync } from 'fs';
import { join } from 'path';
const PROJECT_DIR = process.argv[2] ?? '.';
const MAX_PATH_LENGTH = process.argv[3] ?? 140;
const IS_CI = process.env.CI === 'true';
const IS_AZURE_PIPELINES = process.env.TF_BUILD === 'true';
const IS_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true';
const FILE_PATH_COLOR = '\x1b[36m%s\x1b[0m';
const ERROR_COLOR = '\x1b[31m%s\x1b[0m';
const SUCCESS_COLOR = '\x1b[32m%s\x1b[0m';
const processExitCode = 1; // Default to 1 to fail the build, 0 to just log the issues
console.log(`Checking path length in ${PROJECT_DIR} for paths exceeding ${MAX_PATH_LENGTH}...`);
console.log('CI detected:', IS_CI);
console.log('\n-----------------------------------');
console.log('Results:');
console.log('-----------------------------------\n');
const hasError = checkPathLength(PROJECT_DIR);
if (hasError) {
console.log('\n-----------------------------------');
console.log(ERROR_COLOR, 'Path length check failed');
console.log('-----------------------------------\n');
if (IS_CI && processExitCode) {
process.exit(processExitCode);
}
} else {
console.log('\n-----------------------------------');
console.log(SUCCESS_COLOR, 'Path length check passed');
console.log('-----------------------------------\n');
}
// Functions
/**
* Recursively check the path length of all files in a directory.
* @param {string} dir - The directory to check for path lengths
* @returns {boolean}
*/
function checkPathLength(dir) {
const files = readdirSync(dir);
let hasError = false;
files.forEach(file => {
const filePath = join(dir, file);
if (filePath.length > MAX_PATH_LENGTH) {
hasError = true;
if (IS_AZURE_PIPELINES) {
console.error(`##vso[task.logissue type=error;sourcepath=${mapFileToSourcePath(filePath)};]Path exceeds maximum length of ${MAX_PATH_LENGTH} characters: ${filePath} with ${filePath.length} characters`);
} else if (IS_GITHUB_ACTIONS) {
console.error(`::error file=${mapFileToSourcePath(filePath)},title=Path exceeds ${MAX_PATH_LENGTH} characters::Paths should not be longer than ${MAX_PATH_LENGTH} characters to support WIN32 systems. The file ${filePath} exceeds that with ${filePath.length - MAX_PATH_LENGTH} characters.`);
} else {
console.error(FILE_PATH_COLOR, mapFileToSourcePath(filePath), '(exceeds by', filePath.length - MAX_PATH_LENGTH, 'chars)');
}
}
if (statSync(filePath).isDirectory()) {
const subHasError = checkPathLength(filePath);
if (subHasError) {
hasError = true;
}
}
});
return hasError;
}
/**
* Maps a file path to a source path for CI logs.
* @remark This might not always work as expected, especially on bundled files, but it's a best effort to map the file path to a source path.
* @param {string} file - The file path to map to a source path
* @returns {string}
*/
function mapFileToSourcePath(file) {
return file.replace(PROJECT_DIR, 'src').replace('.js', '.ts');
}

View File

@@ -0,0 +1,9 @@
import { cpSync, rmSync } from 'fs';
const srcDir = './dist-cms';
const outputDir = '../Umbraco.Cms.StaticAssets/wwwroot/umbraco/backoffice';
rmSync(outputDir, { recursive: true, force: true });
cpSync(srcDir, outputDir, { recursive: true });
console.log('--- Copied build output to CMS successfully. ---');

View File

@@ -0,0 +1,23 @@
import { createImportMap } from "../importmap/index.js";
import { writeFileSync, rmSync } from "fs";
import { packageJsonName, packageJsonVersion } from "../package/index.js";
const srcDir = './dist-cms';
const outputModuleList = `${srcDir}/umbraco-package.json`;
const importmap = createImportMap({ rootDir: '/umbraco/backoffice', additionalImports: {} });
const umbracoPackageJson = {
name: packageJsonName,
version: packageJsonVersion,
extensions: [],
importmap
};
try {
rmSync(outputModuleList, { force: true });
writeFileSync(outputModuleList, JSON.stringify(umbracoPackageJson));
console.log(`Wrote manifest to ${outputModuleList}`);
} catch (e) {
console.error(`Failed to write manifest to ${outputModuleList}`, e);
process.exit(1);
}

View File

@@ -0,0 +1,25 @@
import { createImportMap } from '../importmap/index.js';
import { writeFileWithDir } from '../utils/index.js';
const tsPath = './dist-cms/packages/extension-types/index.d.ts';
const importmap = createImportMap({
rootDir: './src',
replaceModuleExtensions: true,
});
const paths = Object.keys(importmap.imports);
const content = `
${paths.map((path) => `import '${path}';`).join('\n')}
`;
writeFileWithDir(tsPath, content, (err) => {
if (err) {
// eslint-disable-next-line no-undef
console.log(err);
}
// eslint-disable-next-line no-undef
console.log(`global-types file generated`);
});

View File

@@ -0,0 +1,31 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensures the use of the `import type` operator from the `src/core/models/index.ts` file.',
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
},
create: function (context) {
return {
ImportDeclaration: function (node) {
if (
node.source.parent.importKind !== 'type' &&
(node.source.value.endsWith('/models') || node.source.value === 'router-slot/model')
) {
const sourceCode = context.getSourceCode();
const nodeSource = sourceCode.getText(node);
context.report({
node,
message: 'Use `import type` instead of `import`.',
fix: (fixer) => fixer.replaceText(node, nodeSource.replace('import', 'import type')),
});
}
},
};
},
};

View File

@@ -0,0 +1,31 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Enforce Element class name to end with "Element".',
category: 'Naming',
recommended: true,
},
schema: [],
},
create: function (context) {
return {
ClassDeclaration(node) {
// check if the class extends HTMLElement, LitElement, or UmbLitElement
const isExtendingElement =
node.superClass && ['HTMLElement', 'LitElement', 'UmbLitElement'].includes(node.superClass.name);
// check if the class name ends with 'Element'
const isClassNameValid = node.id.name.endsWith('Element');
if (isExtendingElement && !isClassNameValid) {
context.report({
node,
message: "Element class name should end with 'Element'.",
// There us no fixer on purpose because it's not safe to rename the class. We want to do that trough the refactoring tool.
});
}
},
};
},
};

View File

@@ -0,0 +1,41 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Enforce Custom Element names to start with "umb-".',
category: 'Naming',
recommended: true,
},
schema: [],
},
create: function (context) {
return {
CallExpression(node) {
// check if the expression is @customElement decorator
const isCustomElementDecorator =
node.callee.type === 'Identifier' &&
node.callee.name === 'customElement' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'Literal' &&
typeof node.arguments[0].value === 'string';
if (isCustomElementDecorator) {
const elementName = node.arguments[0].value;
// check if the element name starts with 'umb-', or 'test-', to be allow tests to have custom elements:
const isElementNameValid = elementName.startsWith('umb-') ? true : elementName.startsWith('test-');
if (!isElementNameValid) {
context.report({
node,
message: 'Custom Element name should start with "umb-".',
// There is no fixer on purpose because it's not safe to automatically rename the element name.
// Renaming should be done manually with consideration of potential impacts.
});
}
}
},
};
},
};

View File

@@ -0,0 +1,50 @@
/** @type {import('eslint').Rule.RuleModule}*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensures that the application strictly uses node_modules imports from `@umbraco-cms/backoffice/external`. This is needed to run the application in the browser.',
recommended: true,
},
fixable: 'code',
schema: {
type: 'array',
minItems: 0,
maxItems: 1,
items: [
{
type: 'object',
properties: {
exceptions: { type: 'array' },
},
additionalProperties: false,
},
],
},
},
create: (context) => {
return {
ImportDeclaration: (node) => {
const { source } = node;
const { value } = source;
const options = context.options[0] || {};
const exceptions = options.exceptions || [];
// If import starts with any of the following, then it's allowed
if (exceptions.some((v) => value.startsWith(v))) {
return;
}
context.report({
node,
message:
'node_modules imports should be proxied through `@umbraco-cms/backoffice/external`. Please create it if it does not exist.',
fix: (fixer) =>
fixer.replaceText(source, `'@umbraco-cms/backoffice/external${value.startsWith('/') ? '' : '/'}${value}'`),
});
},
};
},
};

View File

@@ -0,0 +1,91 @@
/** @type {import('eslint').Rule.RuleModule}*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensures relative imports use the ".js" file extension.',
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
},
create: (context) => {
function correctImport(value) {
if (value === '.') {
return './index.js';
}
if (
value &&
value.startsWith('.') &&
!value.endsWith('.js') &&
!value.endsWith('.css') &&
!value.endsWith('.json') &&
!value.endsWith('.svg') &&
!value.endsWith('.jpg') &&
!value.endsWith('.png')
) {
return (value.endsWith('/') ? value + 'index' : value) + '.js';
}
return null;
}
return {
ImportDeclaration: (node) => {
const { source } = node;
const { value } = source;
const fixedValue = correctImport(value);
if (fixedValue) {
context.report({
node,
message: 'Relative imports should use the ".js" file extension.',
fix: (fixer) => fixer.replaceText(source, `'${fixedValue}'`),
});
}
},
ImportExpression: (node) => {
const { source } = node;
const { value } = source;
const fixedSource = correctImport(value);
if (fixedSource) {
context.report({
node: source,
message: 'Relative imports should use the ".js" file extension.',
fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`),
});
}
},
ExportAllDeclaration: (node) => {
const { source } = node;
const { value } = source;
const fixedSource = correctImport(value);
if (fixedSource) {
context.report({
node: source,
message: 'Relative exports should use the ".js" file extension.',
fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`),
});
}
},
ExportNamedDeclaration: (node) => {
const { source } = node;
if (!source) return;
const { value } = source;
const fixedSource = correctImport(value);
if (fixedSource) {
context.report({
node: source,
message: 'Relative exports should use the ".js" file extension.',
fix: (fixer) => fixer.replaceText(source, `'${fixedSource}'`),
});
}
},
};
},
};

View File

@@ -0,0 +1,54 @@
/** @type {import('eslint').Rule.RuleModule}*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure all exported string constants should be in uppercase with words separated by underscores and prefixed with UMB_',
},
schema: [
{
type: 'object',
properties: {
excludedFileNames: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
],
},
create: function (context) {
const excludedFileNames = context.options[0]?.excludedFileNames || [];
return {
ExportNamedDeclaration(node) {
const fileName = context.filename;
if (excludedFileNames.some((excludedFileName) => fileName.includes(excludedFileName))) {
// Skip the rule check for files in the excluded list
return;
}
if (node.declaration && node.declaration.type === 'VariableDeclaration') {
const declaration = node.declaration.declarations[0];
const { id, init } = declaration;
if (id && id.type === 'Identifier' && init && init.type === 'Literal' && typeof init.value === 'string') {
const isValidName = /^[A-Z]+(_[A-Z]+)*$/.test(id.name);
if (!isValidName || !id.name.startsWith('UMB_')) {
context.report({
node: id,
message:
'Exported string constant should be in uppercase with words separated by underscores and prefixed with UMB_',
});
}
}
}
},
};
},
};

View File

@@ -0,0 +1,42 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
docs: {
description:
'Ensures that any API resources from the `@umbraco-cms/backoffice/external/backend-api` module are not used directly. Instead you should use the `tryExecuteAndNotify` function from the `@umbraco-cms/backoffice/resources` module.',
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
},
create: function (context) {
return {
// If methods called on *Service classes are not already wrapped with `await tryExecuteAndNotify()`, then we should suggest to wrap them.
CallExpression: function (node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name.endsWith('Service') &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name !== 'constructor'
) {
const hasTryExecuteAndNotify =
node.parent &&
node.parent.callee &&
(node.parent.callee.name === 'tryExecute' || node.parent.callee.name === 'tryExecuteAndNotify');
if (!hasTryExecuteAndNotify) {
context.report({
node,
message: 'Wrap this call with `tryExecuteAndNotify()`. Make sure to `await` the result.',
fix: (fixer) => [
fixer.insertTextBefore(node, 'tryExecuteAndNotify(this, '),
fixer.insertTextAfter(node, ')'),
],
});
}
}
},
};
},
};

View File

@@ -0,0 +1,56 @@
const fs = require('fs');
const path = require('path');
const getDirectories = (source) =>
fs
.readdirSync(source, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
// TODO: get the correct list of modules. This is a temporary solution where we assume that a directory is equivalent to a module
// TODO: include package modules in this list
const coreRoot = path.join(__dirname, '../../../', 'src/packages/core');
const externalRoot = path.join(__dirname, '../../../', 'src/external');
const libsRoot = path.join(__dirname, '../../../', 'src/libs');
const coreModules = getDirectories(coreRoot).map((dir) => `/core/${dir}/`);
const externalModules = getDirectories(externalRoot).map((dir) => `/${dir}/`);
const libsModules = getDirectories(libsRoot).map((dir) => `/${dir}/`);
const modulePathIdentifiers = [...coreModules, ...externalModules, ...libsModules];
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Prevent relative import to a module that is in the import map.',
category: 'Best Practices',
recommended: true,
},
schema: [],
messages: {
unexpectedValue: 'Relative import paths should include "{{value}}".',
},
},
create: function (context) {
return {
ImportDeclaration(node) {
// exclude test and story files
if (context.filename.endsWith('.test.ts') || context.filename.endsWith('.stories.ts')) {
return {};
}
const importPath = node.source.value;
if (importPath.startsWith('./') || importPath.startsWith('../')) {
if (modulePathIdentifiers.some((moduleName) => importPath.includes(moduleName))) {
context.report({
node,
message: 'Use the correct import map alias instead of a relative import path: ' + importPath,
});
}
}
},
};
},
};

View File

@@ -0,0 +1,26 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Ensures that the application does not rely on file system paths for imports. Instead, use import aliases or relative imports. This also solves a problem where GitHub fails on the test runner step.',
category: 'Best Practices',
recommended: true,
},
schema: [],
},
create: function (context) {
return {
ImportDeclaration: function (node) {
if (node.source.value.startsWith('src/')) {
context.report({
node,
message:
'Prefer using import aliases or relative imports instead of absolute imports. Example: `import { MyComponent } from "src/components/MyComponent";` should be `import { MyComponent } from "@components/MyComponent";`',
});
}
},
};
},
};

View File

@@ -0,0 +1,46 @@
/** @type {import('eslint').Rule.RuleModule}*/
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Enforce the "styles" property with the static modifier to be the last property of a class that ends with "Element".',
category: 'Best Practices',
recommended: true,
},
fixable: 'code',
schema: [],
},
create: function (context) {
return {
ClassDeclaration(node) {
const className = node.id.name;
if (className.endsWith('Element')) {
const staticStylesProperty = node.body.body.find((bodyNode) => {
return bodyNode.type === 'PropertyDefinition' && bodyNode.key.name === 'styles' && bodyNode.static;
});
if (staticStylesProperty) {
const lastProperty = node.body.body[node.body.body.length - 1];
if (lastProperty.key.name !== staticStylesProperty.key.name) {
context.report({
node: staticStylesProperty,
message: 'The "styles" property should be the last property of a class declaration.',
data: {
className: className,
},
fix: function (fixer) {
const sourceCode = context.getSourceCode();
const staticStylesPropertyText = sourceCode.getText(staticStylesProperty);
return [
fixer.replaceTextRange(staticStylesProperty.range, ''),
fixer.insertTextAfterRange(lastProperty.range, '\n \n ' + staticStylesPropertyText),
];
},
});
}
}
}
},
};
},
};

View File

@@ -0,0 +1,26 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure that all class declarations are prefixed with "Umb"',
category: 'Best Practices',
recommended: true,
},
schema: [],
},
create: function (context) {
function checkClassName(node) {
if (node.id && node.id.name && !node.id.name.startsWith('Umb')) {
context.report({
node: node.id,
message: 'Class declaration should be prefixed with "Umb"',
});
}
}
return {
ClassDeclaration: checkClassName,
};
},
};

View File

@@ -0,0 +1,49 @@
import * as readline from 'readline';
import { execSync } from 'child_process';
import { readdir } from 'fs/promises';
const exampleDirectory = 'examples';
const getDirectories = async (source) =>
(await readdir(source, { withFileTypes: true }))
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
async function pickExampleUI(){
// Find sub folder:
const exampleFolderNames = await getDirectories(`${exampleDirectory}`);
// Create UI:
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// List examples:
console.log('Please select an example by entering the corresponding number:');
exampleFolderNames.forEach((folder, index) => {
console.log(`[${index + 1}] ${folder}`);
});
// Ask user to select an example:
rl.question('Enter your selection: ', (answer) => {
// User picked an example:
const selectedFolder = exampleFolderNames[parseInt(answer) - 1];
console.log(`You selected: ${selectedFolder}`);
process.env['VITE_EXAMPLE_PATH'] = `${exampleDirectory}/${selectedFolder}`;
// Start vite server:
try {
execSync('npm run dev', {stdio: 'inherit'});
} catch (error) {
// Nothing, cause this is most likely just the server begin stopped.
//console.log(error);
}
});
};
pickExampleUI();

View File

@@ -0,0 +1,203 @@
import { readFileSync, writeFile, mkdir, rmSync } from 'fs';
import * as globModule from 'tiny-glob';
import * as pathModule from 'path';
const path = pathModule.default;
const getDirName = path.dirname;
const glob = globModule.default;
const moduleDirectory = 'src/packages/core/icon-registry';
const iconsOutputDirectory = `${moduleDirectory}/icons`;
const umbracoSvgDirectory = `${moduleDirectory}/svgs`;
const iconMapJson = `${moduleDirectory}/icon-dictionary.json`;
const lucideSvgDirectory = 'node_modules/lucide-static/icons';
const simpleIconsSvgDirectory = 'node_modules/simple-icons/icons';
const run = async () => {
// Empty output directory:
rmSync(iconsOutputDirectory, { recursive: true });
var icons = await collectDictionaryIcons();
icons = await collectDiskIcons(icons);
writeIconsToDisk(icons);
generateJS(icons);
};
const collectDictionaryIcons = async () => {
const rawData = readFileSync(iconMapJson);
const fileRaw = rawData.toString();
const fileJSON = JSON.parse(fileRaw);
let icons = [];
// Lucide:
fileJSON.lucide.forEach((iconDef) => {
if (iconDef.file && iconDef.name) {
const path = lucideSvgDirectory + '/' + iconDef.file;
try {
const rawData = readFileSync(path);
// For Lucide icons specially we adjust the icons a bit for them to work in our case: [NL]
let svg = rawData.toString().replace(' width="24"\n', '');
svg = svg.replace(' height="24"\n', '');
svg = svg.replace('stroke-width="2"', 'stroke-width="1.75"');
const iconFileName = iconDef.name;
const icon = {
name: iconDef.name,
legacy: iconDef.legacy,
fileName: iconFileName,
svg,
output: `${iconsOutputDirectory}/${iconFileName}.ts`,
};
icons.push(icon);
} catch (e) {
console.log(`[Lucide] Could not load file: '${path}'`);
}
}
});
// SimpleIcons:
fileJSON.simpleIcons.forEach((iconDef) => {
if (iconDef.file && iconDef.name) {
const path = simpleIconsSvgDirectory + '/' + iconDef.file;
try {
const rawData = readFileSync(path);
let svg = rawData.toString();
const iconFileName = iconDef.name;
// SimpleIcons need to use fill="currentColor"
const pattern = /fill=/g;
if (!pattern.test(svg)) {
svg = svg.replace(/<path/g, '<path fill="currentColor"');
}
const icon = {
name: iconDef.name,
legacy: iconDef.legacy,
fileName: iconFileName,
svg,
output: `${iconsOutputDirectory}/${iconFileName}.ts`,
};
icons.push(icon);
} catch (e) {
console.log(`[SimpleIcons] Could not load file: '${path}'`);
}
}
});
// Umbraco:
fileJSON.umbraco.forEach((iconDef) => {
if (iconDef.file && iconDef.name) {
const path = umbracoSvgDirectory + '/' + iconDef.file;
try {
const rawData = readFileSync(path);
const svg = rawData.toString();
const iconFileName = iconDef.name;
const icon = {
name: iconDef.name,
legacy: iconDef.legacy,
fileName: iconFileName,
svg,
output: `${iconsOutputDirectory}/${iconFileName}.ts`,
};
icons.push(icon);
} catch (e) {
console.log(`[Umbraco] Could not load file: '${path}'`);
}
}
});
return icons;
};
const collectDiskIcons = async (icons) => {
const iconPaths = await glob(`${umbracoSvgDirectory}/icon-*.svg`);
iconPaths.forEach((path) => {
const rawData = readFileSync(path);
const svg = rawData.toString();
const parsed = pathModule.parse(path);
if (!parsed) {
console.log('No match found for: ', path);
return;
}
const SVGFileName = parsed.name;
const iconFileName = SVGFileName.replace('.svg', '');
const iconName = iconFileName;
// Only append not already defined icons:
if (!icons.find((x) => x.name === iconName)) {
const icon = {
name: iconName,
legacy: true,
fileName: iconFileName,
svg,
output: `${iconsOutputDirectory}/${iconFileName}.ts`,
};
icons.push(icon);
}
});
return icons;
};
const writeIconsToDisk = (icons) => {
icons.forEach((icon) => {
const content = 'export default `' + icon.svg + '`;';
writeFileWithDir(icon.output, content, (err) => {
if (err) {
// eslint-disable-next-line no-undef
console.log(err);
}
// eslint-disable-next-line no-undef
//console.log(`icon: ${icon.name} generated`);
});
});
};
const generateJS = (icons) => {
const JSPath = `${moduleDirectory}/icons.ts`;
const iconDescriptors = icons.map((icon) => {
return `{
name: "${icon.name}",
${icon.legacy ? 'legacy: true,' : ''}
path: () => import("./icons/${icon.fileName}.js"),
}`.replace(/\t/g, ''); // Regex removes white space [NL]
});
const content = `export default [${iconDescriptors.join(',')}];`;
writeFileWithDir(JSPath, content, (err) => {
if (err) {
// eslint-disable-next-line no-undef
console.log(err);
}
// eslint-disable-next-line no-undef
console.log('Icons outputted and Icon Manifests generated!');
});
};
const writeFileWithDir = (path, contents, cb) => {
mkdir(getDirName(path), { recursive: true }, function (err) {
if (err) return cb(err);
writeFile(path, contents, cb);
});
};
run();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
import { packageJsonExports, packageJsonName } from '../package/index.js';
export const createImportMap = (args) => {
const imports = {
...args.additionalImports,
};
// Iterate over the exports in package.json
for (const [key, value] of Object.entries(packageJsonExports || {})) {
// remove leading ./
if (value && value.endsWith('.js')) {
const moduleName = key.replace(/^\.\//, '');
// replace ./dist-cms with src and remove /index.js
let modulePath = value;
if (typeof args.rootDir !== 'undefined') modulePath = modulePath.replace(/^\.\/dist-cms/, args.rootDir);
if (args.replaceModuleExtensions) modulePath = modulePath.replace('.js', '.ts');
console.log('replacing', value, 'with', modulePath);
const importAlias = `${packageJsonName}/${moduleName}`;
imports[importAlias] = modulePath;
}
}
return {
imports,
};
};

View File

@@ -0,0 +1,22 @@
import { writeFileSync } from 'fs';
import { createImportMap } from '../importmap/index.js';
const tsPath = './src/json-schema/all-packages.ts';
const importmap = createImportMap({
rootDir: './src',
replaceModuleExtensions: true,
});
const paths = Object.keys(importmap.imports);
const content = `
${
paths.map(path => `import '${path}';`).join('\n')
}
`;
//const config = await resolveConfig('./.prettierrc.json');
//const formattedContent = await format(content, { ...config, parser: 'json' });
writeFileSync(tsPath, content);

View File

@@ -0,0 +1,66 @@
/**
* This script is used to compare the keys in the localization files. It will take a main language (en.js) and compare with the other languages.
* The script will output the keys that are missing in the other languages and the keys that are missing in the main language.
*
* Note: Since the source files are TypeScript files, the script will only compare on the dist-cms files.
*
* Usage: node devops/localization/compare-languages.js [filter]
* Example: node devops/localization/compare-languages.js da-dk.js
*
* Copyright (c) 2024 by Umbraco HQ
*/
import fs from 'fs';
import path from 'path';
const mainLanguage = 'en.js';
const __dirname = import.meta.dirname;
const languageFolder = path.join(__dirname, '../../dist-cms/assets/lang');
// Check that the languageFolder exists
if (!fs.existsSync(languageFolder)) {
console.error(`The language folder does not exist: ${languageFolder}. You need to build the project first by running 'npm run build'`);
process.exit(1);
}
const mainKeys = (await import(path.join(languageFolder, mainLanguage))).default;
const mainMap = buildMap(mainKeys);
const filter = process.argv[2];
if (filter) {
console.log(`Filtering on: ${filter}`);
}
const languages = fs.readdirSync(languageFolder).filter((file) => file !== mainLanguage && file.endsWith('.js') && (!filter || file.includes(filter)));
const missingKeysInMain = [];
const languagePromise = Promise.all(languages.map(async (language) => {
const languageKeys = (await import(path.join(languageFolder, language))).default;
const languageMap = buildMap(languageKeys);
const missingKeys = Array.from(mainMap.keys()).filter((key) => !languageMap.has(key));
let localMissingKeysInMain = Array.from(languageMap.keys()).filter((key) => !mainMap.has(key));
localMissingKeysInMain = localMissingKeysInMain.map((key) => `${key} (${language})`);
missingKeysInMain.push(...localMissingKeysInMain);
console.log(`\n${language}:`);
console.log(`Missing keys in ${language}:`);
console.log(missingKeys);
}));
await languagePromise;
console.log(`Missing keys in ${mainLanguage}:`);
console.log(missingKeysInMain);
function buildMap(keys) {
const map = new Map();
for (const key in keys) {
for (const subKey in keys[key]) {
map.set(`${key}_${subKey}`, keys[key][subKey]);
}
}
return map;
}

View File

@@ -0,0 +1,64 @@
/**
* This script is used to find unused language keys in the javascript files. It will take a main language (en.js) and compare with the other languages.
*
* Usage: node devops/localization/unused-language-keys.js
* Example: node devops/localization/unused-language-keys.js
*
* Copyright (c) 2024 by Umbraco HQ
*/
import fs from 'fs';
import path from 'path';
import glob from 'tiny-glob';
const mainLanguage = 'en.js';
const __dirname = import.meta.dirname;
const languageFolder = path.join(__dirname, '../../dist-cms/assets/lang');
// Check that the languageFolder exists
if (!fs.existsSync(languageFolder)) {
console.error(`The language folder does not exist: ${languageFolder}. You need to build the project first by running 'npm run build'`);
process.exit(1);
}
const mainKeys = (await import(path.join(languageFolder, mainLanguage))).default;
const mainMap = buildMap(mainKeys);
const keys = Array.from(mainMap.keys());
const usedKeys = new Set();
const elementAndControllerFiles = await glob(`${__dirname}/../../src/**/*.ts`, { filesOnly: true });
console.log(`Checking ${elementAndControllerFiles.length} files for unused keys`);
// Find all the keys used in the javascript files
const filePromise = Promise.all(elementAndControllerFiles.map(async (file) => {
// Check if each key is in the file (simple)
const fileContent = fs.readFileSync(file, 'utf8');
keys.forEach((key) => {
if (fileContent.includes(key)) {
usedKeys.add(key);
}
});
}));
await filePromise;
const unusedKeys = Array.from(mainMap.keys()).filter((key) => !usedKeys.has(key));
console.log(`\n${mainLanguage}:`);
console.log(`Used keys in ${mainLanguage}:`);
console.log(usedKeys);
console.log(`Unused keys in ${mainLanguage}:`);
console.log(unusedKeys);
function buildMap(keys) {
const map = new Map();
for (const key in keys) {
for (const subKey in keys[key]) {
map.set(`${key}_${subKey}`, keys[key][subKey]);
}
}
return map;
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
client: 'fetch',
input: 'https://raw.githubusercontent.com/umbraco/Umbraco-CMS/v15/dev/src/Umbraco.Cms.Api.Management/OpenApi.json',
output: {
path: 'src/external/backend-api/src',
format: 'prettier',
lint: 'eslint',
},
schemas: false,
services: {
asClass: true,
},
types: {
enums: 'typescript',
},
});

View File

@@ -0,0 +1,19 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
client: 'fetch',
debug: true,
input: 'http://localhost:11000/umbraco/swagger/management/swagger.json',
output: {
path: 'src/external/backend-api/src',
format: 'prettier',
lint: 'eslint',
},
schemas: false,
services: {
asClass: true,
},
types: {
enums: 'typescript',
},
});

View File

@@ -0,0 +1,19 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
client: 'fetch',
debug: true,
input: '../Umbraco.Cms.Api.Management/OpenApi.json',
output: {
path: 'src/external/backend-api/src',
format: 'prettier',
lint: 'eslint',
},
schemas: false,
services: {
asClass: true,
},
types: {
enums: 'typescript',
},
});

View File

@@ -0,0 +1 @@
export * from './meta.js';

View File

@@ -0,0 +1,7 @@
import { readFileSync } from 'fs';
export const packageJsonPath = 'package.json';
export const packageJsonData = JSON.parse(readFileSync(packageJsonPath).toString());
export const packageJsonName = packageJsonData.name;
export const packageJsonVersion = packageJsonData.version;
export const packageJsonExports = packageJsonData.exports;

View File

@@ -0,0 +1,26 @@
import { globSync } from 'glob';
import { packageJsonExports } from './meta.js';
const validateExports = async () => {
const errors = [];
// Iterate over the exports in package.json
for (const [key, value] of Object.entries(packageJsonExports || {})) {
if (value) {
const jsFiles = await globSync(value);
// Log an error if the export from the package.json does not exist in the build output
if (jsFiles.length === 0) {
errors.push(`Could not find export: ${key} -> ${value} in the build output.`);
}
}
}
if (errors.length > 0) {
throw new Error(errors.join('\n'));
} else {
console.log('--- Exports validated successfully. ---');
}
};
validateExports();

View File

@@ -0,0 +1,16 @@
import { readFileSync, writeFileSync } from 'fs';
console.log('[Prepublish] Cleansing package.json');
const packageFile = './package.json';
const packageJson = JSON.parse(readFileSync(packageFile, 'utf8'));
// Remove all DevDependencies
delete packageJson.devDependencies;
// Rename dependencies to peerDependencies
packageJson.peerDependencies = { ...packageJson.dependencies };
delete packageJson.dependencies;
// Write the package.json back to disk
writeFileSync(packageFile, JSON.stringify(packageJson, null, 2), 'utf8');

View File

@@ -0,0 +1,111 @@
// Notice: This script is not perfect and may not work in all cases. ex. it places the override term wrong for async and setter/getter methods. But its a help any way. [NL]
import ts from 'typescript';
import path from 'node:path';
import fs from 'node:fs/promises';
const tsconfigPath = './tsconfig.json';
const cwd = process.cwd();
async function fixOverride() {
const configFile = path.isAbsolute(tsconfigPath)
? tsconfigPath
: ts.findConfigFile(cwd, ts.sys.fileExists, tsconfigPath);
if (!configFile) {
console.error('No tsconfig file found for path:', tsconfigPath);
process.exit(1);
}
const config = ts.readConfigFile(configFile, ts.sys.readFile);
const { options, fileNames } = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
// Resolve to the folder where the tsconfig file located
path.dirname(tsconfigPath),
);
const program = ts.createProgram({
rootNames: fileNames,
options,
});
if (fileNames.length === 0) {
console.error('No files in the project.', {
fileNames,
options,
});
process.exit(1);
}
let emitResult = program.emit();
const overrideErrors = ts
.getPreEmitDiagnostics(program)
.concat(emitResult.diagnostics)
.filter((diagnostic) =>
[
// This member must have an 'override' modifier because it overrides a member in the base class '{0}'.
4114,
// This parameter property must have an 'override' modifier because it overrides a member in base class '{0}'
4115,
// This member must have an 'override' modifier because it overrides an abstract method that is declared in the base class '{0}'.
4116,
].includes(diagnostic.code),
);
const sortedErrors = sortErrors(overrideErrors);
for (const diagnostic of sortedErrors) {
await addOverride(diagnostic);
}
}
/**
*
* @param {ts.Diagnostic} diagnostic
* @returns {Promise<void>}
*/
async function addOverride(diagnostic) {
const fileContent = (await fs.readFile(diagnostic.file.fileName, 'utf-8')).toString();
let startIndex = diagnostic.start;
if (fileContent.slice(0, startIndex).endsWith(' get ')) {
startIndex -= 'get '.length;
}
if (fileContent.slice(0, startIndex).endsWith(' async ')) {
startIndex -= 'async '.length;
}
if (fileContent.slice(0, startIndex).endsWith(' readonly ')) {
startIndex -= 'readonly '.length;
}
const newFileContent = [fileContent.slice(0, startIndex), 'override ', fileContent.slice(startIndex)].join('');
await fs.writeFile(diagnostic.file.fileName, newFileContent);
}
/**
*
* @param {ts.Diagnostic[]} errors
* @returns {ts.Diagnostic[]}
*/
function sortErrors(errors) {
// Sort by file path and start position from end to start
// so we can insert override keyword without changing the start position of other errors in the same file that happen before
return errors.slice(0).sort((a, b) => {
if (a.file && b.file) {
if (a.file.fileName === b.file.fileName) {
return b.start - a.start;
}
return a.file.fileName.localeCompare(b.file.fileName);
}
return 0;
});
}
fixOverride();

View File

@@ -0,0 +1,74 @@
import { writeFileSync } from 'fs';
import { format, resolveConfig } from 'prettier';
import { createImportMap } from '../importmap/index.js';
const tsconfigPath = 'tsconfig.json';
const tsconfigComment = `
/* -------------------------------------------------------------------------
DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js
--------------------------------------------------------------------------- */
`;
const tsConfigBase = {
compilerOptions: {
module: 'esnext',
moduleResolution: 'bundler',
moduleDetection: 'force',
verbatimModuleSyntax: true,
target: 'es2022',
lib: ['es2022', 'dom', 'dom.iterable'],
outDir: './types',
allowSyntheticDefaultImports: true,
experimentalDecorators: true,
forceConsistentCasingInFileNames: true,
useDefineForClassFields: false,
baseUrl: '.',
incremental: true,
skipLibCheck: true,
noImplicitOverride: true,
allowImportingTsExtensions: true,
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
/* Linting */
strict: true,
noFallthroughCasesInSwitch: true,
noImplicitReturns: true,
},
include: ['src/**/*.ts', 'apps/**/*.ts', 'e2e/**/*.ts', 'index.ts', 'storybook/stories/**/*.ts', 'examples/**/*.ts'],
references: [
{
path: './tsconfig.node.json',
},
],
};
const importmap = createImportMap({
rootDir: './src',
additionalImports: {
'@umbraco-cms/internal/test-utils': './utils/test-utils.ts',
},
replaceModuleExtensions: true,
});
const paths = {};
for (const [key, value] of Object.entries(importmap.imports)) {
const valueAsArray = [value];
paths[key] = valueAsArray;
}
tsConfigBase.compilerOptions.paths = paths;
const content = tsconfigComment + JSON.stringify(tsConfigBase, null, ' ');
const config = await resolveConfig('./.prettierrc.json');
const formattedContent = await format(content, { ...config, parser: 'json' });
writeFileSync(tsconfigPath, formattedContent);

View File

@@ -0,0 +1,13 @@
import { writeFile, mkdir } from 'fs';
import * as pathModule from 'path';
const path = pathModule.default;
const getDirName = path.dirname;
export const writeFileWithDir = (path, contents, cb) => {
mkdir(getDirName(path), { recursive: true }, function (err) {
if (err) return cb(err);
writeFile(path, contents, cb);
});
};

View File

@@ -0,0 +1,95 @@
const { rest } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import {
type ProblemDetails,
RuntimeLevelModel,
type ServerStatusResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import { expect, test } from './test.js';
test.describe('installer tests', () => {
test.beforeEach(async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.get(umbracoPath('/server/status'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(200),
ctx.json<ServerStatusResponseModel>({
serverStatus: RuntimeLevelModel.INSTALL,
}),
);
}),
);
await page.goto('/install');
await page.waitForSelector('[data-test="installer"]');
});
test('installer is shown', async ({ page }) => {
await expect(page).toHaveURL('/install');
});
test.describe('test success and failure', () => {
test.beforeEach(async ({ page }) => {
await page.waitForSelector('[data-test="installer-user"]');
await page.fill('[aria-label="name"]', 'Test');
await page.fill('[aria-label="email"]', 'test@umbraco');
await page.fill('[aria-label="password"]', 'test123456');
await page.click('[name="subscribeToNewsletter"]');
// Go to the next step
await page.click('[aria-label="Next"]');
// Set telemetry
await page.waitForSelector('[data-test="installer-telemetry"]');
await page.waitForSelector('uui-slider[name="telemetryLevel"]');
// Click [aria-label="Next"]
await page.click('[aria-label="Next"]');
// Database form
await page.waitForSelector('[data-test="installer-database"]');
});
test('installer completes successfully', async ({ page }) => {
await page.click('[aria-label="Install"]');
await page.waitForSelector('umb-backoffice', { timeout: 30000 });
});
test('installer fails', async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.post(umbracoPath('/install/setup'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(400),
ctx.json<ProblemDetails>({
status: 400,
type: 'validation',
detail: 'Something went wrong',
errors: {
databaseName: ['The database name is required'],
},
}),
);
}),
);
await page.click('[aria-label="Install"]');
await page.waitForSelector('[data-test="installer-error"]');
await expect(page.locator('[data-test="error-message"]')).toHaveText('Something went wrong', {
useInnerText: true,
});
// Click reset button
await page.click('#button-reset');
await page.waitForSelector('[data-test="installer-user"]');
});
});
});

View File

@@ -0,0 +1,4 @@
{
"name": "backoffice-e2e",
"type": "commonjs"
}

View File

@@ -0,0 +1,20 @@
import { expect, test as base } from '@playwright/test';
import { createWorkerFixture } from 'playwright-msw';
import type { MockServiceWorker } from 'playwright-msw';
import { handlers } from '../src/mocks/e2e-handlers.js';
const test = base.extend<{
worker: MockServiceWorker;
}>({
worker: createWorkerFixture(handlers),
page: async ({ page }, use) => {
// Set is-authenticated in sessionStorage to true
await page.addInitScript(`window.sessionStorage.setItem('is-authenticated', 'true');`);
// Use signed-in page in all tests
await use(page);
},
});
export { test, expect };

View File

@@ -0,0 +1,65 @@
const { rest } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import {
type ProblemDetails,
RuntimeLevelModel,
type ServerStatusResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import { expect, test } from './test.js';
test.describe('upgrader tests', () => {
test.beforeEach(async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.get(umbracoPath('/server/status'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(200),
ctx.json<ServerStatusResponseModel>({
serverStatus: RuntimeLevelModel.UPGRADE,
}),
);
}),
);
await page.goto('/upgrade');
});
test('upgrader is shown', async ({ page }) => {
await page.waitForSelector('[data-test="upgrader"]');
await expect(page).toHaveURL('/upgrade');
await expect(page.locator('h1')).toHaveText('Upgrading Umbraco', { useInnerText: true });
});
test('upgrader has a "View Report" button', async ({ page }) => {
await expect(page.locator('[data-test="view-report-button"]')).toBeVisible();
});
test('upgrader completes successfully', async ({ page }) => {
await page.click('[data-test="continue-button"]');
await page.waitForSelector('umb-backoffice', { timeout: 30000 });
});
test('upgrader fails and shows error', async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.post(umbracoPath('/upgrade/authorize'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(400),
ctx.json<ProblemDetails>({
status: 400,
type: 'error',
detail: 'Something went wrong',
}),
);
}),
);
await page.click('[data-test="continue-button"]');
await expect(page.locator('[data-test="error-message"]')).toHaveText('Something went wrong', {
useInnerText: true,
});
});
});

View File

@@ -0,0 +1,27 @@
'use strict';
const badTypeImportRule = require('./devops/eslint/rules/bad-type-import.cjs');
const enforceElementSuffixOnElementClassNameRule = require('./devops/eslint/rules/enforce-element-suffix-on-element-class-name.cjs');
const enforceUmbPrefixOnElementNameRule = require('./devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs');
const enforceUmbracoExternalImportsRule = require('./devops/eslint/rules/enforce-umbraco-external-imports.cjs');
const ensureRelativeImportUseJsExtensionRule = require('./devops/eslint/rules/ensure-relative-import-use-js-extension.cjs');
const exportedStringConstantNaming = require('./devops/eslint/rules/exported-string-constant-naming.cjs');
const noDirectApiImportRule = require('./devops/eslint/rules/no-direct-api-import.cjs');
const preferImportAliasesRule = require('./devops/eslint/rules/prefer-import-aliases.cjs');
const preferStaticStylesLastRule = require('./devops/eslint/rules/prefer-static-styles-last.cjs');
const umbClassPrefixRule = require('./devops/eslint/rules/umb-class-prefix.cjs');
const noRelativeImportToImportMapModule = require('./devops/eslint/rules/no-relative-import-to-import-map-module.cjs');
module.exports = {
'bad-type-import': badTypeImportRule,
'enforce-element-suffix-on-element-class-name': enforceElementSuffixOnElementClassNameRule,
'enforce-umb-prefix-on-element-name': enforceUmbPrefixOnElementNameRule,
'enforce-umbraco-external-imports': enforceUmbracoExternalImportsRule,
'ensure-relative-import-use-js-extension': ensureRelativeImportUseJsExtensionRule,
'exported-string-constant-naming': exportedStringConstantNaming,
'no-direct-api-import': noDirectApiImportRule,
'prefer-import-aliases': preferImportAliasesRule,
'prefer-static-styles-last': preferStaticStylesLastRule,
'umb-class-prefix': umbClassPrefixRule,
'no-relative-import-to-import-map-module': noRelativeImportToImportMapModule,
};

View File

@@ -0,0 +1,94 @@
import js from '@eslint/js';
import globals from 'globals';
import importPlugin from 'eslint-plugin-import';
import localRules from 'eslint-plugin-local-rules';
import wcPlugin from 'eslint-plugin-wc';
import litPlugin from 'eslint-plugin-lit';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import tseslint from 'typescript-eslint';
import jsdoc from 'eslint-plugin-jsdoc';
export default [
// Recommended config applied to all files
js.configs.recommended,
...tseslint.configs.recommended,
wcPlugin.configs['flat/recommended'],
litPlugin.configs['flat/recommended'],
jsdoc.configs['flat/recommended'], // We use the non typescript version to allow types to be defined in the jsdoc comments. This will allow js docs as an alternative to typescript types.
localRules.configs.all,
eslintPluginPrettierRecommended,
// Global ignores
{
ignores: [
'**/eslint.config.js',
'**/rollup.config.js',
'**/vite.config.ts',
'src/external',
'src/packages/core/icon-registry/icons',
'src/packages/core/icon-registry/icons.ts',
'src/**/*.test.ts',
],
},
// Global config
{
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.browser,
},
},
plugins: {
import: importPlugin,
'local-rules': localRules,
},
rules: {
semi: ['warn', 'always'],
'prettier/prettier': ['warn', { endOfLine: 'auto' }],
'no-unused-vars': 'off', //Let '@typescript-eslint/no-unused-vars' catch the errors to allow unused function parameters (ex: in interfaces)
'no-var': 'error',
...importPlugin.configs.recommended.rules,
'import/namespace': 'off',
'import/no-unresolved': 'off',
'import/order': ['warn', { groups: ['builtin', 'parent', 'sibling', 'index', 'external'] }],
'import/no-self-import': 'error',
'import/no-cycle': ['error', { maxDepth: 6, allowUnsafeDynamicCyclicDependency: true }],
'import/no-named-as-default': 'off', // Does not work with eslint 9
'import/no-named-as-default-member': 'off', // Does not work with eslint 9
'local-rules/prefer-static-styles-last': 'warn',
'local-rules/enforce-umbraco-external-imports': [
'error',
{
exceptions: ['@umbraco-cms', '@open-wc/testing', '@storybook', 'msw', '.', 'vite'],
},
],
'local-rules/exported-string-constant-naming': [
'error',
{
excludedFileNames: ['umbraco-package', 'input-tiny-mce.defaults'], // TODO: what to do about the tiny mce defaults?
},
],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-import-type-side-effects': 'warn',
},
},
// Pattern-specific overrides
{
files: ['**/*.js'],
...tseslint.configs.disableTypeChecked,
languageOptions: {
globals: {
...globals.node,
},
},
},
];

View File

@@ -0,0 +1,7 @@
# Backoffice Examples
This folder contains example packages showcasing the usage of extensions in Backoffice.
The purpose of these projects includes serving as demonstration or example for
packages, as well as testing to make sure the extension points continue
to work in these situations and to assist in developing new integrations.

View File

@@ -0,0 +1,7 @@
# Property Dataset Dashboard Example
This example is a work in progress example of how to write a property editor.
This example covers a few points:
- Using an existing Property Editor Schema

View File

@@ -0,0 +1,57 @@
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { html, customElement, LitElement, property, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockEditorCustomViewElement } from '@umbraco-cms/backoffice/block-custom-view';
// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name
@customElement('example-block-custom-view')
// eslint-disable-next-line local-rules/umb-class-prefix
export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implements UmbBlockEditorCustomViewElement {
//
@property({ attribute: false })
content?: UmbBlockDataType;
@property({ attribute: false })
settings?: UmbBlockDataType;
override render() {
return html`
<div class="uui-text ${this.settings?.blockAlignment ? 'align-' + this.settings?.blockAlignment : undefined}">
<h5 class="uui-text">My Custom View</h5>
<p>Headline: ${this.content?.headline}</p>
<p>Alignment: ${this.settings?.blockAlignment}</p>
</div>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
height: 100%;
box-sizing: border-box;
background-color: red;
color: white;
border-radius: 9px;
padding: 12px;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
`,
];
}
export default ExampleBlockCustomView;
declare global {
interface HTMLElementTagNameMap {
'example-block-custom-view': ExampleBlockCustomView;
}
}

View File

@@ -0,0 +1,10 @@
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'blockEditorCustomView',
alias: 'Umb.blockEditorCustomView.TestView',
name: 'Block Editor Custom View Test',
element: () => import('./block-custom-view.js'),
forContentTypeAlias: 'headlineUmbracoDemoBlock',
forBlockEditor: ['block-list', 'block-grid'],
},
];

View File

@@ -0,0 +1,46 @@
import { EXAMPLE_MODAL_TOKEN, type ExampleModalData, type ExampleModalResult } from './example-modal-token.js';
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import './example-custom-modal-element.element.js';
@customElement('example-custom-modal-dashboard')
export class UmbExampleCustomModalDashboardElement extends UmbLitElement {
#modalManagerContext? : typeof UMB_MODAL_MANAGER_CONTEXT.TYPE;
constructor() {
super();
this.consumeContext(UMB_MODAL_MANAGER_CONTEXT,(instance)=>{
this.#modalManagerContext = instance;
})
}
#onOpenModal(){
this.#modalManagerContext?.open(this,EXAMPLE_MODAL_TOKEN,{})
}
override render() {
return html`
<uui-box>
<p>Open the custom modal</p>
<uui-button look="primary" @click=${this.#onOpenModal}>Open Modal</uui-button>
</uui-box>
`;
}
static override styles = [css`
:host{
display:block;
padding:20px;
}
`];
}
export default UmbExampleCustomModalDashboardElement
declare global {
interface HTMLElementTagNameMap {
'example-custom-modal-dashboard': UmbExampleCustomModalDashboardElement;
}
}

View File

@@ -0,0 +1,50 @@
import { css, html } from "@umbraco-cms/backoffice/external/lit";
import { defineElement, UUIModalElement } from "@umbraco-cms/backoffice/external/uui";
/**
* This class defines a custom design for the modal it self, in the same was as
* UUIModalSidebarElement and UUIModalDialogElement.
*/
@defineElement('example-modal-element')
export class UmbExampleCustomModalElement extends UUIModalElement {
override render() {
return html`
<dialog>
<h2>Custom Modal-wrapper</h2>
<slot></slot>
</dialog>
`;
}
static override styles = [
...UUIModalElement.styles,
css`
dialog {
width:100%;
height:100%;
max-width: 100%;
max-height: 100%;
top:0;
left:0;
right:0;
bottom:0;
background:#fff;
}
:host([index='0']) dialog {
box-shadow: var(--uui-shadow-depth-5);
}
:host(:not([index='0'])) dialog {
outline: 1px solid rgba(0, 0, 0, 0.1);
}
`,
];
}
export default UmbExampleCustomModalElement;
declare global {
interface HTMLElementTagNameMap {
'example-modal-element': UmbExampleCustomModalElement;
}
}

View File

@@ -0,0 +1,19 @@
import { UmbModalToken } from "@umbraco-cms/backoffice/modal";
export interface ExampleModalData {
unique: string | null;
}
export interface ExampleModalResult {
text : string;
}
export const EXAMPLE_MODAL_TOKEN = new UmbModalToken<
ExampleModalData,
ExampleModalResult
>('example.modal.custom.element', {
modal : {
type : 'custom',
element: () => import('./example-custom-modal-element.element.js'),
}
});

View File

@@ -0,0 +1,51 @@
import type { ExampleModalData, ExampleModalResult } from './example-modal-token.js';
import { css, html, customElement, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbModalContext } from '@umbraco-cms/backoffice/modal';
import './example-custom-modal-element.element.js';
@customElement('example-modal-view')
export class UmbExampleModalViewElement extends UmbLitElement {
@property({ attribute: false })
public modalContext?: UmbModalContext<ExampleModalData, ExampleModalResult>;
onClickDone(){
this.modalContext?.submit();
}
override render() {
return html`
<div id="modal">
<p>Example content of custom modal element</p>
<uui-button look="primary" label="Submit modal" @click=${() => this.onClickDone()}></uui-button>
</div>
`;
}
static override styles = [css`
:host {
background: #eaeaea;
display: block;
box-sizing:border-box;
}
#modal {
box-sizing:border-box;
}
p {
margin:0;
padding:0;
}
`];
}
export default UmbExampleModalViewElement
declare global {
interface HTMLElementTagNameMap {
'example-modal-view': UmbExampleModalViewElement;
}
}

View File

@@ -0,0 +1,29 @@
import type { ManifestDashboard } from '@umbraco-cms/backoffice/dashboard';
import type { ManifestModal } from '@umbraco-cms/backoffice/modal';
const demoModal : ManifestModal = {
type: 'modal',
name: 'Example Custom Modal Element',
alias: 'example.modal.custom.element',
js: () => import('./example-modal-view.element.js'),
}
const demoModalsDashboard : ManifestDashboard = {
type: 'dashboard',
name: 'Example Custom Modal Dashboard',
alias: 'example.dashboard.custom.modal.element',
element: () => import('./example-custom-modal-dashboard.element.js'),
weight: 900,
meta: {
label: 'Custom Modal',
pathname: 'custom-modal',
},
conditions : [
{
alias: 'Umb.Condition.SectionAlias',
match: 'Umb.Section.Content'
}
]
}
export default [demoModal,demoModalsDashboard];

View File

@@ -0,0 +1,10 @@
# Property Dataset Dashboard Example
This example demonstrates the essence of the Property Dataset.
This dashboard implements such, to display a few selected Property Editors and bind the data back to the Dashboard.
## SVG code of Icons
Make sure to use currentColor for fill or stroke color, as that will make the icon adapt to the font color of where its begin used.

View File

@@ -0,0 +1,71 @@
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import { type UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property';
@customElement('example-dataset-dashboard')
export class ExampleDatasetDashboard extends UmbElementMixin(LitElement) {
data: UmbPropertyValueData[] = [
{
alias: 'textProperty',
value: 'Hello',
},
];
#onDataChange(e: Event) {
const oldValue = this.data;
this.data = (e.target as UmbPropertyDatasetElement).value;
this.requestUpdate('data', oldValue);
}
override render() {
return html`
<uui-box class="uui-text">
<h1 class="uui-h2" style="margin-top: var(--uui-size-layout-1);">Dataset Example</h1>
<umb-property-dataset .value=${this.data} @change=${this.#onDataChange}>
<umb-property
label="Textual input"
description="Example of text editor"
alias="textProperty"
property-editor-ui-alias="Umb.PropertyEditorUi.TextBox"></umb-property>
<umb-property
label="List of options"
description="Example of dropdown editor"
alias="listProperty"
.config=${[
{
alias: 'multiple',
value: false,
},
{
alias: 'items',
value: ['First Option', 'Second Option', 'Third Option'],
},
]}
property-editor-ui-alias="Umb.PropertyEditorUi.Dropdown"></umb-property>
</umb-property-dataset>
<h5 class="uui-h3" style="margin-top: var(--uui-size-layout-1);">Output of dashboard data:</h5>
<code> ${JSON.stringify(this.data, null, 2)} </code>
</uui-box>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
padding: var(--uui-size-layout-1);
}
`,
];
}
export default ExampleDatasetDashboard;
declare global {
interface HTMLElementTagNameMap {
'example-dataset-dashboard': ExampleDatasetDashboard;
}
}

View File

@@ -0,0 +1,15 @@
import type { ManifestDashboard } from '@umbraco-cms/backoffice/dashboard';
export const manifests: Array<ManifestDashboard> = [
{
type: 'dashboard',
name: 'Example Dataset Dashboard',
alias: 'example.dashboard.dataset',
element: () => import('./dataset-dashboard.js'),
weight: 900,
meta: {
label: 'Dataset example',
pathname: 'dataset-example',
},
},
];

View File

@@ -0,0 +1,20 @@
const workspace: UmbExtensionManifest = {
type: 'workspaceView',
alias: 'Example.WorkspaceView.EntityContentTypeCondition',
name: 'Example Workspace View With Entity Content Type Condition',
element: () => import('./workspace-view.element.js'),
meta: {
icon: 'icon-bus',
label: 'Conditional',
pathname: 'conditional',
},
conditions: [
{
alias: 'Umb.Condition.WorkspaceContentTypeAlias',
//match : 'blogPost'
oneOf: ['blogPost', 'mediaType1'],
},
],
};
export const manifests = [workspace];

View File

@@ -0,0 +1,19 @@
import { html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-example-entity-content-type-condition')
export class UmbWorkspaceExampleViewElement extends UmbLitElement {
override render() {
return html`<p>
This is a conditional element that is only shown in workspaces based on it's entities content type.
</p>`;
}
}
export default UmbWorkspaceExampleViewElement;
declare global {
interface HTMLElementTagNameMap {
'umb-example-entity-content-type-condition': UmbWorkspaceExampleViewElement;
}
}

View File

@@ -0,0 +1,7 @@
# Icons Example
This example demonstrates how to registerer your own icons.
Currently they have to be made as JavaScript files that exports an SVG string.
Declared as part of a Icon Dictionary in a JavaScript file.

View File

@@ -0,0 +1,14 @@
export default `<!-- @license lucide-static v0.424.0 - ISC -->
<svg
class="lucide lucide-heart"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
`;

View File

@@ -0,0 +1,19 @@
export default `<svg
class="lucide lucide-wand-sparkles"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72" />
<path d="m14 7 3 3" />
<path d="M5 6v4" />
<path d="M19 14v4" />
<path d="M10 2v2" />
<path d="M7 8H3" />
<path d="M21 16h-4" />
<path d="M11 3H9" />
</svg>`;

View File

@@ -0,0 +1,36 @@
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name
@customElement('example-icons-dashboard')
// eslint-disable-next-line local-rules/umb-class-prefix
export class ExampleIconsDashboard extends UmbElementMixin(LitElement) {
override render() {
return html`
<uui-box class="uui-text">
<h1 class="uui-h2" style="margin-top: var(--uui-size-layout-1);">Custom icons:</h1>
<uui-icon name="my-icon-wand"></uui-icon>
<uui-icon name="my-icon-heart"></uui-icon>
</uui-box>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: block;
padding: var(--uui-size-layout-1);
}
`,
];
}
export default ExampleIconsDashboard;
declare global {
interface HTMLElementTagNameMap {
'example-icons-dashboard': ExampleIconsDashboard;
}
}

View File

@@ -0,0 +1,10 @@
export default [
{
name: 'my-icon-wand',
path: () => import('./files/icon-wand.js'),
},
{
name: 'my-icon-heart',
path: () => import('./files/icon-heart.js'),
},
];

Some files were not shown because too many files have changed in this diff Show More