Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin

This commit is contained in:
Patrick Pacher
2024-03-20 10:43:29 +01:00
parent 66381baa1a
commit 4b77945517
922 changed files with 84071 additions and 26 deletions

View File

@@ -0,0 +1,24 @@
# PortmasterApi
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.0.
## Code scaffolding
Run `ng generate component component-name --project portmaster-api` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project portmaster-api`.
> Note: Don't forget to add `--project portmaster-api` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build portmaster-api` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build portmaster-api`, go to the dist folder `cd dist/portmaster-api` and run `npm publish`.
## Running unit tests
Run `ng test portmaster-api` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, '../../../coverage/safing/portmaster-api'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist-lib/safing/portmaster-api",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,132 @@
{
"name": "@safing/portmaster-api",
"version": "0.0.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@safing/portmaster-api",
"version": "0.0.1",
"dependencies": {
"tslib": "^2.3.0"
},
"devDependencies": {
"@types/jasmine": "^4.0.3"
},
"peerDependencies": {
"@angular/common": "^14.0.0",
"@angular/core": "^14.0.0"
}
},
"node_modules/@angular/common": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz",
"integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^14.15.0 || >=16.10.0"
},
"peerDependencies": {
"@angular/core": "14.0.5",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/core": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz",
"integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^14.15.0 || >=16.10.0"
},
"peerDependencies": {
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.11.4"
}
},
"node_modules/@types/jasmine": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz",
"integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==",
"dev": true
},
"node_modules/rxjs": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz",
"integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/zone.js": {
"version": "0.11.6",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz",
"integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
}
}
},
"dependencies": {
"@angular/common": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz",
"integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==",
"peer": true,
"requires": {
"tslib": "^2.3.0"
}
},
"@angular/core": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz",
"integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==",
"peer": true,
"requires": {
"tslib": "^2.3.0"
}
},
"@types/jasmine": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz",
"integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==",
"dev": true
},
"rxjs": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz",
"integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==",
"peer": true,
"requires": {
"tslib": "^2.1.0"
}
},
"tslib": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"zone.js": {
"version": "0.11.6",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz",
"integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==",
"peer": true,
"requires": {
"tslib": "^2.3.0"
}
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@safing/portmaster-api",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^14.0.0",
"@angular/core": "^14.0.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"devDependencies": {
"@types/jasmine": "^4.0.3"
}
}

View File

@@ -0,0 +1,262 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { filter, finalize, map, mergeMap, share, take } from 'rxjs/operators';
import {
AppProfile,
FlatConfigObject,
LayeredProfile,
TagDescription,
flattenProfileConfig,
} from './app-profile.types';
import {
PORTMASTER_HTTP_API_ENDPOINT,
PortapiService,
} from './portapi.service';
import { Process } from './portapi.types';
@Injectable({
providedIn: 'root',
})
export class AppProfileService {
private watchedProfiles = new Map<string, Observable<AppProfile>>();
constructor(
private portapi: PortapiService,
private http: HttpClient,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string
) { }
/**
* Returns the database key of a profile.
*
* @param source The source of the profile.
* @param id The profile ID.
*/
getKey(source: string, id: string): string;
/**
* Returns the database key of a profile
*
* @param p The app-profile itself..
*/
getKey(p: AppProfile): string;
getKey(idOrSourceOrProfile: string | AppProfile, id?: string): string {
if (typeof idOrSourceOrProfile === 'object') {
return this.getKey(idOrSourceOrProfile.Source, idOrSourceOrProfile.ID);
}
let key = idOrSourceOrProfile;
if (!!id) {
key = `core:profiles/${idOrSourceOrProfile}/${id}`;
}
return key;
}
/**
* Load an application profile.
*
* @param sourceAndId The full profile ID including source
*/
getAppProfile(sourceAndId: string): Observable<AppProfile>;
/**
* Load an application profile.
*
* @param source The source of the profile
* @param id The ID of the profile
*/
getAppProfile(source: string, id: string): Observable<AppProfile>;
getAppProfile(
sourceOrSourceAndID: string,
id?: string
): Observable<AppProfile> {
let source = sourceOrSourceAndID;
if (id !== undefined) {
source += '/' + id;
}
const key = `core:profiles/${source}`;
if (this.watchedProfiles.has(key)) {
return this.watchedProfiles.get(key)!.pipe(take(1));
}
return this.getAppProfileFromKey(key);
}
setProfileIcon(
content: string | ArrayBuffer,
mimeType: string
): Observable<{ filename: string }> {
return this.http.post<{ filename: string }>(
`${this.httpAPI}/v1/profile/icon`,
content,
{
headers: new HttpHeaders({
'Content-Type': mimeType,
}),
}
);
}
/**
* Loads an application profile by it's database key.
*
* @param key The key of the application profile.
*/
getAppProfileFromKey(key: string): Observable<AppProfile> {
return this.portapi.get(key);
}
/**
* Loads the global-configuration profile.
*/
globalConfig(): Observable<FlatConfigObject> {
return this.getAppProfile('special', 'global-config').pipe(
map((profile) => flattenProfileConfig(profile.Config))
);
}
/** Returns all possible process tags. */
tagDescriptions(): Observable<TagDescription[]> {
return this.http
.get<{ Tags: TagDescription[] }>(`${this.httpAPI}/v1/process/tags`)
.pipe(map((result) => result.Tags));
}
/**
* Watches an application profile for changes.
*
* @param source The source of the profile
* @param id The ID of the profile
*/
watchAppProfile(sourceAndId: string): Observable<AppProfile>;
/**
* Watches an application profile for changes.
*
* @param source The source of the profile
* @param id The ID of the profile
*/
watchAppProfile(source: string, id: string): Observable<AppProfile>;
watchAppProfile(sourceAndId: string, id?: string): Observable<AppProfile> {
let key = '';
if (id === undefined) {
key = sourceAndId;
if (!key.startsWith('core:profiles/')) {
key = `core:profiles/${key}`;
}
} else {
key = `core:profiles/${sourceAndId}/${id}`;
}
if (this.watchedProfiles.has(key)) {
return this.watchedProfiles.get(key)!;
}
const stream = this.portapi.get<AppProfile>(key).pipe(
mergeMap(() => this.portapi.watch<AppProfile>(key)),
finalize(() => {
console.log(
'watchAppProfile: removing cached profile stream for ' + key
);
this.watchedProfiles.delete(key);
}),
share({
connector: () => new BehaviorSubject<AppProfile | null>(null),
resetOnRefCountZero: true,
}),
filter((profile) => profile !== null)
) as Observable<AppProfile>;
this.watchedProfiles.set(key, stream);
return stream;
}
/** @deprecated use saveProfile instead */
saveLocalProfile(profile: AppProfile): Observable<void> {
return this.saveProfile(profile);
}
/**
* Save an application profile.
*
* @param profile The profile to save
*/
saveProfile(profile: AppProfile): Observable<void> {
profile.LastEdited = Math.floor(new Date().getTime() / 1000);
return this.portapi.update(
`core:profiles/${profile.Source}/${profile.ID}`,
profile
);
}
/**
* Watch all application profiles
*/
watchProfiles(): Observable<AppProfile[]> {
return this.portapi.watchAll<AppProfile>('core:profiles/');
}
watchLayeredProfile(source: string, id: string): Observable<LayeredProfile>;
/**
* Watches the layered runtime profile for a given application
* profile.
*
* @param profile The app profile
*/
watchLayeredProfile(profile: AppProfile): Observable<LayeredProfile>;
watchLayeredProfile(
profileOrSource: string | AppProfile,
id?: string
): Observable<LayeredProfile> {
if (typeof profileOrSource == 'object') {
id = profileOrSource.ID;
profileOrSource = profileOrSource.Source;
}
const key = `runtime:layeredProfile/${profileOrSource}/${id}`;
return this.portapi.watch<LayeredProfile>(key);
}
/**
* Loads the layered runtime profile for a given application
* profile.
*
* @param profile The app profile
*/
getLayeredProfile(profile: AppProfile): Observable<LayeredProfile> {
const key = `runtime:layeredProfile/${profile.Source}/${profile.ID}`;
return this.portapi.get<LayeredProfile>(key);
}
/**
* Delete an application profile.
*
* @param profile The profile to delete
*/
deleteProfile(profile: AppProfile): Observable<void> {
return this.portapi.delete(`core:profiles/${profile.Source}/${profile.ID}`);
}
getProcessesByProfile(profileOrId: AppProfile | string): Observable<Process[]> {
if (typeof profileOrId === 'object') {
profileOrId = profileOrId.Source + "/" + profileOrId.ID
}
return this.http.get<Process[]>(`${this.httpAPI}/v1/process/list/by-profile/${profileOrId}`)
}
getProcessByPid(pid: number): Observable<Process> {
return this.http.get<Process>(`${this.httpAPI}/v1/process/group-leader/${pid}`)
}
}

View File

@@ -0,0 +1,215 @@
import { BaseSetting, OptionValueType, SettingValueType } from './config.types';
import { SecurityLevel } from './core.types';
import { Record } from './portapi.types';
export interface ConfigMap {
[key: string]: ConfigObject;
}
export type ConfigObject = OptionValueType | ConfigMap;
export interface FlatConfigObject {
[key: string]: OptionValueType;
}
export interface LayeredProfile extends Record {
// LayerIDs is a list of all profiles that are used
// by this layered profile. Profiles are evaluated in
// order.
LayerIDs: string[];
// The current revision counter of the layered profile.
RevisionCounter: number;
}
export enum FingerprintType {
Tag = 'tag',
Cmdline = 'cmdline',
Env = 'env',
Path = 'path',
}
export enum FingerpringOperation {
Equal = 'equals',
Prefix = 'prefix',
Regex = 'regex',
}
export interface Fingerprint {
Type: FingerprintType;
Key: string;
Operation: FingerpringOperation;
Value: string;
}
export interface TagDescription {
ID: string;
Name: string;
Description: string;
}
export interface Icon {
Type: 'database' | 'path' | 'api';
Source: '' | 'user' | 'import' | 'core' | 'ui';
Value: string;
}
export interface AppProfile extends Record {
ID: string;
LinkedPath: string; // deprecated
PresentationPath: string;
Fingerprints: Fingerprint[];
Created: number;
LastEdited: number;
Config?: ConfigMap;
Description: string;
Warning: string;
WarningLastUpdated: string;
Homepage: string;
Icons: Icon[];
Name: string;
Internal: boolean;
SecurityLevel: SecurityLevel;
Source: 'local';
}
// flattenProfileConfig returns a flat version of a nested ConfigMap where each property
// can be used as the database key for the associated setting.
export function flattenProfileConfig(
p?: ConfigMap,
prefix = ''
): FlatConfigObject {
if (p === null || p === undefined) {
return {}
}
let result: FlatConfigObject = {};
Object.keys(p).forEach((key) => {
const childPrefix = prefix === '' ? key : `${prefix}/${key}`;
const prop = p[key];
if (isConfigMap(prop)) {
const flattened = flattenProfileConfig(prop, childPrefix);
result = mergeObjects(result, flattened);
return;
}
result[childPrefix] = prop;
});
return result;
}
/**
* Returns the current value (or null) of a setting stored in a config
* map by path.
*
* @param obj The ConfigMap object
* @param path The path of the setting separated by foward slashes.
*/
export function getAppSetting<T extends OptionValueType>(
obj: ConfigMap | null | undefined,
path: string
): T | null {
if (obj === null || obj === undefined) {
return null
}
const parts = path.split('/');
let iter = obj;
for (let idx = 0; idx < parts.length; idx++) {
const propName = parts[idx];
if (iter[propName] === undefined) {
return null;
}
const value = iter[propName];
if (idx === parts.length - 1) {
return value as T;
}
if (!isConfigMap(value)) {
return null;
}
iter = value;
}
return null;
}
export function getActualValue<S extends BaseSetting<any, any>>(
s: S
): SettingValueType<S> {
if (s.Value !== undefined) {
return s.Value;
}
if (s.GlobalDefault !== undefined) {
return s.GlobalDefault;
}
return s.DefaultValue;
}
/**
* Sets the value of a settings inside the nested config object.
*
* @param obj THe config object
* @param path The path of the setting
* @param value The new value to set.
*/
export function setAppSetting(obj: ConfigObject, path: string, value: any) {
const parts = path.split('/');
if (typeof obj !== 'object' || Array.isArray(obj)) {
return;
}
let iter = obj;
for (let idx = 0; idx < parts.length; idx++) {
const propName = parts[idx];
if (idx === parts.length - 1) {
if (value === undefined) {
delete iter[propName];
} else {
iter[propName] = value;
}
return;
}
if (iter[propName] === undefined) {
iter[propName] = {};
}
iter = iter[propName] as ConfigMap;
}
}
/** Typeguard to ensure v is a ConfigMap */
function isConfigMap(v: any): v is ConfigMap {
return typeof v === 'object' && !Array.isArray(v);
}
/**
* Returns a new flat-config object that contains values from both
* parameters.
*
* @param a The first config object
* @param b The second config object
*/
function mergeObjects(
a: FlatConfigObject,
b: FlatConfigObject
): FlatConfigObject {
var res: FlatConfigObject = {};
Object.keys(a).forEach((key) => {
res[key] = a[key];
});
Object.keys(b).forEach((key) => {
res[key] = b[key];
});
return res;
}

View File

@@ -0,0 +1,128 @@
import { Injectable, TrackByFunction } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, share, toArray } from 'rxjs/operators';
import { BaseSetting, BoolSetting, OptionType, Setting, SettingValueType } from './config.types';
import { PortapiService } from './portapi.service';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
networkRatingEnabled$: Observable<boolean>;
/**
* A {@link TrackByFunction} for tracking settings.
*/
static trackBy: TrackByFunction<Setting> = (_: number, obj: Setting) => obj.Name;
readonly trackBy = ConfigService.trackBy;
/** configPrefix is the database key prefix for the config db */
readonly configPrefix = "config:";
constructor(private portapi: PortapiService) {
this.networkRatingEnabled$ = this.watch<BoolSetting>("core/enableNetworkRating")
.pipe(
share({ connector: () => new BehaviorSubject(false) }),
)
}
/**
* Loads a configuration setting from the database.
*
* @param key The key of the configuration setting.
*/
get(key: string): Observable<Setting> {
return this.portapi.get<Setting>(this.configPrefix + key);
}
/**
* Returns all configuration settings that match query. Note that in
* contrast to {@link PortAPI} settings values are collected into
* an array before being emitted. This allows simple usage in *ngFor
* and friends.
*
* @param query The query used to search for configuration settings.
*/
query(query: string): Observable<Setting[]> {
return this.portapi.query<Setting>(this.configPrefix + query)
.pipe(
map(setting => setting.data),
toArray()
);
}
/**
* Save a setting.
*
* @param s The setting to save. Note that the new value should already be set to {@property Value}.
*/
save(s: Setting): Observable<void>;
/**
* Save a setting.
*
* @param key The key of the configuration setting
* @param value The new value of the setting.
*/
save(key: string, value: any): Observable<void>;
// save is overloaded, see above.
save(s: Setting | string, v?: any): Observable<void> {
if (typeof s === 'string') {
return this.portapi.update(this.configPrefix + s, {
Key: s,
Value: v,
});
}
return this.portapi.update(this.configPrefix + s.Key, s);
}
/**
* Watch a configuration setting.
*
* @param key The key of the setting to watch.
*/
watch<T extends Setting>(key: string): Observable<SettingValueType<T>> {
return this.portapi.qsub<BaseSetting<SettingValueType<T>, any>>(this.configPrefix + key)
.pipe(
filter(value => value.key === this.configPrefix + key), // qsub does a query so filter for our key.
map(value => value.data),
map(value => value.Value !== undefined ? value.Value : value.DefaultValue),
distinctUntilChanged(),
)
}
/**
* Tests if a value is valid for a given option.
*
* @param spec The option specification (as returned by get()).
* @param value The value that should be tested.
*/
validate<S extends Setting>(spec: S, value: SettingValueType<S>) {
if (!spec.ValidationRegex) {
return;
}
const re = new RegExp(spec.ValidationRegex);
switch (spec.OptType) {
case OptionType.Int:
case OptionType.Bool:
// todo(ppacher): do we validate that?
return
case OptionType.String:
if (!re.test(value as string)) {
throw new Error(`${value} does not match ${spec.ValidationRegex}`)
}
return;
case OptionType.StringArray:
(value as string[]).forEach(v => {
if (!re.test(v as string)) {
throw new Error(`${value} does not match ${spec.ValidationRegex}`)
}
});
return
}
}
}

View File

@@ -0,0 +1,348 @@
import { FeatureID } from './features';
import { Record } from './portapi.types';
import { deepClone } from './utils';
/**
* ExpertiseLevel defines all available expertise levels.
*/
export enum ExpertiseLevel {
User = 'user',
Expert = 'expert',
Developer = 'developer',
}
export enum ExpertiseLevelNumber {
user = 0,
expert = 1,
developer = 2
}
export function getExpertiseLevelNumber(lvl: ExpertiseLevel): ExpertiseLevelNumber {
switch (lvl) {
case ExpertiseLevel.User:
return ExpertiseLevelNumber.user;
case ExpertiseLevel.Expert:
return ExpertiseLevelNumber.expert;
case ExpertiseLevel.Developer:
return ExpertiseLevelNumber.developer
}
}
/**
* OptionType defines the type of an option as stored in
* the backend. Note that ExternalOptionHint may be used
* to request a different visual representation and edit
* menu on a per-option basis.
*/
export enum OptionType {
String = 1,
StringArray = 2,
Int = 3,
Bool = 4,
}
/**
* Converts an option type to it's string representation.
*
* @param opt The option type to convert
*/
export function optionTypeName(opt: OptionType): string {
switch (opt) {
case OptionType.String:
return 'string';
case OptionType.StringArray:
return '[]string';
case OptionType.Int:
return 'int'
case OptionType.Bool:
return 'bool'
}
}
/** The actual type an option value can be */
export type OptionValueType = string | string[] | number | boolean;
/** Type-guard for string option types */
export function isStringType(opt: OptionType, vt: OptionValueType): vt is string {
return opt === OptionType.String;
}
/** Type-guard for string-array option types */
export function isStringArrayType(opt: OptionType, vt: OptionValueType): vt is string[] {
return opt === OptionType.StringArray;
}
/** Type-guard for number option types */
export function isNumberType(opt: OptionType, vt: OptionValueType): vt is number {
return opt === OptionType.Int;
}
/** Type-guard for boolean option types */
export function isBooleanType(opt: OptionType, vt: OptionValueType): vt is boolean {
return opt === OptionType.Bool;
}
/**
* ReleaseLevel defines the available release and maturity
* levels.
*/
export enum ReleaseLevel {
Stable = 0,
Beta = 1,
Experimental = 2,
}
export function releaseLevelFromName(name: 'stable' | 'beta' | 'experimental'): ReleaseLevel {
switch (name) {
case 'stable':
return ReleaseLevel.Stable;
case 'beta':
return ReleaseLevel.Beta;
case 'experimental':
return ReleaseLevel.Experimental;
}
}
/**
* releaseLevelName returns a string representation of the
* release level.
*
* @args level The release level to convert.
*/
export function releaseLevelName(level: ReleaseLevel): string {
switch (level) {
case ReleaseLevel.Stable:
return 'stable'
case ReleaseLevel.Beta:
return 'beta'
case ReleaseLevel.Experimental:
return 'experimental'
}
}
/**
* ExternalOptionHint tells the UI to use a different visual
* representation and edit menu that the options value would
* imply.
*/
export enum ExternalOptionHint {
SecurityLevel = 'security level',
EndpointList = 'endpoint list',
FilterList = 'filter list',
OneOf = 'one-of',
OrderedList = 'ordered'
}
/** A list of well-known option annotation keys. */
export enum WellKnown {
DisplayHint = "safing/portbase:ui:display-hint",
Order = "safing/portbase:ui:order",
Unit = "safing/portbase:ui:unit",
Category = "safing/portbase:ui:category",
Subsystem = "safing/portbase:module:subsystem",
Stackable = "safing/portbase:options:stackable",
QuickSetting = "safing/portbase:ui:quick-setting",
Requires = "safing/portbase:config:requires",
RestartPending = "safing/portbase:options:restart-pending",
EndpointListVerdictNames = "safing/portmaster:ui:endpoint-list:verdict-names",
RequiresFeatureID = "safing/portmaster:ui:config:requires-feature",
RequiresUIReload = "safing/portmaster:ui:requires-reload",
}
/**
* Annotations describes the annoations object of a configuration
* setting. Well-known annotations are stricktly typed.
*/
export interface Annotations<T extends OptionValueType> {
// Well known option annoations and their
// types.
[WellKnown.DisplayHint]?: ExternalOptionHint;
[WellKnown.Order]?: number;
[WellKnown.Unit]?: string;
[WellKnown.Category]?: string;
[WellKnown.Subsystem]?: string;
[WellKnown.Stackable]?: true;
[WellKnown.QuickSetting]?: QuickSetting<T> | QuickSetting<T>[] | CountrySelectionQuickSetting<T> | CountrySelectionQuickSetting<T>[];
[WellKnown.Requires]?: ValueRequirement | ValueRequirement[];
[WellKnown.RequiresFeatureID]?: FeatureID | FeatureID[];
[WellKnown.RequiresUIReload]?: unknown,
// Any thing else...
[key: string]: any;
}
export interface PossilbeValue<T = any> {
/** Name is the name of the value and should be displayed */
Name: string;
/** Description may hold an additional description of the value */
Description: string;
/** Value is the actual value expected by the portmaster */
Value: T;
}
export interface QuickSetting<T extends OptionValueType> {
// Name is the name of the quick setting.
Name: string;
// Value is the value that the quick-setting configures. It must match
// the expected value type of the annotated option.
Value: T;
// Action defines the action of the quick setting.
Action: 'replace' | 'merge-top' | 'merge-bottom';
}
export interface CountrySelectionQuickSetting<T extends OptionValueType> extends QuickSetting<T> {
// Filename of the flag to be used.
// In most cases this will be the 2-letter country code, but there are also special flags.
FlagID: string;
}
export interface ValueRequirement {
// Key is the configuration key of the required setting.
Key: string;
// Value is the required value of the linked setting.
Value: any;
}
/**
* BaseSetting describes the general shape of a portbase config setting.
*/
export interface BaseSetting<T extends OptionValueType, O extends OptionType> extends Record {
// Value is the value of a setting.
Value?: T;
// DefaultValue is the default value of a setting.
DefaultValue: T;
// Description is a short description.
Description?: string;
// ExpertiseLevel defines the required expertise level for
// this setting to show up.
ExpertiseLevel: ExpertiseLevelNumber;
// Help may contain a longer help text for this option.
Help?: string;
// Key is the database key.
Key: string;
// Name is the name of the option.
Name: string;
// OptType is the option's basic type.
OptType: O;
// Annotations holds option specific annotations.
Annotations: Annotations<T>;
// ReleaseLevel defines the release level of the feature
// or settings changed by this option.
ReleaseLevel: ReleaseLevel;
// RequiresRestart may be set to true if the service requires
// a restart after this option has been changed.
RequiresRestart?: boolean;
// ValidateRegex defines the regex used to validate this option.
// The regex is used in Golang but is expected to be valid in
// JavaScript as well.
ValidationRegex?: string;
PossibleValues?: PossilbeValue[];
// GlobalDefault holds the global default value and is used in the app settings
// This property is NOT defined inside the portmaster!
GlobalDefault?: T;
}
export type IntSetting = BaseSetting<number, OptionType.Int>;
export type StringSetting = BaseSetting<string, OptionType.String>;
export type StringArraySetting = BaseSetting<string[], OptionType.StringArray>;
export type BoolSetting = BaseSetting<boolean, OptionType.Bool>;
/**
* Apply a quick setting to a value.
*
* @param current The current value of the setting.
* @param qs The quick setting to apply.
*/
export function applyQuickSetting<V extends OptionValueType>(current: V | null, qs: QuickSetting<V>): V | null {
if (qs.Action === 'replace' || !qs.Action) {
return deepClone(qs.Value);
}
if ((!Array.isArray(current) && current !== null) || !Array.isArray(qs.Value)) {
console.warn(`Tried to ${qs.Action} quick-setting on non-array type`);
return current;
}
const clone = deepClone(current);
let missing: any[] = [];
qs.Value.forEach(val => {
if (clone.includes(val)) {
return
}
missing.push(val);
});
if (qs.Action === 'merge-bottom') {
return clone.concat(missing) as V;
}
return missing.concat(clone) as V;
}
/**
* Parses the ValidationRegex of a setting and returns a list
* of supported values.
*
* @param s The setting to extract support values from.
*/
export function parseSupportedValues<S extends Setting>(s: S): SettingValueType<S>[] {
if (!s.ValidationRegex) {
return [];
}
const values = s.ValidationRegex.match(/\w+/gmi);
const result: SettingValueType<S>[] = [];
let converter: (s: string) => any;
switch (s.OptType) {
case OptionType.Bool:
converter = s => s === 'true';
break;
case OptionType.Int:
converter = s => +s;
break;
case OptionType.String:
case OptionType.StringArray:
converter = s => s
break
}
values?.forEach(val => {
result.push(converter(val))
});
return result;
}
/**
* isDefaultValue checks if value is the settings default value.
* It supports all available settings type and fallsback to use
* JSON encoded string comparision (JS JSON.stringify is stable).
*/
export function isDefaultValue<T extends OptionValueType>(value: T | undefined | null, defaultValue: T): boolean {
if (value === undefined) {
return true;
}
const isObject = typeof value === 'object';
const isDefault = isObject
? JSON.stringify(value) === JSON.stringify(defaultValue)
: value === defaultValue;
return isDefault;
}
/**
* SettingValueType is used to infer the type of a settings from it's default value.
* Use like this:
*
* validate<S extends Setting>(spec: S, value SettingValueType<S>) { ... }
*/
export type SettingValueType<S extends Setting> = S extends { DefaultValue: infer T } ? T : any;
export type Setting = IntSetting
| StringSetting
| StringArraySetting
| BoolSetting;

View File

@@ -0,0 +1,34 @@
import { TrackByFunction } from '@angular/core';
export enum SecurityLevel {
Off = 0,
Normal = 1,
High = 2,
Extreme = 4,
}
export enum RiskLevel {
Off = 'off',
Auto = 'auto',
Low = 'low',
Medium = 'medium',
High = 'high'
}
/** Interface capturing any object that has an ID member. */
export interface Identifyable {
ID: string | number;
}
/** A TrackByFunction for all Identifyable objects. */
export const trackById: TrackByFunction<Identifyable> = (_: number, obj: Identifyable) => {
return obj.ID;
}
export function getEnumKey(enumLike: any, value: string | number): string {
if (typeof value === 'string') {
return value.toLowerCase()
}
return (enumLike[value] as string).toLowerCase()
}

View File

@@ -0,0 +1,54 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service';
@Injectable({
providedIn: 'root',
})
export class DebugAPI {
constructor(
private http: HttpClient,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
) { }
ping(): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/ping`, {
responseType: 'text'
})
}
getStack(): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/stack`, {
responseType: 'text'
})
}
getDebugInfo(style = 'github'): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/info`, {
params: {
style,
},
responseType: 'text',
})
}
getCoreDebugInfo(style = 'github'): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/core`, {
params: {
style,
},
responseType: 'text',
})
}
getProfileDebugInfo(source: string, id: string, style = 'github'): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/network`, {
params: {
profile: `${source}/${id}`,
style,
},
responseType: 'text',
})
}
}

