@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
533 lines (525 loc) • 86.3 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, InjectionToken, Optional, Inject, Component, Input, NgModule } from '@angular/core';
import * as i3$1 from '@angular/router';
import { RouterLink, RouterModule } from '@angular/router';
import * as i4$1 from 'ngx-bootstrap/datepicker';
import { BsDatepickerModule } from 'ngx-bootstrap/datepicker';
import * as i1 from '@c8y/ngx-components';
import { NavigatorNode, gettext, Permissions, BuiltInActionType, Status, ValidationPattern, C8yTranslatePipe, FormGroupComponent, FormsModule, DateTimePickerModule, CoreModule, hookNavigator, hookRoute, ViewContext } from '@c8y/ngx-components';
import * as i4 from '@angular/common';
import { CommonModule } from '@angular/common';
import * as i3 from '@ngx-translate/core';
import { saveAs } from 'file-saver';
import { BehaviorSubject, from } from 'rxjs';
import { expand, takeWhile, reduce, shareReplay } from 'rxjs/operators';
import * as i1$1 from '@c8y/client';
import { TenantStatus } from '@c8y/client';
import * as i5 from '@angular/forms';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { gettext as gettext$1 } from '@c8y/ngx-components/gettext';
import { get } from 'lodash-es';
class TenantListGuard {
constructor(tenantUiService) {
this.tenantUiService = tenantUiService;
}
/**
* Checks if tenant list should be active,
* i.e. whether the current tenant can read other tenants.
* **Note: the check is executed only once in the runtime.**
*
* @returns True, if the feature should be active.
*/
canActivate() {
if (this.active === undefined) {
this.active = this.tenantUiService.canReadTenants();
}
return this.active;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantListGuard, deps: [{ token: i1.TenantUiService }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantListGuard }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantListGuard, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: i1.TenantUiService }] });
const TENANTS_MODULE_CONFIG = new InjectionToken('TenantsModuleConfig');
class TenantsNavigationFactory {
constructor(tenantListGuard, config) {
this.tenantListGuard = tenantListGuard;
this.config = config;
this.navs = [];
}
async get() {
const canActivateTenantList = await this.tenantListGuard.canActivate();
if (!this.navs.length) {
const subtenantsNavigatorNode = this.config?.subtenantsNavigatorNode ?? true;
if (subtenantsNavigatorNode !== false) {
this.navs.push(new NavigatorNode({
parent: {
label: gettext('Tenants'),
icon: 'c8y-layers'
},
label: gettext('Subtenants'),
icon: 'c8y-sub-tenants',
path: 'tenants',
routerLinkExact: false,
priority: 4000,
hidden: !canActivateTenantList,
...(subtenantsNavigatorNode === true ? {} : subtenantsNavigatorNode)
}));
}
}
return this.navs;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantsNavigationFactory, deps: [{ token: TenantListGuard }, { token: TENANTS_MODULE_CONFIG, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantsNavigationFactory }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantsNavigationFactory, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: TenantListGuard }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [TENANTS_MODULE_CONFIG]
}] }] });
class CreationTimeFilteringFormRendererComponent {
constructor(context, c8yDate, translateService) {
this.context = context;
this.c8yDate = c8yDate;
this.translateService = translateService;
this.model = (this.context.property.externalFilterQuery || {}).model || {};
}
applyFilter() {
this.context.applyFilter({
externalFilterQuery: {
model: this.model,
chips: this.getChipsForModel(this.model)
},
filterPredicate: (tenant) => {
const creationTime = new Date(tenant.creationTime);
let dateFrom;
let dateTo;
if (this.model.dateFrom) {
dateFrom = this.model.dateFrom;
dateFrom.setHours(0, 0, 0, 0);
}
if (this.model.dateTo) {
dateTo = this.model.dateTo;
dateTo.setHours(23, 59, 59, 999);
}
return Boolean((!dateFrom && !dateTo) ||
(dateFrom && !dateTo && dateFrom <= creationTime) ||
(!dateFrom && dateTo && creationTime <= dateTo) ||
(dateFrom && dateTo && dateFrom <= creationTime && creationTime <= dateTo));
}
});
}
getChipsForModel(model) {
const updateChips = externalFilterQuery => {
externalFilterQuery.chips = this.getChipsForModel(externalFilterQuery.model);
};
const createChip = (key, displayValueTpl) => {
return {
columnName: this.context.property.name,
path: ['model', key],
displayValue: this.translateService.instant(displayValueTpl, {
date: this.c8yDate.transform(this.model[key])
}),
value: this.model[key],
remove() {
delete this.externalFilterQuery.model[key];
updateChips(this.externalFilterQuery);
return {
columnName: this.columnName,
externalFilterQuery: this.externalFilterQuery
};
}
};
};
const chips = [];
if (model.dateFrom) {
chips.push(createChip('dateFrom', gettext('from: {{ date }}')));
}
if (model.dateTo) {
chips.push(createChip('dateTo', gettext('to: {{ date }}')));
}
return chips;
}
resetFilter() {
this.context.resetFilter();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CreationTimeFilteringFormRendererComponent, deps: [{ token: i1.FilteringFormRendererContext }, { token: i4.DatePipe }, { token: i3.TranslateService }], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: CreationTimeFilteringFormRendererComponent, selector: "c8y-creation-time-filtering-form-renderer", ngImport: i0, template: "<form #filterForm=\"ngForm\">\n <div class=\"m-b-8 p-t-8\">\n <label>{{ 'Filter by creation time' | translate }}</label>\n <c8y-form-group\n class=\"datepicker d-block m-b-16\"\n style=\"max-height: 32px\"\n >\n <input\n class=\"form-control fit-w text-left\"\n placeholder=\"{{ 'Created from`date`' | translate }}\"\n name=\"dateFrom\"\n [(ngModel)]=\"model.dateFrom\"\n bsDatepicker\n [bsConfig]=\"{ customTodayClass: 'today', returnFocusToInput: true }\"\n [maxDate]=\"model.dateTo\"\n />\n </c8y-form-group>\n <c8y-form-group\n class=\"datepicker m-l-0 d-block\"\n style=\"max-height: 32px\"\n >\n <input\n class=\"form-control fit-w text-left\"\n placeholder=\"{{ 'Created to`date`' | translate }}\"\n name=\"dateTo\"\n [(ngModel)]=\"model.dateTo\"\n bsDatepicker\n [bsConfig]=\"{ customTodayClass: 'today', returnFocusToInput: true }\"\n [minDate]=\"model.dateFrom\"\n />\n </c8y-form-group>\n </div>\n</form>\n\n<div class=\"data-grid__dropdown__footer d-flex separator-top\">\n <button\n class=\"btn btn-default btn-sm m-r-8 flex-grow\"\n title=\"{{ 'Reset' | translate }}\"\n (click)=\"resetFilter()\"\n >\n {{ 'Reset' | translate }}\n </button>\n <button\n class=\"btn btn-primary btn-sm flex-grow\"\n title=\"{{ 'Apply' | translate }}\"\n [disabled]=\"filterForm.invalid || !(model.dateFrom || model.dateTo)\"\n (click)=\"applyFilter()\"\n >\n {{ 'Apply' | translate }}\n </button>\n</div>\n", dependencies: [{ kind: "directive", type: i4$1.BsDatepickerDirective, selector: "[bsDatepicker]", inputs: ["placement", "triggers", "outsideClick", "container", "outsideEsc", "isDisabled", "minDate", "maxDate", "minMode", "daysDisabled", "datesDisabled", "datesEnabled", "dateCustomClasses", "dateTooltipTexts", "isOpen", "bsValue", "bsConfig"], outputs: ["onShown", "onHidden", "bsValueChange"], exportAs: ["bsDatepicker"] }, { kind: "directive", type: i4$1.BsDatepickerInputDirective, selector: "input[bsDatepicker]" }, { kind: "directive", type: i5.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i5.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i5.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i5.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i5.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i5.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: i1.FormGroupComponent, selector: "c8y-form-group", inputs: ["hasError", "hasWarning", "hasSuccess", "novalidation", "status"] }, { kind: "pipe", type: i1.C8yTranslatePipe, name: "translate" }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CreationTimeFilteringFormRendererComponent, decorators: [{
type: Component,
args: [{ selector: 'c8y-creation-time-filtering-form-renderer', template: "<form #filterForm=\"ngForm\">\n <div class=\"m-b-8 p-t-8\">\n <label>{{ 'Filter by creation time' | translate }}</label>\n <c8y-form-group\n class=\"datepicker d-block m-b-16\"\n style=\"max-height: 32px\"\n >\n <input\n class=\"form-control fit-w text-left\"\n placeholder=\"{{ 'Created from`date`' | translate }}\"\n name=\"dateFrom\"\n [(ngModel)]=\"model.dateFrom\"\n bsDatepicker\n [bsConfig]=\"{ customTodayClass: 'today', returnFocusToInput: true }\"\n [maxDate]=\"model.dateTo\"\n />\n </c8y-form-group>\n <c8y-form-group\n class=\"datepicker m-l-0 d-block\"\n style=\"max-height: 32px\"\n >\n <input\n class=\"form-control fit-w text-left\"\n placeholder=\"{{ 'Created to`date`' | translate }}\"\n name=\"dateTo\"\n [(ngModel)]=\"model.dateTo\"\n bsDatepicker\n [bsConfig]=\"{ customTodayClass: 'today', returnFocusToInput: true }\"\n [minDate]=\"model.dateFrom\"\n />\n </c8y-form-group>\n </div>\n</form>\n\n<div class=\"data-grid__dropdown__footer d-flex separator-top\">\n <button\n class=\"btn btn-default btn-sm m-r-8 flex-grow\"\n title=\"{{ 'Reset' | translate }}\"\n (click)=\"resetFilter()\"\n >\n {{ 'Reset' | translate }}\n </button>\n <button\n class=\"btn btn-primary btn-sm flex-grow\"\n title=\"{{ 'Apply' | translate }}\"\n [disabled]=\"filterForm.invalid || !(model.dateFrom || model.dateTo)\"\n (click)=\"applyFilter()\"\n >\n {{ 'Apply' | translate }}\n </button>\n</div>\n" }]
}], ctorParameters: () => [{ type: i1.FilteringFormRendererContext }, { type: i4.DatePipe }, { type: i3.TranslateService }] });
class StatusFilteringFormRendererComponent {
constructor(context) {
this.context = context;
this.model = (this.context.property.externalFilterQuery || {}).model || {};
}
applyFilter() {
this.context.applyFilter({
externalFilterQuery: {
model: this.model
},
filterPredicate: (tenant) => Boolean((!this.model.active && !this.model.suspended) ||
(this.model.active && tenant.status === TenantStatus.ACTIVE) ||
(this.model.suspended && tenant.status === TenantStatus.SUSPENDED))
});
}
resetFilter() {
this.context.resetFilter();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: StatusFilteringFormRendererComponent, deps: [{ token: i1.FilteringFormRendererContext }], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: StatusFilteringFormRendererComponent, selector: "c8y-status-filtering-form-renderer", ngImport: i0, template: "<form #filterForm=\"ngForm\">\n <div class=\"m-b-8 p-t-8\">\n <label>{{ 'Filter by status' | translate }}</label>\n <c8y-form-group class=\"m-b-0\">\n <label class=\"c8y-checkbox\">\n <input type=\"checkbox\" name=\"active\" [(ngModel)]=\"model.active\" />\n <span></span>\n <span>{{ 'Active`tenant`' | translate }}</span>\n </label>\n </c8y-form-group>\n <c8y-form-group class=\"m-b-0\">\n <label class=\"c8y-checkbox\">\n <input type=\"checkbox\" name=\"suspended\" [(ngModel)]=\"model.suspended\" />\n <span></span>\n <span>{{ 'Suspended`tenant`' | translate }}</span>\n </label>\n </c8y-form-group>\n </div>\n</form>\n\n<div class=\"data-grid__dropdown__footer d-flex separator-top\">\n <button\n class=\"btn btn-default btn-sm m-r-8 flex-grow\"\n (click)=\"resetFilter()\"\n title=\"{{ 'Reset' | translate }}\"\n >\n {{ 'Reset' | translate }}\n </button>\n <button\n class=\"btn btn-primary btn-sm flex-grow\"\n [disabled]=\"filterForm.invalid\"\n (click)=\"applyFilter()\"\n title=\"{{ 'Apply' | translate }}\"\n >\n {{ 'Apply' | translate }}\n </button>\n</div>\n", dependencies: [{ kind: "directive", type: i5.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i5.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i5.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i5.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i5.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i5.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: i1.FormGroupComponent, selector: "c8y-form-group", inputs: ["hasError", "hasWarning", "hasSuccess", "novalidation", "status"] }, { kind: "pipe", type: i1.C8yTranslatePipe, name: "translate" }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: StatusFilteringFormRendererComponent, decorators: [{
type: Component,
args: [{ selector: 'c8y-status-filtering-form-renderer', template: "<form #filterForm=\"ngForm\">\n <div class=\"m-b-8 p-t-8\">\n <label>{{ 'Filter by status' | translate }}</label>\n <c8y-form-group class=\"m-b-0\">\n <label class=\"c8y-checkbox\">\n <input type=\"checkbox\" name=\"active\" [(ngModel)]=\"model.active\" />\n <span></span>\n <span>{{ 'Active`tenant`' | translate }}</span>\n </label>\n </c8y-form-group>\n <c8y-form-group class=\"m-b-0\">\n <label class=\"c8y-checkbox\">\n <input type=\"checkbox\" name=\"suspended\" [(ngModel)]=\"model.suspended\" />\n <span></span>\n <span>{{ 'Suspended`tenant`' | translate }}</span>\n </label>\n </c8y-form-group>\n </div>\n</form>\n\n<div class=\"data-grid__dropdown__footer d-flex separator-top\">\n <button\n class=\"btn btn-default btn-sm m-r-8 flex-grow\"\n (click)=\"resetFilter()\"\n title=\"{{ 'Reset' | translate }}\"\n >\n {{ 'Reset' | translate }}\n </button>\n <button\n class=\"btn btn-primary btn-sm flex-grow\"\n [disabled]=\"filterForm.invalid\"\n (click)=\"applyFilter()\"\n title=\"{{ 'Apply' | translate }}\"\n >\n {{ 'Apply' | translate }}\n </button>\n</div>\n" }]
}], ctorParameters: () => [{ type: i1.FilteringFormRendererContext }] });
class TenantListComponent {
constructor(appState, alertService, modalService, translateService, tenantService, tenantUiService, location, passwordService, userService, permissionsService) {
this.appState = appState;
this.alertService = alertService;
this.modalService = modalService;
this.translateService = translateService;
this.tenantService = tenantService;
this.tenantUiService = tenantUiService;
this.location = location;
this.passwordService = passwordService;
this.userService = userService;
this.permissionsService = permissionsService;
this.tenants$ = new BehaviorSubject(undefined);
this.isPermittedToCreateTenanant = this.permissionsService.hasAnyRole([
Permissions.ROLE_TENANT_MANAGEMENT_ADMIN,
Permissions.ROLE_TENANT_MANAGEMENT_CREATE
]);
this.TOP_TENANT_NAME = 'management';
this.title = null;
this.loadMoreItemsLabel = gettext('Load more tenants');
this.loadingItemsLabel = gettext('Loading tenants…');
this.displayOptions = {
bordered: false,
striped: true,
filter: true,
gridHeader: true,
hover: true
};
this.columns = this.getColumns();
this.pagination = this.getPagination();
this.showSearch = true;
this.actionControls = this.getActionControls();
this.noResultsMessage = gettext('No tenants to display.');
this.noDataMessage = gettext('There are no tenants defined.');
this.noResultsSubtitle = gettext('Refine your search terms or check your spelling.');
this.noDataSubtitle = gettext('Create the first tenant.');
this.TenantStatus = TenantStatus;
}
async ngOnInit() {
this.currentTenant = this.appState.currentTenant.value;
this.isManagementTenant = await this.tenantUiService.isManagementTenant();
this.loadTenants();
}
loadTenants() {
this.tenants$.next(undefined);
from(this.tenantService.list({ pageSize: 2000, withTotalPages: true, withApps: false }))
.pipe(expand(resultList => resultList.paging.nextPage !== null && resultList.paging.next()), takeWhile(resultList => resultList.paging.nextPage !== null, true), reduce((tenants, resultList) => [
...tenants,
...resultList.data
], []), shareReplay(1))
.subscribe(tenants => this.tenants$.next(tenants));
}
getColumns() {
return [
{
name: 'company',
header: gettext('Tenant'),
path: 'company',
filterable: true,
sortable: true,
sortOrder: 'asc'
},
{
name: 'id',
header: gettext('ID'),
path: 'id',
filterable: true,
sortable: true
},
{
name: 'domain',
header: gettext('Domain'),
path: 'domain',
filterable: true,
sortable: true
},
{
name: 'parent',
header: gettext('Parent tenant'),
path: 'parent',
filterable: true,
sortable: true
},
{
name: 'contactName',
header: gettext('Contact name'),
path: 'contactName',
filterable: true,
sortable: true
},
{
name: 'creationTime',
header: gettext('Created'),
path: 'creationTime',
filterable: true,
filteringFormRendererComponent: CreationTimeFilteringFormRendererComponent,
sortable: true
},
{
name: 'externalReference',
header: gettext('External reference'),
path: 'customProperties.externalReference',
filterable: true,
sortable: true
},
{
name: 'status',
header: gettext('Status'),
path: 'status',
filterable: true,
filteringFormRendererComponent: StatusFilteringFormRendererComponent,
sortable: true,
resizable: false
}
];
}
getPagination() {
return {
pageSize: 10,
currentPage: 1
};
}
getActionControls() {
return [
{
type: BuiltInActionType.Edit,
text: gettext('Edit`tenant`'),
callback: tenant => this.goToDetails(tenant)
},
{
type: 'activateTenantAction',
icon: 'power-off',
text: gettext('Activate`tenant`'),
callback: (tenant) => this.activateTenant(tenant),
showIf: (tenant) => this.isSuspended(tenant)
},
{
type: 'suspendTenantAction',
icon: 'power-off',
text: gettext('Suspend`tenant`'),
callback: (tenant) => this.suspendTenant(tenant),
showIf: (tenant) => this.isActive(tenant)
},
{
type: BuiltInActionType.Delete,
text: gettext('Delete`tenant`'),
callback: tenant => this.delete(tenant),
showIf: () => this.isManagementTenant
}
];
}
createTenant() {
this.location.go('/tenants/new');
}
goToDetails(tenant) {
this.location.go(`/tenants/${tenant.id}`);
}
async activateTenant(tenant) {
try {
const { data: savedTenant } = await this.tenantService.update({
id: tenant.id,
status: TenantStatus.ACTIVE
});
Object.assign(tenant, savedTenant);
this.alertService.success(gettext('Tenant activated.'));
}
catch (ex) {
if (ex) {
this.alertService.addServerFailure(ex);
}
}
}
async suspendTenant(tenant) {
const title = gettext('Suspend tenant');
const confirmationText = gettext('You are about to suspend tenant "{{ company }}" (ID "{{ id }}").');
const proceed = gettext('Do you want to proceed?');
const body = [
this.translateService.instant(confirmationText, {
company: tenant.company,
id: tenant.id
}),
this.translateService.instant(proceed)
].join(' ');
const labels = {
ok: gettext('Suspend`tenant`')
};
try {
await this.modalService.confirm(title, body, Status.DANGER, labels);
const confirmed = await this.passwordService.confirmPassword().toPromise();
if (confirmed === true) {
const { data: savedTenant } = await this.tenantService.update({
id: tenant.id,
status: TenantStatus.SUSPENDED
});
Object.assign(tenant, savedTenant);
this.alertService.success(gettext('Tenant suspended.'));
}
}
catch (ex) {
if (ex) {
this.alertService.addServerFailure(ex);
}
}
}
async delete(tenant) {
const title = gettext('Delete tenant');
const confirmationText = gettext('You are about to delete tenant "{{ company }}" (ID "{{ id }}").');
const hint = gettext('This operation is irreversible.');
const proceed = gettext('Do you want to proceed?');
const body = [
this.translateService.instant(confirmationText, {
company: tenant.company,
id: tenant.id
}),
this.translateService.instant(hint),
this.translateService.instant(proceed)
].join(' ');
const labels = {
ok: gettext('Delete`tenant`')
};
try {
await this.modalService.confirm(title, body, Status.DANGER, labels);
const confirmed = await this.passwordService.confirmPassword().toPromise();
if (confirmed === true) {
await this.tenantService.delete(tenant);
const tenantsWithoutRemovedOne = this.tenants$.value.filter(t => t !== tenant);
this.tenants$.next(tenantsWithoutRemovedOne);
this.alertService.success(gettext('Tenant is being deleted in the background. This might take a while…'));
}
}
catch (ex) {
if (ex) {
this.alertService.addServerFailure(ex);
}
}
}
isActive(tenant) {
return tenant.status === TenantStatus.ACTIVE;
}
isSuspended(tenant) {
return tenant.status === TenantStatus.SUSPENDED;
}
async downloadNewsletterEmails() {
const { res, data } = await this.userService.getNewsletterEmails();
const contentType = res.headers.get('content-type');
const contentDisposition = res.headers.get('content-disposition');
const filename = /filename="(.*)"/.exec(contentDisposition)[1];
const blob = new Blob([data], { type: contentType });
saveAs(blob, filename);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantListComponent, deps: [{ token: i1.AppStateService }, { token: i1.AlertService }, { token: i1.ModalService }, { token: i3.TranslateService }, { token: i1$1.TenantService }, { token: i1.TenantUiService }, { token: i4.Location }, { token: i1.PasswordService }, { token: i1$1.UserService }, { token: i1.Permissions }], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: TenantListComponent, selector: "c8y-tenant-list", ngImport: i0, template: "<c8y-title>\n {{ 'Subtenants' | translate }}\n</c8y-title>\n\n<c8y-breadcrumb>\n <c8y-breadcrumb-item\n icon=\"c8y-layers\"\n label=\"{{ 'Tenants' | translate }}\"\n ></c8y-breadcrumb-item>\n <c8y-breadcrumb-item\n [icon]=\"'c8y-layers'\"\n [label]=\"'Subtenants' | translate\"\n ></c8y-breadcrumb-item>\n</c8y-breadcrumb>\n\n<c8y-action-bar-item\n *ngIf=\"!!(appState.state$ | async).newsletter\"\n [placement]=\"'right'\"\n>\n <button\n class=\"btn btn-link\"\n title=\"{{\n 'Downloads the list of emails of users subscribed for newsletter on the current tenant and its subtenants.'\n | translate\n }}\"\n type=\"button\"\n (click)=\"downloadNewsletterEmails()\"\n >\n <i c8yIcon=\"download\"></i>\n {{ 'Email addresses' | translate }}\n </button>\n</c8y-action-bar-item>\n\n<c8y-action-bar-item [placement]=\"'right'\">\n <button\n class=\"btn btn-link\"\n title=\"{{ 'Create tenant' | translate }}\"\n type=\"button\"\n (click)=\"createTenant()\"\n [disabled]=\"!isPermittedToCreateTenanant\"\n >\n <i c8yIcon=\"plus-circle\"></i>\n {{ 'Create tenant' | translate }}\n </button>\n</c8y-action-bar-item>\n\n<c8y-help src=\"/docs/enterprise-tenant/managing-tenants/#managing-subtenants\"></c8y-help>\n\n<div class=\"content-fullpage border-top border-bottom\">\n <c8y-data-grid\n [title]=\"title\"\n [loadMoreItemsLabel]=\"loadMoreItemsLabel\"\n [loadingItemsLabel]=\"loadingItemsLabel\"\n [displayOptions]=\"displayOptions\"\n [columns]=\"columns\"\n [rows]=\"tenants$ | async\"\n [pagination]=\"pagination\"\n [showSearch]=\"showSearch\"\n [actionControls]=\"actionControls\"\n (onReload)=\"loadTenants()\"\n >\n <ng-container *ngIf=\"!(tenants$ | async); else empty\">\n <c8y-loading></c8y-loading>\n </ng-container>\n <ng-template #empty>\n <c8y-ui-empty-state\n [icon]=\"stats?.size > 0 ? 'search' : 'c8y-layers'\"\n [title]=\"stats?.size > 0 ? (noResultsMessage | translate) : (noDataMessage | translate)\"\n [subtitle]=\"\n stats?.size > 0 ? (noResultsSubtitle | translate) : (noDataSubtitle | translate)\n \"\n *emptyStateContext=\"let stats\"\n [horizontal]=\"stats?.size > 0\"\n >\n <ng-container *ngIf=\"stats?.size === 0\">\n <div>\n <button\n class=\"btn btn-primary\"\n title=\"{{ 'Create tenant' | translate }}\"\n (click)=\"createTenant()\"\n [disabled]=\"!isPermittedToCreateTenanant\"\n >\n {{ 'Create tenant' | translate }}\n </button>\n </div>\n <p c8y-guide-docs>\n <small\n translate\n ngNonBindable\n >\n Find out more in the\n <a c8y-guide-href=\"/docs/enterprise-tenant/managing-tenants\">user documentation</a>\n .\n </small>\n </p>\n </ng-container>\n </c8y-ui-empty-state>\n </ng-template>\n\n <c8y-column name=\"company\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span title=\"{{ context.value }}\">\n <a [routerLink]=\"['/tenants', context.item.id]\">{{ context.value }}</a>\n </span>\n </ng-container>\n </c8y-column>\n\n <c8y-column name=\"parent\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span title=\"{{ context.value || currentTenant.name }}\">\n {{ context.value || currentTenant.name }}\n </span>\n </ng-container>\n </c8y-column>\n\n <c8y-column name=\"creationTime\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span title=\"{{ context.value | c8yDate }}\">\n {{ context.value | c8yDate }}\n </span>\n </ng-container>\n </c8y-column>\n\n <c8y-column name=\"status\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span\n title=\"{{ 'Active`tenant`' | translate }}\"\n *ngIf=\"context.item.status === TenantStatus.ACTIVE\"\n >\n <i\n class=\"text-success\"\n c8yIcon=\"check-circle\"\n ></i>\n </span>\n <span\n title=\"{{ 'Suspended`tenant`' | translate }}\"\n *ngIf=\"context.item.status === TenantStatus.SUSPENDED\"\n >\n <i\n class=\"text-danger\"\n c8yIcon=\"ban\"\n ></i>\n </span>\n </ng-container>\n </c8y-column>\n </c8y-data-grid>\n</div>\n", dependencies: [{ kind: "directive", type: i3$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: i1.ActionBarItemComponent, selector: "c8y-action-bar-item", inputs: ["placement", "priority", "itemClass", "injector", "groupId", "inGroupPriority"] }, { kind: "component", type: i1.BreadcrumbComponent, selector: "c8y-breadcrumb" }, { kind: "component", type: i1.BreadcrumbItemComponent, selector: "c8y-breadcrumb-item", inputs: ["icon", "translate", "label", "path", "injector"] }, { kind: "component", type: i1.EmptyStateComponent, selector: "c8y-ui-empty-state", inputs: ["icon", "title", "subtitle", "horizontal"] }, { kind: "directive", type: i1.EmptyStateContextDirective, selector: "[emptyStateContext]" }, { kind: "directive", type: i1.IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "directive", type: i1.C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "directive", type: i4.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i1.LoadingComponent, selector: "c8y-loading", inputs: ["layout", "progress", "message"] }, { kind: "directive", type: i1.CellRendererDefDirective, selector: "[c8yCellRendererDef]" }, { kind: "directive", type: i1.ColumnDirective, selector: "c8y-column", inputs: ["name"] }, { kind: "component", type: i1.DataGridComponent, selector: "c8y-data-grid", inputs: ["title", "loadMoreItemsLabel", "loadingItemsLabel", "showSearch", "refresh", "columns", "rows", "pagination", "infiniteScroll", "serverSideDataCallback", "selectable", "singleSelection", "selectionPrimaryKey", "displayOptions", "actionControls", "bulkActionControls", "headerActionControls", "searchText", "configureColumnsEnabled", "showCounterWarning", "activeClassName", "expandableRows"], outputs: ["rowMouseOver", "rowMouseLeave", "rowClick", "onConfigChange", "onBeforeFilter", "onBeforeSearch", "onFilter", "itemsSelect", "onReload", "onAddCustomColumn", "onRemoveCustomColumn", "onColumnFilterReset", "onSort", "onPageSizeChange", "onColumnReordered", "onColumnVisibilityChange"] }, { kind: "component", type: i1.TitleComponent, selector: "c8y-title", inputs: ["pageTitleUpdate"] }, { kind: "directive", type: i1.GuideHrefDirective, selector: "[c8y-guide-href]", inputs: ["c8y-guide-href"] }, { kind: "component", type: i1.GuideDocsComponent, selector: "[c8y-guide-docs]" }, { kind: "component", type: i1.HelpComponent, selector: "c8y-help", inputs: ["src", "isCollapsed", "priority", "icon"] }, { kind: "pipe", type: i1.C8yTranslatePipe, name: "translate" }, { kind: "pipe", type: i4.AsyncPipe, name: "async" }, { kind: "pipe", type: i1.DatePipe, name: "c8yDate" }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: TenantListComponent, decorators: [{
type: Component,
args: [{ selector: 'c8y-tenant-list', template: "<c8y-title>\n {{ 'Subtenants' | translate }}\n</c8y-title>\n\n<c8y-breadcrumb>\n <c8y-breadcrumb-item\n icon=\"c8y-layers\"\n label=\"{{ 'Tenants' | translate }}\"\n ></c8y-breadcrumb-item>\n <c8y-breadcrumb-item\n [icon]=\"'c8y-layers'\"\n [label]=\"'Subtenants' | translate\"\n ></c8y-breadcrumb-item>\n</c8y-breadcrumb>\n\n<c8y-action-bar-item\n *ngIf=\"!!(appState.state$ | async).newsletter\"\n [placement]=\"'right'\"\n>\n <button\n class=\"btn btn-link\"\n title=\"{{\n 'Downloads the list of emails of users subscribed for newsletter on the current tenant and its subtenants.'\n | translate\n }}\"\n type=\"button\"\n (click)=\"downloadNewsletterEmails()\"\n >\n <i c8yIcon=\"download\"></i>\n {{ 'Email addresses' | translate }}\n </button>\n</c8y-action-bar-item>\n\n<c8y-action-bar-item [placement]=\"'right'\">\n <button\n class=\"btn btn-link\"\n title=\"{{ 'Create tenant' | translate }}\"\n type=\"button\"\n (click)=\"createTenant()\"\n [disabled]=\"!isPermittedToCreateTenanant\"\n >\n <i c8yIcon=\"plus-circle\"></i>\n {{ 'Create tenant' | translate }}\n </button>\n</c8y-action-bar-item>\n\n<c8y-help src=\"/docs/enterprise-tenant/managing-tenants/#managing-subtenants\"></c8y-help>\n\n<div class=\"content-fullpage border-top border-bottom\">\n <c8y-data-grid\n [title]=\"title\"\n [loadMoreItemsLabel]=\"loadMoreItemsLabel\"\n [loadingItemsLabel]=\"loadingItemsLabel\"\n [displayOptions]=\"displayOptions\"\n [columns]=\"columns\"\n [rows]=\"tenants$ | async\"\n [pagination]=\"pagination\"\n [showSearch]=\"showSearch\"\n [actionControls]=\"actionControls\"\n (onReload)=\"loadTenants()\"\n >\n <ng-container *ngIf=\"!(tenants$ | async); else empty\">\n <c8y-loading></c8y-loading>\n </ng-container>\n <ng-template #empty>\n <c8y-ui-empty-state\n [icon]=\"stats?.size > 0 ? 'search' : 'c8y-layers'\"\n [title]=\"stats?.size > 0 ? (noResultsMessage | translate) : (noDataMessage | translate)\"\n [subtitle]=\"\n stats?.size > 0 ? (noResultsSubtitle | translate) : (noDataSubtitle | translate)\n \"\n *emptyStateContext=\"let stats\"\n [horizontal]=\"stats?.size > 0\"\n >\n <ng-container *ngIf=\"stats?.size === 0\">\n <div>\n <button\n class=\"btn btn-primary\"\n title=\"{{ 'Create tenant' | translate }}\"\n (click)=\"createTenant()\"\n [disabled]=\"!isPermittedToCreateTenanant\"\n >\n {{ 'Create tenant' | translate }}\n </button>\n </div>\n <p c8y-guide-docs>\n <small\n translate\n ngNonBindable\n >\n Find out more in the\n <a c8y-guide-href=\"/docs/enterprise-tenant/managing-tenants\">user documentation</a>\n .\n </small>\n </p>\n </ng-container>\n </c8y-ui-empty-state>\n </ng-template>\n\n <c8y-column name=\"company\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span title=\"{{ context.value }}\">\n <a [routerLink]=\"['/tenants', context.item.id]\">{{ context.value }}</a>\n </span>\n </ng-container>\n </c8y-column>\n\n <c8y-column name=\"parent\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span title=\"{{ context.value || currentTenant.name }}\">\n {{ context.value || currentTenant.name }}\n </span>\n </ng-container>\n </c8y-column>\n\n <c8y-column name=\"creationTime\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span title=\"{{ context.value | c8yDate }}\">\n {{ context.value | c8yDate }}\n </span>\n </ng-container>\n </c8y-column>\n\n <c8y-column name=\"status\">\n <ng-container *c8yCellRendererDef=\"let context\">\n <span\n title=\"{{ 'Active`tenant`' | translate }}\"\n *ngIf=\"context.item.status === TenantStatus.ACTIVE\"\n >\n <i\n class=\"text-success\"\n c8yIcon=\"check-circle\"\n ></i>\n </span>\n <span\n title=\"{{ 'Suspended`tenant`' | translate }}\"\n *ngIf=\"context.item.status === TenantStatus.SUSPENDED\"\n >\n <i\n class=\"text-danger\"\n c8yIcon=\"ban\"\n ></i>\n </span>\n </ng-container>\n </c8y-column>\n </c8y-data-grid>\n</div>\n" }]
}], ctorParameters: () => [{ type: i1.AppStateService }, { type: i1.AlertService }, { type: i1.ModalService }, { type: i3.TranslateService }, { type: i1$1.TenantService }, { type: i1.TenantUiService }, { type: i4.Location }, { type: i1.PasswordService }, { type: i1$1.UserService }, { type: i1.Permissions }] });
const defaultFilters = {
query: "(type eq 'c8y_JsonSchema') and (appliesTo.TENANTS eq true)",
pageSize: 1000,
withTotalPages: true
};
class CustomPropertiesService {
constructor(inventoryService) {
this.inventoryService = inventoryService;
}
async getFormAndFieldList() {
const schema = await this.getCustomProperties();
const formGroup = this.buildFormGroup(schema);
const fields = this.buildFieldList(formGroup, schema);
return { form: formGroup, fields };
}
async getCustomProperties() {
let customFieldsSchema = {};
const customProperties = await this.inventoryService.list(defaultFilters);
customProperties.data.forEach(item => {
const fieldSchema = item['c8y_JsonSchema'].properties;
customFieldsSchema = { ...customFieldsSchema, ...fieldSchema };
});
return customFieldsSchema;
}
buildFormGroup(schema) {
const fg = new FormGroup({});
for (const [key, value] of Object.entries(schema)) {
const control = new FormControl(value.default, []);
this.applyValidators(control, value);
fg.addControl(key, control);
}
return fg;
}
applyValidators(control, props) {
const validatorsMap = {
required: Validators.required,
minimum: Validators.min(props.minimum),
maximum: Validators.max(props.maximum),
minLength: Validators.minLength(props.minLength),
maxLength: Validators.maxLength(props.maxLength),
pattern: Validators.pattern(props.pattern)
};
Object.entries(validatorsMap).forEach(([key, validator]) => {
if (props[key] !== undefined) {
control.addValidators(validator);
}
});
if (props.type === 'integer') {
control.addValidators(Validators.pattern(ValidationPattern.rules.integer.pattern));
}
}
buildFieldList(form, schema) {
const fieldList = [];
Object.entries(schema).forEach(([key, value]) => {
fieldList.push({
id: key,
label: value.title,
type: value.type,
format: value.format,
formControlReference: form.get(key)
});
});
return fieldList;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CustomPropertiesService, deps: [{ token: i1$1.InventoryService }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CustomPropertiesService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CustomPropertiesService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}], ctorParameters: () => [{ type: i1$1.InventoryService }] });
class CustomPropertyFieldComponent {
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CustomPropertyFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.13", type: CustomPropertyFieldComponent, isStandalone: true, selector: "c8y-custom-property-field", inputs: { fieldDefinition: "fieldDefinition", form: "form" }, ngImport: i0, template: "<ng-container *ngIf=\"form && fieldDefinition\">\n <ng-container [ngSwitch]=\"fieldDefinition.type\">\n <ng-container *ngSwitchCase=\"'boolean'\">\n <c8y-form-group [formGroup]=\"form\">\n <label\n class=\"c8y-checkbox\"\n [title]=\"fieldDefinition.label | translate\"\n [for]=\"fieldDefinition.id\"\n >\n <input\n type=\"checkbox\"\n [id]=\"fieldDefinition.id\"\n [formControlName]=\"fieldDefinition.id\"\n />\n <span></span>\n <span>{{ fieldDefinition.label | translate }}</span>\n </label>\n </c8y-form-group>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'number'\">\n <c8y-form-group [formGroup]=\"form\">\n <label [for]=\"fieldDefinition.id\">\n {{ fieldDefinition.label | translate }}\n </label>\n <input\n class=\"form-control\"\n type=\"number\"\n [id]=\"fieldDefinition.id\"\n [formControlName]=\"fieldDefinition.id\"\n />\n </c8y-form-group>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'integer'\">\n <c8y-form-group [formGroup]=\"form\">\n <label [for]=\"fieldDefinition.id\">\n {{ fieldDefinition.label | translate }}\n </label>\n <input\n class=\"form-control\"\n type=\"number\"\n [id]=\"fieldDefinition.id\"\n [formControlName]=\"fieldDefinition.id\"\n />\n </c8y-form-group>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'string'\">\n <c8y-form-group\n *ngIf=\"!fieldDefinition.format\"\n [formGroup]=\"form\"\n >\n <label [for]=\"fieldDefinition.id\">\n {{ fieldDefinition.label | translate }}\n </label>\n <input\n class=\"form-control\"\n type=\"text\"\n [id]=\"fieldDefinition.id\"\n [formControlName]=\"fieldDefinition.id\"\n />\n </c8y-form-group>\n\n <c8y-form-group\n *ngIf=\"fieldDefinition.format === 'datetime'\"\n [formGroup]=\"form\"\n >\n <label [for]=\"fieldDefinition.id\">\n {{ fieldDefinition.label | translate }}\n </label>\n <c8y-date-time-picker\n [id]=\"fieldDefinition.id\"\n [formControlName]=\"fieldDefinition.id\"\n data-cy=\"c8y-custom-property-field--date-time-picker\"\n ></c8y-date-time-picker>\n </c8y-form-group>\n </ng-container>\n </ng-container>\n</ng-container>\n", dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i4.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i4.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i4.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }, { kind: "component", type: FormGroupComponent, selector: "c8y-form-group", inputs: ["hasError", "hasWarning", "hasSuccess", "novalidation", "status"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i5.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i5.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i5.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i5.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i5.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.RequiredInputPlaceholderDirective, selector: "input[required], input[formControlName]" }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i5.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i5.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: DateTimePickerModule }, { kind: "component", type: i1.DateTimePickerComponent, selector: "c8y-date-time-picker", inputs: ["minDate", "maxDate", "placeholder", "dateInputFormat", "adaptivePosition", "size", "dateType", "config"], outputs: ["onDateSelected"] }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: CustomPropertyFieldComponent, decorators: [{
type: Component,
args: [{ selector: 'c8y-custom-property-field', standalone: true, imports: [
CommonModule,
C8yTranslatePipe,
FormGroupComponent,
FormsModule,
ReactiveFormsModule,
DateTimePickerModule
], template: "<ng-container *ngIf=\"form && fieldDefinition\">\n <ng-container [ngSwitch]=\"fieldDefinition.type\">\n <ng-container *ngSwitchCase=\"'boolean'\">\n <c8y-form-group [formGroup]=\"form\">\n <label\n class=\"c8y-checkbox\"\n [title]=\"fieldDefinition.label | translate\"\n [for]=\"fieldDefinition.id\"\n >\n <input\n type=\"checkbox\"\n [id]=\"fieldDefinition.id\"\n