Merge branch 'main' into feature/observer

This commit is contained in:
Mads Rasmussen
2022-10-12 19:07:23 +02:00
56 changed files with 5306 additions and 200 deletions

View File

@@ -595,6 +595,50 @@ components:
- meta
- alias
- name
MetaSectionView:
type: object
properties:
sections:
type: array
items:
type: string
label:
type: string
pathname:
type: string
weight:
type: number
format: float
icon:
type: string
required:
- sections
- label
- pathname
- weight
- icon
IManifestSectionView:
type: object
properties:
type:
type: string
enum:
- sectionView
meta:
$ref: '#/components/schemas/MetaSectionView'
js:
type: string
elementName:
type: string
alias:
type: string
name:
type: string
required:
- type
- meta
- alias
- name
MetaTree:
type: object
properties:
@@ -659,6 +703,81 @@ components:
- meta
- alias
- name
MetaEditorAction:
type: object
properties:
editors:
type: array
items:
type: string
required:
- editors
IManifestEditorAction:
type: object
properties:
type:
type: string
enum:
- editorAction
meta:
$ref: '#/components/schemas/MetaEditorAction'
js:
type: string
elementName:
type: string
alias:
type: string
name:
type: string
required:
- type
- meta
- alias
- name
MetaEditorView:
type: object
properties:
editors:
type: array
items:
type: string
pathname:
type: string
weight:
type: number
format: float
label:
type: string
icon:
type: string
required:
- editors
- pathname
- weight
- label
- icon
IManifestEditorView:
type: object
properties:
type:
type: string
enum:
- editorView
meta:
$ref: '#/components/schemas/MetaEditorView'
js:
type: string
elementName:
type: string
alias:
type: string
name:
type: string
required:
- type
- meta
- alias
- name
MetaTreeItemAction:
type: object
properties:
@@ -837,50 +956,6 @@ components:
- meta
- alias
- name
MetaEditorView:
type: object
properties:
editors:
type: array
items:
type: string
pathname:
type: string
weight:
type: number
format: float
label:
type: string
icon:
type: string
required:
- editors
- pathname
- weight
- label
- icon
IManifestEditorView:
type: object
properties:
type:
type: string
enum:
- editorView
meta:
$ref: '#/components/schemas/MetaEditorView'
js:
type: string
elementName:
type: string
alias:
type: string
name:
type: string
required:
- type
- meta
- alias
- name
MetaPropertyAction:
type: object
properties:
@@ -979,12 +1054,14 @@ components:
Manifest:
oneOf:
- $ref: '#/components/schemas/IManifestSection'
- $ref: '#/components/schemas/IManifestSectionView'
- $ref: '#/components/schemas/IManifestTree'
- $ref: '#/components/schemas/IManifestEditor'
- $ref: '#/components/schemas/IManifestEditorAction'
- $ref: '#/components/schemas/IManifestEditorView'
- $ref: '#/components/schemas/IManifestTreeItemAction'
- $ref: '#/components/schemas/IManifestPropertyEditorUI'
- $ref: '#/components/schemas/IManifestDashboard'
- $ref: '#/components/schemas/IManifestEditorView'
- $ref: '#/components/schemas/IManifestPropertyAction'
- $ref: '#/components/schemas/IManifestPackageView'
- $ref: '#/components/schemas/IManifestEntrypoint'
@@ -993,12 +1070,14 @@ components:
propertyName: type
mapping:
section: '#/components/schemas/IManifestSection'
sectionView: '#/components/schemas/IManifestSectionView'
tree: '#/components/schemas/IManifestTree'
editor: '#/components/schemas/IManifestEditor'
editorAction: '#/components/schemas/IManifestEditorAction'
editorView: '#/components/schemas/IManifestEditorView'
treeItemAction: '#/components/schemas/IManifestTreeItemAction'
propertyEditorUI: '#/components/schemas/IManifestPropertyEditorUI'
dashboard: '#/components/schemas/IManifestDashboard'
editorView: '#/components/schemas/IManifestEditorView'
propertyAction: '#/components/schemas/IManifestPropertyAction'
packageView: '#/components/schemas/IManifestPackageView'
entrypoint: '#/components/schemas/IManifestEntrypoint'

View File