View File

@@ -0,0 +1,8 @@
export enum FeatureID {
None = "",
SPN = "spn",
PrioritySupport = "support",
History = "history",
Bandwidth = "bw-vis",
VPNCompat = "vpn-compat",
}

View File

@@ -0,0 +1,106 @@
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Inject, Injectable, Optional } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service';
export interface MetaEndpointParameter {
Method: string;
Field: string;
Value: string;
Description: string;
}
export interface MetaEndpoint {
Path: string;
MimeType: string;
Read: number;
Write: number;
Name: string;
Description: string;
Parameters: MetaEndpointParameter[];
}
export interface AuthPermission {
Read: number;
Write: number;
ReadRole: string;
WriteRole: string;
}
export interface MyProfileResponse {
profile: string;
source: string;
name: string;
}
export interface AuthKeyResponse {
key: string;
validUntil: string;
}
@Injectable()
export class MetaAPI {
constructor(
private http: HttpClient,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) @Optional() private httpEndpoint: string = 'http://localhost:817/api',
) { }
listEndpoints(): Observable<MetaEndpoint[]> {
return this.http.get<MetaEndpoint[]>(`${this.httpEndpoint}/v1/endpoints`)
}
permissions(): Observable<AuthPermission> {
return this.http.get<AuthPermission>(`${this.httpEndpoint}/v1/auth/permissions`)
}
myProfile(): Observable<MyProfileResponse> {
return this.http.get<MyProfileResponse>(`${this.httpEndpoint}/v1/app/profile`)
}
requestApplicationAccess(appName: string, read: 'user' | 'admin' = 'user', write: 'user' | 'admin' = 'user'): Observable<AuthKeyResponse> {
let params = new HttpParams()
.set("app-name", appName)
.set("read", read)
.set("write", write)
return this.http.get<AuthKeyResponse>(`${this.httpEndpoint}/v1/app/auth`, { params })
}
login(bearer: string): Observable<boolean>;
login(username: string, password: string): Observable<boolean>;
login(usernameOrBearer: string, password?: string): Observable<boolean> {
let login: Observable<void>;
if (!!password) {
login = this.http.get<void>(`${this.httpEndpoint}/v1/auth/basic`, {
headers: {
'Authorization': `Basic ${btoa(usernameOrBearer + ":" + password)}`
}
})
} else {
login = this.http.get<void>(`${this.httpEndpoint}/v1/auth/bearer`, {
headers: {
'Authorization': `Bearer ${usernameOrBearer}`
}
})
}
return login.pipe(
map(() => true),
catchError(err => {
if (err instanceof HttpErrorResponse) {
if (err.status === 401) {
return of(false);
}
}
return throwError(() => err)
})
)
}
logout(): Observable<void> {
return this.http.get<void>(`${this.httpEndpoint}/v1/auth/reset`);
}
}

View File

@@ -0,0 +1,55 @@
import { ModuleWithProviders, NgModule } from "@angular/core";
import { AppProfileService } from "./app-profile.service";
import { ConfigService } from "./config.service";
import { DebugAPI } from "./debug-api.service";
import { MetaAPI } from "./meta-api.service";
import { Netquery } from "./netquery.service";
import { PortapiService, PORTMASTER_HTTP_API_ENDPOINT, PORTMASTER_WS_API_ENDPOINT } from "./portapi.service";
import { SPNService } from "./spn.service";
import { WebsocketService } from "./websocket.service";
export interface ModuleConfig {
httpAPI?: string;
websocketAPI?: string;
}
@NgModule({})
export class PortmasterAPIModule {
/**
* Configures a module with additional providers.
*
* @param cfg The module configuration defining the Portmaster HTTP and Websocket API endpoints.
*/
static forRoot(cfg: ModuleConfig = {}): ModuleWithProviders<PortmasterAPIModule> {
if (cfg.httpAPI === undefined) {
cfg.httpAPI = `http://${window.location.host}/api`;
}
if (cfg.websocketAPI === undefined) {
cfg.websocketAPI = `ws://${window.location.host}/api/database/v1`;
}
return {
ngModule: PortmasterAPIModule,
providers: [
PortapiService,
WebsocketService,
MetaAPI,
ConfigService,
AppProfileService,
DebugAPI,
Netquery,
SPNService,
{
provide: PORTMASTER_HTTP_API_ENDPOINT,
useValue: cfg.httpAPI,
},
{
provide: PORTMASTER_WS_API_ENDPOINT,
useValue: cfg.websocketAPI
}
]
}
}
}

View File

@@ -0,0 +1,543 @@
import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
import { Inject, Injectable } from "@angular/core";
import { Observable, forkJoin, of } from "rxjs";
import { catchError, map, mergeMap } from "rxjs/operators";
import { AppProfileService } from "./app-profile.service";
import { AppProfile } from "./app-profile.types";
import { DNSContext, IPScope, Reason, TLSContext, TunnelContext, Verdict } from "./network.types";
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from "./portapi.service";
import { Container } from "postcss";
export interface FieldSelect {
field: string;
}
export interface FieldAsSelect {
$field: {
field: string;
as: string;
}
}
export interface Count {
$count: {
field: string;
distinct?: boolean;
as?: string;
}
}
export interface Sum {
$sum: {
condition: Condition;
as: string;
distinct?: boolean;
} | {
field: string;
as: string;
distinct?: boolean;
}
}
export interface Min {
$min: {
condition: Condition;
as: string;
distinct?: boolean;
} | {
field: string;
as: string;
distinct?: boolean;
}
}
export interface Distinct {
$distinct: string;
}
export type Select = FieldSelect | FieldAsSelect | Count | Distinct | Sum | Min;
export interface Equal {
$eq: any;
}
export interface NotEqual {
$ne: any;
}
export interface Like {
$like: string;
}
export interface In {
$in: any[];
}
export interface NotIn {
$notin: string[];
}
export interface Greater {
$gt: number;
}
export interface GreaterOrEqual {
$ge: number;
}
export interface Less {
$lt: number;
}
export interface LessOrEqual {
$le: number;
}
export type Matcher = Equal | NotEqual | Like | In | NotIn | Greater | GreaterOrEqual | Less | LessOrEqual;
export interface OrderBy {
field: string;
desc?: boolean;
}
export interface Condition {
[key: string]: string | Matcher | (string | Matcher)[];
}
export interface TextSearch {
fields: string[];
value: string;
}
export enum Database {
Live = "main",
History = "history"
}
export interface Query {
select?: string | Select | (Select | string)[];
query?: Condition;
orderBy?: string | OrderBy | (OrderBy | string)[];
textSearch?: TextSearch;
groupBy?: string[];
pageSize?: number;
page?: number;
databases?: Database[];
}
export interface NetqueryConnection {
id: string;
allowed: boolean | null;
profile: string;
path: string;
type: 'dns' | 'ip';
external: boolean;
ip_version: number;
ip_protocol: number;
local_ip: string;
local_port: number;
remote_ip: string;
remote_port: number;
domain: string;
country: string;
asn: number;
as_owner: string;
latitude: number;
longitude: number;
scope: IPScope;
verdict: Verdict;
started: string;
ended: string;
tunneled: boolean;
encrypted: boolean;
internal: boolean;
direction: 'inbound' | 'outbound';
profile_revision: number;
exit_node?: string;
extra_data?: {
pid?: number;
processCreatedAt?: number;
cname?: string[];
blockedByLists?: string[];
blockedEntities?: string[];
reason?: Reason;
tunnel?: TunnelContext;
dns?: DNSContext;
tls?: TLSContext;
};
profile_name: string;
active: boolean;
bytes_received: number;
bytes_sent: number;
}
export interface ChartResult {
timestamp: number;
value: number;
countBlocked: number;
}
export interface QueryResult extends Partial<NetqueryConnection> {
[key: string]: any;
}
export interface Identities {
exit_node: string;
count: number;
}
export interface IProfileStats {
ID: string;
Name: string;
size: number;
empty: boolean;
identities: Identities[];
countAllowed: number;
countUnpermitted: number;
countAliveConnections: number;
bytes_sent: number;
bytes_received: number;
}
type BatchResponse<T> = {
[key in keyof T]: QueryResult[]
}
interface BatchRequest {
[key: string]: Query
}
interface BandwidthBaseResult {
timestamp: number;
incoming: number;
outgoing: number;
}
export type ConnKeys = keyof NetqueryConnection
export type BandwidthChartResult<K extends ConnKeys> = {
[key in K]: NetqueryConnection[K];
} & BandwidthBaseResult
export type ProfileBandwidthChartResult = BandwidthChartResult<'profile'>;
export type ConnectionBandwidthChartResult = BandwidthChartResult<'id'>;
@Injectable({ providedIn: 'root' })
export class Netquery {
constructor(
private http: HttpClient,
private profileService: AppProfileService,
private portapi: PortapiService,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
) { }
query(query: Query, origin: string): Observable<QueryResult[]> {
return this.http.post<{ results: QueryResult[] }>(`${this.httpAPI}/v1/netquery/query`, query, {
params: new HttpParams().set("origin", origin)
})
.pipe(map(res => res.results || []));
}
batch<T extends BatchRequest>(queries: T): Observable<BatchResponse<T>> {
return this.http.post<BatchResponse<T>>(`${this.httpAPI}/v1/netquery/query/batch`, queries)
}
cleanProfileHistory(profileIDs: string | string[]): Observable<HttpResponse<any>> {
return this.http.post(`${this.httpAPI}/v1/netquery/history/clear`,
{
profileIDs: Array.isArray(profileIDs) ? profileIDs : [profileIDs]
},
{
observe: 'response',
responseType: 'text',
reportProgress: false,
}
)
}
profileBandwidthChart(profile?: string[], interval?: number): Observable<{ [profile: string]: ProfileBandwidthChartResult[] }> {
const cond: Condition = {}
if (!!profile) {
cond['profile'] = profile
}
return this.bandwidthChart(cond, ['profile'], interval)
.pipe(
map(results => {
const obj: {
[connId: string]: ProfileBandwidthChartResult[]
} = {};
results?.forEach(row => {
const arr = obj[row.profile] || []
arr.push(row)
obj[row.profile] = arr
})
return obj
})
)
}
bandwidthChart<K extends ConnKeys>(query: Condition, groupBy?: K[], interval?: number): Observable<BandwidthChartResult<K>[]> {
return this.http.post<{ results: BandwidthChartResult<K>[] }>(`${this.httpAPI}/v1/netquery/charts/bandwidth`, {
interval,
groupBy,
query,
})
.pipe(
map(response => response.results),
)
}
connectionBandwidthChart(connIds: string[], interval?: number): Observable<{ [connId: string]: ConnectionBandwidthChartResult[] }> {
const cond: Condition = {}
if (!!connIds) {
cond['id'] = connIds
}
return this.bandwidthChart(cond, ['id'], interval)
.pipe(
map(results => {
const obj: {
[connId: string]: ConnectionBandwidthChartResult[]
} = {};
results?.forEach(row => {
const arr = obj[row.id] || []
arr.push(row)
obj[row.id] = arr
})
return obj
})
)
}
activeConnectionChart(cond: Condition, textSearch?: TextSearch): Observable<ChartResult[]> {
return this.http.post<{ results: ChartResult[] }>(`${this.httpAPI}/v1/netquery/charts/connection-active`, {
query: cond,
textSearch,
})
.pipe(map(res => {
const now = new Date();
let data: ChartResult[] = [];
let lastPoint: ChartResult | null = {
timestamp: Math.floor(now.getTime() / 1000 - 600),
value: 0,
countBlocked: 0,
};
res.results?.forEach(point => {
if (!!lastPoint && lastPoint.timestamp < (point.timestamp - 10)) {
for (let i = lastPoint.timestamp; i < point.timestamp; i += 10) {
data.push({
timestamp: i,
value: 0,
countBlocked: 0,
})
}
}
data.push(point);
lastPoint = point;
})
const lastPointTs = Math.round(now.getTime() / 1000);
if (!!lastPoint && lastPoint.timestamp < (lastPointTs - 20)) {
for (let i = lastPoint.timestamp; i < lastPointTs; i += 20) {
data.push({
timestamp: i,
value: 0,
countBlocked: 0
})
}
}
return data;
}));
}
getActiveProfileIDs(): Observable<string[]> {
return this.query({
select: [
'profile',
],
groupBy: [
'profile',
],
}, 'get-active-profile-ids').pipe(
map(result => {
return result.map(res => res.profile!);
})
)
}
getActiveProfiles(): Observable<AppProfile[]> {
return this.getActiveProfileIDs()
.pipe(
mergeMap(profiles => forkJoin(profiles.map(pid => this.profileService.getAppProfile(pid))))
)
}
getProfileStats(query?: Condition): Observable<IProfileStats[]> {
let profileCache = new Map<string, AppProfile>();
return this.batch({
verdicts: {
select: [
'profile',
'verdict',
{ $count: { field: '*', as: 'totalCount' } },
],
groupBy: [
'profile',
'verdict',
],
query: query,
},
conns: {
select: [
'profile',
{ $count: { field: '*', as: 'totalCount' } },
{ $count: { field: 'ended', as: 'countEnded' } },
{ $sum: { field: 'bytes_sent', as: 'bytes_sent' } },
{ $sum: { field: 'bytes_received', as: 'bytes_received' } },
],
groupBy: [
'profile',
],
query: query,
},
identities: {
select: [
'profile',
'exit_node',
{ $count: { field: '*', as: 'totalCount' } }
],
groupBy: [
'profile',
'exit_node',
],
query: {
...query,
exit_node: {
$ne: "",
},
},
}
}).pipe(
map(result => {
let statsMap = new Map<string, IProfileStats>();
const getOrCreate = (id: string) => {
let stats = statsMap.get(id) || {
ID: id,
Name: 'Deleted',
countAliveConnections: 0,
countAllowed: 0,
countUnpermitted: 0,
empty: true,
identities: [],
size: 0,
bytes_received: 0,
bytes_sent: 0
};
statsMap.set(id, stats);
return stats;
}
result.verdicts?.forEach(res => {
const stats = getOrCreate(res.profile!);
switch (res.verdict) {
case Verdict.Accept:
case Verdict.RerouteToNs:
case Verdict.RerouteToTunnel:
case Verdict.Undeterminable:
stats.size += res.totalCount
stats.countAllowed += res.totalCount;
break;
case Verdict.Block:
case Verdict.Drop:
case Verdict.Failed:
case Verdict.Undecided:
stats.size += res.totalCount
stats.countUnpermitted += res.totalCount;
break;
}
stats.empty = stats.size == 0;
})
result.conns?.forEach(res => {
const stats = getOrCreate(res.profile!);
stats.countAliveConnections = res.totalCount - res.countEnded;
stats.bytes_received += res.bytes_received!;
stats.bytes_sent += res.bytes_sent!;
})
result.identities?.forEach(res => {
const stats = getOrCreate(res.profile!);
let ident = stats.identities.find(value => value.exit_node === res.exit_node)
if (!ident) {
ident = {
count: 0,
exit_node: res.exit_node!,
}
stats.identities.push(ident);
}
ident.count += res.totalCount;
})
return Array.from(statsMap.values())
}),
mergeMap(stats => {
return forkJoin(stats.map(p => {
if (profileCache.has(p.ID)) {
return of(profileCache.get(p.ID)!);
}
return this.profileService.getAppProfile(p.ID)
.pipe(catchError(err => {
return of(null)
}))
}))
.pipe(
map((profiles: (AppProfile | null)[]) => {
profileCache = new Map();
let lm = new Map<string, IProfileStats>();
stats.forEach(stat => lm.set(stat.ID, stat));
profiles
.forEach(p => {
if (!p) {
return
}
profileCache.set(`${p.Source}/${p.ID}`, p)
let stat = lm.get(`${p.Source}/${p.ID}`)
if (!stat) {
return;
}
stat.Name = p.Name
})
return Array.from(lm.values())
})
)
})
)
}
}

View File

