Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ when set to ``true`` removes the following UI elements for a leaner experience:
- Titles on embedded entities (since they are already shown in the entity list header)
- Action names (since the title already defines which action is which)

### ``showSearch``

default: ``true``

when set to ``false`` removes the search box from the toolbar above the entity view, regardless of the user's general settings.

### ``autoFollowActionLocationOnSuccess``

default: ``false``
Expand Down Expand Up @@ -161,6 +167,11 @@ The following media types are supported for integrated preview:
- Configuration: `text/toml`, `text/yaml`
- Binary/Generic: `application/octet-stream` (can be manually rendered as text with syntax highlighting options).

## Limitations

- **Nullable objects in action parameters:** a nullable object can not be left unset.
It is always sent as an object, never as `null`, and its required properties must be filled before the action can be submitted.

## 💚 Many thanks to our dear sponsors

<div style="display: flex; justify-content: space-around; align-items: flex-start;">
Expand Down
399 changes: 398 additions & 1 deletion e2e/hypermedia-ui.spec.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/app.config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ declare namespace HypermediaUI {
export interface IAppConfig {
disableDeveloperControls: boolean;
reduceUiElements: boolean;
showSearch?: boolean;
configuredEntryPoints: IConfiguredEntryPointsItem[];
onlyAllowConfiguredEntryPoints: boolean;
relationIconMapping?: Record<string, string>;
Expand Down
1 change: 1 addition & 0 deletions src/app.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"disableDeveloperControls": false,
"reduceUiElements": false,
"showSearch": true,
"autoFollowActionLocationOnSuccess": true,
"configuredEntryPoints": [],
"onlyAllowConfiguredEntryPoints": false,
Expand Down
3 changes: 3 additions & 0 deletions src/app.config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class AppConfigService implements HypermediaUI.IAppConfig {
public disableDeveloperControls: boolean = false;
public reduceUiElements: boolean = false;
public showActionsBeforeProperties: boolean = false;
public showSearch: boolean = true;
public autoFollowActionLocationOnSuccess: boolean = false;
public configuredEntryPoints: ConfiguredEntryPoint[] = [];
public onlyAllowConfiguredEntryPoints: boolean = false;
Expand All @@ -41,6 +42,7 @@ export class AppConfigService implements HypermediaUI.IAppConfig {
disableDeveloperControls: this.disableDeveloperControls,
reduceUiElements: this.reduceUiElements,
showActionsBeforeProperties: this.showActionsBeforeProperties,
showSearch: this.showSearch,
autoFollowActionLocationOnSuccess: this.autoFollowActionLocationOnSuccess,
configuredEntryPoints: this.configuredEntryPoints,
onlyAllowConfiguredEntryPoints: this.onlyAllowConfiguredEntryPoints,
Expand All @@ -61,6 +63,7 @@ export class AppConfig extends ImmutableJsRecord({
disableDeveloperControls: true,
reduceUiElements: true,
showActionsBeforeProperties: false,
showSearch: true,
autoFollowActionLocationOnSuccess: false,
configuredEntryPoints: [] as ConfiguredEntryPoint[],
onlyAllowConfiguredEntryPoints : false,
Expand Down
13 changes: 8 additions & 5 deletions src/app/hypermedia-view/actions-view/actions-view.component.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
<div class="container">
@for (action of actions; track action) {
<div class="actionContainer">
@for (action of actions; track action; let idx = $index) {
<div class="actionContainer"
[attr.data-search-target]="actionTarget(entityKey, idx)"
[class.search-hit]="search.isHitTarget(actionTarget(entityKey, idx))"
[class.search-current]="search.isCurrentTarget(actionTarget(entityKey, idx))">
@switch (action.actionType) {
@case (ActionType.NoParameters) {
<app-parameterless-action-view [action]=action></app-parameterless-action-view>
<app-parameterless-action-view [action]=action [searchTarget]="actionTarget(entityKey, idx)"></app-parameterless-action-view>
}
@case ( ActionType.JsonObjectParameters) {
<app-parameter-action [action]=action></app-parameter-action>
<app-parameter-action [action]=action [searchTarget]="actionTarget(entityKey, idx)"></app-parameter-action>
}
@case (ActionType.FileUpload) {
<app-file-upload-action [action]=action></app-file-upload-action>
<app-file-upload-action [action]=action [searchTarget]="actionTarget(entityKey, idx)"></app-file-upload-action>
}
}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Component, Input } from '@angular/core';
import { Component, inject, Input } from '@angular/core';
import {ActionType, HypermediaAction} from '../siren-parser/hypermedia-action';
import {actionTarget, rootEntityKey} from '../search/entity-search';
import {EntitySearchService} from '../search/entity-search.service';

@Component({
selector: 'app-actions-view',
Expand All @@ -8,7 +10,11 @@ import {ActionType, HypermediaAction} from '../siren-parser/hypermedia-action';
standalone: false
})
export class ActionsViewComponent {
protected search = inject(EntitySearchService);

@Input() actions: HypermediaAction[] = [];
@Input() entityKey: string = rootEntityKey;
protected readonly actionTarget = actionTarget;

protected readonly ActionType = ActionType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<div class="action-header-content">
<button class="execute-button" mat-flat-button color="primary">
<mat-icon class="action-icon">upload_file</mat-icon>
<span class="action-name">{{action.title || action.name}}</span>
<span class="action-name"><app-search-highlight [text]="action.title || action.name" [target]="searchTarget" field="title"></app-search-highlight></span>
@for (config of getActionConfigs(); track config.actionClass) {
<mat-icon iconPositionEnd class="action-icon action-class-icon trailing-icon" [matTooltip]="config.title">{{config.icon}}</mat-icon>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class FileUploadActionComponent implements OnInit {

@Input()
action!: HypermediaAction;
@Input() searchTarget: string = '';
files: File[] = [];
formlyFields: FormlyFieldConfig[] = [];
form = new FormGroup({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
} @else {
<mat-icon class="action-icon">play_circle_outline</mat-icon>
}
<span class="action-name">{{action.title || action.name}}</span>
<span class="action-name"><app-search-highlight [text]="action.title || action.name" [target]="searchTarget" field="title"></app-search-highlight></span>
@for (config of getActionConfigs(); track config.actionClass) {
<mat-icon iconPositionEnd class="action-icon action-class-icon trailing-icon" [matTooltip]="config.title">{{config.icon}}</mat-icon>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class ParameterActionComponent implements OnInit {

@Input()
action!: HypermediaAction;
@Input() searchTarget: string = '';

ActionResultsEnum = ActionResults;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
} @else {
<mat-icon class="action-icon">play_circle_outline</mat-icon>
}
<span class="action-name">{{action.title || action.name}}</span>
<span class="action-name"><app-search-highlight [text]="action.title || action.name" [target]="searchTarget" field="title"></app-search-highlight></span>
@for (config of getActionConfigs(); track config.actionClass) {
<mat-icon iconPositionEnd class="action-icon action-class-icon trailing-icon" [matTooltip]="config.title">{{config.icon}}</mat-icon>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class ParameterlessActionViewComponent {
}>>(Store);

@Input() action!: HypermediaAction;
@Input() searchTarget: string = '';

ActionResultsEnum = ActionResults;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
<div class="container">
<div class="itemsHeader">
<span class="spacer"></span>
@if (embeddedEntities?.length) {
<button mat-icon-button class="panelControl" (click)="expandAll()" [matTooltip]="isTopLevel ? 'Expand all embedded entities (+)' : 'Expand all embedded entities'" aria-label="Expand all embedded entities">
<mat-icon>unfold_more</mat-icon>
</button>
<button mat-icon-button class="panelControl" (click)="collapseAll()" [matTooltip]="isTopLevel ? 'Collapse all embedded entities (-)' : 'Collapse all embedded entities'" aria-label="Collapse all embedded entities">
<mat-icon>unfold_less</mat-icon>
</button>
}
<span class="itemsLabel">{{embeddedEntities?.length + embeddedLinkEntities?.length}} items</span>
</div>

@for (embeddedEntity of embeddedEntities; track embeddedEntity; let idx = $index) {
<mat-expansion-panel class="entityExpander" >
<mat-expansion-panel-header>
<mat-expansion-panel class="entityExpander"
togglePosition="before"
[attr.data-embedded-nav-index]="isTopLevel ? idx : null"
[class.embedded-nav-current]="isTopLevel && navigation.currentIndex() === idx"
(pointerdown)="selectForNavigation(idx)"
(focusin)="selectForNavigation(idx)">
<mat-expansion-panel-header
[matTooltip]="isTopLevel ? 'Expand / collapse (Enter while selected)' : ''"
matTooltipShowDelay="500"
[attr.data-search-target]="embeddedHeaderTarget(embeddedEntityKey(entityKey, idx))"
[class.search-hit]="search.isHitTarget(embeddedHeaderTarget(embeddedEntityKey(entityKey, idx)))"
[class.search-current]="search.isCurrentTarget(embeddedHeaderTarget(embeddedEntityKey(entityKey, idx)))">
<mat-panel-title>
<span class="entity-index-badge">{{idx + 1}}</span>
<mat-chip-set class="relation-chips">
Expand All @@ -15,28 +32,35 @@
@if (getRelationIcon(rel)) {
<mat-icon matChipAvatar>{{getRelationIcon(rel)}}</mat-icon>
}
{{getRelationDisplayText(rel)}}
<app-search-highlight [text]="getRelationDisplayText(rel)" [target]="embeddedHeaderTarget(embeddedEntityKey(entityKey, idx))" field="rel"></app-search-highlight>
</mat-chip>
}
</mat-chip-set>
@if(embeddedEntity.title){
<span class="entity-title">{{embeddedEntity.title}}</span>
<span class="entity-title"><app-search-highlight [text]="embeddedEntity.title" [target]="embeddedHeaderTarget(embeddedEntityKey(entityKey, idx))" field="title"></app-search-highlight></span>
}
</mat-panel-title>
@if (generalSettings.showClasses) {
<mat-panel-description>
Classes: {{embeddedEntity.classes.join(",")}}
Classes: <app-search-highlight [text]="embeddedEntity.classes.join(',')" [target]="embeddedHeaderTarget(embeddedEntityKey(entityKey, idx))" field="classes"></app-search-highlight>
</mat-panel-description>
}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<app-entity-view [entity]="embeddedEntity" [showTitle]="false"></app-entity-view>
<app-entity-view [entity]="embeddedEntity" [showTitle]="false" [entityKey]="embeddedEntityKey(entityKey, idx)"></app-entity-view>
</ng-template>
</mat-expansion-panel>
}

@for (embeddedLinkEntity of embeddedLinkEntities; track embeddedLinkEntity; let idx = $index) {
<mat-card appearance="outlined" class="embeddedLinkEntityCard">
<mat-card appearance="outlined" class="embeddedLinkEntityCard"
[attr.data-embedded-nav-index]="isTopLevel ? idx + embeddedEntities.length : null"
[class.embedded-nav-current]="isTopLevel && navigation.currentIndex() === idx + embeddedEntities.length"
(pointerdown)="selectForNavigation(idx + embeddedEntities.length)"
(focusin)="selectForNavigation(idx + embeddedEntities.length)"
[attr.data-search-target]="embeddedLinkEntityTarget(entityKey, idx)"
[class.search-hit]="search.isHitTarget(embeddedLinkEntityTarget(entityKey, idx))"
[class.search-current]="search.isCurrentTarget(embeddedLinkEntityTarget(entityKey, idx))">
<div class="link-entity-header">
<span class="entity-index-badge">{{idx + 1 + embeddedEntities?.length}}</span>
<mat-chip-set class="relation-chips">
Expand All @@ -45,18 +69,18 @@
@if (getRelationIcon(rel)) {
<mat-icon matChipAvatar>{{getRelationIcon(rel)}}</mat-icon>
}
{{getRelationDisplayText(rel)}}
<app-search-highlight [text]="getRelationDisplayText(rel)" [target]="embeddedLinkEntityTarget(entityKey, idx)" field="rel"></app-search-highlight>
</mat-chip>
}
</mat-chip-set>
@if(embeddedLinkEntity.title){
<span class="entity-title">{{embeddedLinkEntity.title}}</span>
<span class="entity-title"><app-search-highlight [text]="embeddedLinkEntity.title" [target]="embeddedLinkEntityTarget(entityKey, idx)" field="title"></app-search-highlight></span>
}
</div>

<div class="link-entity-classes">
@if (generalSettings.showClasses) {
<span>Classes: {{embeddedLinkEntity.classes.join(",")}}</span>
<span>Classes: <app-search-highlight [text]="embeddedLinkEntity.classes.join(',')" [target]="embeddedLinkEntityTarget(entityKey, idx)" field="classes"></app-search-highlight></span>
}
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,76 @@

.itemsHeader {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
}

.spacer {
flex: auto auto;
.panelControl {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;

mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}

.itemsLabel {
margin-left: 4px;
font-size: 12px;
color: grey;
}

$header-background: #eceef1;
$header-hover-background: #e1e4e8;

// the header is tinted and divided from the body, so it reads as the clickable part
.entityExpander {
margin: defaults.$spacing 0;
// Material's own hover and focus colours; they compete with the rules below, so all use the same colour
--mat-expansion-header-hover-state-layer-color: #{$header-hover-background};
--mat-expansion-header-focus-state-layer-color: #{$header-hover-background};

.mat-expansion-panel-header {
> .mat-expansion-panel-header {
height: auto;
padding-top: 8px;
padding-bottom: 8px;
background: $header-background;

// Material resets open headers on hover and focus
&:hover,
&.cdk-keyboard-focused,
&.cdk-program-focused {
background: $header-hover-background;
}
}

&.mat-expanded > .mat-expansion-panel-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
}

// larger chevron left of the header content, pointing right when closed and down when open, as in a tree
:host ::ng-deep .entityExpander > .mat-expansion-panel-header.mat-expansion-toggle-indicator-before .mat-expansion-indicator {
margin: 0 12px 0 0;
transform: rotate(-90deg);

svg {
width: 36px;
height: 36px;
margin: -6px;
}
}

:host ::ng-deep .entityExpander > .mat-expansion-panel-header.mat-expanded .mat-expansion-indicator {
transform: none;
}

.entity-index-badge {
display: inline-flex;
align-items: center;
Expand Down Expand Up @@ -97,3 +145,13 @@
::ng-deep div.mat-expansion-panel-body {
padding: defaults.$spacing;
}

// the item the page navigation points at
.embedded-nav-current {
outline: 2px solid #90caf9;
outline-offset: 2px;
}

[data-embedded-nav-index] {
scroll-margin-top: 8px;
}
Loading
Loading