@@ -154,6 +154,23 @@ export interface components {
alias: string;
name: string;
};
MetaSectionView: {
sections: string[];
label: string;
pathname: string;
/** Format: float */
weight: number;
icon: string;
};
IManifestSectionView: {
/** @enum {string} */
type: "sectionView";
meta: components["schemas"]["MetaSectionView"];
js?: string;
elementName?: string;
alias: string;
name: string;
};
MetaTree: {
/** Format: float */
weight: number;
@@ -180,6 +197,35 @@ export interface components {
alias: string;
name: string;
};
MetaEditorAction: {
editors: string[];
};
IManifestEditorAction: {
/** @enum {string} */
type: "editorAction";
meta: components["schemas"]["MetaEditorAction"];
js?: string;
elementName?: string;
alias: string;
name: string;
};
MetaEditorView: {
editors: string[];
pathname: string;
/** Format: float */
weight: number;
label: string;
icon: string;
};
IManifestEditorView: {
/** @enum {string} */
type: "editorView";
meta: components["schemas"]["MetaEditorView"];
js?: string;
elementName?: string;
alias: string;
name: string;
};
MetaTreeItemAction: {
trees: string[];
label: string;
@@ -253,23 +299,6 @@ export interface components {
alias: string;
name: string;
};
MetaEditorView: {
editors: string[];
pathname: string;
/** Format: float */
weight: number;
label: string;
icon: string;
};
IManifestEditorView: {
/** @enum {string} */
type: "editorView";
meta: components["schemas"]["MetaEditorView"];
js?: string;
elementName?: string;
alias: string;
name: string;
};
MetaPropertyAction: {
propertyEditors: string[];
};
@@ -310,12 +339,14 @@ export interface components {
};
Manifest:
| components["schemas"]["IManifestSection"]
| components["schemas"]["IManifestSectionView"]
| components["schemas"]["IManifestTree"]
| components["schemas"]["IManifestEditor"]
| components["schemas"]["IManifestEditorAction"]
| components["schemas"]["IManifestEditorView"]
| components["schemas"]["IManifestTreeItemAction"]
| components["schemas"]["IManifestPropertyEditorUI"]
| components["schemas"]["IManifestDashboard"]
| components["schemas"]["IManifestEditorView"]
| components["schemas"]["IManifestPropertyAction"]
| components["schemas"]["IManifestPackageView"]
| components["schemas"]["IManifestEntrypoint"]

View File

@@ -5,9 +5,9 @@ import './components/backoffice-main.element';
import './components/backoffice-modal-container.element';
import './components/backoffice-notification-container.element';
import './components/node-property/node-property.element';
import './sections/shared/section-layout.element';
import './sections/shared/section-main.element';
import './sections/shared/section-sidebar.element';
import './components/table/table.element';
import './sections/shared/section-main/section-main.element';
import './sections/shared/section-sidebar/section-sidebar.element';
import './sections/shared/section.element';
import './trees/shared/tree-base.element';
import './trees/shared/tree.element';
@@ -24,9 +24,11 @@ import { UmbDocumentTypeStore } from '../core/stores/document-type.store';
import { UmbNodeStore } from '../core/stores/node.store';
import { UmbSectionStore } from '../core/stores/section.store';
import { UmbEntityStore } from '../core/stores/entity.store';
import { UmbUserStore } from '../core/stores/user/user.store';
import { UmbPropertyEditorStore } from '../core/stores/property-editor/property-editor.store';
import { UmbIconStore } from '../core/stores/icon/icon.store';
import { UmbPropertyEditorConfigStore } from '../core/stores/property-editor-config/property-editor-config.store';
import { UmbUserGroupStore } from '../core/stores/user/user-group.store';
@defineElement('umb-backoffice')
export class UmbBackofficeElement extends UmbContextConsumerMixin(UmbContextProviderMixin(LitElement)) {
@@ -58,6 +60,8 @@ export class UmbBackofficeElement extends UmbContextConsumerMixin(UmbContextProv
this.provideContext('umbNodeStore', new UmbNodeStore(this._umbEntityStore));
this.provideContext('umbDataTypeStore', new UmbDataTypeStore(this._umbEntityStore));
this.provideContext('umbDocumentTypeStore', new UmbDocumentTypeStore(this._umbEntityStore));
this.provideContext('umbUserStore', new UmbUserStore(this._umbEntityStore));
this.provideContext('umbUserGroupStore', new UmbUserGroupStore(this._umbEntityStore));
this.provideContext('umbPropertyEditorStore', new UmbPropertyEditorStore());
this.provideContext('umbPropertyEditorConfigStore', new UmbPropertyEditorConfigStore());
this.provideContext('umbNotificationService', new UmbNotificationService());

View File

@@ -23,6 +23,9 @@ export class UmbBackofficeMain extends UmbContextProviderMixin(UmbContextConsume
height: 100%;
overflow: hidden;
}
router-slot {
height: 100%;
}
`,
];

View File

@@ -0,0 +1,282 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
export interface UmbTableItem {
key: string;
icon?: string;
data: Array<UmbTableItemData>;
}
export interface UmbTableItemData {
columnAlias: string;
value: any;
}
export interface UmbTableColumn {
name: string;
alias: string;
elementName?: string;
}
export interface UmbTableConfig {
allowSelection: boolean;
}
export class UmbTableSelectedEvent extends Event {
public constructor() {
super('selected', { bubbles: true, composed: true });
}
}
export class UmbTableDeselectedEvent extends Event {
public constructor() {
super('deselected', { bubbles: true, composed: true });
}
}
export class UmbTableOrderedEvent extends Event {
public constructor() {
super('ordered', { bubbles: true, composed: true });
}
}
/**
* @element umb-table
* @description - Element for displaying a table
* @fires {UmbTableSelectedEvent} selected - fires when a row is selected
* @fires {UmbTableDeselectedEvent} deselected - fires when a row is deselected
* @fires {UmbTableOrderedEvent} sort - fires when a column order is changed
* @extends LitElement
*/
@customElement('umb-table')
export class UmbTableElement extends LitElement {
static styles = [
UUITextStyles,
css`
:host {
height: 100%;
overflow: auto;
padding: var(--uui-size-space-4);
padding-top: 0;
}
uui-table {
box-shadow: var(--uui-shadow-depth-1);
}
uui-table-head {
position: sticky;
top: 0;
background: white;
z-index: 1;
}
uui-table-row uui-checkbox {
display: none;
}
uui-table-row:focus uui-icon,
uui-table-row:focus-within uui-icon,
uui-table-row:hover uui-icon,
uui-table-row[select-only] uui-icon {
display: none;
}
uui-table-row:focus uui-checkbox,
uui-table-row:focus-within uui-checkbox,
uui-table-row:hover uui-checkbox,
uui-table-row[select-only] uui-checkbox {
display: inline-block;
}
uui-table-head-cell:focus,
uui-table-head-cell:focus-within,
uui-table-head-cell:hover {
--uui-symbol-sort-hover: 1;
}
uui-table-head-cell button {
padding: 0;
background-color: transparent;
color: inherit;
border: none;
cursor: pointer;
font-weight: inherit;
font-size: inherit;
display: inline-flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
`,
];
/**
* Table Items
* @type {Array<UmbTableItem>}
* @memberof UmbTableElement
*/
@property({ type: Array, attribute: false })
public items: Array<UmbTableItem> = [];
/**
* @description Table Columns
* @type {Array<UmbTableColumn>}
* @memberof UmbTableElement
*/
@property({ type: Array, attribute: false })
public columns: Array<UmbTableColumn> = [];
/**
* @description Table Config
* @type {UmbTableConfig}
* @memberof UmbTableElement
*/
@property({ type: Object, attribute: false })
public config: UmbTableConfig = {
allowSelection: false,
};
/**
* @description Table Selection
* @type {Array<string>}
* @memberof UmbTableElement
*/
@property({ type: Array, attribute: false })
public selection: Array<string> = [];
@property({ type: String, attribute: false })
public orderingColumn = '';
@property({ type: String, attribute: false })
public orderingDesc = false;
@state()
private _selectionMode = false;
private _isSelected(key: string) {
return this.selection.includes(key);
}
private _handleRowCheckboxChange(event: Event, item: UmbTableItem) {
const checkboxElement = event.target as HTMLInputElement;
checkboxElement.checked ? this._selectRow(item.key) : this._deselectRow(item.key);
}
private _handleAllRowsCheckboxChange(event: Event) {
const checkboxElement = event.target as HTMLInputElement;
checkboxElement.checked ? this._selectAllRows() : this._deselectAllRows();
}
private _handleOrderingChange(column: UmbTableColumn) {
this.orderingDesc = this.orderingColumn === column.alias ? !this.orderingDesc : false;
this.orderingColumn = column.alias;
this.dispatchEvent(new UmbTableOrderedEvent());
}
private _selectRow(key: string) {
this.selection = [...this.selection, key];
this._selectionMode = this.selection.length > 0;
this.dispatchEvent(new UmbTableSelectedEvent());
}
private _deselectRow(key: string) {
this.selection = this.selection.filter((selectionKey) => selectionKey !== key);
this._selectionMode = this.selection.length > 0;
this.dispatchEvent(new UmbTableDeselectedEvent());
}
private _selectAllRows() {
this.selection = this.items.map((item: UmbTableItem) => item.key);
this._selectionMode = true;
this.dispatchEvent(new UmbTableSelectedEvent());
}
private _deselectAllRows() {
this.selection = [];
this._selectionMode = false;
this.dispatchEvent(new UmbTableDeselectedEvent());
}
render() {
return html` <uui-table class="uui-text">
<uui-table-column style="width: 60px;"></uui-table-column>
<uui-table-head>
<uui-table-head-cell style="--uui-table-cell-padding: 0">
<uui-checkbox
label="Select All"
style="padding: var(--uui-size-4) var(--uui-size-5);"
@change="${this._handleAllRowsCheckboxChange}"
?checked="${this.selection.length === this.items.length}">
</uui-checkbox>
</uui-table-head-cell>
${this.columns.map((column) => this._renderHeaderCell(column))}
</uui-table-head>
${repeat(this.items, (item) => item.key, this._renderRow)}
</uui-table>`;
}
private _renderHeaderCell(column: UmbTableColumn) {
return html`
<uui-table-head-cell style="--uui-table-cell-padding: 0">
<button
style="padding: var(--uui-size-4) var(--uui-size-5);"
@click="${() => this._handleOrderingChange(column)}">
${column.name}
<uui-symbol-sort ?active=${this.orderingColumn === column.alias} ?descending=${this.orderingDesc}>
</uui-symbol-sort></button
></uui-table-head-cell>
`;
}
private _renderRow = (item: UmbTableItem) => {
return html`<uui-table-row
?selectable="${this.config.allowSelection && this._selectionMode}"
?select-only=${this._selectionMode}
?selected=${this._isSelected(item.key)}
@selected=${() => this._selectRow(item.key)}
@unselected=${() => this._deselectRow(item.key)}>
<uui-table-cell>
${item.icon ? html`<uui-icon name=${item.icon}></uui-icon>` : nothing}
<uui-checkbox
label="Select Row"
@click=${(e: PointerEvent) => e.stopPropagation()}
@change=${(event: Event) => this._handleRowCheckboxChange(event, item)}
?checked="${this._isSelected(item.key)}">
</uui-checkbox>
</uui-table-cell>
${this.columns.map((column) => this._renderRowCell(column, item))}
</uui-table-row>`;
};
private _renderRowCell(column: UmbTableColumn, item: UmbTableItem) {
return html`<uui-table-cell style="--uui-table-cell-padding: 0"
>${this._renderCellContent(column, item)}</uui-table-cell
>
</uui-table-cell>`;
}
private _renderCellContent(column: UmbTableColumn, item: UmbTableItem) {
const value = item.data.find((data) => data.columnAlias === column.alias)?.value;
if (column.elementName) {
const element = document.createElement(column.elementName) as any; // TODO: add interface for UmbTableColumnLayoutElement
element.column = column;
element.item = item;
element.value = value;
return element;
}
return value;
}
}
export default UmbTableElement;
declare global {
interface HTMLElementTagNameMap {
'umb-table': UmbTableElement;
}
}

View File

@@ -0,0 +1,79 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import { v4 as uuidv4 } from 'uuid';
import type { UmbTableElement, UmbTableColumn, UmbTableConfig, UmbTableItem } from './table.element';
import './table.element';
export default {
title: 'Components/Table',
component: 'umb-table',
id: 'umb-table',
} as Meta;
const columns: Array<UmbTableColumn> = [
{
name: 'Name',
alias: 'name',
},
{
name: 'Date',
alias: 'date',
},
];
const today = new Intl.DateTimeFormat('en-US').format(new Date());
const items: Array<UmbTableItem> = [
{
key: uuidv4(),
icon: 'umb:wand',
data: [
{
columnAlias: 'name',
value: 'Item 1',
},
{
columnAlias: 'date',
value: today,
},
],
},
{
key: uuidv4(),
icon: 'umb:document',
data: [
{
columnAlias: 'name',
value: 'Item 2',
},
{
columnAlias: 'date',
value: today,
},
],
},
{
key: uuidv4(),
icon: 'umb:user',
data: [
{
columnAlias: 'name',
value: 'Item 3',
},
{
columnAlias: 'date',
value: today,
},
],
},
];
const config: UmbTableConfig = {
allowSelection: true,
};
export const AAAOverview: Story<UmbTableElement> = () =>
html`<umb-table .items=${items} .columns=${columns} .config=${config}></umb-table>`;
AAAOverview.storyName = 'Overview';

View File

@@ -1,23 +0,0 @@
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('umb-dashboard-settings-about')
export class UmbDashboardSettingsAboutElement extends LitElement {
static styles = [UUITextStyles, css``];
render() {
return html`
<uui-box>
<h1>Settings</h1>
</uui-box>
`;
}
}
export default UmbDashboardSettingsAboutElement;
declare global {
interface HTMLElementTagNameMap {
'umb-dashboard-settings-about': UmbDashboardSettingsAboutElement;
}
}

View File

@@ -1,15 +0,0 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import type { UmbDashboardSettingsAboutElement } from './dashboard-settings-about.element';
import './dashboard-settings-about.element';
export default {
title: 'Dashboards/Settings About',
component: 'umb-dashboard-settings-about',
id: 'umb-dashboard-settings-about',
} as Meta;
export const AAAOverview: Story<UmbDashboardSettingsAboutElement> = () =>
html` <umb-dashboard-settings-about></umb-dashboard-settings-about>`;
AAAOverview.storyName = 'Overview';

View File

@@ -0,0 +1,32 @@
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('umb-dashboard-settings-welcome')
export class UmbDashboardSettingsWelcomeElement extends LitElement {
static styles = [UUITextStyles, css``];
render() {
return html`
<uui-box>
<h1>Start here</h1>
<p>This section contains the building blocks for your Umbraco site. Follow the below links to find out more about working with the items in the Settings section.</p>
<h2>Find out more:</h2>
<ul>
<li>Read more about working with the items in Settings <a href="https://our.umbraco.com/documentation/Getting-Started/Backoffice/Sections/" target="_blank" rel="noopener">in the Documentation section</a> of Our Umbraco</li>
<li>Ask a question in the <a href="https://our.umbraco.com/forum" target="_blank" rel="noopener">Community Forum</a></li>
<li>Watch our free <a href="https://umbra.co/ulb" target="_blank" rel="noopener">tutorial videos on the Umbraco Learning Base</a></li>
<li>Find out about our <a href="https://umbraco.com/products/" target="_blank" rel="noopener">productivity boosting tools and commercial support</a></li>
<li>Find out about real-life <a href="https://umbraco.com/training/" target="_blank" rel="noopener">training and certification</a> opportunities</li>
</ul>
</uui-box>
`;
}
}
export default UmbDashboardSettingsWelcomeElement;
declare global {
interface HTMLElementTagNameMap {
'umb-dashboard-settings-welcome': UmbDashboardSettingsWelcomeElement;
}
}

View File

@@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import type { UmbDashboardSettingsWelcomeElement } from './dashboard-settings-welcome.element';
import './dashboard-settings-welcome.element';
export default {
title: 'Dashboards/Settings Welcome',
component: 'umb-dashboard-settings-welcome',
id: 'umb-dashboard-settings-welcome',
} as Meta;
export const AAAOverview: Story<UmbDashboardSettingsWelcomeElement> = () =>
html` <umb-dashboard-settings-welcome></umb-dashboard-settings-welcome>`;
AAAOverview.storyName = 'Overview';

View File

@@ -0,0 +1,20 @@
import { expect, fixture, html } from '@open-wc/testing';
import { defaultA11yConfig } from '../../../core/helpers/chai';
import { UmbDashboardSettingsWelcomeElement } from './dashboard-settings-welcome.element';
describe('UmbDashboardSettingsWelcomeElement', () => {
let element: UmbDashboardSettingsWelcomeElement;
beforeEach(async () => {
element = await fixture(html`<umb-dashboard-settings-welcome></umb-dashboard-settings-welcome>`);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbDashboardSettingsWelcomeElement);
});
it('passes the a11y audit', async () => {
await expect(element).to.be.accessible(defaultA11yConfig);
});
});

View File

@@ -0,0 +1,46 @@
import { UUITextStyles } from '@umbraco-ui/uui';
import { CSSResultGroup, html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createExtensionElement } from '../../../../core/extension';
import type { ManifestEditorAction } from '../../../../core/models';
@customElement('umb-editor-action-extension')
export class UmbEditorActionExtensionElement extends LitElement {
static styles: CSSResultGroup = [UUITextStyles];
private _editorAction?: ManifestEditorAction;
@property({ type: Object })
public get editorAction(): ManifestEditorAction | undefined {
return this._editorAction;
}
public set editorAction(value: ManifestEditorAction | undefined) {
this._editorAction = value;
this._createElement();
}
@state()
private _element?: any;
private async _createElement() {
if (!this.editorAction) return;
try {
this._element = await createExtensionElement(this.editorAction);
if (!this._element) return;
this._element.editorAction = this.editorAction;
} catch (error) {
// TODO: loading JS failed so we should do some nice UI. (This does only happen if extension has a js prop, otherwise we concluded that no source was needed resolved the load.)
}
}
render() {
return html`${this._element}`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-editor-action-extension': UmbEditorActionExtensionElement;
}
}

View File

@@ -6,10 +6,11 @@ import { map } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../../core/context';
import { createExtensionElement, UmbExtensionRegistry } from '../../../../core/extension';
import type { ManifestEditorView } from '../../../../core/models';
import { UmbObserverMixin } from '../../../../core/observer';
import type { ManifestEditorAction, ManifestEditorView } from '../../../../core/models';
import '../editor-layout/editor-layout.element';
import '../editor-action-extension/editor-action-extension.element';
/**
* @element umb-editor-entity-layout
@@ -87,6 +88,9 @@ export class UmbEditorEntityLayout extends UmbContextConsumerMixin(UmbObserverMi
@state()
private _editorViews: Array<ManifestEditorView> = [];
@state()
private _editorActions: Array<ManifestEditorAction> = [];
@state()
private _currentView = '';
@@ -94,6 +98,7 @@ export class UmbEditorEntityLayout extends UmbContextConsumerMixin(UmbObserverMi
private _routes: Array<IRoute> = [];
private _extensionRegistry?: UmbExtensionRegistry;
private _editorActionsSubscription?: Subscription;
private _routerFolder = '';
constructor() {
@@ -101,7 +106,8 @@ export class UmbEditorEntityLayout extends UmbContextConsumerMixin(UmbObserverMi
this.consumeContext('umbExtensionRegistry', (extensionRegistry: UmbExtensionRegistry) => {
this._extensionRegistry = extensionRegistry;
this._useEditorViews();
this._observeEditorViews();
this._observeEditorActions();
});
}
@@ -111,7 +117,7 @@ export class UmbEditorEntityLayout extends UmbContextConsumerMixin(UmbObserverMi
this._routerFolder = window.location.pathname.split('/view')[0];
}
private _useEditorViews() {
private _observeEditorViews() {
if (!this._extensionRegistry) return;
this.observe<ManifestEditorView[]>(
@@ -131,6 +137,19 @@ export class UmbEditorEntityLayout extends UmbContextConsumerMixin(UmbObserverMi
);
}
private _observeEditorActions() {
if (!this._extensionRegistry) return;
this.observe(
this._extensionRegistry
?.extensionsOfType('editorAction')
.pipe(map((extensions) => extensions.filter((extension) => extension.meta.editors.includes(this.alias)))),
(editorActions) => {
this._editorActions = editorActions;
}
);
}
private async _createRoutes() {
if (this._editorViews.length > 0) {
this._routes = [];
@@ -204,7 +223,12 @@ export class UmbEditorEntityLayout extends UmbContextConsumerMixin(UmbObserverMi
<div id="footer" slot="footer">
<slot name="footer"></slot>
<slot id="actions" name="actions"></slot>
<div id="actions">
${this._editorActions.map(
(action) => html`<umb-editor-action-extension .editorAction=${action}></umb-editor-action-extension>`
)}
<slot name="actions"></slot>
</div>
</div>
</umb-editor-layout>
`;

View File

@@ -30,7 +30,7 @@ export class UmbEditorLayout extends LitElement {
}
#main {
padding: var(--uui-size-6);
/* padding: 0 var(--uui-size-6); */
display: flex;
flex: 1;
flex-direction: column;

View File

@@ -0,0 +1,283 @@
import { UUIInputElement, UUIInputEvent } from '@umbraco-ui/uui';
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
@customElement('umb-editor-user-group')
export class UmbEditorUserGroupElement extends LitElement {
static styles = [
UUITextStyles,
css`
:host {
display: block;
height: 100%;
}
#main {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--uui-size-space-6);
padding: var(--uui-size-space-6);
}
#left-column {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-4);
}
#right-column > uui-box > div {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-2);
}
hr {
border: none;
border-bottom: 1px solid var(--uui-color-divider);
width: 100%;
}
uui-input {
width: 100%;
}
.faded-text {
color: var(--uui-color-text-alt);
font-size: 0.8rem;
}
#default-permissions {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-4);
}
.default-permission {
display: flex;
align-items: center;
gap: var(--uui-size-space-4);
padding: var(--uui-size-space-2);
}
.default-permission:not(:last-child) {
border-bottom: 1px solid var(--uui-color-divider);
}
.permission-info {
display: flex;
flex-direction: column;
}
`,
];
@state()
private _userName = '';
@property({ type: String })
entityKey = '';
defaultPermissions: Array<{
name: string;
permissions: Array<{ name: string; description: string; value: boolean }>;
}> = [
{
name: 'Administration',
permissions: [
{
name: 'Culture and Hostnames',
description: 'Allow access to assign culture and hostnames',
value: false,
},
{
name: 'Restrict Public Access',
description: 'Allow access to set and change access restrictions for a node',
value: false,
},
{
name: 'Rollback',
description: 'Allow access to roll back a node to a previous state',
value: false,
},
],
},
{
name: 'Content',
permissions: [
{
name: 'Browse Node',
description: 'Allow access to view a node',
value: false,
},
{
name: 'Create Content Template',
description: 'Allow access to create a Content Template',
value: false,
},
{
name: 'Delete',
description: 'Allow access to delete nodes',
value: false,
},
{
name: 'Create',
description: 'Allow access to create nodes',
value: false,
},
{
name: 'Publish',
description: 'Allow access to publish nodes',
value: false,
},
{
name: 'Permissions',
description: 'Allow access to change permissions for a node',
value: false,
},
{
name: 'Send To Publish',
description: 'Allow access to send a node for approval before publishing',
value: false,
},
{
name: 'Unpublish',
description: 'Allow access to unpublish a node',
value: false,
},
{
name: 'Update',
description: 'Allow access to save a node',
value: false,
},
{
name: 'Full restore',
description: 'Allow the user to restore items',
value: false,
},
{
name: 'Partial restore',
description: 'Allow the user to partial restore items',
value: false,
},
{
name: 'Queue for transfer',
description: 'Allow the user to queue item(s)',
value: false,
},
],
},
{
name: 'Structure',
permissions: [
{
name: 'Copy',
description: 'Allow access to copy a node',
value: false,
},
{
name: 'Move',
description: 'Allow access to move a node',
value: false,
},
{
name: 'Sort',
description: 'Allow access to change the sort order for nodes',
value: false,
},
],
},
];
private renderLeftColumn() {
return html` <uui-box>
<div slot="headline">Assign access</div>
<div>
<b>Sections</b>
<div class="faded-text">Add sections to give users access</div>
</div>
<div>
<b>Content start nodes</b>
<div class="faded-text">Limit the content tree to specific start nodes</div>
<umb-property-editor-ui-content-picker></umb-property-editor-ui-content-picker>
</div>
<div>
<b>Media start nodes</b>
<div class="faded-text">Limit the media library to specific start nodes</div>
<umb-property-editor-ui-content-picker></umb-property-editor-ui-content-picker>
</div>
<b>Content</b>
<div class="access-content">
<uui-icon name="folder"></uui-icon>
<span>Content Root</span>
</div>
<b>Media</b>
<div class="access-content">
<uui-icon name="folder"></uui-icon>
<span>Media Root</span>
</div>
</uui-box>
<uui-box>
<div slot="headline">Default Permissions</div>
<div id="default-permissions">
${repeat(
this.defaultPermissions,
(defaultPermission) => html`
<div>
<b>${defaultPermission.name}</b>
${repeat(
defaultPermission.permissions,
(permission) => html`
<div class="default-permission">
<uui-toggle
.checked=${permission.value}
@change=${(e: Event) => {
permission.value = (e.target as HTMLInputElement).checked;
}}></uui-toggle>
<div class="permission-info">
<b>${permission.name}</b>
<span class="faded-text">${permission.description}</span>
</div>
</div>
`
)}
</div>
`
)}
</div>
</uui-box>
<uui-box>
<div slot="headline">Granular permissions</div>
</uui-box>`;
}
private renderRightColumn() {
return html`<uui-box>
<div slot="headline">Users</div>
</uui-box>`;
}
// TODO. find a way where we don't have to do this for all editors.
private _handleInput(event: UUIInputEvent) {
if (event instanceof UUIInputEvent) {
const target = event.composedPath()[0] as UUIInputElement;
console.log('input', target.value);
}
}
render() {
return html`
<umb-editor-entity-layout alias="Umb.Editor.UserGroup">
<uui-input id="name" slot="name" .value=${this._userName} @input="${this._handleInput}"></uui-input>
<div id="main">
<div id="left-column">${this.renderLeftColumn()}</div>
<div id="right-column">${this.renderRightColumn()}</div>
</div>
</umb-editor-entity-layout>
`;
}
}
export default UmbEditorUserGroupElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-user-group': UmbEditorUserGroupElement;
}
}

View File

@@ -0,0 +1,72 @@
import { css, html, LitElement, nothing } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../../core/context';
import { UmbUserStore } from '../../../../core/stores/user/user.store';
import type { UserEntity } from '../../../../core/models';
import { UmbUserContext } from '../user.context';
import { UUIButtonState } from '@umbraco-ui/uui';
import { UmbNotificationDefaultData } from '../../../../core/services/notification/layouts/default';
import { UmbNotificationService } from '../../../../core/services/notification';
@customElement('umb-editor-action-user-save')
export class UmbEditorActionUserSaveElement extends UmbContextConsumerMixin(LitElement) {
static styles = [UUITextStyles, css``];
@state()
private _saveButtonState?: UUIButtonState;
private _userStore?: UmbUserStore;
private _userContext?: UmbUserContext;
private _notificationService?: UmbNotificationService;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUserStore', (userStore: UmbUserStore) => {
this._userStore = userStore;
});
this.consumeContext('umbUserContext', (userContext: UmbUserContext) => {
this._userContext = userContext;
});
this.consumeContext('umbNotificationService', (service: UmbNotificationService) => {
this._notificationService = service;
});
}
private async _handleSave() {
// TODO: What if store is not present, what if node is not loaded....
if (!this._userStore || !this._userContext) return;
try {
this._saveButtonState = 'waiting';
const user = this._userContext.getData();
await this._userStore.save([user]);
const data: UmbNotificationDefaultData = { message: 'User Saved' };
this._notificationService?.peek('positive', { data });
this._saveButtonState = 'success';
} catch (error) {
this._saveButtonState = 'failed';
}
}
render() {
return html`<uui-button
@click=${this._handleSave}
look="primary"
color="positive"
label="save"
.state="${this._saveButtonState}"></uui-button>`;
}
}
export default UmbEditorActionUserSaveElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-action-user-save': UmbEditorActionUserSaveElement;
}
}