@@ -0,0 +1,314 @@
import { Record } from './portapi.types';
export enum Verdict {
Undecided = 0,
Undeterminable = 1,
Accept = 2,
Block = 3,
Drop = 4,
RerouteToNs = 5,
RerouteToTunnel = 6,
Failed = 7
}
export enum IPProtocol {
ICMP = 1,
IGMP = 2,
TCP = 6,
UDP = 17,
ICMPv6 = 58,
UDPLite = 136,
RAW = 255, // TODO(ppacher): what is RAW used for?
}
export enum IPVersion {
V4 = 4,
V6 = 6,
}
export enum IPScope {
Invalid = -1,
Undefined = 0,
HostLocal = 1,
LinkLocal = 2,
SiteLocal = 3,
Global = 4,
LocalMulticast = 5,
GlobalMulitcast = 6
}
let globalScopes = new Set([IPScope.GlobalMulitcast, IPScope.Global])
let localScopes = new Set([IPScope.SiteLocal, IPScope.LinkLocal, IPScope.LocalMulticast])
// IsGlobalScope returns true if scope represents a globally
// routed destination.
export function IsGlobalScope(scope: IPScope): scope is IPScope.GlobalMulitcast | IPScope.Global {
return globalScopes.has(scope);
}
// IsLocalScope returns true if scope represents a locally
// routed destination.
export function IsLANScope(scope: IPScope): scope is IPScope.SiteLocal | IPScope.LinkLocal | IPScope.LocalMulticast {
return localScopes.has(scope);
}
// IsLocalhost returns true if scope represents localhost.
export function IsLocalhost(scope: IPScope): scope is IPScope.HostLocal {
return scope === IPScope.HostLocal;
}
const deniedVerdicts = new Set([
Verdict.Drop,
Verdict.Block,
])
// IsDenied returns true if the verdict v represents a
// deny or block decision.
export function IsDenied(v: Verdict): boolean {
return deniedVerdicts.has(v);
}
export interface CountryInfo {
Code: string;
Name: string;
Center: GeoCoordinates;
Continent: ContinentInfo;
}
export interface ContinentInfo {
Code: string;
Region: string;
Name: string;
}
export interface GeoCoordinates {
AccuracyRadius: number;
Latitude: number;
Longitude: number;
}
export const UnknownLocation: GeoCoordinates = {
AccuracyRadius: 0,
Latitude: 0,
Longitude: 0
}
export interface IntelEntity {
// Protocol is the IP protocol used to connect/communicate
// the the described entity.
Protocol: IPProtocol;
// Port is the remote port number used.
Port: number;
// Domain is the domain name of the entity. This may either
// be the domain name used in the DNS request or the
// named returned from reverse PTR lookup.
Domain: string;
// CNAME is a list of CNAMEs that have been used
// to resolve this entity.
CNAME: string[] | null;
// IP is the IP address of the entity.
IP: string;
// IPScope holds the classification of the IP address.
IPScope: IPScope;
// Country holds the country of residence of the IP address.
Country: string;
// ASN holds the number of the autonoumous system that operates
// the IP.
ASN: number;
// ASOrg holds the AS owner name.
ASOrg: string;
// Coordinates contains the geographic coordinates of the entity.
Coordinates: GeoCoordinates | null;
// BlockedByLists holds a list of filter list IDs that
// would have blocked the entity.
BlockedByLists: string[] | null;
// BlockedEntities holds a list of entities that have been
// blocked by filter lists. Those entities can be ASNs, domains,
// CNAMEs, IPs or Countries.
BlockedEntities: string[] | null;
// ListOccurences maps the blocked entity (see BlockedEntities)
// to a list of filter-list IDs that contains it.
ListOccurences: { [key: string]: string[] } | null;
}
export enum ScopeIdentifier {
IncomingHost = "IH",
IncomingLAN = "IL",
IncomingInternet = "II",
IncomingInvalid = "IX",
PeerHost = "PH",
PeerLAN = "PL",
PeerInternet = "PI",
PeerInvalid = "PX"
}
export const ScopeTranslation: { [key: string]: string } = {
[ScopeIdentifier.IncomingHost]: "Device-Local Incoming",
[ScopeIdentifier.IncomingLAN]: "LAN Incoming",
[ScopeIdentifier.IncomingInternet]: "Internet Incoming",
[ScopeIdentifier.PeerHost]: "Device-Local Outgoing",
[ScopeIdentifier.PeerLAN]: "LAN Peer-to-Peer",
[ScopeIdentifier.PeerInternet]: "Internet Peer-to-Peer",
[ScopeIdentifier.IncomingInvalid]: "N/A",
[ScopeIdentifier.PeerInvalid]: "N/A",
}
export interface ProcessContext {
BinaryPath: string;
ProcessName: string;
ProfileName: string;
PID: number;
Profile: string;
Source: string
}
// Reason justifies the decision on a connection
// verdict.
export interface Reason {
// Msg holds a human readable message of the reason.
Msg: string;
// OptionKey, if available, holds the key of the
// configuration option that caused the verdict.
OptionKey: string;
// Profile holds the profile the option setting has
// been configured in.
Profile: string;
// Context may holds additional data about the reason.
Context: any;
}
export enum ConnectionType {
Undefined = 0,
IPConnection = 1,
DNSRequest = 2
}
export function IsDNSRequest(t: ConnectionType): t is ConnectionType.DNSRequest {
return t === ConnectionType.DNSRequest;
}
export function IsIPConnection(t: ConnectionType): t is ConnectionType.IPConnection {
return t === ConnectionType.IPConnection;
}
export interface DNSContext {
Domain: string;
ServedFromCache: boolean;
RequestingNew: boolean;
IsBackup: boolean;
Filtered: boolean;
FilteredEntries: string[], // RR
Question: 'A' | 'AAAA' | 'MX' | 'TXT' | 'SOA' | 'SRV' | 'PTR' | 'NS' | string;
RCode: 'NOERROR' | 'SERVFAIL' | 'NXDOMAIN' | 'REFUSED' | string;
Modified: string;
Expires: string;
}
export interface TunnelContext {
Path: TunnelNode[];
PathCost: number;
RoutingAlg: 'default';
}
export interface GeoIPInfo {
IP: string;
Country: string;
ASN: number;
ASOwner: string;
}
export interface TunnelNode {
ID: string;
Name: string;
IPv4?: GeoIPInfo;
IPv6?: GeoIPInfo;
}
export interface CertInfo<dateType extends string | Date = string> {
Subject: string;
Issuer: string;
AlternateNames: string[];
NotBefore: dateType;
NotAfter: dateType;
}
export interface TLSContext {
Version: string;
VersionRaw: number;
SNI: string;
Chain: CertInfo[][];
}
export interface Connection extends Record {
// ID is a unique ID for the connection.
ID: string;
// Type defines the connection type.
Type: ConnectionType;
// TLS may holds additional data for the TLS
// session.
TLS: TLSContext | null;
// DNSContext holds additional data about the DNS request for
// this connection.
DNSContext: DNSContext | null;
// TunnelContext holds additional data about the SPN tunnel used for
// the connection.
TunnelContext: TunnelContext | null;
// Scope defines the scope of the connection. It's an somewhat
// weired field that may contain a ScopeIdentifier or a string.
// In case of a string it may eventually be interpreted as a
// domain name.
Scope: ScopeIdentifier | string;
// IPVersion is the version of the IP protocol used.
IPVersion: IPVersion;
// Inbound is true if the connection is incoming to
// hte local system.
Inbound: boolean;
// IPProtocol is the protocol used by the connection.
IPProtocol: IPProtocol;
// LocalIP is the local IP address that is involved into
// the connection.
LocalIP: string;
// LocalIPScope holds the classification of the local IP
// address;
LocalIPScope: IPScope;
// LocalPort is the local port that is involved into the
// connection.
LocalPort: number;
// Entity describes the remote entity that is part of the
// connection.
Entity: IntelEntity;
// Verdict defines the final verdict.
Verdict: Verdict;
// Reason is the reason justifying the verdict of the connection.
Reason: Reason;
// Started holds the number of seconds in UNIX epoch time at which
// the connection was initiated.
Started: number;
// End dholds the number of seconds in UNIX epoch time at which
// the connection was considered terminated.
Ended: number;
// Tunneled is set to true if the connection was tunneled through the
// SPN.
Tunneled: boolean;
// VerdictPermanent is set to true if the connection was marked and
// handed back to the operating system.
VerdictPermanent: boolean;
// Inspecting is set to true if the connection is being inspected.
Inspecting: boolean;
// Encrypted is set to true if the connection is estimated as being
// encrypted. Interpreting this field must be done with care!
Encrypted: boolean;
// Internal is set to true if this connection is done by the Portmaster
// or any associated helper processes/binaries itself.
Internal: boolean;
// ProcessContext holds additional information about the process
// that initated the connection.
ProcessContext: ProcessContext;
// ProfileRevisionCounter is used to track changes to the process
// profile.
ProfileRevisionCounter: number;
}
export interface ReasonContext {
[key: string]: any;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,453 @@
import { iif, MonoTypeOperatorFunction, of, Subscriber, throwError } from 'rxjs';
import { concatMap, delay, retryWhen } from 'rxjs/operators';
/**
* ReplyType contains all possible message types of a reply.
*/
export type ReplyType = 'ok'
| 'upd'
| 'new'
| 'del'
| 'success'
| 'error'
| 'warning'
| 'done';
/**
* RequestType contains all possible message types of a request.
*/
export type RequestType = 'get'
| 'query'
| 'sub'
| 'qsub'
| 'create'
| 'update'
| 'insert'
| 'delete'
| 'cancel';
// RecordMeta describes the meta-data object that is part of
// every API resource.
export interface RecordMeta {
// Created hold a unix-epoch timestamp when the record has been
// created.
Created: number;
// Deleted hold a unix-epoch timestamp when the record has been
// deleted.
Deleted: number;
// Expires hold a unix-epoch timestamp when the record has been
// expires.
Expires: number;
// Modified hold a unix-epoch timestamp when the record has been
// modified last.
Modified: number;
// Key holds the database record key.
Key: string;
}
export interface Process extends Record {
Name: string;
UserID: number;
UserName: string;
UserHome: string;
Pid: number;
Pgid: number;
CreatedAt: number;
ParentPid: number;
ParentCreatedAt: number;
Path: string;
ExecName: string;
Cwd: string;
CmdLine: string;
FirstArg: string;
Env: {
[key: string]: string
} | null;
Tags: {
Key: string;
Value: string;
}[] | null;
MatchingPath: string;
PrimaryProfileID: string;
FirstSeen: number;
LastSeen: number;
Error: string;
ExecHashes: {
[key: string]: string
} | null;
}
// Record describes the base record structure of all API resources.
export interface Record {
_meta?: RecordMeta;
}
/**
* All possible MessageType that are available in PortAPI.
*/
export type MessageType = RequestType | ReplyType;
/**
* BaseMessage describes the base message type that is exchanged
* via PortAPI.
*/
export interface BaseMessage<M extends MessageType = MessageType> {
// ID of the request. Used to correlated (multiplex) requests and
// responses across a single websocket connection.
id: string;
// Type is the request/response message type.
type: M;
}
/**
* DoneReply marks the end of a PortAPI stream.
*/
export interface DoneReply extends BaseMessage<'done'> { }
/**
* DataReply is either sent once as a result on a `get` request or
* is sent multiple times in the course of a PortAPI stream.
*/
export interface DataReply<T extends Record> extends BaseMessage<'ok' | 'upd' | 'new' | 'del'> {
// Key is the database key including the database prefix.
key: string;
// Data is the actual data of the entry.
data: T;
}
/**
* Returns true if d is a DataReply message type.
*
* @param d The reply message to check
*/
export function isDataReply(d: ReplyMessage): d is DataReply<any> {
return d.type === 'ok'
|| d.type === 'upd'
|| d.type === 'new'
|| d.type === 'del';
//|| d.type === 'done'; // done is actually not correct
}
/**
* SuccessReply is used to mark an operation as successfully. It does not carry any
* data. Think of it as a "201 No Content" in HTTP.
*/
export interface SuccessReply extends BaseMessage<'success'> { }
/**
* ErrorReply describes an error that happened while processing a
* request. Note that an `error` type message may be sent for single
* and response-stream requests. In case of a stream the `error` type
* message marks the end of the stream. See WarningReply for a simple
* warning message that can be transmitted via PortAPI.
*/
export interface ErrorReply extends BaseMessage<'error'> {
// Message is the error message from the backend.
message: string;
}
/**
* WarningReply contains a warning message that describes an error
* condition encountered when processing a single entitiy of a
* response stream. In contrast to `error` type messages, a `warning`
* can only occure during data streams and does not end the stream.
*/
export interface WarningReply extends BaseMessage<'warning'> {
// Message describes the warning/error condition the backend
// encountered.
message: string;
}
/**
* QueryRequest defines the payload for `query`, `sub` and `qsub` message
* types. The result of a query request is always a stream of responses.
* See ErrorReply, WarningReply and DoneReply for more information.
*/
export interface QueryRequest extends BaseMessage<'query' | 'sub' | 'qsub'> {
// Query is the query for the database.
query: string;
}
/**
* KeyRequests defines the payload for a `get` or `delete` request. Those
* message type only carry the key of the database entry to delete. Note that
* `delete` can only return a `success` or `error` type message while `get` will
* receive a `ok` or `error` type message.
*/
export interface KeyRequest extends BaseMessage<'delete' | 'get'> {
// Key is the database entry key.
key: string;
}
/**
* DataRequest is used during create, insert or update operations.
* TODO(ppacher): check what's the difference between create and insert,
* both seem to error when trying to create a new entry.
*/
export interface DataRequest<T> extends BaseMessage<'update' | 'create' | 'insert'> {
// Key is the database entry key.
key: string;
// Data is the data to store.
data: T;
}
/**
* CancelRequest can be sent on stream operations to early-abort the request.
*/
export interface CancelRequest extends BaseMessage<'cancel'> { }
/**
* ReplyMessage is a union of all reply message types.
*/
export type ReplyMessage<T extends Record = any> = DataReply<T>
| DoneReply
| SuccessReply
| WarningReply
| ErrorReply;
/**
* RequestMessage is a union of all request message types.
*/
export type RequestMessage<T = any> = QueryRequest
| KeyRequest
| DataRequest<T>
| CancelRequest;
/**
* Requestable can be used to accept only properties that match
* the request message type M.
*/
export type Requestable<M extends RequestType> = RequestMessage & { type: M };
/**
* Returns true if m is a cancellable message type.
*
* @param m The message type to check.
*/
export function isCancellable(m: MessageType): boolean {
switch (m) {
case 'qsub':
case 'sub':
return true;
default:
return false;
}
}
/**
* Reflects a currently in-flight PortAPI request. Used to
* intercept and mangle with responses.
*/
export interface InspectedActiveRequest {
// The type of request.
type: RequestType;
// The actual request payload.
// @todo(ppacher): typings
payload: any;
// The request observer. Use to inject data
// or complete/error the subscriber. Use with
// care!
observer: Subscriber<DataReply<any>>;
// Counter for the number of messages received
// for this request.
messagesReceived: number;
// The last data received on the request
lastData: any;
// The last key received on the request
lastKey: string;
}
export interface RetryableOpts {
// A delay in milliseconds before retrying an operation.
retryDelay?: number;
// The maximum number of retries.
maxRetries?: number;
}
export interface ProfileImportResult extends ImportResult {
replacesProfiles: string[];
}
export interface ImportResult {
restartRequired: boolean;
replacesExisting: boolean;
containsUnknown: boolean;
}
/**
* Returns a RxJS operator function that implements a retry pipeline
* with a configurable retry delay and an optional maximum retry count.
* If maxRetries is reached the last error captured is thrown.
*
* @param opts Configuration options for the retryPipeline.
* see {@type RetryableOpts} for more information.
*/
export function retryPipeline<T>({ retryDelay, maxRetries }: RetryableOpts = {}): MonoTypeOperatorFunction<T> {
return retryWhen(errors => errors.pipe(
// use concatMap to keep the errors in order and make sure
// they don't execute in parallel.
concatMap((e, i) =>
iif(
// conditional observable seletion, throwError if i > maxRetries
// or a retryDelay otherwise
() => i > (maxRetries || Infinity),
throwError(() => e),
of(e).pipe(delay(retryDelay || 1000))
)
)
))
}
export interface WatchOpts extends RetryableOpts {
// Whether or not `new` updates should be filtered
// or let through. See {@method PortAPI.watch} for
// more information.
ingoreNew?: boolean;
ignoreDelete?: boolean;
}
/**
* Serializes a request or reply message into it's wire format.
*
* @param msg The request or reply messsage to serialize
*/
export function serializeMessage(msg: RequestMessage | ReplyMessage): any {
if (msg === undefined) {
return undefined;
}
let blob = `${msg.id}|${msg.type}`;
switch (msg.type) {
case 'done': // reply
case 'success': // reply
case 'cancel': // request
break;
case 'error': // reply
case 'warning': // reply
blob += `|${msg.message}`
break;
case 'ok': // reply
case 'upd': // reply
case 'new': // reply
case 'insert': // request
case 'update': // request
case 'create': // request
blob += `|${msg.key}|J${JSON.stringify(msg.data)}`
break;
case 'del': // reply
case 'get': // request
case 'delete': // request
blob += `|${msg.key}`
break;
case 'query': // request
case 'sub': // request
case 'qsub': // request
blob += `|query ${msg.query}`
break;
default:
// We need (msg as any) here because typescript knows that we covered
// all possible values above and that .type can never be something else.
// Still, we want to guard against unexpected portmaster message
// types.
console.error(`Unknown message type ${(msg as any).type}`);
}
return blob;
}
/**
* Deserializes (loads) a PortAPI message from a WebSocket message event.
*
* @param event The WebSocket MessageEvent to parse.
*/
export function deserializeMessage(event: MessageEvent): RequestMessage | ReplyMessage {
let data: string;
if (typeof event.data !== 'string') {
data = new TextDecoder("utf-8").decode(event.data)
} else {
data = event.data;
}
const parts = data.split("|");
if (parts.length < 2) {
throw new Error(`invalid number of message parts, expected 3-4 but got ${parts.length}`);
}
const id = parts[0];
const type = parts[1] as MessageType;
var msg: Partial<RequestMessage | ReplyMessage> = {
id,
type,
}
if (parts.length > 4) {
parts[3] = parts.slice(3).join('|')
}
switch (msg.type) {
case 'done': // reply
case 'success': // reply
case 'cancel': // request
break;
case 'error': // reply
case 'warning': // reply
msg.message = parts[2];
break;
case 'ok': // reply
case 'upd': // reply
case 'new': // reply
case 'insert': // request
case 'update': // request
case 'create': // request
msg.key = parts[2];
try {
if (parts[3][0] === 'J') {
msg.data = JSON.parse(parts[3].slice(1));
} else {
msg.data = parts[3];
}
} catch (e) {
console.log(e, data)
}
break;
case 'del': // reply
case 'get': // request
case 'delete': // request
msg.key = parts[2];
break;
case 'query': // request
case 'sub': // request
case 'qsub': // request
msg.query = parts[2];
if (msg.query.startsWith("query ")) {
msg.query = msg.query.slice(6);
}
break;
default:
// We need (msg as any) here because typescript knows that we covered
// all possible values above and that .type can never be something else.
// Still, we want to guard against unexpected portmaster message
// types.
console.error(`Unknown message type ${(msg as any).type}`);
}
return msg as (ReplyMessage | RequestMessage); // it's not partitial anymore
}

View File

@@ -0,0 +1,171 @@
import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
import { Inject, Injectable } from "@angular/core";
import { BehaviorSubject, Observable, of } from "rxjs";
import { filter, map, share, switchMap } from "rxjs/operators";
import { FeatureID } from "./features";
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service';
import { Feature, Pin, SPNStatus, UserProfile } from "./spn.types";
@Injectable({ providedIn: 'root' })
export class SPNService {
/** Emits the SPN status whenever it changes */
status$: Observable<SPNStatus>;
profile$ = this.watchProfile()
.pipe(
share({ connector: () => new BehaviorSubject<UserProfile | null | undefined>(undefined) }),
filter(val => val !== undefined)
) as Observable<UserProfile | null>;
private pins$: Observable<Pin[]>;
constructor(
private portapi: PortapiService,
private http: HttpClient,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
) {
this.status$ = this.portapi.watch<SPNStatus>('runtime:spn/status', { ignoreDelete: true })
.pipe(
share({ connector: () => new BehaviorSubject<any | null>(null) }),
filter(val => val !== null),
)
this.pins$ = this.status$
.pipe(
switchMap(status => {
if (status.Status !== "disabled") {
return this.portapi.watchAll<Pin>("map:main/", { retryDelay: 50000 })
}
return of([] as Pin[]);
}),
share({ connector: () => new BehaviorSubject<Pin[] | undefined>(undefined) }),
filter(val => val !== undefined)
) as Observable<Pin[]>;
}
/**
* Watches all pins of the "main" SPN map.
*/
watchPins(): Observable<Pin[]> {
return this.pins$;
}
/**
* Encodes a unicode string to base64.
* See https://developer.mozilla.org/en-US/docs/Web/API/btoa
* and https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
*/
b64EncodeUnicode(str: string): string {
return window.btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
return String.fromCharCode(parseInt(p1, 16))
}))
}
/**
* Logs into the SPN user account
*/
login({ username, password }: { username: string, password: string }): Observable<HttpResponse<string>> {
return this.http.post(`${this.httpAPI}/v1/spn/account/login`, undefined, {
headers: {
Authorization: `Basic ${this.b64EncodeUnicode(username + ':' + password)}`
},
responseType: 'text',
observe: 'response'
});
}
/**
* Log out of the SPN user account
*
* @param purge Whether or not the portmaster should keep user/device information for the next login
*/
logout(purge = false): Observable<HttpResponse<string>> {
let params = new HttpParams();
if (!!purge) {
params = params.set("purge", "true")
}
return this.http.delete(`${this.httpAPI}/v1/spn/account/logout`, {
params,
responseType: 'text',
observe: 'response'
})
}
watchEnabledFeatures(): Observable<(Feature & { enabled: boolean })[]> {
return this.profile$
.pipe(
switchMap(profile => {
return this.loadFeaturePackages()
.pipe(
map(features => {
return features.map(feature => {
// console.log(feature, profile?.current_plan?.feature_ids)
return {
...feature,
enabled: feature.RequiredFeatureID === FeatureID.None || profile?.current_plan?.feature_ids?.includes(feature.RequiredFeatureID) || false,
}
})
})
)
})
);
}
/** Returns a list of all feature packages */
loadFeaturePackages(): Observable<Feature[]> {
return this.http.get<{ Features: Feature[] }>(`${this.httpAPI}/v1/account/features`)
.pipe(
map(response => response.Features.map(feature => {
return {
...feature,
IconURL: `${this.httpAPI}/v1/account/features/${feature.ID}/icon`,
}
}))
);
}
/**
* Returns the current SPN user profile.
*
* @param refresh Whether or not the user profile should be refreshed from the ticket agent
* @returns
*/
userProfile(refresh = false): Observable<UserProfile> {
let params = new HttpParams();
if (!!refresh) {
params = params.set("refresh", true)
}
return this.http.get<UserProfile>(`${this.httpAPI}/v1/spn/account/user/profile`, {
params
});
}
/**
* Watches the user profile. It will emit null if there is no profile available yet.
*/
watchProfile(): Observable<UserProfile | null> {
let hasSent = false;
return this.portapi.watch<UserProfile>('core:spn/account/user', { ignoreDelete: true }, { forwardDone: true })
.pipe(
filter(result => {
if ('type' in result && result.type === 'done') {
if (hasSent) {
return false;
}
}
return true
}),
map(result => {
hasSent = true;
if ('type' in result) {
return null;
}
return result;
})
);
}
}

View File

@@ -0,0 +1,104 @@
import { FeatureID } from './features';
import { CountryInfo, GeoCoordinates, IntelEntity } from './network.types';
import { Record } from './portapi.types';
export interface SPNStatus extends Record {
Status: 'failed' | 'disabled' | 'connecting' | 'connected';
HomeHubID: string;
HomeHubName: string;
ConnectedIP: string;
ConnectedTransport: string;
ConnectedCountry: CountryInfo | null;
ConnectedSince: string | null;
}
export interface Pin extends Record {
ID: string;
Name: string;
FirstSeen: string;
EntityV4?: IntelEntity | null;
EntityV6?: IntelEntity | null;
States: string[];
SessionActive: boolean;
HopDistance: number;
ConnectedTo: {
[key: string]: Lane,
};
Route: string[] | null;
VerifiedOwner: string;
}
export interface Lane {
HubID: string;
Capacity: number;
Latency: number;
}
export function getPinCoords(p: Pin): GeoCoordinates | null {
if (p.EntityV4 && p.EntityV4.Coordinates) {
return p.EntityV4.Coordinates;
}
return p.EntityV6?.Coordinates || null;
}
export interface Device {
name: string;
id: string;
}
export interface Subscription {
ends_at: string;
state: 'manual' | 'active' | 'cancelled';
next_billing_date: string;
payment_provider: string;
}
export interface Plan {
name: string;
amount: number;
months: number;
renewable: boolean;
feature_ids: FeatureID[];
}
export interface View {
Message: string;
ShowAccountData: boolean;
ShowAccountButton: boolean;
ShowLoginButton: boolean;
ShowRefreshButton: boolean;
ShowLogoutButton: boolean;
}
export interface UserProfile extends Record {
username: string;
state: string;
balance: number;
device: Device | null;
subscription: Subscription | null;
current_plan: Plan | null;
next_plan: Plan | null;
view: View | null;
LastNotifiedOfEnd?: string;
LoggedInAt?: string;
}
export interface Package {
Name: string;
HexColor: string;
}
export interface Feature {
ID: string;
Name: string;
ConfigKey: string;
ConfigScope: string;
RequiredFeatureID: FeatureID;
InPackage: Package | null;
Comment: string;
Beta?: boolean;
ComingSoon?: boolean;
// does not come from the PM API but is set by SPNService
IconURL: string;
}

View File

@@ -0,0 +1,13 @@
export function deepClone<T = any>(o?: T | null): T {
if (o === null || o === undefined) {
return null as any as T;
}
let _out: T = (Array.isArray(o) ? [] : {}) as T;
for (let _key in (o as T)) {
let v = o[_key];
_out[_key] = (typeof v === "object") ? deepClone(v) : v;
}
return _out as T;
}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { webSocket, WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket';
@Injectable()
export class WebsocketService {
constructor() { }
/**
* createConnection creates a new websocket connection using opts.
*
* @param opts Options for the websocket connection.
*/
createConnection<T>(opts: WebSocketSubjectConfig<T>): WebSocketSubject<T> {
return webSocket(opts);
}
}

View File

@@ -0,0 +1,22 @@
/*
* Public API Surface of portmaster-api
*/
export * from './lib/app-profile.service';
export * from './lib/app-profile.types';
export * from './lib/config.service';
export * from './lib/config.types';
export * from './lib/core.types';
export * from './lib/debug-api.service';
export * from './lib/features';
export * from './lib/meta-api.service';
export * from './lib/module';
export * from './lib/netquery.service';
export * from './lib/network.types';
export * from './lib/portapi.service';
export * from './lib/portapi.types';
export * from './lib/spn.service';
export * from './lib/spn.types';
export * from './lib/utils';
export * from './lib/websocket.service';

View File

@@ -0,0 +1,15 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);

View File

@@ -0,0 +1,16 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/test.ts",
"testing/**/*",
"**/*.spec.ts"
]
}

View File

@@ -0,0 +1,7 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
}

View File

@@ -0,0 +1,18 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../../../out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"testing/**/*.ts"
],
"include": [
"testing/**/*.ts",
"**/*.spec.ts",
"**/*.d.ts"
]
}

View File

@@ -0,0 +1,44 @@
{
"extends": "../../../.eslintrc.json",
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"projects/safing/ui/tsconfig.lib.json",
"projects/safing/ui/tsconfig.spec.json"
],
"createDefaultProgram": true
},
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "sfng",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "sfng",
"style": "kebab-case"
}
]
}
},
{
"files": [
"*.html"
],
"rules": {}
}
]
}

View File

@@ -0,0 +1,24 @@
# Ui
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.2.0.
## Code scaffolding
Run `ng generate component component-name --project ui` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ui`.
> Note: Don't forget to add `--project ui` or else it will be added to the default project in your `angular.json` file.
## Build
Run `ng build ui` to build the project. The build artifacts will be stored in the `dist/` directory.
## Publishing
After building your library with `ng build ui`, go to the dist folder `cd dist/ui` and run `npm publish`.
## Running unit tests
Run `ng test ui` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, '../../../coverage/safing/ui'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

View File

@@ -0,0 +1,11 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../../dist-lib/safing/ui",
"lib": {
"entryFile": "src/public-api.ts"
},
"assets": [
"theming.scss",
"**/_*.scss"
]
}

View File

@@ -0,0 +1,17 @@
{
"name": "@safing/ui",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "~12.2.0",
"@angular/core": "~12.2.0",
"@angular/cdk": "~12.2.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"exports": {
"./theming": {
"sass": "./theming.scss"
}
}
}

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -0,0 +1,116 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, Component, Input, OnDestroy, TemplateRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { SfngAccordionComponent } from './accordion';
@Component({
selector: 'sfng-accordion-group',
templateUrl: './accordion-group.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngAccordionGroupComponent implements OnDestroy {
/** @private Currently registered accordion components */
accordions: SfngAccordionComponent[] = [];
/**
* A template-ref to render as the header for each accordion-component.
* Receives the accordion data as an $implicit context.
*/
@Input()
set headerTemplate(v: TemplateRef<any> | null) {
this._headerTemplate = v;
if (!!this.accordions.length) {
this.accordions.forEach(a => {
a.headerTemplate = v;
a.cdr.markForCheck();
})
}
}
get headerTemplate() { return this._headerTemplate }
private _headerTemplate: TemplateRef<any> | null = null;
/** Whether or not one or more components can be expanded. */
@Input()
set singleMode(v: any) {
this._singleMode = coerceBooleanProperty(v);
}
get singleMode() { return this._singleMode }
private _singleMode = false;
/** Whether or not the accordion is disabled and does not allow expanding */
@Input()
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v);
if (this._disabled) {
this.accordions.forEach(a => a.active = false);
}
}
get disabled(): boolean { return this._disabled; }
private _disabled = false;
/** A list of subscriptions to the activeChange output of the registered accordion-components */
private subscriptions: Subscription[] = [];
/**
* Registeres an accordion component to be handled together with this
* accordion group.
*
* @param a The accordion component to register
*/
register(a: SfngAccordionComponent) {
this.accordions.push(a);
// Tell the accordion-component about the default header-template.
if (!a.headerTemplate) {
a.headerTemplate = this.headerTemplate;
}
// Subscribe to the activeChange output of the registered
// accordion and call toggle() for each event emitted.
this.subscriptions.push(a.activeChange.subscribe(() => {
if (this.disabled) {
return;
}
this.toggle(a);
}))
}
/**
* Unregisters a accordion component
*
* @param a The accordion component to unregister
*/
unregister(a: SfngAccordionComponent) {
const index = this.accordions.indexOf(a);
if (index === -1) return;
const subscription = this.subscriptions[index];
subscription.unsubscribe();
this.accordions = this.accordions.splice(index, 1);
this.subscriptions = this.subscriptions.splice(index, 1);
}
ngOnDestroy() {
this.subscriptions.forEach(s => s.unsubscribe());
this.subscriptions = [];
this.accordions = [];
}
/**
* Expand an accordion component and collaps all others if
* single-mode is selected.
*
* @param a The accordion component to toggle.
*/
private toggle(a: SfngAccordionComponent) {
if (!a.active && this._singleMode) {
this.accordions?.forEach(a => a.active = false);
}
a.active = !a.active;
}
}

View File

@@ -0,0 +1,10 @@
<div [class.active]="active" [class.cursor-pointer]="!group || !group.disabled" (click)="toggle($event)">
<ng-container *ngTemplateOutlet="headerTemplate; context: {$implicit: data, active: active, accordion: component}">
</ng-container>
</div>
<div class="h-auto overflow-visible opacity-100" *ngIf="active" [@fadeIn] [@fadeOut]>
<ng-container>
<ng-content></ng-content>
</ng-container>
</div>

View File

@@ -0,0 +1,19 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngAccordionComponent } from "./accordion";
import { SfngAccordionGroupComponent } from "./accordion-group";
@NgModule({
imports: [
CommonModule,
],
declarations: [
SfngAccordionGroupComponent,
SfngAccordionComponent,
],
exports: [
SfngAccordionGroupComponent,
SfngAccordionComponent,
]
})
export class SfngAccordionModule { }

View File

@@ -0,0 +1,88 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Optional, Output, TemplateRef, TrackByFunction } from '@angular/core';
import { fadeInAnimation, fadeOutAnimation } from '../animations';
import { SfngAccordionGroupComponent } from './accordion-group';
@Component({
selector: 'sfng-accordion',
templateUrl: './accordion.html',
exportAs: 'sfngAccordion',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeInAnimation,
fadeOutAnimation
]
})
export class SfngAccordionComponent<T = any> implements OnInit, OnDestroy {
/** @deprecated in favor of [data] */
@Input()
title: string = '';
/** A reference to the component provided via the template context */
component = this;
/**
* The data the accordion component is used for. This is passed as an $implicit context
* to the header template.
*/
@Input()
data: T | undefined = undefined;
@Input()
trackBy: TrackByFunction<T | null> = (_, c) => c
/** Whether or not the accordion component starts active. */
@Input()
set active(v: any) {
this._active = coerceBooleanProperty(v);
}
get active() {
return this._active;
}
private _active: boolean = false;
/** Emits whenever the active value changes. Supports two-way bindings. */
@Output()
activeChange = new EventEmitter<boolean>();
/**
* The header-template to render for this component. If null, the default template from
* the parent accordion-group will be used.
*/
@Input()
headerTemplate: TemplateRef<any> | null = null;
@HostBinding('class.active')
/** @private Whether or not the accordion should have the 'active' class */
get activeClass(): string {
return this.active;
}
ngOnInit(): void {
// register at our parent group-component (if any).
this.group?.register(this);
}
ngOnDestroy(): void {
this.group?.unregister(this);
}
/**
* Toggle the active-state of the accordion-component.
*
* @param event The mouse event.
*/
toggle(event?: Event) {
if (!!this.group && this.group.disabled) {
return;
}
event?.preventDefault();
this.activeChange.emit(!this.active);
}
constructor(
public cdr: ChangeDetectorRef,
@Optional() public group: SfngAccordionGroupComponent,
) { }
}

View File

@@ -0,0 +1,4 @@
export { SfngAccordionComponent } from './accordion';
export { SfngAccordionGroupComponent } from './accordion-group';
export { SfngAccordionModule } from './accordion.module';

View File

@@ -0,0 +1,88 @@
import { animate, query, stagger, style, transition, trigger } from '@angular/animations';
export const fadeInAnimation = trigger(
'fadeIn',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateY(-5px)' }),
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
style({ opacity: 1, transform: 'translateY(0px)' }))
]
),
]
);
export const fadeOutAnimation = trigger(
'fadeOut',
[
transition(
':leave',
[
style({ opacity: 1, transform: 'translateY(0px)' }),
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
style({ opacity: 0, transform: 'translateY(-5px)' }))
]
),
]
);
export const fadeInListAnimation = trigger(
'fadeInList',
[
transition(':enter, * => 0, * => -1', []),
transition(':increment', [
query(':enter', [
style({ opacity: 0 }),
stagger(5, [
animate('300ms ease-out', style({ opacity: 1 })),
]),
], { optional: true })
]),
]
)
export const moveInOutAnimation = trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX(100%)' }),
animate('.2s ease-in',
style({ opacity: 1, transform: 'translateX(0%)' }))
]
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s ease-out',
style({ opacity: 0, transform: 'translateX(100%)' }))
]
)
]
)
export const moveInOutListAnimation = trigger(
'moveInOutList',
[
transition(':enter, * => 0, * => -1', []),
transition(':increment', [
query(':enter', [
style({ opacity: 0, transform: 'translateX(100%)' }),
stagger(50, [
animate('200ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' })),
]),
], { optional: true })
]),
transition(':decrement', [
query(':leave', [
stagger(-50, [
animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(100%)' })),
]),
], { optional: true })
]),
]
)

View File

@@ -0,0 +1,95 @@
.sfng-confirm-dialog {
display: flex;
flex-direction: column;
align-items: flex-start;
caption {
@apply text-sm;
opacity: .6;
font-size: .6rem;
}
h1 {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 1rem;
}
.message,
h1 {
flex-shrink: 0;
text-overflow: ellipsis;
word-break: normal;
}
.message {
font-size: 0.75rem;
flex-grow: 1;
opacity: .6;
max-width: 300px;
}
.message~input {
margin-top: 0.5rem;
font-size: 95%;
}
.close-icon {
position: absolute;
top: 1rem;
right: 1rem;
opacity: .7;
cursor: pointer;
&:hover {
opacity: 1;
}
}
input[type="text"] {
@apply text-primary;
@apply bg-gray-500 border-gray-400 bg-opacity-75 border-opacity-75;
&::placeholder {
@apply text-tertiary;
}
}
.actions {
margin-top: 1rem;
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
button.action-button {
&:not(:last-child) {
margin-right: 0.5rem;
}
&:not(.outline) {
@apply bg-blue;
}
&.danger {
@apply bg-red-300;
}
&.outline {
@apply outline-none;
@apply border;
@apply border-gray-400;
}
}
&>span {
display: flex;
align-items: center;
label {
margin-left: .5rem;
user-select: none;
}
}
}
}

View File

@@ -0,0 +1,28 @@
sfng-dialog-container {
.container {
display: block;
box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.75);
@apply p-6;
@apply bg-gray-300;
@apply rounded;
min-width: 20rem;
width: fit-content;
position: relative;
}
#drag-handle {
display: block;
height: 6px;
background-color: white;
opacity: .4;
border-radius: 3px;
position: absolute;
bottom: calc(0.5rem - 2px);
width: 30%;
left: calc(50% - 15%);
&:hover {
opacity: .8;
}
}
}

View File

@@ -0,0 +1,22 @@
<div class="sfng-confirm-dialog">
<caption *ngIf="config.caption">{{config.caption}}</caption>
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="config.canCancel" class="w-5 h-5 close-icon" viewBox="0 0 20 20"
fill="currentColor" (click)="select()">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
<h1 *ngIf="config.header">{{config.header}}</h1>
<span class="message" *ngIf="config.message">{{ config.message }}</span>
<input *ngIf="!!config.inputType" [attr.type]="config.inputType" [(ngModel)]="config.inputModel"
[attr.placeholder]="config.inputPlaceholder || null">
<div class="actions" *ngIf="!!config.buttons">
<button *ngFor="let button of config.buttons" (click)="select(button.id)" type="button"
class="action-button {{button.class}}">{{button.text}}</button>
</div>
</div>

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, Inject, InjectionToken } from '@angular/core';
import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref';
export interface ConfirmDialogButton {
text: string;
id: string;
class?: 'danger' | 'outline';
}
export interface ConfirmDialogConfig {
buttons?: ConfirmDialogButton[];
canCancel?: boolean;
header?: string;
message?: string;
caption?: string;
inputType?: 'text' | 'password';
inputModel?: string;
inputPlaceholder?: string;
}
export const CONFIRM_DIALOG_CONFIG = new InjectionToken<ConfirmDialogConfig>('ConfirmDialogConfig');
@Component({
templateUrl: './confirm.dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngConfirmDialogComponent {
constructor(
@Inject(SFNG_DIALOG_REF) private dialogRef: SfngDialogRef<any>,
@Inject(CONFIRM_DIALOG_CONFIG) public config: ConfirmDialogConfig,
) {
if (config.inputType !== undefined && config.inputModel === undefined) {
config.inputModel = '';
}
}
select(action?: string) {
this.dialogRef.close(action || null);
}
}

View File

@@ -0,0 +1,19 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
export const dialogAnimation = trigger(
'dialogContainer',
[
state('void, exit', style({ opacity: 0, transform: 'scale(0.7)' })),
state('enter', style({ transform: 'none', opacity: 1 })),
transition(
'* => enter',
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
style({ opacity: 1, transform: 'translateY(0px)' }))
),
transition(
'* => void, * => exit',
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
style({ opacity: 0, transform: 'scale(0.7)' }))
),
]
);

View File

@@ -0,0 +1,76 @@
import { AnimationEvent } from '@angular/animations';
import { CdkDrag } from '@angular/cdk/drag-drop';
import { CdkPortalOutlet, ComponentPortal, Portal, TemplatePortal } from '@angular/cdk/portal';
import { ChangeDetectorRef, Component, ComponentRef, EmbeddedViewRef, HostBinding, HostListener, InjectionToken, Input, ViewChild } from '@angular/core';
import { Subject } from 'rxjs';
import { dialogAnimation } from './dialog.animations';
export const SFNG_DIALOG_PORTAL = new InjectionToken<Portal<any>>('SfngDialogPortal');
export type SfngDialogState = 'opening' | 'open' | 'closing' | 'closed';
@Component({
selector: 'sfng-dialog-container',
template: `
<div class="container" cdkDrag cdkDragRootElement=".cdk-overlay-pane" [cdkDragDisabled]="!dragable">
<div *ngIf="dragable" cdkDragHandle id="drag-handle"></div>
<ng-container cdkPortalOutlet></ng-container>
</div>
`,
animations: [dialogAnimation]
})
export class SfngDialogContainerComponent<T> {
onStateChange = new Subject<SfngDialogState>();
ref: ComponentRef<T> | EmbeddedViewRef<T> | null = null;
constructor(
private cdr: ChangeDetectorRef,
) { }
@HostBinding('@dialogContainer')
state = 'enter';
@ViewChild(CdkPortalOutlet, { static: true })
_portalOutlet: CdkPortalOutlet | null = null;
@ViewChild(CdkDrag, { static: true })
drag!: CdkDrag;
attachComponentPortal(portal: ComponentPortal<T>): ComponentRef<T> {
this.ref = this._portalOutlet!.attachComponentPortal(portal)
return this.ref;
}
attachTemplatePortal(portal: TemplatePortal<T>): EmbeddedViewRef<T> {
this.ref = this._portalOutlet!.attachTemplatePortal(portal);
return this.ref;
}
@Input()
dragable: boolean = false;
@HostListener('@dialogContainer.start', ['$event'])
onAnimationStart({ toState }: AnimationEvent) {
if (toState === 'enter') {
this.onStateChange.next('opening');
} else if (toState === 'exit') {
this.onStateChange.next('closing');
}
}
@HostListener('@dialogContainer.done', ['$event'])
onAnimationEnd({ toState }: AnimationEvent) {
if (toState === 'enter') {
this.onStateChange.next('open');
} else if (toState === 'exit') {
this.onStateChange.next('closed');
}
}
/** Starts the exit animation */
_startExit() {
this.state = 'exit';
this.cdr.markForCheck();
}
}

View File

@@ -0,0 +1,23 @@
import { DragDropModule } from "@angular/cdk/drag-drop";
import { OverlayModule } from "@angular/cdk/overlay";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { SfngConfirmDialogComponent } from "./confirm.dialog";
import { SfngDialogContainerComponent } from "./dialog.container";
@NgModule({
imports: [
CommonModule,
OverlayModule,
PortalModule,
DragDropModule,
FormsModule,
],
declarations: [
SfngDialogContainerComponent,
SfngConfirmDialogComponent,
]
})
export class SfngDialogModule { }

View File

@@ -0,0 +1,62 @@
import { OverlayRef } from "@angular/cdk/overlay";
import { InjectionToken } from "@angular/core";
import { Observable, PartialObserver, Subject } from "rxjs";
import { filter, take } from "rxjs/operators";
import { SfngDialogContainerComponent, SfngDialogState } from "./dialog.container";
export const SFNG_DIALOG_REF = new InjectionToken<SfngDialogRef<any>>('SfngDialogRef');
export class SfngDialogRef<T, R = any, D = any> {
constructor(
private _overlayRef: OverlayRef,
private container: SfngDialogContainerComponent<T>,
public readonly data: D,
) {
this.container.onStateChange
.pipe(
filter(state => state === 'closed'),
take(1)
)
.subscribe(() => {
this._overlayRef.detach();
this._overlayRef.dispose();
this.onClose.next(this.value);
this.onClose.complete();
});
}
get onStateChange(): Observable<SfngDialogState> {
return this.container.onStateChange;
}
/**
* @returns The overlayref that holds the dialog container.
*/
overlay() { return this._overlayRef }
/**
* @returns the instance attached to the dialog container
*/
contentRef() { return this.container.ref! }
/** Value holds the value passed on close() */
private value: R | null = null;
/**
* Emits the result of the dialog and closes the overlay.
*/
onClose = new Subject<R | null>()
/** onAction only emits if close() is called with action. */
onAction<T extends R>(action: T, observer: PartialObserver<T> | ((value: T) => void)): this {
(this.onClose.pipe(filter(val => val === action)) as Observable<T>)
.subscribe(observer as any); // typescript does not select the correct type overload here.
return this;
}
close(result?: R) {
this.value = result || null;
this.container._startExit();
}
}

View File

@@ -0,0 +1,154 @@
import { Overlay, OverlayConfig, OverlayPositionBuilder, PositionStrategy } from '@angular/cdk/overlay';
import { ComponentPortal, ComponentType, TemplatePortal } from '@angular/cdk/portal';
import { EmbeddedViewRef, Injectable, Injector } from '@angular/core';
import { filter, take, takeUntil } from 'rxjs/operators';
import { ConfirmDialogConfig, CONFIRM_DIALOG_CONFIG, SfngConfirmDialogComponent } from './confirm.dialog';
import { SfngDialogContainerComponent } from './dialog.container';
import { SfngDialogModule } from './dialog.module';
import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref';
export interface BaseDialogConfig {
/** whether or not the dialog should close on outside-clicks and ESC */
autoclose?: boolean;
/** whether or not a backdrop should be visible */
backdrop?: boolean | 'light';
/** whether or not the dialog should be dragable */
dragable?: boolean;
/**
* optional position strategy for the overlay. if omitted, the
* overlay will be centered on the screen
*/
positionStrategy?: PositionStrategy;
/**
* Optional data for the dialog that is available either via the
* SfngDialogRef for ComponentPortals as an $implicit context value
* for TemplatePortals.
*
* Note, for template portals, data is only set as an $implicit context
* value if it is not yet set in the portal!
*/
data?: any;
}
export interface ComponentPortalConfig {
injector?: Injector;
}
@Injectable({ providedIn: SfngDialogModule })
export class SfngDialogService {
constructor(
private injector: Injector,
private overlay: Overlay,
) { }
position(): OverlayPositionBuilder {
return this.overlay.position();
}
create<T>(template: TemplatePortal<T>, opts?: BaseDialogConfig): SfngDialogRef<EmbeddedViewRef<T>>;
create<T>(target: ComponentType<T>, opts?: BaseDialogConfig & ComponentPortalConfig): SfngDialogRef<T>;
create<T>(target: ComponentType<T> | TemplatePortal<T>, opts: BaseDialogConfig & ComponentPortalConfig = {}): SfngDialogRef<any> {
let position: PositionStrategy = opts?.positionStrategy || this.overlay
.position()
.global()
.centerVertically()
.centerHorizontally();
let hasBackdrop = true;
let backdropClass = 'dialog-screen-backdrop';
if (opts.backdrop !== undefined) {
if (opts.backdrop === false) {
hasBackdrop = false;
} else if (opts.backdrop === 'light') {
backdropClass = 'dialog-screen-backdrop-light';
}
}
const cfg = new OverlayConfig({
scrollStrategy: this.overlay.scrollStrategies.noop(),
positionStrategy: position,
hasBackdrop: hasBackdrop,
backdropClass: backdropClass,
});
const overlayref = this.overlay.create(cfg);
// create our dialog container and attach it to the
// overlay.
const containerPortal = new ComponentPortal<SfngDialogContainerComponent<T>>(
SfngDialogContainerComponent,
undefined,
this.injector,
)
const containerRef = containerPortal.attach(overlayref);
if (!!opts.dragable) {
containerRef.instance.dragable = true;
}
// create the dialog ref
const dialogRef = new SfngDialogRef<T>(overlayref, containerRef.instance, opts.data);
// prepare the content portal and attach it to the container
let result: any;
if (target instanceof TemplatePortal) {
let r = containerRef.instance.attachTemplatePortal(target)
if (!!r.context && typeof r.context === 'object' && !('$implicit' in r.context)) {
r.context = {
$implicit: opts.data,
...r.context,
}
}
result = r
} else {
const contentPortal = new ComponentPortal(target, null, Injector.create({
providers: [
{
provide: SFNG_DIALOG_REF,
useValue: dialogRef,
}
],
parent: opts?.injector || this.injector,
}));
result = containerRef.instance.attachComponentPortal(contentPortal);
}
// update the container position now that we have some content.
overlayref.updatePosition();
if (!!opts?.autoclose) {
overlayref.outsidePointerEvents()
.pipe(take(1))
.subscribe(() => dialogRef.close());
overlayref.keydownEvents()
.pipe(
takeUntil(overlayref.detachments()),
filter(event => event.key === 'Escape')
)
.subscribe(() => {
dialogRef.close();
})
}
return dialogRef;
}
confirm(opts: ConfirmDialogConfig): SfngDialogRef<SfngConfirmDialogComponent, string> {
return this.create(SfngConfirmDialogComponent, {
autoclose: opts.canCancel,
injector: Injector.create({
providers: [
{
provide: CONFIRM_DIALOG_CONFIG,
useValue: opts,
},
],
parent: this.injector,
})
})
}
}

View File

@@ -0,0 +1,5 @@
export { ConfirmDialogConfig } from './confirm.dialog';
export * from './dialog.module';
export * from './dialog.ref';
export * from './dialog.service';

View File

@@ -0,0 +1,27 @@
<div *ngIf="!externalTrigger" class="w-full" cdkOverlayOrigin #trigger="cdkOverlayOrigin" (click)="toggle(trigger)">
<ng-template [ngTemplateOutlet]="triggerTemplate || defaultTriggerTemplate"></ng-template>
</div>
<ng-template #defaultTriggerTemplate>
<!-- TODO(ppacher): use a button rather than a div but first fix the button styling -->
<div [class.rounded-b]="!isOpen"
class="flex flex-row items-center justify-between w-full px-4 py-2 mt-6 bg-gray-100 rounded-t cursor-pointer hover:bg-gray-100 hover:bg-opacity-75 text-secondary">
{{ label }}
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</ng-template>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOffsetY]="offsetY" [cdkConnectedOverlayOffsetX]="offsetX"
[cdkConnectedOverlayMinWidth]="minWidth" [cdkConnectedOverlayMinHeight]="minHeight"
[cdkConnectedOverlayOrigin]="trigger!" [cdkConnectedOverlayOpen]="isOpen" (detach)="onOverlayClosed()"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy" (overlayOutsideClick)="onOutsideClick($event)"
[cdkConnectedOverlayPositions]="positions">
<div class="w-full overflow-hidden bg-gray-200 rounded-b shadow {{ overlayClass }}" [style.maxHeight]="maxHeight"
[style.maxWidth]="maxWidth" [@fadeIn] [@fadeOut]>
<ng-content></ng-content>
</div>
</ng-template>

View File

@@ -0,0 +1,18 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngDropdownComponent } from "./dropdown";
@NgModule({
imports: [
CommonModule,
OverlayModule,
],
declarations: [
SfngDropdownComponent,
],
exports: [
SfngDropdownComponent,
]
})
export class SfngDropDownModule { }

View File

@@ -0,0 +1,216 @@
import { coerceBooleanProperty, coerceCssPixelValue, coerceNumberProperty } from "@angular/cdk/coercion";
import { CdkOverlayOrigin, ConnectedPosition, ScrollStrategy, ScrollStrategyOptions } from "@angular/cdk/overlay";
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnInit, Output, Renderer2, TemplateRef, ViewChild } from "@angular/core";
import { fadeInAnimation, fadeOutAnimation } from '../animations';
@Component({
selector: 'sfng-dropdown',
exportAs: 'sfngDropdown',
templateUrl: './dropdown.html',
styles: [
`
:host {
display: block;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInAnimation, fadeOutAnimation],
})
export class SfngDropdownComponent implements OnInit {
/** The trigger origin used to open the drop-down */
@ViewChild('trigger', { read: CdkOverlayOrigin })
trigger: CdkOverlayOrigin | null = null;
/**
* The button/drop-down label. Only when not using
* {@Link SfngDropdown.externalTrigger}
*/
@Input()
label: string = '';
/** The trigger template to use when {@Link SfngDropdown.externalTrigger} */
@Input()
triggerTemplate: TemplateRef<any> | null = null;
/** Set to true to provide an external dropdown trigger template using {@Link SfngDropdown.triggerTemplate} */
@Input()
set externalTrigger(v: any) {
this._externalTrigger = coerceBooleanProperty(v)
}
get externalTrigger() {
return this._externalTrigger;
}
private _externalTrigger = false;
/** A list of classes to apply to the overlay element */
@Input()
overlayClass: string = '';
/** Whether or not the drop-down is disabled. */
@Input()
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v)
}
get disabled() {
return this._disabled;
}
private _disabled = false;
/** The Y-offset of the drop-down overlay */
@Input()
set offsetY(v: any) {
this._offsetY = coerceNumberProperty(v);
}
get offsetY() { return this._offsetY }
private _offsetY = 4;
/** The X-offset of the drop-down overlay */
@Input()
set offsetX(v: any) {
this._offsetX = coerceNumberProperty(v);
}
get offsetX() { return this._offsetX }
private _offsetX = 0;
/** The scrollStrategy of the drop-down */
@Input()
scrollStrategy!: ScrollStrategy;
/** Whether or not the pop-over is currently shown. Do not modify this directly */
isOpen = false;
/** The minimum width of the drop-down */
@Input()
set minWidth(val: any) {
this._minWidth = coerceCssPixelValue(val)
}
get minWidth() { return this._minWidth }
private _minWidth: string | number = 0;
/** The maximum width of the drop-down */
@Input()
set maxWidth(val: any) {
this._maxWidth = coerceCssPixelValue(val)
}
get maxWidth() { return this._maxWidth }
private _maxWidth: string | number | null = null;
/** The minimum height of the drop-down */
@Input()
set minHeight(val: any) {
this._minHeight = coerceCssPixelValue(val)
}
get minHeight() { return this._minHeight }
private _minHeight: string | number | null = null;
/** The maximum width of the drop-down */
@Input()
set maxHeight(val: any) {
this._maxHeight = coerceCssPixelValue(val)
}
get maxHeight() { return this._maxHeight }
private _maxHeight: string | number | null = null;
/** Emits whenever the drop-down is opened */
@Output()
opened = new EventEmitter<void>();
/** Emits whenever the drop-down is closed. */
@Output()
closed = new EventEmitter<void>();
@Input()
positions: ConnectedPosition[] = [
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
},
{
originX: 'end',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
},
]
constructor(
public readonly elementRef: ElementRef,
private changeDetectorRef: ChangeDetectorRef,
private renderer: Renderer2,
private scrollOptions: ScrollStrategyOptions,
) {
}
ngOnInit() {
this.scrollStrategy = this.scrollStrategy || this.scrollOptions.close();
}
onOutsideClick(event: MouseEvent) {
if (!!this.trigger) {
const triggerEl = this.trigger.elementRef.nativeElement;
let node = event.target;
while (!!node) {
if (node === triggerEl) {
return;
}
node = this.renderer.parentNode(node);
}
}
this.close();
}
onOverlayClosed() {
this.closed.next();
}
close() {
if (!this.isOpen) {
return;
}
this.isOpen = false;
this.changeDetectorRef.markForCheck();
}
toggle(t: CdkOverlayOrigin | null = this.trigger) {
if (this.isOpen) {
this.close();
return;
}
this.show(t);
}
show(t: CdkOverlayOrigin | null = this.trigger) {
if (t === null) {
return;
}
if (this.isOpen || this._disabled) {
return;
}
if (!!t) {
this.trigger = t;
const rect = (this.trigger.elementRef.nativeElement as HTMLElement).getBoundingClientRect()
this.minWidth = rect ? rect.width : this.trigger.elementRef.nativeElement.offsetWidth;
}
this.isOpen = true;
this.opened.next();
this.changeDetectorRef.markForCheck();
}
}