View File

@@ -0,0 +1,308 @@
import { UUIInputElement, UUIInputEvent } from '@umbraco-ui/uui';
import { css, html, LitElement, nothing } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, property, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { UmbContextProviderMixin, UmbContextConsumerMixin } from '../../../core/context';
import UmbSectionViewUsersElement from '../../sections/users/views/users/section-view-users.element';
import { UmbUserStore } from '../../../core/stores/user/user.store';
import type { UserDetails } from '../../../core/models';
import { UmbUserContext } from './user.context';
import '../../property-editor-uis/content-picker/property-editor-ui-content-picker.element';
import '../shared/editor-entity-layout/editor-entity-layout.element';
import { getTagLookAndColor } from '../../sections/users/user-extensions';
@customElement('umb-editor-user')
export class UmbEditorUserElement extends UmbContextProviderMixin(UmbContextConsumerMixin(LitElement)) {
static styles = [
UUITextStyles,
css`
:host {
display: block;
height: 100%;
}
#main {
display: grid;
grid-template-columns: 1fr 350px;
gap: var(--uui-size-space-6);
padding: var(--uui-size-space-6);
}
#left-column {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-4);
}
#right-column > uui-box > div {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-2);
}
uui-avatar {
font-size: var(--uui-size-16);
place-self: center;
}
hr {
border: none;
border-bottom: 1px solid var(--uui-color-divider);
width: 100%;
}
uui-input {
width: 100%;
}
.faded-text {
color: var(--uui-color-text-alt);
font-size: 0.8rem;
}
uui-tag {
width: fit-content;
}
#user-info {
display: flex;
gap: var(--uui-size-space-6);
}
#user-info > div {
display: flex;
flex-direction: column;
}
#assign-access {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-4);
}
.access-content {
margin-top: var(--uui-size-space-1);
margin-bottom: var(--uui-size-space-4);
display: flex;
align-items: center;
line-height: 1;
gap: var(--uui-size-space-3);
}
.access-content > span {
align-self: end;
}
`,
];
@state()
private _user?: UserDetails | null;
@state()
private _userName = '';
@property({ type: String })
entityKey = '';
protected _userStore?: UmbUserStore;
protected _usersSubscription?: Subscription;
private _userContext?: UmbUserContext;
private _userNameSubscription?: Subscription;
private _languages = []; //TODO Add languages
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUserStore', (usersContext: UmbUserStore) => {
this._userStore = usersContext;
this._observeUser();
});
}
private _observeUser() {
this._usersSubscription?.unsubscribe();
this._usersSubscription = this._userStore?.getByKey(this.entityKey).subscribe((user) => {
this._user = user;
if (!this._user) return;
if (!this._userContext) {
this._userContext = new UmbUserContext(this._user);
this.provideContext('umbUserContext', this._userContext);
} else {
this._userContext.update(this._user);
}
this._userNameSubscription = this._userContext.data.subscribe((user) => {
if (user && user.name !== this._userName) {
this._userName = user.name;
}
});
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._usersSubscription?.unsubscribe();
this._userNameSubscription?.unsubscribe();
}
private _updateUserStatus() {
if (!this._user || !this._userStore) return;
const isDisabled = this._user.status === 'disabled';
isDisabled ? this._userStore.enableUsers([this._user.key]) : this._userStore.disableUsers([this._user.key]);
}
private _deleteUser() {
if (!this._user || !this._userStore) return;
this._userStore.deleteUsers([this._user.key]);
history.pushState(null, '', '/section/users/view/users/overview');
}
private renderLeftColumn() {
if (!this._user) return nothing;
return html` <uui-box>
<div slot="headline">Profile</div>
<uui-form-layout-item style="margin-top: 0">
<uui-label for="email">Email</uui-label>
<uui-input name="email" label="email" readonly value=${this._user.email}></uui-input>
</uui-form-layout-item>
<uui-form-layout-item style="margin-bottom: 0">
<uui-label for="language">Language</uui-label>
<uui-select name="language" label="language" .options=${this._languages}> </uui-select>
</uui-form-layout-item>
</uui-box>
<uui-box>
<div id="assign-access">
<div slot="headline">Assign access</div>
<div>
<b>Groups</b>
<div class="faded-text">Add groups to assign access and permissions</div>
</div>
<div>
<b>Content start nodes</b>
<div class="faded-text">Limit the content tree to specific start nodes</div>
<umb-property-editor-ui-content-picker></umb-property-editor-ui-content-picker>
</div>
<div>
<b>Media start nodes</b>
<div class="faded-text">Limit the media library to specific start nodes</div>
<umb-property-editor-ui-content-picker></umb-property-editor-ui-content-picker>
</div>
</div>
</uui-box>
<uui-box>
<div slot="headline">Access</div>
<div slot="header" class="faded-text">
Based on the assigned groups and start nodes, the user has access to the following nodes
</div>
<b>Content</b>
<div class="access-content">
<uui-icon name="folder"></uui-icon>
<span>Content Root</span>
</div>
<b>Media</b>
<div class="access-content">
<uui-icon name="folder"></uui-icon>
<span>Media Root</span>
</div>
</uui-box>`;
}
private renderRightColumn() {
if (!this._user || !this._userStore) return nothing;
const statusLook = getTagLookAndColor(this._user.status);
return html` <uui-box>
<div id="user-info">
<uui-avatar .name=${this._user?.name || ''}></uui-avatar>
<uui-button label="Change photo"></uui-button>
<hr />
${this._user?.status !== 'invited'
? html`
<uui-button
@click=${this._updateUserStatus}
look="primary"
color="${this._user.status === 'disabled' ? 'positive' : 'warning'}"
label="${this._user.status === 'disabled' ? 'Enable' : 'Disable'}"></uui-button>
`
: nothing}
<uui-button @click=${this._deleteUser} look="primary" color="danger" label="Delete User"></uui-button>
<div>
<b>Status:</b>
<uui-tag look="${ifDefined(statusLook?.look)}" color="${ifDefined(statusLook?.color)}">
${this._user.status}
</uui-tag>
</div>
${this._user?.status === 'invited'
? html`
<uui-textarea placeholder="Enter a message..."> </uui-textarea>
<uui-button look="primary" label="Resend invitation"></uui-button>
`
: nothing}
<div>
<b>Last login:</b>
<span>${this._user.lastLoginDate || `${this._user.name} has not logged in yet`}</span>
</div>
<div>
<b>Failed login attempts</b>
<span>${this._user.failedLoginAttempts}</span>
</div>
<div>
<b>Last lockout date:</b>
<span>${this._user.lastLockoutDate || `${this._user.name} has not been locked out`}</span>
</div>
<div>
<b>Password last changed:</b>
<span>${this._user.lastLoginDate || `${this._user.name} has not changed password`}</span>
</div>
<div>
<b>User created:</b>
<span>${this._user.createDate}</span>
</div>
<div>
<b>User last updated:</b>
<span>${this._user.updateDate}</span>
</div>
<div>
<b>Key:</b>
<span>${this._user.key}</span>
</div>
</div>
</uui-box>`;
}
// TODO. find a way where we don't have to do this for all editors.
private _handleInput(event: UUIInputEvent) {
if (event instanceof UUIInputEvent) {
const target = event.composedPath()[0] as UUIInputElement;
if (typeof target?.value === 'string') {
this._userContext?.update({ name: target.value });
}
}
}
render() {
if (!this._user) return html`User not found`;
return html`
<umb-editor-entity-layout alias="Umb.Editor.User">
<uui-input id="name" slot="name" .value=${this._userName} @input="${this._handleInput}"></uui-input>
<div id="main">
<div id="left-column">${this.renderLeftColumn()}</div>
<div id="right-column">${this.renderRightColumn()}</div>
</div>
</umb-editor-entity-layout>
`;
}
}
export default UmbEditorUserElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-users-user-details': UmbEditorUserElement;
}
}

View File

@@ -0,0 +1,36 @@
import { BehaviorSubject, Observable } from 'rxjs';
import type { UserDetails } from '../../../core/models';
export class UmbUserContext {
// TODO: figure out how fine grained we want to make our observables.
private _data = new BehaviorSubject<UserDetails>({
key: '',
name: '',
icon: '',
type: 'user',
hasChildren: false,
parentKey: '',
isTrashed: false,
email: '',
language: '',
status: 'enabled',
updateDate: '8/27/2022',
createDate: '9/19/2022',
failedLoginAttempts: 0,
});
public readonly data: Observable<UserDetails> = this._data.asObservable();
constructor(user: UserDetails) {
if (!user) return;
this._data.next(user);
}
// TODO: figure out how we want to update data
public update(data: Partial<UserDetails>) {
this._data.next({ ...this._data.getValue(), ...data });
}
public getData() {
return this._data.getValue();
}
}