View File

@@ -0,0 +1,3 @@
export * from './dropdown';
export * from './dropdown.module';

View File

@@ -0,0 +1,5 @@
export * from './overlay-stepper';
export * from './overlay-stepper.module';
export * from './refs';
export * from './step';

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="canAbort" (click)="close()"
class="absolute top-0 right-0 w-5 h-5 -mt-2 -mr-2 opacity-75 cursor-pointer hover:opacity-100" fill="none"
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<div class="flex-grow py-4 mb-4" [@moveInOut]="portal.hasAttached()">
<ng-container cdkPortalOutlet #portal="cdkPortalOutlet"></ng-container>
</div>
<ng-template [ngIf]="!!currentStep">
<ng-container *ngTemplateOutlet="currentStep?.buttonTemplate || defaultButtonTemplate"></ng-container>
</ng-template>
<ng-template #defaultButtonTemplate>
<div class="flex flex-row justify-between">
<button class="w-32 py-2" (click)="goBack()">Go Back</button>
<button class="w-32 py-2 custom bg-blue hover:bg-blue hover:bg-opacity-75 active:shadow-inner"
[disabled]="(currentStep?.validChange | async) === false" (click)="next()">
{{ currentStep?.nextButtonLabel || (!isLast ? 'Next' : 'Finish') }}</button>
</div>
</ng-template>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,261 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { CdkPortalOutlet, ComponentPortal, ComponentType } from "@angular/cdk/portal";
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, Injector, isDevMode, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { Subject } from "rxjs";
import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog";
import { StepperControl, StepRef, STEP_REF } from "./refs";
import { Step, StepperConfig } from "./step";
import { StepOutletComponent, STEP_ANIMATION_DIRECTION, STEP_PORTAL } from "./step-outlet";
/**
* STEP_CONFIG is used to inject the StepperConfig into the OverlayStepperContainer.
*/
export const STEP_CONFIG = new InjectionToken<StepperConfig>('StepperConfig');
@Component({
templateUrl: './overlay-stepper-container.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
position: relative;
display: flex;
flex-direction: column;
width: 600px;
}
`
],
animations: [
trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX({{ in }})' }),
animate('.2s cubic-bezier(0.4, 0, 0.2, 1)',
style({ opacity: 1, transform: 'translateX(0%)' }))
],
{ params: { in: '100%' } } // default parameters
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s cubic-bezier(0.4, 0, 0.2, 1)',
style({ opacity: 0, transform: 'translateX({{ out }})' }))
],
{ params: { out: '-100%' } } // default parameters
)
]
)]
})
export class OverlayStepperContainerComponent implements OnInit, OnDestroy, StepperControl {
/** Used to keep cache the stepRef instances. See documentation for {@class StepRef} */
private stepRefCache = new Map<number, StepRef>();
/** Used to emit when the stepper finished. This is always folled by emitting on onClose$ */
private onFinish$ = new Subject<void>();
/** Emits when the stepper finished - also see {@link OverlayStepperContainerComponent.onClose}*/
get onFinish() {
return this.onFinish$.asObservable();
}
/**
* Emits when the stepper is closed.
* If the stepper if finished then onFinish will emit first
*/
get onClose() {
return this.dialogRef.onClose;
}
/** The index of the currently displayed step */
currentStepIndex = -1;
/** The component instance of the current step */
currentStep: Step | null = null;
/** A reference to the portalOutlet used to render our steps */
@ViewChild(CdkPortalOutlet, { static: true })
portalOutlet!: CdkPortalOutlet;
/** Whether or not the user can go back */
canGoBack = false;
/** Whether or not the user can abort and close the stepper */
canAbort = false;
/** Whether the current step is the last step */
get isLast() {
return this.currentStepIndex + 1 >= this.config.steps.length;
}
constructor(
@Inject(STEP_CONFIG) public readonly config: StepperConfig,
@Inject(SFNG_DIALOG_REF) public readonly dialogRef: SfngDialogRef<void>,
private injector: Injector,
private cdr: ChangeDetectorRef
) { }
/**
* Moves forward to the next step or closes the stepper
* when moving beyond the last one.
*/
next(): Promise<void> {
if (this.isLast) {
this.onFinish$.next();
this.close();
return Promise.resolve();
}
return this.attachStep(this.currentStepIndex + 1, true)
}
/**
* Moves back to the previous step. This does not take canGoBack
* into account.
*/
goBack(): Promise<void> {
return this.attachStep(this.currentStepIndex - 1, false)
}
/** Closes the stepper - this does not run the onFinish hooks of the steps */
async close(): Promise<void> {
this.dialogRef.close();
}
ngOnInit(): void {
this.next();
}
ngOnDestroy(): void {
this.onFinish$.complete();
}
/**
* Attaches a new step component in the current outlet. It detaches any previous
* step and calls onBeforeBack and onBeforeNext respectively.
*
* @param index The index of the new step to attach.
* @param forward Whether or not the new step is attached by going "forward" or "backward"
* @returns
*/
private async attachStep(index: number, forward = true) {
if (index >= this.config.steps.length) {
if (isDevMode()) {
throw new Error(`Cannot attach step at ${index}: index out of range`)
}
return;
}
// call onBeforeNext or onBeforeBack of the current step
if (this.currentStep) {
if (forward) {
if (!!this.currentStep.onBeforeNext) {
try {
await this.currentStep.onBeforeNext();
} catch (err) {
console.error(`Failed to move to next step`, err)
// TODO(ppacher): display error
return;
}
}
} else {
if (!!this.currentStep.onBeforeBack) {
try {
await this.currentStep.onBeforeBack()
} catch (err) {
console.error(`Step onBeforeBack callback failed`, err)
}
}
}
// detach the current step component.
this.portalOutlet.detach();
}
const stepType = this.config.steps[index];
const contentPortal = this.createStepContentPortal(stepType, index)
const outletPortal = this.createStepOutletPortal(contentPortal, forward ? 'right' : 'left')
// attach the new step (which is wrapped in a StepOutletComponent).
const ref = this.portalOutlet.attachComponentPortal(outletPortal);
// We need to wait for the step to be actually attached in the outlet
// to get access to the actual step component instance.
ref.instance.portalOutlet!.attached
.subscribe((stepRef: ComponentRef<Step>) => {
this.currentStep = stepRef.instance;
this.currentStepIndex = index;
if (typeof this.config.canAbort === 'function') {
this.canAbort = this.config.canAbort(this.currentStepIndex, this.currentStep);
}
// make sure we trigger a change-detection cycle now
// markForCheck() is not enough here as we need a CD to run
// immediately for the Step.buttonTemplate to be accounted for correctly.
this.cdr.detectChanges();
})
}
/**
* Creates a new component portal for a step and provides access to the {@class StepRef}
* using dependency injection.
*
* @param stepType The component type of the step for which a new portal should be created.
* @param index The index of the current step. Used to create/cache the {@class StepRef}
*/
private createStepContentPortal(stepType: ComponentType<Step>, index: number): ComponentPortal<Step> {
let stepRef = this.stepRefCache.get(index);
if (stepRef === undefined) {
stepRef = new StepRef(index, this)
this.stepRefCache.set(index, stepRef);
}
const injector = Injector.create({
providers: [
{
provide: STEP_REF,
useValue: stepRef,
}
],
parent: this.config.injector || this.injector,
})
return new ComponentPortal(stepType, undefined, injector);
}
/**
* Creates a new component portal for a step outlet component that will attach another content
* portal and wrap the attachment in a "move in" animation for a given direction.
*
* @param contentPortal The portal of the actual content that should be attached in the outlet
* @param dir The direction for the animation of the step outlet.
*/
private createStepOutletPortal(contentPortal: ComponentPortal<Step>, dir: 'left' | 'right'): ComponentPortal<StepOutletComponent> {
const injector = Injector.create({
providers: [
{
provide: STEP_PORTAL,
useValue: contentPortal,
},
{
provide: STEP_ANIMATION_DIRECTION,
useValue: dir,
},
],
parent: this.injector,
})
return new ComponentPortal(
StepOutletComponent,
undefined,
injector,
)
}
}

View File

@@ -0,0 +1,21 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngDialogModule } from "../dialog";
import { OverlayStepperContainerComponent } from "./overlay-stepper-container";
import { StepOutletComponent } from "./step-outlet";
@NgModule({
imports: [
CommonModule,
PortalModule,
OverlayModule,
SfngDialogModule,
],
declarations: [
OverlayStepperContainerComponent,
StepOutletComponent,
]
})
export class OverlayStepperModule { }

View File

@@ -0,0 +1,57 @@
import { ComponentRef, Injectable, Injector } from "@angular/core";
import { SfngDialogService } from "../dialog";
import { OverlayStepperContainerComponent, STEP_CONFIG } from "./overlay-stepper-container";
import { OverlayStepperModule } from "./overlay-stepper.module";
import { StepperRef } from "./refs";
import { StepperConfig } from "./step";
@Injectable({ providedIn: OverlayStepperModule })
export class OverlayStepper {
constructor(
private injector: Injector,
private dialog: SfngDialogService,
) { }
/**
* Creates a new overlay stepper given it's configuration and returns
* a reference to the stepper that can be used to wait for or control
* the stepper from outside.
*
* @param config The configuration for the overlay stepper.
*/
create(config: StepperConfig): StepperRef {
// create a new injector for our OverlayStepperContainer
// that holds a reference to the StepperConfig.
const injector = this.createInjector(config);
const dialogRef = this.dialog.create(OverlayStepperContainerComponent, {
injector: injector,
autoclose: false,
backdrop: 'light',
dragable: false,
})
const containerComponentRef = dialogRef.contentRef() as ComponentRef<OverlayStepperContainerComponent>;
return new StepperRef(containerComponentRef.instance);
}
/**
* Creates a new dependency injector that provides access to the
* stepper configuration using the STEP_CONFIG injection token.
*
* @param config The stepper configuration to provide using DI
* @returns
*/
private createInjector(config: StepperConfig): Injector {
return Injector.create({
providers: [
{
provide: STEP_CONFIG,
useValue: config,
},
],
parent: this.injector,
})
}
}

View File

@@ -0,0 +1,143 @@
import { InjectionToken } from "@angular/core";
import { Observable } from "rxjs";
import { take } from "rxjs/operators";
import { OverlayStepperContainerComponent } from "./overlay-stepper-container";
/**
* STEP_REF is the injection token that is used to provide a reference to the
* Stepper to each step.
*/
export const STEP_REF = new InjectionToken<StepRef<any>>('StepRef')
export interface StepperControl {
/**
* Next should move the stepper forward to the next
* step or close the stepper if no more steps are
* available.
* If the stepper is closed this way all onFinish hooks
* registered at {@link StepRef} are executed.
*/
next(): Promise<void>;
/**
* goBack should move the stepper back to the previous
* step. This is a no-op if there's no previous step to
* display.
*/
goBack(): Promise<void>;
/**
* close closes the stepper but does not run any onFinish hooks
* of {@link StepRef}.
*/
close(): Promise<void>;
}
/**
* StepRef is a reference to the overlay stepper and can be used to control, abort
* or otherwise interact with the stepper.
*
* It is made available to individual steps using the STEP_REF injection token.
* Each step in the OverlayStepper receives it's own StepRef instance and will receive
* a reference to the same instance in case the user goes back and re-opens a step
* again.
*
* Steps should therefore store any configuration data that is needed to restore
* the previous view in the StepRef using it's save() and load() methods.
*/
export class StepRef<T = any> implements StepperControl {
private onFinishHooks: (() => PromiseLike<void> | void)[] = [];
private data: T | null = null;
constructor(
private currentStepIndex: number,
private stepContainerRef: OverlayStepperContainerComponent,
) {
this.stepContainerRef.onFinish
.pipe(take(1))
.subscribe(() => this.runOnFinishHooks)
}
next(): Promise<void> {
return this.stepContainerRef.next();
}
goBack(): Promise<void> {
return this.stepContainerRef.goBack();
}
close(): Promise<void> {
return this.stepContainerRef.close();
}
/**
* Save saves data of the current step in the stepper session.
* This data is saved in case the user decides to "go back" to
* to a previous step so the old view can be restored.
*
* @param data The data to save in the stepper session.
*/
save(data: T): void {
this.data = data;
}
/**
* Load returns the data previously stored using save(). The
* StepperRef automatically makes sure the correct data is returned
* for the current step.
*/
load(): T | null {
return this.data;
}
/**
* registerOnFinish registers fn to be called when the last step
* completes and the stepper is going to finish.
*/
registerOnFinish(fn: () => PromiseLike<void> | void) {
this.onFinishHooks.push(fn);
}
/**
* Executes all onFinishHooks in the order they have been defined
* and waits for each hook to complete.
*/
private async runOnFinishHooks() {
for (let i = 0; i < this.onFinishHooks.length; i++) {
let res = this.onFinishHooks[i]();
if (typeof res === 'object' && 'then' in res) {
// res is a PromiseLike so wait for it
try {
await res;
} catch (err) {
console.error(`Failed to execute on-finish hook of step ${this.currentStepIndex}: `, err)
}
}
}
}
}
export class StepperRef implements StepperControl {
constructor(private stepContainerRef: OverlayStepperContainerComponent) { }
next(): Promise<void> {
return this.stepContainerRef.next();
}
goBack(): Promise<void> {
return this.stepContainerRef.goBack();
}
close(): Promise<void> {
return this.stepContainerRef.close();
}
get onFinish(): Observable<void> {
return this.stepContainerRef.onFinish;
}
get onClose(): Observable<void> {
return this.stepContainerRef.onClose;
}
}

View File

@@ -0,0 +1,90 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal";
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, ViewChild } from "@angular/core";
import { Step } from "./step";
export const STEP_PORTAL = new InjectionToken<ComponentPortal<Step>>('STEP_PORTAL')
export const STEP_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('STEP_ANIMATION_DIRECTION');
/**
* A simple wrapper component around CdkPortalOutlet to add nice
* move animations.
*/
@Component({
template: `
<div [@moveInOut]="{value: _appAnimate, params: {in: in, out: out}}" class="flex flex-col overflow-auto">
<ng-template [cdkPortalOutlet]="portal"></ng-template>
</div>
`,
styles: [
`
:host{
display: flex;
flex-direction: column;
overflow: hidden;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX({{ in }})' }),
animate('.2s ease-in',
style({ opacity: 1, transform: 'translateX(0%)' }))
],
{ params: { in: '100%' } } // default parameters
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s ease-out',
style({ opacity: 0, transform: 'translateX({{ out }})' }))
],
{ params: { out: '-100%' } } // default parameters
)
]
)]
})
export class StepOutletComponent implements AfterViewInit {
/** @private - Whether or not the animation should run. */
_appAnimate = false;
/** The actual step instance that has been attached. */
stepInstance: ComponentRef<Step> | null = null;
/** @private - used in animation interpolation for translateX */
get in() {
return this._animateDirection == 'left' ? '-100%' : '100%'
}
/** @private - used in animation interpolation for traslateX */
get out() {
return this._animateDirection == 'left' ? '100%' : '-100%'
}
/** The portal outlet in our view used to attach the step */
@ViewChild(CdkPortalOutlet, { static: true })
portalOutlet!: CdkPortalOutlet;
constructor(
@Inject(STEP_PORTAL) public portal: ComponentPortal<Step>,
@Inject(STEP_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right',
private cdr: ChangeDetectorRef
) { }
ngAfterViewInit(): void {
this.portalOutlet?.attached
.subscribe(ref => {
this.stepInstance = ref as ComponentRef<Step>;
this._appAnimate = true;
this.cdr.detectChanges();
})
}
}

View File

@@ -0,0 +1,64 @@
import { Injector, TemplateRef, Type } from "@angular/core";
import { Observable } from "rxjs";
export interface Step {
/**
* validChange should emit true or false when the current step
* is valid and the "next" button should be visible.
*/
validChange: Observable<boolean>;
/**
* onBeforeBack, if it exists, is called when the user
* clicks the "Go Back" button but before the current step
* is unloaded.
*
* The OverlayStepper will wait for the callback to resolve or
* reject but will not abort going back!
*/
onBeforeBack?: () => Promise<void>;
/**
* onBeforeNext, if it exists, is called when the user
* clicks the "Next" button but before the current step
* is unloaded.
*
* The OverlayStepper willw ait for the callback to resolve
* or reject. If it rejects the current step will not be unloaded
* and the rejected error will be displayed to the user.
*/
onBeforeNext?: () => Promise<void>;
/**
* nextButtonLabel can overwrite the label for the "Next" button.
*/
nextButtonLabel?: string;
/**
* buttonTemplate may hold a tempalte ref that is rendered instead
* of the default button row with a "Go Back" and a "Next" button.
* Note that if set, the step component must make sure to handle
* navigation itself. See {@class StepRef} for more information on how
* to control the stepper.
*/
buttonTemplate?: TemplateRef<any>;
}
export interface StepperConfig {
/**
* canAbort can be set to a function that is called
* for each step to determine if the stepper is abortable.
*/
canAbort?: (idx: number, step: Step) => boolean;
/** steps holds the list of steps to execute */
steps: Array<Type<Step>>
/**
* injector, if set, defines the parent injector used to
* create dedicated instances of the step types.
*/
injector?: Injector;
}

View File

@@ -0,0 +1,22 @@
sfng-pagination {
.pagination {
@apply my-2 w-full flex justify-between;
button {
@apply text-xxs px-2 flex items-center justify-start;
&.page {
@apply bg-cards-secondary;
@apply opacity-50;
&:hover {
@apply opacity-100;
}
}
&.active-page {
@apply text-blue font-medium opacity-100;
}
}
}
}

View File

@@ -0,0 +1,64 @@
import { BehaviorSubject, Observable, Subscription } from "rxjs";
import { Pagination, clipPage } from "./pagination";
export interface Datasource<T> {
// view should emit all items in the given page using the specified page number.
view(page: number, pageSize: number): Observable<T[]>;
}
export class DynamicItemsPaginator<T> implements Pagination<T> {
private _total = 0;
private _pageNumber$ = new BehaviorSubject<number>(1);
private _pageItems$ = new BehaviorSubject<T[]>([]);
private _pageLoading$ = new BehaviorSubject<boolean>(false);
private _pageSubscription = Subscription.EMPTY;
/** Returns the number of total pages. */
get total() { return this._total; }
/** Emits the current page number */
get pageNumber$() { return this._pageNumber$.asObservable() }
/** Emits all items of the current page */
get pageItems$() { return this._pageItems$.asObservable() }
/** Emits whether or not we're loading the next page */
get pageLoading$() { return this._pageLoading$.asObservable() }
constructor(
private source: Datasource<T>,
public readonly pageSize = 25,
) { }
reset(newTotal: number) {
this._total = Math.ceil(newTotal / this.pageSize);
this.openPage(1);
}
/** Clear resets the current total and emits an empty item set. */
clear() {
this._total = 0;
this._pageItems$.next([]);
this._pageNumber$.next(1);
this._pageSubscription.unsubscribe();
}
openPage(pageNumber: number): void {
pageNumber = clipPage(pageNumber, this.total);
this._pageLoading$.next(true);
this._pageSubscription.unsubscribe()
this._pageSubscription = this.source.view(pageNumber, this.pageSize)
.subscribe({
next: results => {
this._pageLoading$.next(false);
this._pageItems$.next(results);
this._pageNumber$.next(pageNumber);
}
});
}
nextPage(): void { this.openPage(this._pageNumber$.getValue() + 1) }
prevPage(): void { this.openPage(this._pageNumber$.getValue() - 1) }
}

View File

@@ -0,0 +1,5 @@
export * from './dynamic-items-paginator';
export * from './pagination';
export * from './pagination.module';
export * from './snapshot-paginator';

View File

@@ -0,0 +1,33 @@
<!-- Pagination template -->
<ng-template #paginationTpl>
<div class="pagination" *ngIf="source!.total > 1">
<button class="btn-outline" [disabled]="currentPageIdx === 1" (click)="source!.prevPage()">
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
Previous
</button>
<span class="flex flex-row items-center gap-1">
<button class="page" *ngFor="let page of pageNumbers" [class.active-page]="page === currentPageIdx"
(click)="source!.openPage(page)">{{ page }}</button>
</span>
<button class="btn-outline" [disabled]="currentPageIdx+1 > source!.total" (click)="source!.nextPage()">
Next
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd" />
</svg>
</button>
</div>
</ng-template>
<!-- End Pagination Template -->
<ng-container *ngIf="!!content && !!source">
<ng-container *ngTemplateOutlet="paginationTpl"></ng-container>
<ng-container *ngTemplateOutlet="content!.templateRef">
</ng-container>
<ng-container *ngTemplateOutlet="paginationTpl"></ng-container>
</ng-container>

View File

@@ -0,0 +1,19 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngPaginationContentDirective } from ".";
import { SfngPaginationWrapperComponent } from "./pagination";
@NgModule({
imports: [
CommonModule,
],
declarations: [
SfngPaginationContentDirective,
SfngPaginationWrapperComponent,
],
exports: [
SfngPaginationContentDirective,
SfngPaginationWrapperComponent,
],
})
export class SfngPaginationModule { }

View File

@@ -0,0 +1,132 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Directive, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, TemplateRef } from "@angular/core";
import { Observable, Subscription } from "rxjs";
export interface Pagination<T> {
/**
* Total should return the total number of pages
*/
total: number;
/**
* pageNumber$ should emit the currently displayed page
*/
pageNumber$: Observable<number>;
/**
* pageItems$ should emit all items of the current page
*/
pageItems$: Observable<T[]>;
/**
* nextPage should progress to the next page. If there are no more
* pages than nextPage() should be a no-op.
*/
nextPage(): void;
/**
* prevPage should move back the the previous page. If there is no
* previous page, prevPage should be a no-op.
*/
prevPage(): void;
/**
* openPage opens the page @pageNumber. If pageNumber is greater than
* the total amount of pages it is clipped to the lastPage. If it is
* less than 1, it is clipped to 1.
*/
openPage(pageNumber: number): void
}
@Directive({
selector: '[sfngPageContent]'
})
export class SfngPaginationContentDirective<T = any> {
constructor(public readonly templateRef: TemplateRef<T>) { }
}
export interface PageChangeEvent {
totalPages: number;
currentPage: number;
}
@Component({
selector: 'sfng-pagination',
templateUrl: './pagination.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngPaginationWrapperComponent<T = any> implements OnChanges, OnDestroy {
private _sub: Subscription = Subscription.EMPTY;
@Input()
source: Pagination<T> | null = null;
@Output()
pageChange = new EventEmitter<PageChangeEvent>();
@ContentChild(SfngPaginationContentDirective)
content: SfngPaginationContentDirective | null = null;
currentPageIdx: number = 0;
pageNumbers: number[] = [];
ngOnChanges(changes: SimpleChanges) {
if ('source' in changes) {
this.subscribeToSource(changes.source.currentValue);
}
}
ngOnDestroy() {
this._sub.unsubscribe();
}
private subscribeToSource(source: Pagination<T>) {
// Unsubscribe from the previous pagination, if any
this._sub.unsubscribe();
this._sub = new Subscription();
this._sub.add(
source.pageNumber$
.subscribe(current => {
this.currentPageIdx = current;
this.pageNumbers = generatePageNumbers(current - 1, source.total);
this.cdr.markForCheck();
this.pageChange.next({
totalPages: source.total,
currentPage: current,
})
})
)
}
constructor(private cdr: ChangeDetectorRef) { }
}
/**
* Generates an array of page numbers that should be displayed in paginations.
*
* @param current The current page number
* @param countPages The total number of pages
* @returns An array of page numbers to display
*/
export function generatePageNumbers(current: number, countPages: number): number[] {
let delta = 2;
let leftRange = current - delta;
let rightRange = current + delta + 1;
return Array.from({ length: countPages }, (v, k) => k + 1)
.filter(i => i === 1 || i === countPages || (i >= leftRange && i < rightRange));
}
export function clipPage(pageNumber: number, total: number): number {
if (pageNumber < 1) {
return 1;
}
if (pageNumber > total) {
return total;
}
return pageNumber;
}

View File

@@ -0,0 +1,64 @@
import { BehaviorSubject, Observable } from "rxjs";
import { debounceTime, map } from "rxjs/operators";
import { clipPage, Pagination } from "./pagination";
export class SnapshotPaginator<T> implements Pagination<T> {
private _itemSnapshot: T[] = [];
private _activePageItems = new BehaviorSubject<T[]>([]);
private _totalPages = 1;
private _updatePending = false;
constructor(
public items$: Observable<T[]>,
public readonly pageSize: number,
) {
items$
.pipe(debounceTime(100))
.subscribe(data => {
this._itemSnapshot = data;
this.openPage(this._currentPage.getValue());
});
this._currentPage
.subscribe(page => {
this._updatePending = false;
const start = this.pageSize * (page - 1);
const end = this.pageSize * page;
this._totalPages = Math.ceil(this._itemSnapshot.length / this.pageSize) || 1;
this._activePageItems.next(this._itemSnapshot.slice(start, end));
})
}
private _currentPage = new BehaviorSubject<number>(0);
get updatePending() {
return this._updatePending;
}
get pageNumber$(): Observable<number> {
return this._activePageItems.pipe(map(() => this._currentPage.getValue()));
}
get pageNumber(): number {
return this._currentPage.getValue();
}
get total(): number {
return this._totalPages
}
get pageItems$(): Observable<T[]> {
return this._activePageItems.asObservable();
}
get pageItems(): T[] {
return this._activePageItems.getValue();
}
get snapshot(): T[] { return this._itemSnapshot };
reload(): void { this.openPage(this._currentPage.getValue()) }
nextPage(): void { this.openPage(this._currentPage.getValue() + 1) }
prevPage(): void { this.openPage(this._currentPage.getValue() - 1) }
openPage(pageNumber: number): void {
pageNumber = clipPage(pageNumber, this.total);
this._currentPage.next(pageNumber);
}
}

View File

@@ -0,0 +1,73 @@
.sfng-select {
@apply cursor-pointer relative p-0 flex whitespace-nowrap w-full items-center outline-none self-center overflow-hidden;
@apply hover:bg-gray-400;
@apply bg-gray-300 border border-gray-300 transition ease-in-out duration-200;
&.disabled {
@apply cursor-not-allowed opacity-75 hover:bg-gray-400;
}
min-width: 6rem;
max-width: 12rem;
&.active {
@apply bg-gray-400;
div.arrow svg {
@apply transform -rotate-90;
}
}
& > span {
@apply flex-grow text-ellipsis inline-block overflow-hidden;
@apply px-2;
}
div.arrow {
@apply flex flex-row items-center justify-center bg-gray-200 rounded-r-sm;
@apply w-5 h-7;
svg {
@apply w-4 m-0 p-0 rotate-90 transform transition ease-in-out duration-100;
g {
@apply text-white;
stroke: currentColor;
}
}
}
}
.sfng-select-dropdown {
ul {
max-height: 12rem;
@apply relative py-1 overflow-auto;
li {
@apply py-2;
@apply flex flex-row items-center justify-start gap-1 transition duration-200 ease-in-out cursor-pointer hover:bg-gray-300;
}
li:not(.disabled) {
@apply hover:bg-gray-300;
}
li.disabled {
@apply cursor-not-allowed;
}
}
}
.sfng-select-dropdown.sfng-select-inline {
ul {
max-height: unset;
}
}
sfng-select-item {
@apply text-xxs w-full font-medium gap-3 text-primary flex flex-row items-center justify-start;
&.disabled {
@apply opacity-75 cursor-not-allowed;
}
}

View File

@@ -0,0 +1,4 @@
export * from './item';
export * from './select';
export * from './select.module';

View File

@@ -0,0 +1,64 @@
import { ListKeyManagerOption } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Component, Directive, HostBinding, Input, Optional, TemplateRef } from '@angular/core';
export interface SelectOption<T = any> extends ListKeyManagerOption {
value: any;
selected: boolean;
data?: T;
label?: string;
description?: string;
templateRef?: TemplateRef<any>;
disabled?: boolean;
}
@Component({
selector: 'sfng-select-item',
template: `<ng-content></ng-content>`,
})
export class SfngSelectItemComponent implements ListKeyManagerOption {
@HostBinding('class.disabled')
get disabled() {
return this.sfngSelectValue?.disabled || false;
}
getLabel() {
return this.sfngSelectValue?.label || '';
}
constructor(@Optional() private sfngSelectValue: SfngSelectValueDirective) { }
}
@Directive({
selector: '[sfngSelectValue]',
})
export class SfngSelectValueDirective<T = any> implements SelectOption<T> {
@Input('sfngSelectValue')
value: any;
@Input('sfngSelectValueLabel')
label?: string;
@Input('sfngSelectValueData')
data?: T;
@Input('sfngSelectValueDescription')
description = '';
@Input('sfngSelectValueDisabled')
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v)
}
get disabled() { return this._disabled }
private _disabled = false;
getLabel() {
return this.label || ('' + this.value);
}
/** Whether or not the item is currently selected */
selected = false;
constructor(public templateRef: TemplateRef<any>) { }
}

View File

@@ -0,0 +1,88 @@
<ng-template #customTriggerTemplate>
<button [class.active]="dropdown?.isOpen" type="button" class="sfng-select" [class.disabled]="disabled">
<ng-template [ngIf]="mode !== 'multi' || (currentItems.length || 0) <= 1" [ngIfElse]="multiTemplate">
<span *ngIf="!currentItems.length; else: itemTemplate">
{{ placeholder }}
</span>
<ng-template #itemTemplate>
<span class="flex flex-row items-center justify-start">
<ng-template [ngIf]="!!currentItems[0].label" [ngIfElse]="renderTemplate">
{{ currentItems[0].label }}
</ng-template>
<ng-template #renderTemplate>
<ng-container
*ngTemplateOutlet="currentItems[0].templateRef || dynamicValueTemplate || defaultDynamicValueTemplate; context: {$implicit: currentItems[0]}">
</ng-container>
</ng-template>
</span>
</ng-template>
</ng-template>
<ng-template #multiTemplate>
<span>
{{ itemName ? itemName + ': ' : '' }}{{currentItems.length}} selected
</span>
</ng-template>
<div class="arrow">
<svg viewBox="0 0 24 24" class="arrow-icon">
<g fill="none" class="inner">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M10 16l4-4-4-4" />
</g>
</svg>
</div>
</button>
</ng-template>
<ng-template #content>
<input *ngIf="allowSearch && (userProvidedItems?.length || 0) >= searchItemThreshold" type="text"
class="w-full mb-2 rounded-t" [placeholder]="searchPlaceholder" [ngModel]="searchText"
(ngModelChange)="onSearch($event)" (keydown)="onKeyDown($event)" (keydown.enter)="onEnter($event)">
<ul #scrollable>
<li *ngFor="let item of items" (click)="selectItem(item)" [sfng-tooltip]="item.description || null"
snfgTooltipPosition="left" [class.disabled]="item.disabled" #renderedItem [sfngSelectRenderedListItem]="item"
class="pl-1 pr-5">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-grow-0 flex-shrink-0 w-4 h-4 transition-all duration-200"
viewBox="0 0 20 20" fill="currentColor" [class.opacity-0]="!item.selected">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
<ng-container
*ngTemplateOutlet="item.templateRef || dynamicValueTemplate || defaultDynamicValueTemplate; context: {$implicit: item}">
</ng-container>
</li>
<!-- fake item for "dynamic" values -->
<li *ngIf="!!searchText && items.length === 0 && dynamicValues"
(click)="selectItem({selected: false, value: searchText})" class="pl-1 pr-5">
<sfng-select-item>
<span>
<span class="mx-2 text-tertiary">Add </span> {{ searchText }}
</span>
</sfng-select-item>
</li>
</ul>
</ng-template>
<!-- This template displays the overlay content and is connected to the button -->
<ng-container [ngSwitch]="displayMode">
<sfng-dropdown *ngSwitchCase="'dropdown'" #dropdown="sfngDropdown" [triggerTemplate]="customTriggerTemplate"
overlayClass="sfng-select-dropdown" [disabled]="allItems.length === 0 && searchText === '' && disableWhenEmpty"
[minWidth]="minWidth" [minHeight]="minHeight" (opened)="onDropdownOpen()" (closed)="onDropdownClose()">
<ng-container *ngTemplateOutlet="content"></ng-container>
</sfng-dropdown>
<div *ngSwitchCase="'inline'" class="sfng-select-dropdown sfng-select-inline">
<ng-container *ngTemplateOutlet="content"></ng-container>
</div>
</ng-container>
<ng-template #defaultDynamicValueTemplate let-data>
<sfng-select-item>{{ data.label || data.value }}</sfng-select-item>
</ng-template>

View File

@@ -0,0 +1,31 @@
import { CdkScrollableModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { SfngDropDownModule } from "../dropdown";
import { SfngTooltipModule } from "../tooltip";
import { SfngSelectItemComponent, SfngSelectValueDirective } from "./item";
import { SfngSelectComponent, SfngSelectRenderedItemDirective } from "./select";
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
SfngDropDownModule,
SfngTooltipModule,
CdkScrollableModule
],
declarations: [
SfngSelectComponent,
SfngSelectValueDirective,
SfngSelectItemComponent,
SfngSelectRenderedItemDirective
],
exports: [
SfngSelectComponent,
SfngSelectValueDirective,
SfngSelectItemComponent,
]
})
export class SfngSelectModule { }

View File

@@ -0,0 +1,495 @@
import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y';
import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, DestroyRef, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, OnDestroy, Output, QueryList, TemplateRef, ViewChild, ViewChildren, forwardRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { SfngDropdownComponent } from '../dropdown';
import { SelectOption, SfngSelectValueDirective } from './item';
export type SelectModes = 'single' | 'multi';
type ModeInput = {
mode: SelectModes;
}
type SelectValue<T, S extends ModeInput> = S['mode'] extends 'single' ? T : T[];
export type SortByFunc = (a: SelectOption, b: SelectOption) => number;
export type SelectDisplayMode = 'dropdown' | 'inline';
@Directive({
selector: '[sfngSelectRenderedListItem]'
})
export class SfngSelectRenderedItemDirective implements ListKeyManagerOption {
@Input('sfngSelectRenderedListItem')
option: SelectOption | null = null;
getLabel() {
return this.option?.label || '';
}
get disabled() {
return this.option?.disabled || false;
}
@HostBinding('class.bg-gray-300')
set focused(v: boolean) {
this._focused = v;
}
get focused() { return this._focused }
private _focused = false;
constructor(public readonly elementRef: ElementRef) { }
}
@Component({
selector: 'sfng-select',
templateUrl: './select.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SfngSelectComponent),
multi: true,
},
]
})
export class SfngSelectComponent<T> implements AfterViewInit, ControlValueAccessor, OnDestroy {
/** emits the search text entered by the user */
private search$ = new BehaviorSubject('');
/** emits and completes when the component is destroyed. */
private destroyRef = inject(DestroyRef);
/** the key manager used for keyboard support */
private keyManager!: ListKeyManager<SfngSelectRenderedItemDirective>;
@ViewChild(SfngDropdownComponent, { static: false })
dropdown: SfngDropdownComponent | null = null;
/** A reference to the cdk-scrollable directive that's placed on the item list */
@ViewChild('scrollable', { read: ElementRef })
scrollableList?: ElementRef;
@ContentChildren(SfngSelectValueDirective)
userProvidedItems!: QueryList<SfngSelectValueDirective>;
@ViewChildren('renderedItem', { read: SfngSelectRenderedItemDirective })
renderedItems!: QueryList<SfngSelectRenderedItemDirective>;
/** A list of all items available in the select box including dynamic ones. */
allItems: SelectOption[] = []
/** The acutally rendered list of items after applying search and item threshold */
items: SelectOption[] = [];
@Input()
@HostBinding('attr.tabindex')
readonly tabindex = 0;
@HostBinding('attr.role')
readonly role = 'listbox';
value?: SelectValue<T, this>;
/** A list of currently selected items */
currentItems: SelectOption[] = [];
/** The current search text. Used by ngModel */
searchText = '';
/** Whether or not the select operates in "single" or "multi" mode */
@Input()
mode: SelectModes = 'single';
@Input()
displayMode: SelectDisplayMode = 'dropdown';
/** The placehodler to show when nothing is selected */
@Input()
placeholder = 'Select'
/** The type of item to show in multi mode when more than one value is selected */
@Input()
itemName = '';
/** The maximum number of items to render. */
@Input()
set itemLimit(v: any) {
this._maxItemLimit = coerceNumberProperty(v)
}
get itemLimit(): number { return this._maxItemLimit }
private _maxItemLimit = Infinity;
/** The placeholder text for the search bar */
@Input()
searchPlaceholder = '';
/** Whether or not the search bar is visible */
@Input()
set allowSearch(v: any) {
this._allowSearch = coerceBooleanProperty(v);
}
get allowSearch(): boolean {
return this._allowSearch;
}
private _allowSearch = false;
/** The minimum number of items required for the search bar to be visible */
@Input()
set searchItemThreshold(v: any) {
this._searchItemThreshold = coerceNumberProperty(v);
}
get searchItemThreshold(): number {
return this._searchItemThreshold;
}
private _searchItemThreshold = 0;
/**
* Whether or not the select should be disabled when not options
* are available.
*/
@Input()
set disableWhenEmpty(v: any) {
this._disableWhenEmpty = coerceBooleanProperty(v);
}
get disableWhenEmpty() {
return this._disableWhenEmpty;
}
private _disableWhenEmpty = false;
/** Whether or not the select component will add options for dynamic values as well. */
@Input()
set dynamicValues(v: any) {
this._dynamicValues = coerceBooleanProperty(v);
}
get dynamicValues() {
return this._dynamicValues
}
private _dynamicValues = false;
/** An optional template to use for dynamic values. */
@Input()
dynamicValueTemplate?: TemplateRef<any>;
/** The minimum-width of the drop-down. See {@link SfngDropdownComponent.minWidth} */
@Input()
minWidth: any;
/** The minimum-width of the drop-down. See {@link SfngDropdownComponent.minHeight} */
@Input()
minHeight: any;
/** Whether or not selected items should be sorted to the top */
@Input()
set sortValues(v: any) {
this._sortValues = coerceBooleanProperty(v);
}
get sortValues() {
if (this._sortValues === null) {
return this.mode === 'multi';
}
return this._sortValues;
}
private _sortValues: boolean | null = null;
/** The sort function to use. Defaults to sort by label/value */
@Input()
sortBy: SortByFunc = (a: SelectOption, b: SelectOption) => {
if ((a.label || a.value) < (b.label || b.value)) {
return 1;
}
if ((a.label || a.value) > (b.label || b.value)) {
return -1;
}
return 0;
}
@Input()
set disabled(v: any) {
const disabled = coerceBooleanProperty(v);
this.setDisabledState(disabled);
}
get disabled() {
return this._disabled;
}
private _disabled: boolean = false;
@HostListener('keydown.enter', ['$event'])
@HostListener('keydown.space', ['$event'])
onEnter(event: Event) {
if (!this.dropdown?.isOpen) {
this.dropdown?.toggle()
event.preventDefault();
event.stopPropagation();
return;
}
if (this.keyManager.activeItem !== null && !!this.keyManager.activeItem?.option) {
this.selectItem(this.keyManager.activeItem.option)
event.preventDefault();
event.stopPropagation();
return;
}
}
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
this.keyManager.onKeydown(event);
}
@Output()
closed = new EventEmitter<void>();
@Output()
opened = new EventEmitter<void>();
trackItem(_: number, item: SelectOption) {
return item.value;
}
setDisabledState(disabled: boolean) {
this._disabled = disabled;
this.cdr.markForCheck();
}
constructor(private cdr: ChangeDetectorRef) { }
ngAfterViewInit(): void {
this.keyManager = new ListKeyManager(this.renderedItems)
.withVerticalOrientation()
.withHomeAndEnd()
.withWrap()
.withTypeAhead();
this.keyManager.change
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(itemIdx => {
this.renderedItems.forEach(item => {
item.focused = false;
})
this.keyManager.activeItem!.focused = true;
// the item might be out-of-view so make sure
// we scroll enough to have it inside the view
const scrollable = this.scrollableList?.nativeElement;
if (!!scrollable) {
const active = this.keyManager.activeItem!.elementRef.nativeElement;
const activeHeight = active.getBoundingClientRect().height;
const bottom = scrollable.scrollTop + scrollable.getBoundingClientRect().height;
const top = scrollable.scrollTop;
let scrollTo = -1;
if (active.offsetTop >= bottom) {
scrollTo = top + active.offsetTop - bottom + activeHeight;
} else if (active.offsetTop < top) {
scrollTo = active.offsetTop;
}
if (scrollTo > -1) {
scrollable.scrollTo({
behavior: 'smooth',
top: scrollTo,
})
}
}
this.cdr.markForCheck();
})
combineLatest([
this.userProvidedItems!.changes
.pipe(startWith(undefined)),
this.search$
])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
([_, search]) => {
this.updateItems();
search = (search || '').toLocaleLowerCase()
let items: SelectOption[] = [];
if (search === '') {
items = this.allItems!;
} else {
items = this.allItems!.filter(item => {
// we always count selected items as a "match" in search mode.
// this is to ensure the user always see all selected items.
if (item.selected) {
return true;
}
if (!!item.value && typeof item.value === 'string') {
if (item.value.toLocaleLowerCase().includes(search)) {
return true;
}
}
if (!!item.label) {
if (item.label.toLocaleLowerCase().includes(search)) {
return true
}
}
return false;
})
}
this.items = items.slice(0, this._maxItemLimit);
this.keyManager.setActiveItem(0);
this.cdr.detectChanges();
}
);
}
ngOnDestroy(): void {
this.search$.complete();
}
@HostListener('blur')
onBlur(): void {
this.onTouch();
}
/** @private - called when the internal dropdown opens */
onDropdownOpen() {
// emit the open event on this component as well
this.opened.next();
// reset the search. We do that when opened instead of closed
// to avoid flickering when the component height increases
// during the "close" animation
this.onSearch('');
}
/** @private - called when the internal dropdown closes */
onDropdownClose() {
this.closed.next();
}
onSearch(text: string) {
this.searchText = text;
this.search$.next(text);
}
selectItem(item: SelectOption) {
if (item.disabled) {
return;
}
const isSelected = this.currentItems.findIndex(selected => item.value === selected.value);
if (isSelected === -1) {
item.selected = true;
if (this.mode === 'single') {
this.currentItems.forEach(i => i.selected = false);
this.currentItems = [item];
this.value = item.value;
} else {
this.currentItems.push(item);
// TODO(ppacher): somehow typescript does not correctly pick up
// the type of this.value here although it can be infered from the
// mode === 'single' check above.
this.value = [
...(this.value || []) as any,
item.value,
] as any
}
} else if (this.mode !== 'single') { // "unselecting" a value is not allowed in single mode
this.currentItems.splice(isSelected, 1)
item.selected = false;
// same note about typescript as above.
this.value = (this.value as T[]).filter(val => val !== item.value) as any;
}
// only close the drop down in single mode. In multi-mode
// we keep it open as the user might want to select an additional
// item as well.
if (this.mode === 'single') {
this.dropdown?.close();
}
this.onChange(this.value!);
}
private updateItems() {
let values: T[] = [];
if (this.mode === 'single') {
values = [this.value as T];
} else {
values = (this.value as T[]) || [];
}
this.currentItems = [];
this.allItems = [];
// mark all user-selected items as "deselected" first
this.userProvidedItems?.forEach(item => {
item.selected = false;
this.allItems.push(item);
});
for (let i = 0; i < values.length; i++) {
const val = values[i];
let option: SelectOption | undefined = this.userProvidedItems?.find(item => item.value === val);
if (!option) {
if (!this._dynamicValues) {
continue
}
option = {
selected: true,
value: val,
label: `${val}`,
}
this.allItems.push(option);
} else {
option.selected = true
}
this.currentItems.push(option);
}
if (this.sortValues) {
this.allItems.sort((a, b) => {
if (b.selected && !a.selected) {
return 1;
}
if (a.selected && !b.selected) {
return -1;
}
return this.sortBy(a, b)
})
}
}
writeValue(value: SelectValue<T, this>): void {
this.value = value;
this.updateItems();
this.cdr.markForCheck();
}
onChange = (value: SelectValue<T, this>): void => { }
registerOnChange(fn: (value: SelectValue<T, this>) => void): void {
this.onChange = fn;
}
onTouch = (): void => { }
registerOnTouched(fn: () => void): void {
this.onTouch = fn;
}
}