View File

@@ -8,13 +8,11 @@ export class UmbPackagesEditor extends LitElement {
render() {
return html`
<uui-icon-registry-essential>
<umb-section-layout>
<umb-section-main>
<umb-editor-entity alias="Umb.Editor.Packages">
<h1 slot="name">Packages</h1>
</umb-editor-entity>
</umb-section-main>
</umb-section-layout>
<umb-section-main>
<umb-editor-entity alias="Umb.Editor.Packages">
<h1 slot="name">Packages</h1>
</umb-editor-entity>
</umb-section-main>
</uui-icon-registry-essential>
`;
}

View File

@@ -1,6 +1,6 @@
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import type { ManifestSection, ManifestTree } from '../../core/models';
import type { ManifestSection, ManifestSectionView, ManifestTree } from '../../core/models';
import { Entity } from '../../mocks/data/entities';
export class UmbSectionContext {
@@ -27,6 +27,10 @@ export class UmbSectionContext {
private _activeTreeItem = new ReplaySubject<Entity>(1);
public readonly activeTreeItem = this._activeTreeItem.asObservable();
// TODO: what is the best context to put this in?
private _activeView = new ReplaySubject<ManifestSectionView>(1);
public readonly activeView = this._activeView.asObservable();
constructor(section: ManifestSection) {
if (!section) return;
this._data.next(section);
@@ -48,4 +52,8 @@ export class UmbSectionContext {
public setActiveTreeItem(treeItem: Entity) {
this._activeTreeItem.next(treeItem);
}
public setActiveView(view: ManifestSectionView) {
this._activeView.next(view);
}
}

View File

@@ -4,20 +4,21 @@ import { customElement, state } from 'lit/decorators.js';
import { IRoutingInfo } from 'router-slot';
import { first, map } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../core/context';
import { createExtensionElement, UmbExtensionRegistry } from '../../../core/extension';
import { UmbSectionContext } from '../section.context';
import type { ManifestDashboard, ManifestSection } from '../../../core/models';
import { UmbObserverMixin } from '../../../core/observer';
import { UmbContextConsumerMixin } from '../../../../core/context';
import { createExtensionElement, UmbExtensionRegistry } from '../../../../core/extension';
import { UmbSectionContext } from '../../section.context';
import type { ManifestDashboard, ManifestSection } from '../../../../core/models';
import { UmbObserverMixin } from '../../../../core/observer';
@customElement('umb-section-dashboards')
export class UmbSectionDashboards extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
export class UmbSectionDashboardsElement extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
static styles = [
UUITextStyles,
css`
:host {
display: block;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
@@ -150,10 +151,10 @@ export class UmbSectionDashboards extends UmbContextConsumerMixin(UmbObserverMix
}
}
export default UmbSectionDashboards;
export default UmbSectionDashboardsElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-dashboards': UmbSectionDashboards;
'umb-section-dashboards': UmbSectionDashboardsElement;
}
}

View File

@@ -0,0 +1,25 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import { internalManifests } from '../../../../temp-internal-manifests';
import type { ManifestSection } from '../../../../core/models';
import { UmbSectionContext } from '../../section.context';
import type { UmbSectionDashboardsElement } from './section-dashboards.element';
import './section-dashboards.element';
const contentSectionManifest = internalManifests.find((m) => m.alias === 'Umb.Section.Content') as ManifestSection;
export default {
title: 'Sections/Shared/Section Dashboards',
component: 'umb-section-dashboards',
id: 'umb-section-dashboards',
decorators: [
(story) =>
html` <umb-context-provider key="umbSectionContext" .value=${new UmbSectionContext(contentSectionManifest)}>
${story()}
</umb-context-provider>`,
],
} as Meta;
export const AAAOverview: Story<UmbSectionDashboardsElement> = () =>
html` <umb-section-dashboards></umb-section-dashboards> `;
AAAOverview.storyName = 'Overview';

View File

@@ -1,27 +0,0 @@
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('umb-section-layout')
export class UmbSectionLayout extends LitElement {
static styles = [
UUITextStyles,
css`
:host {
display: flex;
width: 100%;
height: 100%;
}
`,
];
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-section-layout': UmbSectionLayout;
}
}

View File

@@ -3,7 +3,7 @@ import { css, html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('umb-section-main')
export class UmbSectionMain extends LitElement {
export class UmbSectionMainElement extends LitElement {
static styles = [
UUITextStyles,
css`
@@ -11,6 +11,11 @@ export class UmbSectionMain extends LitElement {
flex: 1 1 auto;
height: 100%;
}
slot {
display: flex;
flex-direction: column;
height: 100%;
}
`,
];
@@ -21,6 +26,6 @@ export class UmbSectionMain extends LitElement {
declare global {
interface HTMLElementTagNameMap {
'umb-section-main': UmbSectionMain;
'umb-section-main': UmbSectionMainElement;
}
}

View File

@@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import type { UmbSectionMainElement } from './section-main.element';
import './section-main.element';
export default {
title: 'Sections/Shared/Section Main',
component: 'umb-section-main',
id: 'umb-section-main',
} as Meta;
export const AAAOverview: Story<UmbSectionMainElement> = () =>
html` <umb-section-main>Section Main Area</umb-section-main> `;
AAAOverview.storyName = 'Overview';

View File

@@ -1,14 +1,15 @@
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { css, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbContextConsumerMixin } from '../../../core/context';
import { UmbSectionContext } from '../section.context';
import '../../trees/shared/context-menu/tree-context-menu.service';
import { UmbObserverMixin } from '../../../core/observer';
import type { ManifestSection } from '../../../core/models';
import { UmbContextConsumerMixin } from '../../../../core/context';
import { UmbSectionContext } from '../../section.context';
import { UmbObserverMixin } from '../../../../core/observer';
import type { ManifestSection } from '../../../../core/models';
import '../../../trees/shared/context-menu/tree-context-menu.service';
@customElement('umb-section-sidebar')
export class UmbSectionSidebar extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
export class UmbSectionSidebarElement extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
static styles = [
UUITextStyles,
css`
@@ -71,6 +72,6 @@ export class UmbSectionSidebar extends UmbContextConsumerMixin(UmbObserverMixin(
declare global {
interface HTMLElementTagNameMap {
'umb-section-sidebar': UmbSectionSidebar;
'umb-section-sidebar': UmbSectionSidebarElement;
}
}

View File

@@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import type { UmbSectionSidebarElement } from './section-sidebar.element';
import './section-sidebar.element';
export default {
title: 'Sections/Shared/Section Sidebar',
component: 'umb-section-sidebar',
id: 'umb-section-sidebar',
} as Meta;
export const AAAOverview: Story<UmbSectionSidebarElement> = () =>
html` <umb-section-sidebar>Section Sidebar Area</umb-section-sidebar> `;
AAAOverview.storyName = 'Overview';

View File

@@ -3,15 +3,15 @@ import { html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { map, switchMap, EMPTY, of } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../core/context';
import { UmbExtensionRegistry } from '../../../core/extension';
import { UmbSectionContext } from '../section.context';
import { UmbContextConsumerMixin } from '../../../../core/context';
import { UmbExtensionRegistry } from '../../../../core/extension';
import { UmbSectionContext } from '../../section.context';
import { UmbObserverMixin } from '../../../../core/observer';
import '../../trees/shared/tree-extension.element';
import { UmbObserverMixin } from '../../../core/observer';
import '../../../trees/shared/tree-extension.element';
@customElement('umb-section-trees')
export class UmbSectionTrees extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
export class UmbSectionTreesElement extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
static styles = [UUITextStyles];
@state()
@@ -66,10 +66,10 @@ export class UmbSectionTrees extends UmbContextConsumerMixin(UmbObserverMixin(Li
}
}
export default UmbSectionTrees;
export default UmbSectionTreesElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-trees': UmbSectionTrees;
'umb-section-trees': UmbSectionTreesElement;
}
}

View File

@@ -0,0 +1,14 @@
import { Meta, Story } from '@storybook/web-components';
import { html } from 'lit-html';
import type { UmbSectionTreesElement } from './section-trees.element';
import './section-trees.element';
export default {
title: 'Sections/Shared/Section Sidebar',
component: 'umb-section-sidebar',
id: 'umb-section-sidebar',
} as Meta;
export const AAAOverview: Story<UmbSectionTreesElement> = () => html` <umb-section-trees></umb-section-trees>`;
AAAOverview.storyName = 'Overview';

View File

@@ -0,0 +1,142 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EMPTY, map, of, Subscription, switchMap } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../../core/context';
import { UmbExtensionRegistry } from '../../../../core/extension';
import type { ManifestSectionView } from '../../../../core/models';
import { UmbSectionContext } from '../../section.context';
@customElement('umb-section-views')
export class UmbSectionViewsElement extends UmbContextConsumerMixin(LitElement) {
static styles = [
UUITextStyles,
css`
#header {
background-color: var(--uui-color-surface);
border-bottom: 1px solid var(--uui-color-divider-standalone);
}
uui-tab-group {
justify-content: flex-end;
--uui-tab-divider: var(--uui-color-divider-standalone);
}
uui-tab-group uui-tab:first-child {
border-left: 1px solid var(--uui-color-divider-standalone);
}
`,
];
@state()
private _views: Array<ManifestSectionView> = [];
@state()
private _routerFolder = '';
@state()
private _activeView?: ManifestSectionView;
private _extensionRegistry?: UmbExtensionRegistry;
private _sectionContext?: UmbSectionContext;
private _viewsSubscription?: Subscription;
private _activeViewSubscription?: Subscription;
constructor() {
super();
// TODO: wait for more contexts
this.consumeContext('umbExtensionRegistry', (extensionsRegistry: UmbExtensionRegistry) => {
this._extensionRegistry = extensionsRegistry;
this._observeViews();
});
this.consumeContext('umbSectionContext', (sectionContext: UmbSectionContext) => {
this._sectionContext = sectionContext;
this._observeViews();
this._observeActiveView();
});
}
connectedCallback(): void {
super.connectedCallback();
/* TODO: find a way to construct absolute urls */
this._routerFolder = window.location.pathname.split('/view')[0];
}
private _observeViews() {
if (!this._sectionContext || !this._extensionRegistry) return;
this._viewsSubscription?.unsubscribe();
this._viewsSubscription = this._sectionContext?.data
.pipe(
switchMap((section) => {
if (!section) return EMPTY;
return (
this._extensionRegistry
?.extensionsOfType('sectionView')
.pipe(
map((views) =>
views
.filter((view) => view.meta.sections.includes(section.alias))
.sort((a, b) => b.meta.weight - a.meta.weight)
)
) ?? of([])
);
})
)
.subscribe((views) => {
this._views = views;
});
}
private _observeActiveView() {
this._activeViewSubscription?.unsubscribe();
this._activeViewSubscription = this._sectionContext?.activeView.subscribe((view) => {
this._activeView = view;
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._viewsSubscription?.unsubscribe();
this._activeViewSubscription?.unsubscribe();
}
render() {
return html` ${this._views.length > 0 ? html` <div id="header">${this._renderViews()}</div> ` : nothing} `;
}
private _renderViews() {
return html`
${this._views?.length > 0
? html`
<uui-tab-group>
${this._views.map(
(view: ManifestSectionView) => html`
<uui-tab
.label="${view.meta.label || view.name}"
href="${this._routerFolder}/view/${view.meta.pathname}"
?active="${this._activeView?.meta?.pathname.includes(view.meta.pathname)}">
<uui-icon slot="icon" name=${view.meta.icon}></uui-icon>
${view.meta.label || view.name}
</uui-tab>
`
)}
</uui-tab-group>
`
: nothing}
`;
}
}
export default UmbSectionViewsElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-views': UmbSectionViewsElement;
}
}

View File

@@ -1,17 +1,17 @@
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { css, html, LitElement } from 'lit';
import { css, html, LitElement, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { map, switchMap, EMPTY, of } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../core/context';
import { UmbExtensionRegistry } from '../../../core/extension';
import { createExtensionElement, UmbExtensionRegistry } from '../../../core/extension';
import { UmbSectionContext } from '../section.context';
import type { ManifestTree } from '../../../core/models';
import type { ManifestTree, ManifestSectionView } from '../../../core/models';
import { UmbEditorEntityElement } from '../../editors/shared/editor-entity/editor-entity.element';
import { UmbEntityStore } from '../../../core/stores/entity.store';
import { UmbObserverMixin } from '../../../core/observer';
import '../shared/section-trees.element.ts';
import './section-trees/section-trees.element.ts';
import '../shared/section-views/section-views.element.ts';
@customElement('umb-section')
export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(LitElement)) {
@@ -21,6 +21,24 @@ export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(
:host {
flex: 1 1 auto;
height: 100%;
display: flex;
}
#header {
display: flex;
gap: 16px;
align-items: center;
min-height: 60px;
}
uui-tab-group {
--uui-tab-divider: var(--uui-color-border);
border-left: 1px solid var(--uui-color-border);
border-right: 1px solid var(--uui-color-border);
}
#router-slot {
overflow: auto;
height: 100%;
}
`,
];
@@ -30,7 +48,10 @@ export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(
private _routes: Array<any> = [];
@state()
private _trees?: Array<ManifestTree>;
private _trees: Array<ManifestTree> = [];
@state()
private _views: Array<ManifestSectionView> = [];
private _entityStore?: UmbEntityStore;
private _sectionContext?: UmbSectionContext;
@@ -43,16 +64,19 @@ export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(
this.consumeContext('umbExtensionRegistry', (extensionsRegistry: UmbExtensionRegistry) => {
this._extensionRegistry = extensionsRegistry;
this._observeTrees();
this._observeViews();
});
this.consumeContext('umbSectionContext', (sectionContext: UmbSectionContext) => {
this._sectionContext = sectionContext;
this._observeTrees();
this._observeViews();
});
this.consumeContext('umbEntityStore', (entityStore: UmbEntityStore) => {
this._entityStore = entityStore;
this._observeTrees();
this._observeViews();
});
}
@@ -79,12 +103,13 @@ export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(
),
(trees) => {
this._trees = trees;
this._createRoutes();
if (this._trees.length === 0) return;
this._createTreeRoutes();
}
);
}
private _createRoutes() {
private _createTreeRoutes() {
const treeRoutes =
this._trees?.map(() => {
return {
@@ -100,7 +125,7 @@ export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(
this._routes = [
{
path: 'dashboard',
component: () => import('../shared/section-dashboards.element'),
component: () => import('./section-dashboards/section-dashboards.element'),
},
...treeRoutes,
{
@@ -110,16 +135,66 @@ export class UmbSectionElement extends UmbContextConsumerMixin(UmbObserverMixin(
];
}
private _observeViews() {
if (!this._sectionContext || !this._extensionRegistry || !this._entityStore) return;
this.observe<ManifestSectionView[]>(
this._sectionContext.data.pipe(
switchMap((section) => {
if (!section) return EMPTY;
return (
this._extensionRegistry
?.extensionsOfType('sectionView')
.pipe(
map((views) =>
views
.filter((view) => view.meta.sections.includes(section.alias))
.sort((a, b) => b.meta.weight - a.meta.weight)
)
) ?? of([])
);
})
),
(views) => {
this._views = views;
if (this._views.length === 0) return;
this._createViewRoutes();
}
);
}
private _createViewRoutes() {
this._routes =
this._views?.map((view) => {
return {
path: 'view/' + view.meta.pathname,
component: () => createExtensionElement(view),
setup: () => {
this._sectionContext?.setActiveView(view);
},
};
}) ?? [];
this._routes.push({
path: '**',
redirectTo: 'view/' + this._views?.[0]?.meta.pathname,
});
}
render() {
return html`
<umb-section-layout>
<umb-section-sidebar>
<umb-section-trees></umb-section-trees>
</umb-section-sidebar>
<umb-section-main>
<router-slot id="router-slot" .routes="${this._routes}"></router-slot>
</umb-section-main>
</umb-section-layout>
${this._trees.length > 0
? html`
<umb-section-sidebar>
<umb-section-trees></umb-section-trees>
</umb-section-sidebar>
`
: nothing}
<umb-section-main>
${this._views.length > 0 ? html`<umb-section-views></umb-section-views>` : nothing}
<router-slot id="router-slot" .routes="${this._routes}"></router-slot>
</umb-section-main>
`;
}
}

View File

@@ -0,0 +1,17 @@
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('umb-section-users')
export class UmbSectionUsersElement extends LitElement {
render() {
return html` <umb-section></umb-section> `;
}
}
export default UmbSectionUsersElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-users': UmbSectionUsersElement;
}
}

View File

@@ -0,0 +1,17 @@
import { InterfaceColor, InterfaceLook } from '@umbraco-ui/uui-base/lib/types';
export type UserStatus = 'enabled' | 'inactive' | 'invited' | 'disabled';
export const getTagLookAndColor = (status: UserStatus): { look: InterfaceLook; color: InterfaceColor } => {
switch ((status || '').toLowerCase()) {
case 'invited':
case 'inactive':
return { look: 'primary', color: 'warning' };
case 'enabled':
return { look: 'primary', color: 'positive' };
case 'disabled':
return { look: 'primary', color: 'danger' };
default:
return { look: 'secondary', color: 'default' };
}
};

View File

@@ -0,0 +1,163 @@
import { UUITextStyles } from '@umbraco-ui/uui-css';
import { css, html, LitElement } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../../../core/context';
import type { UserGroupDetails } from '../../../../../core/models';
import { UmbUserGroupStore } from '../../../../../core/stores/user/user-group.store';
import UmbTableElement, {
UmbTableColumn,
UmbTableConfig,
UmbTableDeselectedEvent,
UmbTableItem,
UmbTableOrderedEvent,
UmbTableSelectedEvent,
} from '../../../../components/table/table.element';
import './user-group-table-name-column-layout.element';
@customElement('umb-editor-view-user-groups')
export class UmbEditorViewUserGroupsElement extends UmbContextConsumerMixin(LitElement) {
static styles = [
UUITextStyles,
css`
:host {
height: 100%;
display: flex;
flex-direction: column;
}
`,
];
@state()
private _userGroups: Array<UserGroupDetails> = [];
@state()
private _tableConfig: UmbTableConfig = {
allowSelection: true,
};
@state()
private _tableColumns: Array<UmbTableColumn> = [
{
name: 'Name',
alias: 'userGroupName',
elementName: 'umb-user-group-table-name-column-layout',
},
{
name: 'Sections',
alias: 'userGroupSections',
},
{
name: 'Content start node',
alias: 'userGroupContentStartNode',
},
{
name: 'Media start node',
alias: 'userGroupMediaStartNode',
},
];
@state()
private _tableItems: Array<UmbTableItem> = [];
@state()
private _selection: Array<string> = [];
private _userGroupStore?: UmbUserGroupStore;
private _userGroupsSubscription?: Subscription;
private _selectionSubscription?: Subscription;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUserGroupStore', (userStore: UmbUserGroupStore) => {
this._userGroupStore = userStore;
this._observeUsers();
});
}
private _observeUsers() {
this._userGroupsSubscription?.unsubscribe();
this._userGroupsSubscription = this._userGroupStore?.getAll().subscribe((userGroups) => {
this._userGroups = userGroups;
console.log('user groups', userGroups);
this._createTableItems(this._userGroups);
});
}
private _createTableItems(userGroups: Array<UserGroupDetails>) {
this._tableItems = userGroups.map((userGroup) => {
return {
key: userGroup.key,
icon: userGroup.icon,
data: [
{
columnAlias: 'userGroupName',
value: {
name: userGroup.name,
},
},
{
columnAlias: 'userGroupSections',
value: userGroup.sections,
},
{
columnAlias: 'userGroupContentStartNode',
value: userGroup.contentStartNode,
},
{
columnAlias: 'userGroupMediaStartNode',
value: userGroup.mediaStartNode,
},
],
};
});
}
private _handleSelected(event: UmbTableSelectedEvent) {
event.stopPropagation();
console.log('HANDLE SELECT');
}
private _handleDeselected(event: UmbTableDeselectedEvent) {
event.stopPropagation();
console.log('HANDLE DESELECT');
}
private _handleOrdering(event: UmbTableOrderedEvent) {
const table = event.target as UmbTableElement;
const orderingColumn = table.orderingColumn;
const orderingDesc = table.orderingDesc;
console.log(`fetch users, order column: ${orderingColumn}, desc: ${orderingDesc}`);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._userGroupsSubscription?.unsubscribe();
this._selectionSubscription?.unsubscribe();
}
render() {
return html`
<umb-table
.config=${this._tableConfig}
.columns=${this._tableColumns}
.items=${this._tableItems}
.selection=${this._selection}
@selected="${this._handleSelected}"
@deselected="${this._handleDeselected}"
@ordered="${this._handleOrdering}"></umb-table>
`;
}
}
export default UmbEditorViewUserGroupsElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-user-groups': UmbEditorViewUserGroupsElement;
}
}

View File

@@ -0,0 +1,19 @@
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import './editor-view-user-groups.element';
@customElement('umb-section-view-user-groups')
export class UmbSectionViewUserGroupsElement extends LitElement {
render() {
return html`<umb-editor-view-user-groups></umb-editor-view-user-groups>`;
}
}
export default UmbSectionViewUserGroupsElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-view-user-groups': UmbSectionViewUserGroupsElement;
}
}

View File

@@ -0,0 +1,26 @@
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { UmbTableItem } from '../../../../components/table/table.element';
@customElement('umb-user-group-table-name-column-layout')
export class UmbUserGroupTableNameColumnLayoutElement extends LitElement {
@property({ type: Object, attribute: false })
item!: UmbTableItem;
@property({ attribute: false })
value!: any;
render() {
return html` <a style="font-weight: bold;" href="/section/users/view/users/userGroup/${this.item.key}">
${this.value.name}
</a>`;
}
}
export default UmbUserGroupTableNameColumnLayoutElement;
declare global {
interface HTMLElementTagNameMap {
'umb-user-group-table-name-column-layout': UmbUserGroupTableNameColumnLayoutElement;
}
}

View File

@@ -0,0 +1,186 @@
import { css, html, nothing } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, query, state } from 'lit/decorators.js';
import { UmbContextConsumerMixin } from '../../../../../core/context';
import { UmbModalLayoutElement } from '../../../../../core/services/modal/layouts/modal-layout.element';
import { UmbUserStore } from '../../../../../core/stores/user/user.store';
import type { UserDetails } from '../../../../../core/models';
import { UmbNotificationService } from '../../../../../core/services/notification';
export type UsersViewType = 'list' | 'grid';
@customElement('umb-editor-view-users-invite')
export class UmbEditorViewUsersInviteElement extends UmbContextConsumerMixin(UmbModalLayoutElement) {
static styles = [
UUITextStyles,
css`
:host {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
uui-box {
max-width: 500px;
}
uui-form-layout-item {
display: flex;
flex-direction: column;
}
uui-input {
width: 100%;
}
form {
display: flex;
flex-direction: column;
box-sizing: border-box;
}
uui-form-layout-item {
margin-bottom: 0;
}
uui-textarea {
--uui-textarea-min-height: 100px;
}
uui
`,
];
@query('#invite-form')
private _form!: HTMLFormElement;
@state()
private _invitedUser?: UserDetails;
protected _userStore?: UmbUserStore;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUserStore', (usersContext: UmbUserStore) => {
this._userStore = usersContext;
});
}
private _handleSubmit(e: Event) {
e.preventDefault();
const form = e.target as HTMLFormElement;
if (!form) return;
const isValid = form.checkValidity();
if (!isValid) return;
const formData = new FormData(form);
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const userGroup = formData.get('userGroup') as string;
const message = formData.get('message') as string;
this._userStore?.invite(name, email, message, [userGroup]).then((user) => {
if (user) {
this._invitedUser = user;
}
});
}
private _submitForm() {
this._form?.requestSubmit();
}
private _closeModal() {
this.modalHandler?.close();
}
private _resetForm() {
this._invitedUser = undefined;
}
private _goToProfile() {
if (!this._invitedUser) return;
this._closeModal();
history.pushState(null, '', '/section/users/view/users/user/' + this._invitedUser?.key); //TODO: URL Should be dynamic
}
private _renderForm() {
return html` <h1>Invite user</h1>
<p style="margin-top: 0">
Invite new users to give them access to Umbraco. An invite email will be sent to the user with information on
how to log in to Umbraco. Invites last for 72 hours.
</p>
<uui-form>
<form id="invite-form" name="invite-form" @submit="${this._handleSubmit}">
<uui-form-layout-item>
<uui-label slot="label" for="name" required>Name</uui-label>
<uui-input id="name" label="name" type="text" name="name" required></uui-input>
</uui-form-layout-item>
<uui-form-layout-item>
<uui-label slot="label" for="email" required>Email</uui-label>
<uui-input id="email" label="email" type="email" name="email" required></uui-input>
</uui-form-layout-item>
<uui-form-layout-item>
<uui-label slot="label" for="userGroup" required>User group</uui-label>
<span slot="description">Add groups to assign access and permissions</span>
<b>ADD USER GROUP PICKER HERE</b>
</uui-form-layout-item>
<uui-form-layout-item>
<uui-label slot="label" for="message" required>Message</uui-label>
<uui-textarea id="message" label="message" name="message" required></uui-textarea>
</uui-form-layout-item>
</form>
</uui-form>`;
}
private _renderPostInvite() {
if (!this._invitedUser) return nothing;
return html`<div id="post-invite">
<h1><b style="color: var(--uui-color-interactive-emphasis)">${this._invitedUser.name}</b> has been invited</h1>
<p>An invitation has been sent to the new user with details about how to log in to Umbraco.</p>
</div>`;
}
render() {
return html`<uui-dialog-layout>
${this._invitedUser ? this._renderPostInvite() : this._renderForm()}
${this._invitedUser
? html`
<uui-button
@click=${this._closeModal}
style="margin-right: auto"
slot="actions"
label="Close"
look="secondary"></uui-button>
<uui-button
@click=${this._resetForm}
slot="actions"
label="Invite another user"
look="secondary"></uui-button>
<uui-button @click=${this._goToProfile} slot="actions" label="Go to profile" look="primary"></uui-button>
`
: html`
<uui-button
@click=${this._closeModal}
style="margin-right: auto"
slot="actions"
label="Cancel"
look="secondary"></uui-button>
<uui-button
@click="${this._submitForm}"
slot="actions"
type="submit"
label="Send invite"
look="primary"></uui-button>
`}
</uui-dialog-layout>`;
}
}
export default UmbEditorViewUsersInviteElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-users-invite': UmbEditorViewUsersInviteElement;
}
}

View File

@@ -0,0 +1,215 @@
import { css, html, LitElement, nothing } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import './list-view-layouts/table/editor-view-users-table.element';
import './list-view-layouts/grid/editor-view-users-grid.element';
import './editor-view-users-selection.element';
import './editor-view-users-invite.element';
import { IRoute } from 'router-slot';
import { UUIPopoverElement } from '@umbraco-ui/uui';
import { UmbContextConsumerMixin } from '../../../../../core/context';
import UmbSectionViewUsersElement from './section-view-users.element';
import { UmbModalService } from '../../../../../core/services/modal';
export type UsersViewType = 'list' | 'grid';
@customElement('umb-editor-view-users-overview')
export class UmbEditorViewUsersOverviewElement extends UmbContextConsumerMixin(LitElement) {
static styles = [
UUITextStyles,
css`
:host {
height: 100%;
display: flex;
flex-direction: column;
}
#sticky-top {
position: sticky;
top: -1px;
z-index: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0), 0 1px 2px rgba(0, 0, 0, 0);
transition: 250ms box-shadow ease-in-out;
}
#sticky-top.header-shadow {
box-shadow: var(--uui-shadow-depth-2);
}
#user-list-top-bar {
padding: var(--uui-size-space-4) var(--uui-size-space-6);
background-color: var(--uui-color-surface-alt);
display: flex;
justify-content: space-between;
white-space: nowrap;
gap: 16px;
align-items: center;
}
#user-list {
padding: var(--uui-size-space-6);
padding-top: var(--uui-size-space-2);
}
#input-search {
width: 100%;
}
uui-popover {
width: unset;
}
.filter-dropdown {
display: flex;
gap: 8px;
flex-direction: column;
background-color: var(--uui-color-surface);
padding: var(--uui-size-space-4);
border-radius: var(--uui-size-border-radius);
box-shadow: var(--uui-shadow-depth-2);
width: fit-content;
}
a {
color: inherit;
text-decoration: none;
}
router-slot {
overflow: hidden;
}
`,
];
@state()
private _selection: Array<string> = [];
@state()
private _routes: IRoute[] = [
{
path: 'grid',
component: () => import('./list-view-layouts/grid/editor-view-users-grid.element'),
},
{
path: 'list',
component: () => import('./list-view-layouts/table/editor-view-users-table.element'),
},
{
path: '**',
redirectTo: '/section/users/view/users/overview/grid', //TODO: this should be dynamic
},
];
private _usersContext?: UmbSectionViewUsersElement;
private _selectionSubscription?: Subscription;
private _modalService?: UmbModalService;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUsersContext', (usersContext: UmbSectionViewUsersElement) => {
this._usersContext = usersContext;
this._selectionSubscription?.unsubscribe();
this._selectionSubscription = this._usersContext?.selection.subscribe((selection: Array<string>) => {
this._selection = selection;
});
});
this.consumeContext('umbModalService', (modalService: UmbModalService) => {
this._modalService = modalService;
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._selectionSubscription?.unsubscribe();
}
private _toggleViewType() {
const isList = window.location.pathname.split('/').pop() === 'list';
isList
? history.pushState(null, '', '/section/users/view/users/overview/grid')
: history.pushState(null, '', '/section/users/view/users/overview/list');
}
private _renderSelection() {
if (this._selection.length === 0) return nothing;
return html`<umb-editor-view-users-selection></umb-editor-view-users-selection>`;
}
private _handleTogglePopover(event: PointerEvent) {
const composedPath = event.composedPath();
const popover = composedPath.find((el) => el instanceof UUIPopoverElement) as UUIPopoverElement;
if (popover) {
popover.open = !popover.open;
}
}
private _showInvite() {
const invite = document.createElement('umb-editor-view-users-invite');
this._modalService?.open(invite, { type: 'dialog' });
}
render() {
return html`
<div id="sticky-top">
<div id="user-list-top-bar">
<uui-button @click=${this._showInvite} label="Invite user" look="outline"></uui-button>
<uui-input label="search" id="input-search"></uui-input>
<div>
<uui-popover margin="8">
<uui-button @click=${this._handleTogglePopover} slot="trigger" label="status">
Status: <b>All</b>
</uui-button>
<div slot="popover" class="filter-dropdown">
<uui-checkbox label="Active"></uui-checkbox>
<uui-checkbox label="Inactive"></uui-checkbox>
<uui-checkbox label="Invited"></uui-checkbox>
<uui-checkbox label="Disabled"></uui-checkbox>
</div>
</uui-popover>
<uui-popover margin="8">
<uui-button @click=${this._handleTogglePopover} slot="trigger" label="groups">
Groups: <b>All</b>
</uui-button>
<div slot="popover" class="filter-dropdown">
<uui-checkbox label="Active"></uui-checkbox>
<uui-checkbox label="Inactive"></uui-checkbox>
<uui-checkbox label="Invited"></uui-checkbox>
<uui-checkbox label="Disabled"></uui-checkbox>
</div>
</uui-popover>
<uui-popover margin="8">
<uui-button @click=${this._handleTogglePopover} slot="trigger" label="order by">
Order by: <b>Name (A-Z)</b>
</uui-button>
<div slot="popover" class="filter-dropdown">
<uui-checkbox label="Active"></uui-checkbox>
<uui-checkbox label="Inactive"></uui-checkbox>
<uui-checkbox label="Invited"></uui-checkbox>
<uui-checkbox label="Disabled"></uui-checkbox>
</div>
</uui-popover>
<uui-button label="view toggle" @click=${this._toggleViewType} compact look="outline">
<uui-icon name="settings"></uui-icon>
</uui-button>
</div>
</div>
${this._renderSelection()}
</div>
<router-slot .routes=${this._routes}></router-slot>
`;
}
}
export default UmbEditorViewUsersOverviewElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-users-overview': UmbEditorViewUsersOverviewElement;
}
}

View File

@@ -0,0 +1,95 @@
import { css, html, LitElement } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../../../core/context';
import type { UmbUserStore } from '../../../../../core/stores/user/user.store';
import { UmbSectionViewUsersElement } from './section-view-users.element';
@customElement('umb-editor-view-users-selection')
export class UmbEditorViewUsersSelectionElement extends UmbContextConsumerMixin(LitElement) {
static styles = [
UUITextStyles,
css`
:host {
display: flex;
gap: var(--uui-size-3);
width: 100%;
padding: var(--uui-size-space-4) var(--uui-size-space-6);
background-color: var(--uui-color-selected);
color: var(--uui-color-selected-contrast);
align-items: center;
box-sizing: border-box;
}
`,
];
@state()
private _selection: Array<string> = [];
@state()
private _totalUsers = 0;
private _usersContext?: UmbSectionViewUsersElement;
private _selectionSubscription?: Subscription;
private _userStore?: UmbUserStore;
private _totalUsersSubscription?: Subscription;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUsersContext', (usersContext: UmbSectionViewUsersElement) => {
this._usersContext = usersContext;
this._observeSelection();
});
this.consumeContext('umbUserStore', (userStore: UmbUserStore) => {
this._userStore = userStore;
this._observeTotalUsers();
});
}
private _observeSelection() {
this._selectionSubscription?.unsubscribe();
this._selectionSubscription = this._usersContext?.selection.subscribe((selection: Array<string>) => {
this._selection = selection;
});
}
private _observeTotalUsers() {
this._totalUsersSubscription?.unsubscribe();
this._userStore?.totalUsers.subscribe((totalUsers: number) => {
this._totalUsers = totalUsers;
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._selectionSubscription?.unsubscribe();
this._totalUsersSubscription?.unsubscribe();
}
private _handleClearSelection() {
this._usersContext?.setSelection([]);
}
private _renderSelectionCount() {
return html`<div>${this._selection.length} of ${this._totalUsers} selected</div>`;
}
render() {
return html`<uui-button @click=${this._handleClearSelection} label="Clear selection" look="secondary"></uui-button>
${this._renderSelectionCount()}
<uui-button style="margin-left: auto" label="Set group" look="secondary"></uui-button>
<uui-button label="Enable" look="secondary"></uui-button>
<uui-button label="Unlock" disabled look="secondary"></uui-button>
<uui-button label="Disable" look="secondary"></uui-button> `;
}
}
export default UmbEditorViewUsersSelectionElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-users-selection': UmbEditorViewUsersSelectionElement;
}
}

View File

@@ -0,0 +1,161 @@
import { css, html, LitElement, nothing } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { Subscription } from 'rxjs';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { UmbContextConsumerMixin } from '../../../../../../../core/context';
import UmbSectionViewUsersElement from '../../section-view-users.element';
import { UmbUserStore } from '../../../../../../../core/stores/user/user.store';
import type { UserDetails, UserEntity } from '../../../../../../../core/models';
import { getTagLookAndColor } from '../../../../user-extensions';
@customElement('umb-editor-view-users-grid')
export class UmbEditorViewUsersGridElement extends UmbContextConsumerMixin(LitElement) {
static styles = [
UUITextStyles,
css`
:host {
height: 100%;
display: flex;
flex-direction: column;
}
#user-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--uui-size-space-4);
padding: var(--uui-size-space-4);
padding-top: 0;
}
uui-card-user {
width: 100%;
height: 180px;
}
.user-login-time {
margin-top: auto;
}
`,
];
@state()
private _users: Array<UserDetails> = [];
@state()
private _selection: Array<string> = [];
private _userStore?: UmbUserStore;
private _usersContext?: UmbSectionViewUsersElement;
private _usersSubscription?: Subscription;
private _selectionSubscription?: Subscription;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUserStore', (userStore: UmbUserStore) => {
this._userStore = userStore;
this._observeUsers();
});
this.consumeContext('umbUsersContext', (usersContext: UmbSectionViewUsersElement) => {
this._usersContext = usersContext;
this._observeSelection();
});
}
private _observeUsers() {
this._usersSubscription?.unsubscribe();
this._usersSubscription = this._userStore?.getAll().subscribe((users) => {
this._users = users;
});
}
private _observeSelection() {
this._selectionSubscription?.unsubscribe();
this._selectionSubscription = this._usersContext?.selection.subscribe((selection: Array<string>) => {
this._selection = selection;
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._usersSubscription?.unsubscribe();
this._selectionSubscription?.unsubscribe();
}
private _isSelected(key: string) {
return this._selection.includes(key);
}
//TODO How should we handle url stuff?
private _handleOpenCard(key: string) {
history.pushState(null, '', '/section/users/view/users/user/' + key); //TODO Change to a tag with href and make dynamic
}
private _selectRowHandler(user: UserEntity) {
this._usersContext?.select(user.key);
}
private _deselectRowHandler(user: UserEntity) {
this._usersContext?.deselect(user.key);
}
private renderUserCard(user: UserDetails) {
if (!this._userStore) return;
const statusLook = getTagLookAndColor(user.status);
return html`
<uui-card-user
.name=${user.name}
selectable
?select-only=${this._selection.length > 0}
?selected=${this._isSelected(user.key)}
@open=${() => this._handleOpenCard(user.key)}
@selected=${() => this._selectRowHandler(user)}
@unselected=${() => this._deselectRowHandler(user)}>
${user.status && user.status !== 'enabled'
? html`<uui-tag
slot="tag"
size="s"
look="${ifDefined(statusLook?.look)}"
color="${ifDefined(statusLook?.color)}">
${user.status}
</uui-tag>`
: nothing}
<div>USER GROUPS NOT IMPLEMENTED</div>
${user.lastLoginDate
? html`<div class="user-login-time">
<div>Last login</div>
${user.lastLoginDate}
</div>`
: html`<div class="user-login-time">${`${user.name} has not logged in yet`}</div>`}
</uui-card-user>
`;
}
render() {
return html`
<uui-scroll-container>
<div id="user-grid">
${repeat(
this._users,
(user) => user.key,
(user) => this.renderUserCard(user)
)}
</div>
</uui-scroll-container>
`;
}
}
export default UmbEditorViewUsersGridElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-users-grid': UmbEditorViewUsersGridElement;
}
}

View File

@@ -0,0 +1,30 @@
import { html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { UmbTableColumn, UmbTableItem } from '../../../../../../../../components/table/table.element';
@customElement('umb-user-table-name-column-layout')
export class UmbUserTableNameColumnLayoutElement extends LitElement {
@property({ type: Object, attribute: false })
column!: UmbTableColumn;
@property({ type: Object, attribute: false })
item!: UmbTableItem;
@property({ attribute: false })
value!: any;
render() {
return html` <div style="display: flex; align-items: center;">
<uui-avatar name="${this.value.name}" style="margin-right: 10px;"></uui-avatar>
<a style="font-weight: bold;" href="/section/users/view/users/user/${this.item.key}">${this.value.name}</a>
</div>`;
}
}
export default UmbUserTableNameColumnLayoutElement;
declare global {
interface HTMLElementTagNameMap {
'umb-user-table-name-column-layout': UmbUserTableNameColumnLayoutElement;
}
}

View File

@@ -0,0 +1,28 @@
import { html, LitElement, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { getTagLookAndColor } from '../../../../../../user-extensions';
@customElement('umb-user-table-status-column-layout')
export class UmbUserTableStatusColumnLayoutElement extends LitElement {
@property({ attribute: false })
value: any;
render() {
return html`${this.value.status && this.value.status !== 'enabled'
? html`<uui-tag
size="s"
look="${getTagLookAndColor(this.value.status).look}"
color="${getTagLookAndColor(this.value.status).color}">
${this.value.status}
</uui-tag>`
: nothing}`;
}
}
export default UmbUserTableStatusColumnLayoutElement;
declare global {
interface HTMLElementTagNameMap {
'umb-user-table-status-column-layout': UmbUserTableStatusColumnLayoutElement;
}
}

View File

@@ -0,0 +1,184 @@
import { css, html, LitElement } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, state } from 'lit/decorators.js';
import { Subscription } from 'rxjs';
import { UmbContextConsumerMixin } from '../../../../../../../core/context';
import type { UmbSectionViewUsersElement } from '../../section-view-users.element';
import { UmbUserStore } from '../../../../../../../core/stores/user/user.store';
import type { UserDetails } from '../../../../../../../core/models';
import {
UmbTableElement,
UmbTableColumn,
UmbTableDeselectedEvent,
UmbTableItem,
UmbTableSelectedEvent,
UmbTableConfig,
UmbTableOrderedEvent,
} from '../../../../../../components/table/table.element';
import './column-layouts/name/user-table-name-column-layout.element';
import './column-layouts/status/user-table-status-column-layout.element';
@customElement('umb-editor-view-users-table')
export class UmbEditorViewUsersTableElement extends UmbContextConsumerMixin(LitElement) {
static styles = [
UUITextStyles,
css`
:host {
height: 100%;
display: flex;
flex-direction: column;
}
`,
];
@state()
private _users: Array<UserDetails> = [];
@state()
private _tableConfig: UmbTableConfig = {
allowSelection: true,
};
@state()
private _tableColumns: Array<UmbTableColumn> = [
{
name: 'Name',
alias: 'userName',
elementName: 'umb-user-table-name-column-layout',
},
{
name: 'User group',
alias: 'userGroup',
},
{
name: 'Last login',
alias: 'userLastLogin',
},
{
name: 'Status',
alias: 'userStatus',
elementName: 'umb-user-table-status-column-layout',
},
];
@state()
private _tableItems: Array<UmbTableItem> = [];
@state()
private _selection: Array<string> = [];
private _userStore?: UmbUserStore;
private _usersContext?: UmbSectionViewUsersElement;
private _usersSubscription?: Subscription;
private _selectionSubscription?: Subscription;
connectedCallback(): void {
super.connectedCallback();
this.consumeContext('umbUserStore', (userStore: UmbUserStore) => {
this._userStore = userStore;
this._observeUsers();
});
this.consumeContext('umbUsersContext', (usersContext: UmbSectionViewUsersElement) => {
this._usersContext = usersContext;
this._observeSelection();
});
}
private _observeUsers() {
this._usersSubscription?.unsubscribe();
this._usersSubscription = this._userStore?.getAll().subscribe((users) => {
this._users = users;
this._createTableItems(this._users);
});
}
private _observeSelection() {
this._selectionSubscription = this._usersContext?.selection.subscribe((selection: Array<string>) => {
if (this._selection === selection) return;
this._selection = selection;
});
}
private _createTableItems(users: Array<UserDetails>) {
this._tableItems = users.map((user) => {
return {
key: user.key,
icon: 'umb:user',
data: [
{
columnAlias: 'userName',
value: {
name: user.name,
},
},
{
columnAlias: 'userGroup',
value: user.userGroup,
},
{
columnAlias: 'userLastLogin',
value: user.lastLoginDate,
},
{
columnAlias: 'userStatus',
value: {
status: user.status,
},
},
],
};
});
}
private _handleSelected(event: UmbTableSelectedEvent) {
event.stopPropagation();
const table = event.target as UmbTableElement;
const selection = table.selection;
this._usersContext?.setSelection(selection);
}
private _handleDeselected(event: UmbTableDeselectedEvent) {
event.stopPropagation();
const table = event.target as UmbTableElement;
const selection = table.selection;
this._usersContext?.setSelection(selection);
}
private _handleOrdering(event: UmbTableOrderedEvent) {
const table = event.target as UmbTableElement;
const orderingColumn = table.orderingColumn;
const orderingDesc = table.orderingDesc;
console.log(`fetch users, order column: ${orderingColumn}, desc: ${orderingDesc}`);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._usersSubscription?.unsubscribe();
this._selectionSubscription?.unsubscribe();
}
render() {
return html`
<umb-table
.config=${this._tableConfig}
.columns=${this._tableColumns}
.items=${this._tableItems}
.selection=${this._selection}
@selected="${this._handleSelected}"
@deselected="${this._handleDeselected}"
@ordered="${this._handleOrdering}"></umb-table>
`;
}
}
export default UmbEditorViewUsersTableElement;
declare global {
interface HTMLElementTagNameMap {
'umb-editor-view-users-table': UmbEditorViewUsersTableElement;
}
}

View File

@@ -0,0 +1,84 @@
import { css, html, LitElement } from 'lit';
import { UUITextStyles } from '@umbraco-ui/uui-css/lib';
import { customElement, state } from 'lit/decorators.js';
import { BehaviorSubject, Observable } from 'rxjs';
import type { IRoute, IRoutingInfo } from 'router-slot';
import { UmbContextProviderMixin } from '../../../../../core/context';
import type { UmbEditorEntityElement } from '../../../../editors/shared/editor-entity/editor-entity.element';
import './list-view-layouts/table/editor-view-users-table.element';
import './list-view-layouts/grid/editor-view-users-grid.element';
import './editor-view-users-selection.element';
import './editor-view-users-invite.element';
@customElement('umb-section-view-users')
export class UmbSectionViewUsersElement extends UmbContextProviderMixin(LitElement) {
static styles = [
UUITextStyles,
css`
:host {
height: 100%;
}
`,
];
@state()
private _routes: IRoute[] = [
{
path: 'overview',
component: () => import('./editor-view-users-overview.element'),
},
{
path: `:entityType/:key`,
component: () => import('../../../../editors/shared/editor-entity/editor-entity.element'),
setup: (component: HTMLElement, info: IRoutingInfo) => {
const element = component as UmbEditorEntityElement;
element.entityKey = info.match.params.key;
element.entityType = info.match.params.entityType;
},
},
{
path: '**',
redirectTo: '/section/users/view/users/overview', //TODO: this should be dynamic
},
];
private _selection: BehaviorSubject<Array<string>> = new BehaviorSubject(<Array<string>>[]);
public readonly selection: Observable<Array<string>> = this._selection.asObservable();
constructor() {
super();
this.provideContext('umbUsersContext', this);
}
public setSelection(value: Array<string>) {
if (!value) return;
this._selection.next(value);
this.requestUpdate('selection');
}
public select(key: string) {
const selection = this._selection.getValue();
this._selection.next([...selection, key]);
this.requestUpdate('selection');
}
public deselect(key: string) {
const selection = this._selection.getValue();
this._selection.next(selection.filter((k) => k !== key));
this.requestUpdate('selection');
}
render() {
return html` <router-slot .routes=${this._routes}></router-slot> `;
}
}
export default UmbSectionViewUsersElement;
declare global {
interface HTMLElementTagNameMap {
'umb-section-view-users': UmbSectionViewUsersElement;
}
}

View File

@@ -7,9 +7,11 @@ import type {
ManifestPropertyAction,
ManifestPropertyEditorUI,
ManifestSection,
ManifestSectionView,
ManifestTree,
ManifestTreeItemAction,
ManifestEditor,
ManifestEditorAction,
ManifestCustom,
ManifestPackageView,
} from '../models';
@@ -58,11 +60,13 @@ export class UmbExtensionRegistry {
// Typings concept, need to put all core types to get a good array return type for the provided type...
extensionsOfType(type: 'section'): Observable<Array<ManifestSection>>;
extensionsOfType(type: 'sectionView'): Observable<Array<ManifestSectionView>>;
extensionsOfType(type: 'tree'): Observable<Array<ManifestTree>>;
extensionsOfType(type: 'editor'): Observable<Array<ManifestEditor>>;
extensionsOfType(type: 'treeItemAction'): Observable<Array<ManifestTreeItemAction>>;
extensionsOfType(type: 'dashboard'): Observable<Array<ManifestDashboard>>;
extensionsOfType(type: 'editorView'): Observable<Array<ManifestEditorView>>;
extensionsOfType(type: 'editorAction'): Observable<Array<ManifestEditorAction>>;
extensionsOfType(type: 'propertyEditorUI'): Observable<Array<ManifestPropertyEditorUI>>;
extensionsOfType(type: 'propertyAction'): Observable<Array<ManifestPropertyAction>>;
extensionsOfType(type: 'packageView'): Observable<Array<ManifestPackageView>>;

View File

@@ -1,4 +1,6 @@
import type { components } from '../../../schemas/generated-schema';
import type { UserStatus } from '../../backoffice/sections/users/user-extensions';
import { Entity } from '../../mocks/data/entities';
export type PostInstallRequest = components['schemas']['InstallSetupRequest'];
export type StatusResponse = components['schemas']['StatusResponse'];
@@ -19,9 +21,11 @@ export type TelemetryModel = components['schemas']['TelemetryModel'];
export type ServerStatus = components['schemas']['ServerStatus'];
export type ManifestTypes = components['schemas']['Manifest'];
export type ManifestSection = components['schemas']['IManifestSection'];
export type ManifestSectionView = components['schemas']['IManifestSectionView'];
export type ManifestTree = components['schemas']['IManifestTree'];
export type ManifestTreeItemAction = components['schemas']['IManifestTreeItemAction'];
export type ManifestEditor = components['schemas']['IManifestEditor'];
export type ManifestEditorAction = components['schemas']['IManifestEditorAction'];
export type ManifestPropertyEditorUI = components['schemas']['IManifestPropertyEditorUI'];
export type ManifestDashboard = components['schemas']['IManifestDashboard'];
export type ManifestEditorView = components['schemas']['IManifestEditorView'];
@@ -43,6 +47,7 @@ export type PropertyEditorConfigDefaultData = components['schemas']['PropertyEdi
export type ManifestElementType =
| ManifestSection
| ManifestSectionView
| ManifestTree
| ManifestTreeItemAction
| ManifestEditor
@@ -50,7 +55,39 @@ export type ManifestElementType =
| ManifestPropertyEditorUI
| ManifestDashboard
| ManifestEditorView
| ManifestEditorAction
| ManifestPackageView;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type HTMLElementConstructor<T = HTMLElement> = new (...args: any[]) => T;
// Users
export interface UserEntity extends Entity {
type: 'user';
}
export interface UserDetails extends UserEntity {
email: string;
status: UserStatus;
language: string;
lastLoginDate?: string;
lastLockoutDate?: string;
lastPasswordChangeDate?: string;
updateDate: string;
createDate: string;
failedLoginAttempts: number;
userGroup?: string; //TODO Implement this
}
export interface UserGroupEntity extends Entity {
type: 'userGroup';
}
export interface UserGroupDetails extends UserGroupEntity {
key: string;
name: string;
icon: string;
sections?: Array<string>;
contentStartNode?: string;
mediaStartNode?: string;
}

View File

@@ -18,9 +18,16 @@ export interface UmbDataStore<T> {
* @description - Base class for Data Stores
*/
export class UmbDataStoreBase<T extends UmbDataStoreIdentifiers> implements UmbDataStore<T> {
private _items: BehaviorSubject<Array<T>> = new BehaviorSubject(<Array<T>>[]);
protected _items: BehaviorSubject<Array<T>> = new BehaviorSubject(<Array<T>>[]);
public readonly items: Observable<Array<T>> = this._items.asObservable();
public delete(deletedKeys: Array<string>): void {
const remainingItems = this._items
.getValue()
.filter((item) => item.key && deletedKeys.includes(item.key) === false);
this._items.next(remainingItems);
}
/**
* @description - Update the store with new items. Existing items are updated, new items are added. Existing items are matched by the compareKey.
* @param {Array<T>} updatedItems

View File

@@ -0,0 +1,32 @@
import { BehaviorSubject, map, Observable } from 'rxjs';
import type { UserDetails, UserEntity, UserGroupDetails } from '../../models';
import { UmbEntityStore } from '../entity.store';
import { UmbDataStoreBase } from '../store';
import { v4 as uuidv4 } from 'uuid';
/**
* @export
* @class UmbUserGroupStore
* @extends {UmbDataStoreBase<UserGroupEntity>}
* @description - Data Store for Users
*/
export class UmbUserGroupStore extends UmbDataStoreBase<UserGroupDetails> {
private _entityStore: UmbEntityStore;
constructor(entityStore: UmbEntityStore) {
super();
this._entityStore = entityStore;
}
getAll(): Observable<Array<UserGroupDetails>> {
// TODO: use Fetcher API.
// TODO: only fetch if the data type is not in the store?
fetch(`/umbraco/backoffice/user-groups/list/items`)
.then((res) => res.json())
.then((data) => {
this.update(data.items);
});
return this.items;
}
}

View File

@@ -0,0 +1,201 @@
import { BehaviorSubject, map, Observable } from 'rxjs';
import type { UserDetails, UserEntity } from '../../models';
import { UmbEntityStore } from '../entity.store';
import { UmbDataStoreBase } from '../store';
import { v4 as uuidv4 } from 'uuid';
/**
* @export
* @class UmbUserStore
* @extends {UmbDataStoreBase<UserEntity>}
* @description - Data Store for Users
*/
export class UmbUserStore extends UmbDataStoreBase<UserDetails> {
private _entityStore: UmbEntityStore;
private _totalUsers: BehaviorSubject<number> = new BehaviorSubject(0);
public readonly totalUsers: Observable<number> = this._totalUsers.asObservable();
constructor(entityStore: UmbEntityStore) {
super();
this._entityStore = entityStore;
}
getAll(): Observable<Array<UserDetails>> {
// TODO: use Fetcher API.
// TODO: only fetch if the data type is not in the store?
fetch(`/umbraco/backoffice/users/list/items`)
.then((res) => res.json())
.then((data) => {
this._totalUsers.next(data.total);
this.update(data.items);
});
return this.items;
}
/**
* @description - Request a Data Type by key. The Data Type is added to the store and is returned as an Observable.
* @param {string} key
* @return {*} {(Observable<DataTypeDetails | null>)}
* @memberof UmbDataTypeStore
*/
getByKey(key: string): Observable<UserDetails | null> {
// TODO: use Fetcher API.
// TODO: only fetch if the data type is not in the store?
fetch(`/umbraco/backoffice/users/${key}`)
.then((res) => res.json())
.then((data) => {
this.update([data]);
});
return this.items.pipe(
map((items: Array<UserDetails>) => items.find((node: UserDetails) => node.key === key) || null)
);
}
async enableUsers(userKeys: Array<string>): Promise<void> {
// TODO: use Fetcher API.
try {
const res = await fetch('/umbraco/backoffice/users/enable', {
method: 'POST',
body: JSON.stringify(userKeys),
headers: {
'Content-Type': 'application/json',
},
});
const enabledKeys = await res.json();
const storedUsers = this._items.getValue().filter((user) => enabledKeys.includes(user.key));
storedUsers.forEach((user) => {
user.status = 'enabled';
});
this.update(storedUsers);
this._entityStore.update(storedUsers);
} catch (error) {
console.error('Enable Users failed', error);
}
}
async disableUsers(userKeys: Array<string>): Promise<void> {
// TODO: use Fetcher API.
try {
const res = await fetch('/umbraco/backoffice/users/disable', {
method: 'POST',
body: JSON.stringify(userKeys),
headers: {
'Content-Type': 'application/json',
},
});
const disabledKeys = await res.json();
const storedUsers = this._items.getValue().filter((user) => disabledKeys.includes(user.key));
storedUsers.forEach((user) => {
user.status = 'disabled';
});
this.update(storedUsers);
this._entityStore.update(storedUsers);
} catch (error) {
console.error('Disable Users failed', error);
}
}
async deleteUsers(userKeys: Array<string>): Promise<void> {
// TODO: use Fetcher API.
try {
const res = await fetch('/umbraco/backoffice/users/delete', {
method: 'POST',
body: JSON.stringify(userKeys),
headers: {
'Content-Type': 'application/json',
},
});
const deletedKeys = await res.json();
this.delete(deletedKeys);
this._entityStore.delete(deletedKeys);
} catch (error) {
console.error('Delete Users failed', error);
}
}
async save(users: Array<UserDetails>): Promise<void> {
// TODO: use Fetcher API.
try {
const res = await fetch('/umbraco/backoffice/users/save', {
method: 'POST',
body: JSON.stringify(users),
headers: {
'Content-Type': 'application/json',
},
});
const json = await res.json();
this.update(json);
this._entityStore.update(json);
} catch (error) {
console.error('Save Data Type error', error);
}
}
async invite(name: string, email: string, message: string, userGroups: Array<string>): Promise<UserDetails | null> {
// TODO: use Fetcher API.
try {
const res = await fetch('/umbraco/backoffice/users/invite', {
method: 'POST',
body: JSON.stringify({ name, email, message, userGroups }),
headers: {
'Content-Type': 'application/json',
},
});
const json = (await res.json()) as UserDetails[];
this.update(json);
this._entityStore.update(json);
return json[0];
} catch (error) {
console.error('Invite user error', error);
}
return null;
}
// public updateUser(user: UserItem) {
// const users = this._users.getValue();
// const index = users.findIndex((u) => u.key === user.key);
// if (index === -1) return;
// users[index] = { ...users[index], ...user };
// console.log('updateUser', user, users[index]);
// this._users.next(users);
// this.requestUpdate('users');
// }
// public inviteUser(name: string, email: string, userGroup: string, message: string): UserItem {
// const users = this._users.getValue();
// const user = {
// id: this._users.getValue().length + 1,
// key: uuidv4(),
// name: name,
// email: email,
// status: 'invited',
// language: 'en',
// updateDate: new Date().toISOString(),
// createDate: new Date().toISOString(),
// failedLoginAttempts: 0,
// userGroup: userGroup,
// };
// this._users.next([...users, user]);
// this.requestUpdate('users');
// //TODO: Send invite email with message
// return user;
// }
// public deleteUser(key: string) {
// const users = this._users.getValue();
// const index = users.findIndex((u) => u.key === key);
// if (index === -1) return;
// users.splice(index, 1);
// this._users.next(users);
// this.requestUpdate('users');
// }
}

View File

@@ -10,6 +10,8 @@ import { handlers as upgradeHandlers } from './domains/upgrade.handlers';
import { handlers as userHandlers } from './domains/user.handlers';
import { handlers as telemetryHandlers } from './domains/telemetry.handlers';
import { handlers as propertyEditorHandlers } from './domains/property-editor.handlers';
import { handlers as usersHandlers } from './domains/users.handlers';
import { handlers as userGroupsHandlers } from './domains/user-groups.handlers';
const handlers = [
serverHandlers.serverVersionHandler,
@@ -24,6 +26,8 @@ const handlers = [
...manifestsHandlers.default,
...telemetryHandlers,
...publishedStatusHandlers,
...usersHandlers,
...userGroupsHandlers,
];
switch (import.meta.env.VITE_UMBRACO_INSTALL_STATUS) {

View File

@@ -7,9 +7,9 @@ export class UmbEntityData<T extends Entity> extends UmbData<T> {
super(data);
}
getItems(type = '', parentKey = '') {
getItems(type: string, parentKey = '') {
if (!type) return [];
return entities.filter((item) => item.type === type && item.parentKey === parentKey);
return this.data.filter((item) => item.type === type && item.parentKey === parentKey);
}
getByKey(key: string) {
@@ -47,6 +47,12 @@ export class UmbEntityData<T extends Entity> extends UmbData<T> {
return trashedItems;
}
delete(keys: Array<string>) {
const deletedKeys = this.data.filter((item) => keys.includes(item.key)).map((item) => item.key);
this.data = this.data.filter((item) => keys.indexOf(item.key) === -1);
return deletedKeys;
}
protected updateData(updateItem: T) {
const itemIndex = this.data.findIndex((item) => item.key === updateItem.key);
const item = this.data[itemIndex];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import { rest } from 'msw';
import type { UserGroupDetails } from '../../core/models';
export const handlers = [
rest.get('/umbraco/backoffice/user-groups/list/items', (req, res, ctx) => {
const items = fakeData;
const response = {
total: items.length,
items,
};
return res(ctx.status(200), ctx.json(response));
}),
];
const fakeData: Array<UserGroupDetails> = [
{
key: '10000000-0000-0000-0000-000000000000',
name: 'Administrators',
icon: 'umb:medal',
parentKey: '',
type: 'userGroup',
hasChildren: false,
isTrashed: false,
},
{
key: '20000000-0000-0000-0000-000000000000',
name: 'Editors',
icon: 'umb:tools',
parentKey: '',
type: 'userGroup',
hasChildren: false,
isTrashed: false,
},
{
key: '20000000-0000-0000-0000-000000000000',
name: 'Sensitive Data',
icon: 'umb:lock',
parentKey: '',
type: 'userGroup',
hasChildren: false,
isTrashed: false,
},
{
key: '20000000-0000-0000-0000-000000000000',
name: 'Translators',
icon: 'umb:globe',
parentKey: '',
type: 'userGroup',
hasChildren: false,
isTrashed: false,
},
{
key: '20000000-0000-0000-0000-000000000000',
name: 'Writers',
icon: 'umb:edit',
parentKey: '',
type: 'userGroup',
hasChildren: false,
isTrashed: false,
},
];

View File

@@ -0,0 +1,93 @@
import { rest } from 'msw';
import { v4 as uuidv4 } from 'uuid';
import type { UserDetails } from '../../core/models';
import { umbUsersData } from '../data/users.data';
// TODO: add schema
export const handlers = [
rest.get('/umbraco/backoffice/users/list/items', (req, res, ctx) => {
const items = umbUsersData.getItems('user');
const response = {
total: items.length,
items,
};
return res(ctx.status(200), ctx.json(response));
}),
rest.get('/umbraco/backoffice/users/:key', (req, res, ctx) => {
const key = req.params.key as string;
if (!key) return;
const user = umbUsersData.getByKey(key);
return res(ctx.status(200), ctx.json(user));
}),
rest.post<UserDetails[]>('/umbraco/backoffice/users/save', async (req, res, ctx) => {
const data = await req.json();
if (!data) return;
const saved = umbUsersData.save(data);
console.log('saved', saved);
return res(ctx.status(200), ctx.json(saved));
}),
rest.post<UserDetails[]>('/umbraco/backoffice/users/invite', async (req, res, ctx) => {
const data = await req.json();
if (!data) return;
const newUser: UserDetails = {
key: uuidv4(),
name: data.name,
email: data.email,
status: 'invited',
language: 'en',
updateDate: new Date().toISOString(),
createDate: new Date().toISOString(),
failedLoginAttempts: 0,
parentKey: '',
isTrashed: false,
hasChildren: false,
type: 'user',
icon: 'umb:icon-user',
userGroup: data.userGroups[0],
};
const invited = umbUsersData.save([newUser]);
console.log('invited', invited);
return res(ctx.status(200), ctx.json(invited));
}),
rest.post<Array<string>>('/umbraco/backoffice/users/enable', async (req, res, ctx) => {
const data = await req.json();
if (!data) return;
const enabledKeys = umbUsersData.enable(data);
return res(ctx.status(200), ctx.json(enabledKeys));
}),
rest.post<Array<string>>('/umbraco/backoffice/users/disable', async (req, res, ctx) => {
const data = await req.json();
if (!data) return;
const disabledKeys = umbUsersData.disable(data);
return res(ctx.status(200), ctx.json(disabledKeys));
}),
rest.post<Array<string>>('/umbraco/backoffice/users/delete', async (req, res, ctx) => {
const data = await req.json();
if (!data) return;
const deletedKeys = umbUsersData.delete(data);
return res(ctx.status(200), ctx.json(deletedKeys));
}),
];

View File

@@ -63,6 +63,17 @@ export const internalManifests: Array<ManifestTypes & { loader: () => Promise<ob
weight: 20,
},
},
{
type: 'section',
alias: 'Umb.Section.Users',
name: 'Users',
loader: () => import('../backoffice/sections/users/section-users.element'),
meta: {
label: 'Users',
pathname: 'users',
weight: 20,
},
},
{
type: 'dashboard',
alias: 'Umb.Dashboard.Welcome',
@@ -91,14 +102,14 @@ export const internalManifests: Array<ManifestTypes & { loader: () => Promise<ob
},
{
type: 'dashboard',
alias: 'Umb.Dashboard.SettingsAbout',
name: 'About Settings Dashboard',
elementName: 'umb-dashboard-settings-about',
loader: () => import('../backoffice/dashboards/settings-about/dashboard-settings-about.element'),
alias: 'Umb.Dashboard.SettingsWelcome',
name: 'Welcome Settings Dashboard',
elementName: 'umb-dashboard-settings-welcome',
loader: () => import('../backoffice/dashboards/settings-welcome/dashboard-settings-welcome.element'),
meta: {
label: 'About',
label: 'Welcome',
sections: ['Umb.Section.Settings'],
pathname: 'about', // TODO: how to we want to support pretty urls?
pathname: 'welcome', // TODO: how to we want to support pretty urls?
weight: 10,
},
},
@@ -273,6 +284,33 @@ export const internalManifests: Array<ManifestTypes & { loader: () => Promise<ob
weight: 0,
},
},
{
type: 'editor',
alias: 'Umb.Editor.User',
name: 'User Editor',
loader: () => import('../backoffice/editors/user/editor-user.element'),
meta: {
entityType: 'user',
},
},
{
type: 'editor',
alias: 'Umb.Editor.UserGroup',
name: 'User Group Editor',
loader: () => import('../backoffice/editors/user-group/editor-user-group.element'),
meta: {
entityType: 'userGroup',
},
},
{
type: 'editorAction',
alias: 'Umb.EditorAction.User.Save',
name: 'EditorActionUserSave',
loader: () => import('../backoffice/editors/user/actions/editor-action-user-save.element'),
meta: {
editors: ['Umb.Editor.User'],
},
},
{
type: 'propertyAction',
alias: 'Umb.PropertyAction.Copy',
@@ -498,4 +536,30 @@ export const internalManifests: Array<ManifestTypes & { loader: () => Promise<ob
weight: 100,
},
},
{
type: 'sectionView',
alias: 'Umb.SectionView.Users',
name: 'Users Section View',
loader: () => import('../backoffice/sections/users/views/users/section-view-users.element'),
meta: {
sections: ['Umb.Section.Users'],
label: 'Users',
pathname: 'users',
weight: 200,
icon: 'umb:user',
},
},
{
type: 'sectionView',
alias: 'Umb.SectionView.UserGroups',
name: 'User Groups Section View',
loader: () => import('../backoffice/sections/users/views/user-groups/section-view-user-groups.element'),
meta: {
sections: ['Umb.Section.Users'],
label: 'User Groups',
pathname: 'user-groups',
weight: 100,
icon: 'umb:users',
},
},
];

View File

@@ -32,12 +32,14 @@ export class ManifestsPackagesInstalled {
export type Manifest =
| IManifestSection
| IManifestSectionView
| IManifestTree
| IManifestEditor
| IManifestEditorAction
| IManifestEditorView
| IManifestTreeItemAction
| IManifestPropertyEditorUI
| IManifestDashboard
| IManifestEditorView
| IManifestPropertyAction
| IManifestPackageView
| IManifestEntrypoint
@@ -45,12 +47,14 @@ export type Manifest =
export type ManifestStandardTypes =
| 'section'
| 'sectionView'
| 'tree'
| 'editor'
| 'editorView'
| 'editorAction'
| 'treeItemAction'
| 'propertyEditorUI'
| 'dashboard'
| 'editorView'
| 'propertyAction'
| 'packageView'
| 'entrypoint';
@@ -85,6 +89,14 @@ export interface MetaSection {
weight: number;
}
export interface MetaSectionView {
sections: Array<string>;
label: string;
pathname: string;
weight: number;
icon: string;
}
export interface MetaTree {
weight: number;
sections: Array<string>;
@@ -148,6 +160,20 @@ export interface IManifestSection extends IManifestElement {
meta: MetaSection;
}
export interface IManifestSectionView extends IManifestElement {
type: 'sectionView';
meta: MetaSectionView;
}
export interface IManifestEditorAction extends IManifestElement {
type: 'editorAction';
meta: MetaEditorAction;
}
export interface MetaEditorAction {
editors: Array<string>;
}
export interface IManifestTree extends IManifestElement {
type: 'tree';
meta: MetaTree;