View File

@@ -0,0 +1,3 @@
sfng-tab-group {
@apply flex flex-col overflow-hidden;
}

View File

@@ -0,0 +1,4 @@
export { SfngTabComponent, SfngTabContentDirective } from './tab';
export { SfngTabContentScrollEvent, SfngTabGroupComponent } from './tab-group';
export { SfngTabModule as TabModule } from './tabs.module';

View File

@@ -0,0 +1,24 @@
<div *ngIf="!customHeader" class="relative flex flex-row mb-2 border-b outline-none border-secondary" tabindex="0"
(keydown)="onKeydown($event)">
<!-- Tab Group Header -->
<div *ngFor="let tab of (tabs$ | async); let index=index"
class="flex flex-row items-center justify-center px-4 py-2 space-x-1 cursor-pointer hover:text-primary" #tabHeader
[ngClass]="{'text-primary': index === activeTabIndex, 'text-secondary': index !== activeTabIndex}"
(click)="activateTab(index)">
<span>{{ tab.title }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-yellow-300" fill="none" viewBox="0 0 24 24"
*ngIf="tab.warning" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<sfng-tipup [key]="tab.tipUpKey" *ngIf="tab.tipUpKey"></sfng-tipup>
</div>
<!-- There are no "transition" classes yet because we add it AFTER the first animation -->
<div class="absolute top-0 left-0 border-t border-white opacity-0" #activeTabBar></div>
</div>
<ng-container cdkPortalOutlet></ng-container>

View File

@@ -0,0 +1,352 @@
import { ListKeyManager } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal";
import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, ContentChildren, DestroyRef, ElementRef, EventEmitter, Injector, Input, OnInit, Output, QueryList, ViewChild, ViewChildren, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { Observable, Subject } from "rxjs";
import { distinctUntilChanged, map, startWith } from "rxjs/operators";
import { SfngTabComponent, TAB_ANIMATION_DIRECTION, TAB_PORTAL, TAB_SCROLL_HANDLER, TabOutletComponent } from "./tab";
export interface SfngTabContentScrollEvent {
event?: Event;
scrollTop: number;
previousScrollTop: number;
}
/**
* Tab group component for rendering a tab-style navigation with support for
* keyboard navigation and type-ahead. Tab content are lazy loaded using a
* structural directive.
* The tab group component also supports adding the current active tab index
* to the active route so it is possible to navigate through tabs using back/forward
* keys (browser history) as well.
*
* Example:
* <sfng-tab-group>
*
* <sfng-tab id="tab1" title="Overview">
* <div *sfngTabContent>
* Some content
* </div>
* </sfng-tab>
*
* <sfng-tab id="tab2" title="Settings">
* <div *sfngTabContent>
* Some different content
* </div>
* </sfng-tab>
*
* </sfng-tab-group>
*/
@Component({
selector: 'sfng-tab-group',
templateUrl: './tab-group.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngTabGroupComponent implements AfterContentInit, AfterViewInit, OnInit {
@ContentChildren(SfngTabComponent)
tabs: QueryList<SfngTabComponent> | null = null;
/** References to all tab header elements */
@ViewChildren('tabHeader', { read: ElementRef })
tabHeaders: QueryList<ElementRef<HTMLDivElement>> | null = null;
/** Reference to the active tab bar element */
@ViewChild('activeTabBar', { read: ElementRef, static: false })
activeTabBar: ElementRef<HTMLDivElement> | null = null;
/** Reference to the portal outlet that we will use to render a TabOutletComponent. */
@ViewChild(CdkPortalOutlet, { static: true })
portalOutlet: CdkPortalOutlet | null = null;
@Output()
tabContentScroll = new EventEmitter<SfngTabContentScrollEvent>();
/** The name of the tab group. Used to update the currently active tab in the route */
@Input()
name = 'tab'
@Input()
outletClass = '';
private scrollTop: number = 0;
/** Whether or not the current tab should be syncronized with the angular router using a query parameter */
@Input()
set linkRouter(v: any) {
this._linkRouter = coerceBooleanProperty(v)
}
get linkRouter() { return this._linkRouter }
private _linkRouter = true;
/** Whether or not the default tab header should be rendered */
@Input()
set customHeader(v: any) {
this._customHeader = coerceBooleanProperty(v)
}
get customHeader() { return this._customHeader }
private _customHeader = false;
private tabActivate$ = new Subject<string>();
private destroyRef = inject(DestroyRef);
/** Emits the tab QueryList every time there are changes to the content-children */
get tabs$() {
return this.tabs?.changes
.pipe(
map(() => this.tabs),
startWith(this.tabs)
)
}
/** onActivate fires when a tab has been activated. */
get onActivate(): Observable<string> { return this.tabActivate$.asObservable() }
/** the index of the currently active tab. */
activeTabIndex = -1;
/** The key manager used to support keyboard navigation and type-ahead in the tab group */
private keymanager: ListKeyManager<SfngTabComponent> | null = null;
/** Used to force the animation direction when calling activateTab. */
private forceAnimationDirection: 'left' | 'right' | null = null;
/**
* pendingTabIdx holds the id or the index of a tab that should be activated after the component
* has been bootstrapped. We need to cache this value here because the ActivatedRoute might emit
* before we are AfterViewInit.
*/
private pendingTabIdx: string | null = null;
constructor(
private injector: Injector,
private route: ActivatedRoute,
private router: Router,
private cdr: ChangeDetectorRef
) { }
/**
* @private
* Used to forward keyboard events to the keymanager.
*/
onKeydown(v: KeyboardEvent) {
this.keymanager?.onKeydown(v);
}
ngOnInit(): void {
this.route.queryParamMap
.pipe(
takeUntilDestroyed(this.destroyRef),
map(params => params.get(this.name)),
distinctUntilChanged(),
)
.subscribe(newIdx => {
if (!this._linkRouter) {
return;
}
if (!!this.keymanager && !!this.tabs) {
const actualIndex = this.getIndex(newIdx);
if (actualIndex !== null) {
this.keymanager.setActiveItem(actualIndex);
this.cdr.markForCheck();
}
} else {
this.pendingTabIdx = newIdx;
}
})
}
ngAfterContentInit(): void {
this.keymanager = new ListKeyManager(this.tabs!)
.withHomeAndEnd()
.withHorizontalOrientation("ltr")
.withTypeAhead()
.withWrap()
this.tabs!.changes
.subscribe(() => {
if (this.portalOutlet?.hasAttached()) {
if (this.tabs!.length === 0) {
this.portalOutlet.detach();
}
} else {
if (this.tabs!.length > 0) {
this.activateTab(0)
}
}
})
this.keymanager.change
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(change => {
const activeTab = this.tabs!.get(change);
if (!!activeTab && !!activeTab.tabContent) {
const prevIdx = this.activeTabIndex;
let animationDirection: 'left' | 'right' = prevIdx < change ? 'left' : 'right';
if (this.forceAnimationDirection !== null) {
animationDirection = this.forceAnimationDirection;
this.forceAnimationDirection = null;
}
if (this.portalOutlet?.attachedRef) {
// we know for sure that attachedRef is a ComponentRef of TabOutletComponent
const ref = (this.portalOutlet.attachedRef as ComponentRef<TabOutletComponent>)
ref.instance._animateDirection = animationDirection;
ref.instance.outletClass = this.outletClass;
ref.changeDetectorRef.detectChanges();
}
this.portalOutlet?.detach();
const newOutletPortal = this.createTabOutlet(activeTab, animationDirection);
this.activeTabIndex = change;
this.tabContentScroll.next({
scrollTop: 0,
previousScrollTop: this.scrollTop,
})
this.scrollTop = 0;
this.tabActivate$.next(activeTab.id);
this.portalOutlet?.attach(newOutletPortal);
this.repositionTabBar();
if (this._linkRouter) {
this.router.navigate([], {
queryParams: {
...this.route.snapshot.queryParams,
[this.name]: this.activeTabIndex,
}
})
}
this.cdr.markForCheck();
}
});
if (this.pendingTabIdx === null) {
// active the first tab that is NOT disabled
const firstActivatable = this.tabs?.toArray().findIndex(tap => !tap.disabled);
if (firstActivatable !== undefined) {
this.keymanager.setActiveItem(firstActivatable);
}
} else {
const idx = this.getIndex(this.pendingTabIdx);
if (idx !== null) {
this.keymanager.setActiveItem(idx);
this.pendingTabIdx = null;
}
}
}
ngAfterViewInit(): void {
this.repositionTabBar();
this.tabHeaders?.changes.subscribe(() => this.repositionTabBar())
setTimeout(() => this.repositionTabBar(), 250)
}
/**
* @private
* Activates a new tab
*
* @param idx The index of the new tab.
*/
activateTab(idx: number, forceDirection?: 'left' | 'right') {
if (forceDirection !== undefined) {
this.forceAnimationDirection = forceDirection;
}
this.keymanager?.setActiveItem(idx);
}
private getIndex(newIdx: string | null): number | null {
let actualIndex: number = -1;
if (!this.tabs) {
return null;
}
if (newIdx === undefined || newIdx === null) { // not present in the URL
return null;
}
if (isNaN(+newIdx)) { // likley the ID of a tab
actualIndex = this.tabs?.toArray().findIndex(tab => tab.id === newIdx) || -1;
} else { // it's a number as a string
actualIndex = +newIdx;
}
if (actualIndex < 0) {
return null;
}
return actualIndex;
}
private repositionTabBar() {
if (!this.tabHeaders) {
return;
}
requestAnimationFrame(() => {
const tabHeader = this.tabHeaders!.get(this.activeTabIndex);
if (!tabHeader || !this.activeTabBar) {
return;
}
const rect = tabHeader.nativeElement.getBoundingClientRect();
const transform = `translate(${tabHeader.nativeElement.offsetLeft}px, ${tabHeader.nativeElement.offsetTop + rect.height}px)`
this.activeTabBar.nativeElement.style.width = `${rect.width}px`
this.activeTabBar.nativeElement.style.transform = transform;
this.activeTabBar.nativeElement.style.opacity = '1';
// initialize animations on the active-tab-bar required
if (!this.activeTabBar.nativeElement.classList.contains("transition-all")) {
// only initialize the transitions if this is the very first "reposition"
// this is to prevent the bar from animating to the "bottom" line of the tab
// header the first time.
requestAnimationFrame(() => {
this.activeTabBar?.nativeElement.classList.add("transition-all", "duration-200");
})
}
})
}
private createTabOutlet(tab: SfngTabComponent, animationDir: 'left' | 'right'): ComponentPortal<TabOutletComponent> {
const injector = Injector.create({
providers: [
{
provide: TAB_PORTAL,
useValue: tab.tabContent!.portal,
},
{
provide: TAB_ANIMATION_DIRECTION,
useValue: animationDir,
},
{
provide: TAB_SCROLL_HANDLER,
useValue: (e: Event) => {
const newScrollTop = (e.target as HTMLElement).scrollTop;
tab.tabContentScroll.next(e);
this.tabContentScroll.next({
event: e,
scrollTop: newScrollTop,
previousScrollTop: this.scrollTop,
});
this.scrollTop = newScrollTop;
}
},
],
parent: this.injector,
name: 'TabOutletInjectot',
})
return new ComponentPortal(
TabOutletComponent,
undefined,
injector
)
}
}

View File

@@ -0,0 +1,167 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { ListKeyManagerOption } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { CdkPortalOutlet, TemplatePortal } from "@angular/cdk/portal";
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Directive, EventEmitter, Inject, InjectionToken, Input, Output, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core";
/** TAB_PORTAL is the injection token used to inject the TabContentDirective portal into TabOutletComponent */
export const TAB_PORTAL = new InjectionToken<TemplatePortal>('TAB_PORTAL');
/** TAB_ANIMATION_DIRECTION is the injection token used to control the :enter animation origin of TabOutletComponent */
export const TAB_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('TAB_ANIMATION_DIRECTION');
/** TAB_SCROLL_HANDLER is called by the SfngTabOutletComponent when a scroll event occurs. */
export const TAB_SCROLL_HANDLER = new InjectionToken<(_: Event) => void>('TAB_SCROLL_HANDLER')
/**
* Structural directive (*sfngTabContent) to defined lazy-loaded tab content.
*/
@Directive({
selector: '[sfngTabContent]',
})
export class SfngTabContentDirective<T> {
portal: TemplatePortal;
constructor(
public readonly templateRef: TemplateRef<T>,
public readonly viewRef: ViewContainerRef,
) {
this.portal = new TemplatePortal(this.templateRef, this.viewRef);
}
}
/**
* The tab component that is used to define a new tab as a part of a tab group.
* The content of the tab is lazy-loaded by using the TabContentDirective.
*/
@Component({
selector: 'sfng-tab',
template: '<ng-content></ng-content>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngTabComponent implements ListKeyManagerOption {
@ContentChild(SfngTabContentDirective, { static: false })
tabContent: SfngTabContentDirective<any> | null = null;
/** The ID of the tab used to programatically activate the tab. */
@Input()
id = '';
/** The title for the tab as displayed in the tab group header. */
@Input()
title = '';
/** The key for the tip up in the tab group header. */
@Input()
tipUpKey = '';
@Input()
set warning(v) {
this._warning = coerceBooleanProperty(v)
}
get warning() { return this._warning }
private _warning = false;
/** Emits when the tab content is scrolled */
@Output()
tabContentScroll = new EventEmitter<Event>();
/** Whether or not the tab is currently disabled. */
@Input()
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v);
}
get disabled() {
return this._disabled;
}
private _disabled: boolean = false;
/** getLabel is used by the list key manager to support type-ahead */
getLabel() { return this.title }
}
/**
* A simple wrapper component around CdkPortalOutlet to add nice
* move animations.
*/
@Component({
selector: 'sfng-tab-outlet',
template: `
<div [@moveInOut]="{value: _appAnimate, params: {in: in, out: out}}" class="flex flex-col overflow-auto {{ outletClass }}" (scroll)="onTabContentScroll($event)">
<ng-template [cdkPortalOutlet]="portal"></ng-template>
</div>
`,
styles: [
`
:host{
display: flex;
flex-direction: column;
overflow: hidden;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX({{ in }})' }),
animate('.2s ease-in',
style({ opacity: 1, transform: 'translateX(0%)' }))
],
{ params: { in: '100%' } } // default parameters
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s ease-out',
style({ opacity: 0, transform: 'translateX({{ out }})' }))
],
{ params: { out: '-100%' } } // default parameters
)
]
)]
})
export class TabOutletComponent implements AfterViewInit {
_appAnimate = false;
@Input()
outletClass = ''
get in() {
return this._animateDirection == 'left' ? '100%' : '-100%'
}
get out() {
return this._animateDirection == 'left' ? '-100%' : '100%'
}
onTabContentScroll(event: Event) {
if (!!this.scrollHandler) {
this.scrollHandler(event)
}
}
@ViewChild(CdkPortalOutlet, { static: true })
portalOutlet!: CdkPortalOutlet;
constructor(
@Inject(TAB_PORTAL) public portal: TemplatePortal<any>,
@Inject(TAB_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right',
@Inject(TAB_SCROLL_HANDLER) public scrollHandler: (_: Event) => void,
private cdr: ChangeDetectorRef
) { }
ngAfterViewInit(): void {
this.portalOutlet?.attached
.subscribe(() => {
this._appAnimate = true;
this.cdr.detectChanges();
})
}
}

View File

@@ -0,0 +1,28 @@
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { SfngTipUpModule } from "../tipup";
import { SfngTabComponent, SfngTabContentDirective, TabOutletComponent } from "./tab";
import { SfngTabGroupComponent } from "./tab-group";
@NgModule({
imports: [
CommonModule,
PortalModule,
SfngTipUpModule,
BrowserAnimationsModule
],
declarations: [
SfngTabContentDirective,
SfngTabComponent,
SfngTabGroupComponent,
TabOutletComponent,
],
exports: [
SfngTabContentDirective,
SfngTabComponent,
SfngTabGroupComponent
]
})
export class SfngTabModule { }

View File

@@ -0,0 +1,52 @@
sfng-tipup-container {
display: block;
caption {
@apply text-sm;
opacity: .6;
font-size: .6rem;
}
h1 {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 1rem;
}
.message,
h1 {
flex-shrink: 0;
text-overflow: ellipsis;
word-break: normal;
}
.message {
font-size: 0.75rem;
flex-grow: 1;
opacity: .8;
max-width: 300px;
padding: 0;
}
.close-icon {
position: absolute;
top: 1rem;
right: 1rem;
opacity: .7;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.buttons {
width: 100%;
display: flex;
justify-content: space-between;
}
a {
text-decoration: underline;
}
}

View File

@@ -0,0 +1,43 @@
import { Directive, ElementRef, HostBinding, Input, isDevMode } from "@angular/core";
import { SfngTipUpPlacement } from "./utils";
@Directive({
selector: '[sfngTipUpAnchor]',
})
export class SfngTipUpAnchorDirective implements SfngTipUpPlacement {
constructor(
public readonly elementRef: ElementRef,
) { }
origin: 'left' | 'right' = 'right';
offset: number = 10;
@HostBinding('class.active-tipup-anchor')
isActiveAnchor = false;
@Input()
set sfngTipUpAnchor(posSpec: string | undefined) {
const parts = (posSpec || '').split(';')
if (parts.length > 2) {
if (isDevMode()) {
throw new Error(`Invalid value "${posSpec}" for [sfngTipUpAnchor]`);
}
return;
}
if (parts[0] === 'left') {
this.origin = 'left';
} else {
this.origin = 'right';
}
if (parts.length === 2) {
this.offset = +parts[1];
if (isNaN(this.offset)) {
this.offset = 10;
}
} else {
this.offset = 10;
}
}
}

View File

@@ -0,0 +1,128 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/** Creates a deep clone of an element. */
export function deepCloneNode(node: HTMLElement): HTMLElement {
const clone = node.cloneNode(true) as HTMLElement;
const descendantsWithId = clone.querySelectorAll('[id]');
const nodeName = node.nodeName.toLowerCase();
// Remove the `id` to avoid having multiple elements with the same id on the page.
clone.removeAttribute('id');
for (let i = 0; i < descendantsWithId.length; i++) {
descendantsWithId[i].removeAttribute('id');
}
if (nodeName === 'canvas') {
transferCanvasData(node as HTMLCanvasElement, clone as HTMLCanvasElement);
} else if (nodeName === 'input' || nodeName === 'select' || nodeName === 'textarea') {
transferInputData(node as HTMLInputElement, clone as HTMLInputElement);
}
transferData('canvas', node, clone, transferCanvasData);
transferData('input, textarea, select', node, clone, transferInputData);
return clone;
}
/** Matches elements between an element and its clone and allows for their data to be cloned. */
function transferData<T extends Element>(selector: string, node: HTMLElement, clone: HTMLElement,
callback: (source: T, clone: T) => void) {
const descendantElements = node.querySelectorAll<T>(selector);
if (descendantElements.length) {
const cloneElements = clone.querySelectorAll<T>(selector);
for (let i = 0; i < descendantElements.length; i++) {
callback(descendantElements[i], cloneElements[i]);
}
}
}
// Counter for unique cloned radio button names.
let cloneUniqueId = 0;
/** Transfers the data of one input element to another. */
function transferInputData(source: Element & { value: string },
clone: Element & { value: string; name: string; type: string }) {
// Browsers throw an error when assigning the value of a file input programmatically.
if (clone.type !== 'file') {
clone.value = source.value;
}
// Radio button `name` attributes must be unique for radio button groups
// otherwise original radio buttons can lose their checked state
// once the clone is inserted in the DOM.
if (clone.type === 'radio' && clone.name) {
clone.name = `sfng-clone-${clone.name}-${cloneUniqueId++}`;
}
}
/** Transfers the data of one canvas element to another. */
function transferCanvasData(source: HTMLCanvasElement, clone: HTMLCanvasElement) {
const context = clone.getContext('2d');
if (context) {
// In some cases `drawImage` can throw (e.g. if the canvas size is 0x0).
// We can't do much about it so just ignore the error.
try {
context.drawImage(source, 0, 0);
} catch { }
}
}
/**
* Gets a 3d `transform` that can be applied to an element.
* @param x Desired position of the element along the X axis.
* @param y Desired position of the element along the Y axis.
*/
export function getTransform(x: number, y: number): string {
// Round the transforms since some browsers will
// blur the elements for sub-pixel transforms.
return `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
}
/**
* Matches the target element's size to the source's size.
* @param target Element that needs to be resized.
* @param sourceRect Dimensions of the source element.
*/
export function matchElementSize(target: HTMLElement, sourceRect: ClientRect): void {
target.style.width = `${sourceRect.width}px`;
target.style.height = `${sourceRect.height}px`;
target.style.transform = getTransform(sourceRect.left, sourceRect.top);
}
/**
* Shallow-extends a stylesheet object with another stylesheet-like object.
* Note that the keys in `source` have to be dash-cased.
*/
export function extendStyles(dest: CSSStyleDeclaration,
source: Record<string, string>,
importantProperties?: Set<string>) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
const value = source[key];
if (value) {
dest.setProperty(key, value, importantProperties?.has(key) ? 'important' : '');
} else {
dest.removeProperty(key);
}
}
}
return dest;
}
export function removeNode(node: Node | null) {
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
}

View File

@@ -0,0 +1,87 @@
export function synchronizeCssStyles(src: HTMLElement, destination: HTMLElement, skipStyles: Set<string>) {
// Get a list of all the source and destination elements
const srcElements = <HTMLCollectionOf<HTMLElement>>src.getElementsByTagName('*');
const dstElements = <HTMLCollectionOf<HTMLElement>>destination.getElementsByTagName('*');
cloneStyle(src, destination, skipStyles);
// For each element
for (let i = srcElements.length; i--;) {
const srcElement = srcElements[i];
const dstElement = dstElements[i];
cloneStyle(srcElement, dstElement, skipStyles);
}
}
function cloneStyle(srcElement: HTMLElement, dstElement: HTMLElement, skipStyles: Set<string>) {
const sourceElementStyles = document.defaultView!.getComputedStyle(srcElement, '');
const styleAttributeKeyNumbers = Object.keys(sourceElementStyles);
// Copy the attribute
for (let j = 0; j < styleAttributeKeyNumbers.length; j++) {
const attributeKeyNumber = styleAttributeKeyNumbers[j];
const attributeKey: string = sourceElementStyles[attributeKeyNumber as any];
if (!isNaN(+attributeKey)) {
continue
}
if (attributeKey === 'cssText') {
continue
}
if (skipStyles.has(attributeKey)) {
continue
}
try {
dstElement.style[attributeKey as any] = sourceElementStyles[attributeKey as any];
} catch (e) {
console.error(attributeKey, e);
}
}
}
/**
* Returns a CSS selector for el from rootNode.
*
* @param el The source element to get the CSS path to
* @param rootNode The root node at which the CSS path should be applyable
* @returns A CSS selector to access el from rootNode.
*/
export function getCssSelector(el: HTMLElement, rootNode: HTMLElement | null): string {
if (!el) {
return '';
}
let stack = [];
let isShadow = false;
while (el !== rootNode && el.parentNode !== null) {
// console.log(el.nodeName);
let sibCount = 0;
let sibIndex = 0;
// get sibling indexes
for (let i = 0; i < (el.parentNode as HTMLElement).childNodes.length; i++) {
let sib = (el.parentNode as HTMLElement).childNodes[i];
if (sib.nodeName == el.nodeName) {
if (sib === el) {
sibIndex = sibCount;
}
sibCount++;
}
}
let nodeName = el.nodeName.toLowerCase();
if (isShadow) {
throw new Error(`cannot traverse into shadow dom.`)
}
if (sibCount > 1) {
stack.unshift(nodeName + ':nth-of-type(' + (sibIndex + 1) + ')');
} else {
stack.unshift(nodeName);
}
el = el.parentNode as HTMLElement;
if (el.nodeType === 11) { // for shadow dom, we
isShadow = true;
el = (el as any).host;
}
}
return stack.join(' > ');
}

View File

@@ -0,0 +1,6 @@
export * from './anchor';
export * from './tipup';
export * from './tipup-component';
export * from './tipup.module';
export * from './translations';

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser';
@Pipe({
name: 'safe'
})
export class SafePipe implements PipeTransform {
constructor(protected sanitizer: DomSanitizer) { }
public transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
switch (type) {
case 'html': return this.sanitizer.bypassSecurityTrustHtml(value);
case 'style': return this.sanitizer.bypassSecurityTrustStyle(value);
case 'script': return this.sanitizer.bypassSecurityTrustScript(value);
case 'url': return this.sanitizer.bypassSecurityTrustUrl(value);
case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value);
default: throw new Error(`Invalid safe type specified: ${type}`);
}
}
}

View File

@@ -0,0 +1,67 @@
import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core";
import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog";
import { SfngTipUpService } from "./tipup";
import { ActionRunner, Button, SFNG_TIP_UP_ACTION_RUNNER, TipUp } from './translations';
import { TIPUP_TOKEN } from "./utils";
@Component({
selector: 'sfng-tipup-container',
templateUrl: './tipup.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngTipUpComponent implements OnInit, TipUp<any> {
title: string = 'N/A';
content: string = 'N/A';
nextKey?: string;
buttons?: Button<any>[];
url?: string;
urlText: string = 'Read More';
constructor(
@Inject(TIPUP_TOKEN) public readonly token: string,
@Inject(SFNG_DIALOG_REF) private readonly dialogRef: SfngDialogRef<SfngTipUpComponent>,
@Inject(SFNG_TIP_UP_ACTION_RUNNER) private runner: ActionRunner<any>,
private tipupService: SfngTipUpService,
) { }
ngOnInit() {
const doc = this.tipupService.getTipUp(this.token);
if (!!doc) {
Object.assign(this, doc);
this.urlText = doc.urlText || 'Read More';
}
}
async next() {
if (!this.nextKey) {
return;
}
this.tipupService.open(this.nextKey);
this.dialogRef.close();
}
async runAction(btn: Button<any>) {
await this.runner.performAction(btn.action);
// if we have a nextKey for the button but do not do in-app
// routing we should be able to open the next tipup as soon
// as the action finished
if (!!btn.nextKey) {
this.tipupService.waitFor(btn.nextKey!)
.subscribe({
next: () => {
this.dialogRef.close();
this.tipupService.open(btn.nextKey!);
},
error: console.error
})
} else {
this.close();
}
}
close() {
this.dialogRef.close();
}
}

View File

@@ -0,0 +1,22 @@
<div class="flex flex-col items-start">
<caption>Tip</caption>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 close-icon" viewBox="0 0 20 20" fill="currentColor"
(click)="close()">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
<h1 [innerHTML]="title | safe: 'html'"></h1>
<markdown emoji [data]="content" class="message"></markdown>
<a *ngIf="!!url" [href]="url" target="_blank">{{ urlText }}</a>
<div class="buttons">
<div class="actions">
<button *ngFor="let btn of buttons" (click)="runAction(btn)">{{ btn.name }}</button>
</div>
<button *ngIf="!!nextKey" class="btn" (click)="next()">Next</button>
</div>
</div>

View File

@@ -0,0 +1,47 @@
import { CommonModule } from "@angular/common";
import { ModuleWithProviders, NgModule, Type } from "@angular/core";
import { MarkdownModule } from "ngx-markdown";
import { SfngDialogModule } from "../dialog";
import { SfngTipUpAnchorDirective } from './anchor';
import { SfngsfngTipUpTriggerDirective, SfngTipUpIconComponent } from './tipup';
import { SfngTipUpComponent } from './tipup-component';
import { ActionRunner, HelpTexts, SFNG_TIP_UP_ACTION_RUNNER, SFNG_TIP_UP_CONTENTS } from "./translations";
import { SafePipe } from "./safe.pipe";
@NgModule({
imports: [
CommonModule,
MarkdownModule.forChild(),
SfngDialogModule,
],
declarations: [
SfngTipUpIconComponent,
SfngsfngTipUpTriggerDirective,
SfngTipUpComponent,
SfngTipUpAnchorDirective,
SafePipe
],
exports: [
SfngTipUpIconComponent,
SfngsfngTipUpTriggerDirective,
SfngTipUpComponent,
SfngTipUpAnchorDirective
],
})
export class SfngTipUpModule {
static forRoot(text: HelpTexts<any>, runner: Type<ActionRunner<any>>): ModuleWithProviders<SfngTipUpModule> {
return {
ngModule: SfngTipUpModule,
providers: [
{
provide: SFNG_TIP_UP_CONTENTS,
useValue: text,
},
{
provide: SFNG_TIP_UP_ACTION_RUNNER,
useExisting: runner,
}
]
}
}
}

View File

@@ -0,0 +1,526 @@
/* eslint-disable @angular-eslint/no-input-rename */
import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { _getShadowRoot } from '@angular/cdk/platform';
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, ElementRef, HostBinding, HostListener, Inject, Injectable, Injector, Input, NgZone, OnDestroy, Optional, Renderer2, RendererFactory2 } from '@angular/core';
import { Observable, of, Subject } from 'rxjs';
import { debounce, debounceTime, filter, map, skip, take, timeout } from 'rxjs/operators';
import { SfngDialogRef, SfngDialogService } from '../dialog';
import { SfngTipUpAnchorDirective } from './anchor';
import { deepCloneNode, extendStyles, matchElementSize, removeNode } from './clone-node';
import { getCssSelector, synchronizeCssStyles } from './css-utils';
import { SfngTipUpComponent } from './tipup-component';
import { Button, HelpTexts, SFNG_TIP_UP_CONTENTS, TipUp } from './translations';
import { SfngTipUpPlacement, TIPUP_TOKEN } from './utils';
@Directive({
selector: '[sfngTipUpTrigger]',
})
export class SfngsfngTipUpTriggerDirective implements OnDestroy {
constructor(
public readonly elementRef: ElementRef,
public dialog: SfngDialogService,
@Optional() @Inject(SfngTipUpAnchorDirective) public anchor: SfngTipUpAnchorDirective | ElementRef<any> | HTMLElement,
@Inject(SFNG_TIP_UP_CONTENTS) private tipUpContents: HelpTexts<any>,
private tipupService: SfngTipUpService,
private cdr: ChangeDetectorRef,
) { }
private dialogRef: SfngDialogRef<SfngTipUpComponent> | null = null;
/**
* The helptext token used to search for the tip up defintion.
*/
@Input('sfngTipUpTrigger')
set textKey(s: string) {
if (!!this._textKey) {
this.tipupService.deregister(this._textKey, this);
}
this._textKey = s;
this.tipupService.register(this._textKey, this);
}
get textKey() { return this._textKey; }
private _textKey: string = '';
/**
* The text to display inside the tip up. If unset, the tipup definition
* will be loaded form helptexts.yaml.
* This input property is mainly designed for programatic/dynamic tip-up generation
*/
@Input('sfngTipUpText')
text: string | undefined;
@Input('sfngTipUpTitle')
title: string | undefined;
@Input('sfngTipUpButtons')
buttons: Button<any>[] | undefined;
/**
* asTipUp returns a tip-up definition built from the input
* properties sfngTipUpText and sfngTipUpTitle. If none are set
* then null is returned.
*/
asTipUp(): TipUp<any> | null {
// TODO(ppacher): we could also merge the defintions from MyYamlFile
// and the properties set on this directive....
if (!this.text) {
return this.tipUpContents[this.textKey];
}
return {
title: this.title || '',
content: this.text,
buttons: this.buttons,
}
}
/**
* The default anchor for the tipup if non is provided via Dependency-Injection
* or using sfngTipUpAnchorRef
*/
@Input('sfngTipUpDefaultAnchor')
defaultAnchor: ElementRef<any> | HTMLElement | null = null;
/** Optionally overwrite the anchor element received via Dependency Injection */
@Input('sfngTipUpAnchorRef')
set anchorRef(ref: ElementRef<any> | HTMLElement | null) {
this.anchor = ref ?? this.anchor;
}
/** Used to ensure all tip-up triggers have a pointer cursor */
@HostBinding('style.cursor')
cursor = 'pointer';
/** De-register ourself upon destroy */
ngOnDestroy() {
this.tipupService.deregister(this.textKey, this);
}
/** Whether or not we're passive-only and thus do not handle click-events form the user */
@Input('sfngTipUpPassive')
set passive(v: any) {
this._passive = coerceBooleanProperty(v ?? true);
}
get passive() { return this._passive; }
private _passive = false;
@Input('sfngTipUpOffset')
set offset(v: any) {
this._defaultOffset = coerceNumberProperty(v)
}
get offset() { return this._defaultOffset }
private _defaultOffset = 20;
@Input('sfngTipUpPlacement')
placement: SfngTipUpPlacement | null = null;
@HostListener('click', ['$event'])
onClick(event?: MouseEvent): Promise<any> {
if (!!event) {
// if there's a click event the user actually clicked the element.
// we only handle this if we're not marked as passive.
if (this._passive) {
return Promise.resolve();
}
event.preventDefault();
event.stopPropagation();
}
if (!!this.dialogRef) {
this.dialogRef.close();
return Promise.resolve();
}
let anchorElement: ElementRef<any> | HTMLElement | null = this.defaultAnchor || this.elementRef;
let placement: SfngTipUpPlacement | null = this.placement;
if (!!this.anchor) {
if (this.anchor instanceof SfngTipUpAnchorDirective) {
anchorElement = this.anchor.elementRef;
placement = this.anchor;
} else {
anchorElement = this.anchor;
}
}
this.dialogRef = this.tipupService.createTipup(
anchorElement,
this.textKey,
this,
placement,
)
this.dialogRef.onClose
.pipe(take(1))
.subscribe(() => {
this.dialogRef = null;
this.cdr.markForCheck();
});
this.cdr.detectChanges();
return this.dialogRef.onStateChange
.pipe(
filter(state => state === 'opening'),
take(1),
)
.toPromise()
}
}
@Component({
selector: 'sfng-tipup',
template:
`<svg viewBox="0 0 24 24"
class="tipup"
[sfngTipUpTrigger]="key"
[sfngTipUpDefaultAnchor]="parent"
[sfngTipUpPlacement]="placement"
[sfngTipUpText]="text"
[sfngTipUpTitle]="title"
[sfngTipUpButtons]="buttons"
[sfngTipUpAnchorRef]="anchor">
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" >
<path stroke="#ffff" shape-rendering="geometricPrecision" d="M12 21v0c-4.971 0-9-4.029-9-9v0c0-4.971 4.029-9 9-9v0c4.971 0 9 4.029 9 9v0c0 4.971-4.029 9-9 9z"/>
<path stroke="#ffff" shape-rendering="geometricPrecision" d="M12 17v-5h-1M11.749 8c-.138 0-.25.112-.249.25 0 .138.112.25.25.25s.25-.112.25-.25-.112-.25-.251-.25"/>
</g>
</svg>`,
styles: [
`
:host {
display: inline-block;
width : 1rem;
position: relative;
opacity: 0.55;
cursor : pointer;
align-self: center;
}
:host:hover {
opacity: 1;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SfngTipUpIconComponent implements SfngTipUpPlacement {
@Input()
key: string = '';
// see sfngTipUpTrigger sfngTipUpText and sfngTipUpTitle
@Input() text: string | undefined = undefined;
@Input() title: string | undefined = undefined;
@Input() buttons: Button<any>[] | undefined = undefined;
@Input()
anchor: ElementRef<any> | HTMLElement | null = null;
@Input('placement')
origin: 'left' | 'right' = 'right';
@Input()
set offset(v: any) {
this._offset = coerceNumberProperty(v);
}
get offset() { return this._offset; }
private _offset: number = 10;
constructor(private elementRef: ElementRef<any>) { }
get placement(): SfngTipUpPlacement {
return this
}
get parent(): HTMLElement | null {
return (this.elementRef?.nativeElement as HTMLElement)?.parentElement;
}
}
@Injectable({
providedIn: 'root'
})
export class SfngTipUpService {
tipups = new Map<string, SfngsfngTipUpTriggerDirective>();
private _onRegister = new Subject<string>();
private _onUnregister = new Subject<string>();
get onRegister(): Observable<string> {
return this._onRegister.asObservable();
}
get onUnregister(): Observable<string> {
return this._onUnregister.asObservable();
}
waitFor(key: string): Observable<void> {
if (this.tipups.has(key)) {
return of(undefined);
}
return this.onRegister
.pipe(
filter(val => val === key),
debounce(() => this.ngZone.onStable.pipe(skip(2))),
debounceTime(1000),
take(1),
map(() => { }),
timeout(5000),
);
}
private renderer: Renderer2;
constructor(
@Inject(DOCUMENT) private _document: Document,
private dialog: SfngDialogService,
private ngZone: NgZone,
private injector: Injector,
rendererFactory: RendererFactory2
) {
this.renderer = rendererFactory.createRenderer(null, null)
}
register(key: string, trigger: SfngsfngTipUpTriggerDirective) {
if (this.tipups.has(key)) {
return;
}
this.tipups.set(key, trigger);
this._onRegister.next(key);
}
deregister(key: string, trigger: SfngsfngTipUpTriggerDirective) {
if (this.tipups.get(key) === trigger) {
this.tipups.delete(key);
this._onUnregister.next(key);
}
}
getTipUp(key: string): TipUp<any> | null {
return this.tipups.get(key)?.asTipUp() || null;
}
private _latestTipUp: SfngDialogRef<SfngTipUpComponent> | null = null;
createTipup(
anchor: HTMLElement | ElementRef<any>,
key: string,
origin?: SfngsfngTipUpTriggerDirective,
opts: SfngTipUpPlacement | null = {},
injector?: Injector): SfngDialogRef<SfngTipUpComponent> {
const lastTipUp = this._latestTipUp
let closePrevious = () => {
if (!!lastTipUp) {
lastTipUp.close();
}
}
// make sure we have an ElementRef to work with
if (!(anchor instanceof ElementRef)) {
anchor = new ElementRef(anchor)
}
// the the origin placement of the tipup
const positions: ConnectedPosition[] = [];
if (opts?.origin === 'left') {
positions.push({
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
})
} else {
positions.push({
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
})
}
// determine the offset to the tipup origin
let offset = opts?.offset ?? 10;
if (opts?.origin === 'left') {
offset *= -1;
}
let postitionStrategy = this.dialog.position()
.flexibleConnectedTo(anchor)
.withPositions(positions)
.withDefaultOffsetX(offset);
const inj = Injector.create({
providers: [
{
useValue: key,
provide: TIPUP_TOKEN,
}
],
parent: injector || this.injector,
});
const newTipUp = this.dialog.create(SfngTipUpComponent, {
dragable: false,
autoclose: true,
backdrop: 'light',
injector: inj,
positionStrategy: postitionStrategy
});
this._latestTipUp = newTipUp;
const _preview = this._createPreview(anchor.nativeElement, _getShadowRoot(anchor.nativeElement));
// construct a CSS selector that targets the clicked origin (sfngTipUpTriggerDirective) from within
// the anchor. We use that path to highlight the copy of the trigger-directive in the preview.
if (!!origin) {
const originSelector = getCssSelector(origin.elementRef.nativeElement, anchor.nativeElement);
let target: HTMLElement | null = null;
if (!!originSelector) {
target = _preview.querySelector(originSelector);
} else {
target = _preview;
}
this.renderer.addClass(target, 'active-tipup-trigger')
}
newTipUp.onStateChange
.pipe(
filter(state => state === 'open'),
take(1)
)
.subscribe(() => {
closePrevious();
_preview.attach()
})
newTipUp.onStateChange
.pipe(
filter(state => state === 'closing'),
take(1)
)
.subscribe(() => {
if (this._latestTipUp === newTipUp) {
this._latestTipUp = null;
}
_preview.classList.remove('visible');
setTimeout(() => {
removeNode(_preview);
}, 300)
});
return newTipUp;
}
private _createPreview(element: HTMLElement, shadowRoot: ShadowRoot | null): HTMLElement & { attach: () => void } {
const preview = deepCloneNode(element);
// clone all CSS styles by applying them directly to the copied
// nodes. Though, we skip the opacity property because we use that
// a lot and it makes the preview strange ....
synchronizeCssStyles(element, preview, new Set([
'opacity'
]));
// make sure the preview element is at the exact same position
// as the original one.
matchElementSize(preview, element.getBoundingClientRect());
extendStyles(preview.style, {
// We have to reset the margin, because it can throw off positioning relative to the viewport.
'margin': '0',
'position': 'fixed',
'top': '0',
'left': '0',
'z-index': '1000',
'opacity': 'unset',
}, new Set(['position']));
// We add a dedicated class to the preview element so
// it can handle special higlighting itself.
preview.classList.add('tipup-preview')
// since the user might want to click on the preview element we must
// intercept the click-event, determine the path to the target element inside
// the preview and eventually dispatch a click-event on the actual
// - real - target inside the cloned element.
preview.onclick = function (event: MouseEvent) {
let path = getCssSelector(event.target as HTMLElement, preview);
if (!!path) {
// find the target by it's CSS path
let actualTarget: HTMLElement | null = element.querySelector<HTMLElement>(path);
// some (SVG) elements don't have a direct click() listener so we need to search
// the parents upwards to find one that implements click().
// we're basically searching up until we reach the <html> tag.
//
// TODO(ppacher): stop searching at the respective root node.
if (!!actualTarget) {
let iter: HTMLElement = actualTarget;
while (iter != null) {
if ('click' in iter && typeof iter['click'] === 'function') {
iter.click();
break;
}
iter = iter.parentNode as HTMLElement;
}
}
} else {
// the user clicked the preview element directly
try {
element.click()
} catch (e) {
console.error(e);
}
}
}
let attach = () => {
const parent = this._getPreviewInserationPoint(shadowRoot)
const cdkOverlayContainer = parent.getElementsByClassName('cdk-overlay-container')[0]
// if we find a cdkOverlayContainer in our inseration point (which we expect to be there)
// we insert the preview element right after the overlay-backdrop. This way the tip-up
// dialog will still be on top of the preview.
if (!!cdkOverlayContainer) {
const reference = cdkOverlayContainer.getElementsByClassName("cdk-overlay-backdrop")[0].nextSibling;
cdkOverlayContainer.insertBefore(preview, reference)
} else {
parent.appendChild(preview);
}
setTimeout(() => {
preview.classList.add('visible');
})
}
Object.defineProperty(preview, 'attach', {
value: attach,
})
return preview as any;
}
private _getPreviewInserationPoint(shadowRoot: ShadowRoot | null): HTMLElement {
const documentRef = this._document;
return shadowRoot ||
documentRef.fullscreenElement ||
(documentRef as any).webkitFullscreenElement ||
(documentRef as any).mozFullScreenElement ||
(documentRef as any).msFullscreenElement ||
documentRef.body;
}
async open(key: string) {
const comp = this.tipups.get(key);
if (!comp) {
console.error('Tried to open unknown tip-up with key ' + key);
return;
}
comp.onClick()
}
}

View File

@@ -0,0 +1,27 @@
import { InjectionToken } from '@angular/core';
export const SFNG_TIP_UP_CONTENTS = new InjectionToken<HelpTexts<any>>('SfngTipUpContents');
export const SFNG_TIP_UP_ACTION_RUNNER = new InjectionToken<ActionRunner<any>>('SfngTipUpActionRunner')
export interface Button<T> {
name: string;
action: T;
nextKey?: string;
}
export interface TipUp<T> {
title: string;
content: string;
url?: string;
urlText?: string;
buttons?: Button<T>[];
nextKey?: string;
}
export interface HelpTexts<T> {
[key: string]: TipUp<T>;
}
export interface ActionRunner<T> {
performAction(action: T): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
import { InjectionToken } from "@angular/core";
export const TIPUP_TOKEN = new InjectionToken<string>('TipUPJSONToken');
export interface SfngTipUpPlacement {
origin?: 'left' | 'right';
offset?: number;
}

View File

@@ -0,0 +1,35 @@
sfng-toggle {
@apply flex items-center;
label {
@apply inline-block w-10 h-5 relative bg-gray-500 rounded-full;
}
.slider {
@apply absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-600 transition-all duration-100 rounded-full shadow-inner-xs;
}
.dot {
@apply absolute transition-all duration-200 rounded-full bg-white;
height: 18px;
width: 18px;
bottom: 1px;
left: 1px;
}
input:checked:not(:disabled)+.slider {
@apply bg-green-300 bg-opacity-50 text-green;
}
input:disabled+.slider {
@apply opacity-75 cursor-not-allowed;
}
.dot.checked {
transform: translateX(calc(2.5rem - 18px - 2px));
}
.dot.disabled {
transform: translateX(calc((2.5rem - 18px - 2px)/2));
}
}

View File

@@ -0,0 +1,3 @@
export * from './toggle-switch';
export * from './toggle.module';

View File

@@ -0,0 +1,20 @@
<label>
<input type="checkbox" class="block w-0 h-0 opacity-0" [ngModel]="value" (ngModelChange)="onValueChange($event)" [disabled]="disabled">
<span class="slider">
<span class="flex items-center justify-center dot" [class.checked]="value && !disabled" [class.disabled]="disabled">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" *ngIf="value && !disabled">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" *ngIf="!value && !disabled">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" *ngIf="disabled">
<path fill-rule="evenodd" d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clip-rule="evenodd" />
</svg>
</span>
</span>
</label>

View File

@@ -0,0 +1,59 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostListener } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'sfng-toggle',
templateUrl: './toggle-switch.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SfngToggleSwitchComponent),
multi: true,
}
]
})
export class SfngToggleSwitchComponent implements ControlValueAccessor {
@HostListener('blur')
onBlur() {
this.onTouch();
}
set disabled(v: any) {
this.setDisabledState(coerceBooleanProperty(v))
}
get disabled() {
return this._disabled;
}
private _disabled = false;
value: boolean = false;
constructor(private _changeDetector: ChangeDetectorRef) { }
setDisabledState(isDisabled: boolean) {
this._disabled = isDisabled;
this._changeDetector.markForCheck();
}
onValueChange(value: boolean) {
this.value = value;
this.onChange(this.value);
}
writeValue(value: boolean) {
this.value = value;
this._changeDetector.markForCheck();
}
onChange = (_: any): void => { };
registerOnChange(fn: (value: any) => void) {
this.onChange = fn;
}
onTouch = (): void => { };
registerOnTouched(fn: () => void) {
this.onTouch = fn;
}
}

View File

@@ -0,0 +1,18 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { SfngToggleSwitchComponent } from "./toggle-switch";
@NgModule({
imports: [
CommonModule,
FormsModule,
],
declarations: [
SfngToggleSwitchComponent,
],
exports: [
SfngToggleSwitchComponent,
]
})
export class SfngToggleSwitchModule { }

View File

@@ -0,0 +1,5 @@
sfng-tooltip-container {
@apply relative block;
max-width: 16rem;
}

View File

@@ -0,0 +1,3 @@
export * from './tooltip';
export * from './tooltip.module';

View File

@@ -0,0 +1,6 @@
<div [@moveInOut]="{value: _appAnimate, params: {value: value, what: what}}" *ngIf="_appAnimate"
[style.transformOrigin]="transformOrigin" (@moveInOut.done)="animationDone($event)"
class="relative px-2 py-0.5 text-white bg-gray-100 text-xxs border rounded shadow-lg w-fit">
{{ message }}
<ng-container [cdkPortalOutlet]="portal"></ng-container>
</div>

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