Browse Source

feat(admin-ui): Add support for dashboard widgets

Relates to #334
Michael Bromley 5 năm trước cách đây
mục cha
commit
aa835e89cd
44 tập tin đã thay đổi với 729 bổ sung61 xóa
  1. 5 4
      packages/admin-ui/scripts/build-public-api.js
  2. 1 0
      packages/admin-ui/src/app/app.module.ts
  3. 36 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  4. 9 0
      packages/admin-ui/src/lib/core/src/data/definitions/administrator-definitions.ts
  5. 13 0
      packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts
  6. 12 0
      packages/admin-ui/src/lib/core/src/data/providers/administrator-data.service.ts
  7. 16 6
      packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts
  8. 30 0
      packages/admin-ui/src/lib/core/src/providers/dashboard-widget/dashboard-widget-types.ts
  9. 50 0
      packages/admin-ui/src/lib/core/src/providers/dashboard-widget/dashboard-widget.service.ts
  10. 26 0
      packages/admin-ui/src/lib/core/src/providers/dashboard-widget/register-dashboard-widget.ts
  11. 3 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  12. 32 2
      packages/admin-ui/src/lib/core/src/shared/directives/if-permissions.directive.spec.ts
  13. 22 11
      packages/admin-ui/src/lib/core/src/shared/directives/if-permissions.directive.ts
  14. 8 0
      packages/admin-ui/src/lib/dashboard/src/components/dashboard-widget/dashboard-widget.component.html
  15. 9 0
      packages/admin-ui/src/lib/dashboard/src/components/dashboard-widget/dashboard-widget.component.scss
  16. 50 0
      packages/admin-ui/src/lib/dashboard/src/components/dashboard-widget/dashboard-widget.component.ts
  17. 4 3
      packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.html
  18. 4 0
      packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.scss
  19. 0 25
      packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.spec.ts
  20. 44 4
      packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.ts
  21. 14 4
      packages/admin-ui/src/lib/dashboard/src/dashboard.module.ts
  22. 34 0
      packages/admin-ui/src/lib/dashboard/src/default-widgets.ts
  23. 6 0
      packages/admin-ui/src/lib/dashboard/src/public_api.ts
  24. 20 0
      packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.html
  25. 9 0
      packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.scss
  26. 35 0
      packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.ts
  27. 29 0
      packages/admin-ui/src/lib/dashboard/src/widgets/order-summary-widget/order-summary-widget.component.html
  28. 29 0
      packages/admin-ui/src/lib/dashboard/src/widgets/order-summary-widget/order-summary-widget.component.scss
  29. 59 0
      packages/admin-ui/src/lib/dashboard/src/widgets/order-summary-widget/order-summary-widget.component.ts
  30. 1 0
      packages/admin-ui/src/lib/dashboard/src/widgets/test-widget/test-widget.component.html
  31. 0 0
      packages/admin-ui/src/lib/dashboard/src/widgets/test-widget/test-widget.component.scss
  32. 14 0
      packages/admin-ui/src/lib/dashboard/src/widgets/test-widget/test-widget.component.ts
  33. 11 0
      packages/admin-ui/src/lib/dashboard/src/widgets/welcome-widget/welcome-widget.component.html
  34. 9 0
      packages/admin-ui/src/lib/dashboard/src/widgets/welcome-widget/welcome-widget.component.scss
  35. 34 0
      packages/admin-ui/src/lib/dashboard/src/widgets/welcome-widget/welcome-widget.component.ts
  36. 2 1
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts
  37. 7 1
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  38. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  39. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  40. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  41. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  42. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  43. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  44. 6 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

+ 5 - 4
packages/admin-ui/scripts/build-public-api.js

@@ -8,7 +8,7 @@ const path = require('path');
 console.log('Generating public apis...');
 const SOURCES_DIR = path.join(__dirname, '/../src/lib');
 const APP_SOURCE_FILE_PATTERN = /\.ts$/;
-const EXCLUDED_PATTERN = /(public_api|spec|mock)\.ts$/;
+const EXCLUDED_PATTERNS = [/(public_api|spec|mock)\.ts$/];
 
 const MODULES = [
     'catalog',
@@ -26,15 +26,16 @@ for (const moduleDir of MODULES) {
     const modulePath = path.join(SOURCES_DIR, moduleDir, 'src');
 
     const files = [];
-    forMatchingFiles(modulePath, APP_SOURCE_FILE_PATTERN, (filename) => {
-        if (!EXCLUDED_PATTERN.test(filename)) {
+    forMatchingFiles(modulePath, APP_SOURCE_FILE_PATTERN, filename => {
+        const excluded = EXCLUDED_PATTERNS.reduce((result, re) => result || re.test(filename), false);
+        if (!excluded) {
             const relativeFilename =
                 '.' + filename.replace(modulePath, '').replace(/\\/g, '/').replace(/\.ts$/, '');
             files.push(relativeFilename);
         }
     });
     const header = `// This file was generated by the build-public-api.ts script\n`;
-    const fileContents = header + files.map((f) => `export * from '${f}';`).join('\n') + '\n';
+    const fileContents = header + files.map(f => `export * from '${f}';`).join('\n') + '\n';
     const publicApiFile = path.join(modulePath, 'public_api.ts');
     fs.writeFileSync(publicApiFile, fileContents, 'utf8');
     console.log(`Created ${publicApiFile}`);

+ 1 - 0
packages/admin-ui/src/app/app.module.ts

@@ -7,6 +7,7 @@ import { routes } from './app.routes';
 @NgModule({
     declarations: [],
     imports: [AppComponentModule, RouterModule.forRoot(routes, { useHash: false })],
+    providers: [],
     bootstrap: [AppComponent],
 })
 export class AppModule {}

+ 36 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -4314,6 +4314,14 @@ export type AdministratorFragment = (
   ) }
 );
 
+export type GetActiveAdministratorQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type GetActiveAdministratorQuery = { activeAdministrator?: Maybe<(
+    { __typename?: 'Administrator' }
+    & AdministratorFragment
+  )> };
+
 export type GetAdministratorsQueryVariables = Exact<{
   options?: Maybe<AdministratorListOptions>;
 }>;
@@ -5389,6 +5397,21 @@ export type TransitionFulfillmentToStateMutation = { transitionFulfillmentToStat
     & ErrorResult_FulfillmentStateTransitionError_Fragment
   ) };
 
+export type GetOrderSummaryQueryVariables = Exact<{
+  start: Scalars['DateTime'];
+  end: Scalars['DateTime'];
+}>;
+
+
+export type GetOrderSummaryQuery = { orders: (
+    { __typename?: 'OrderList' }
+    & Pick<OrderList, 'totalItems'>
+    & { items: Array<(
+      { __typename?: 'Order' }
+      & Pick<Order, 'id' | 'total' | 'currencyCode'>
+    )> }
+  ) };
+
 export type AssetFragment = (
   { __typename?: 'Asset' }
   & Pick<Asset, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'fileSize' | 'mimeType' | 'type' | 'preview' | 'source' | 'width' | 'height'>
@@ -7141,6 +7164,12 @@ export namespace Administrator {
   export type Roles = NonNullable<(NonNullable<(NonNullable<AdministratorFragment['user']>)['roles']>)[number]>;
 }
 
+export namespace GetActiveAdministrator {
+  export type Variables = GetActiveAdministratorQueryVariables;
+  export type Query = GetActiveAdministratorQuery;
+  export type ActiveAdministrator = (NonNullable<GetActiveAdministratorQuery['activeAdministrator']>);
+}
+
 export namespace GetAdministrators {
   export type Variables = GetAdministratorsQueryVariables;
   export type Query = GetAdministratorsQuery;
@@ -7695,6 +7724,13 @@ export namespace TransitionFulfillmentToState {
   export type FulfillmentStateTransitionErrorInlineFragment = (DiscriminateUnion<(NonNullable<TransitionFulfillmentToStateMutation['transitionFulfillmentToState']>), { __typename?: 'FulfillmentStateTransitionError' }>);
 }
 
+export namespace GetOrderSummary {
+  export type Variables = GetOrderSummaryQueryVariables;
+  export type Query = GetOrderSummaryQuery;
+  export type Orders = (NonNullable<GetOrderSummaryQuery['orders']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<GetOrderSummaryQuery['orders']>)['items']>)[number]>;
+}
+
 export namespace Asset {
   export type Fragment = AssetFragment;
   export type FocalPoint = (NonNullable<AssetFragment['focalPoint']>);

+ 9 - 0
packages/admin-ui/src/lib/core/src/data/definitions/administrator-definitions.ts

@@ -36,6 +36,15 @@ export const ADMINISTRATOR_FRAGMENT = gql`
     ${ROLE_FRAGMENT}
 `;
 
+export const GET_ACTIVE_ADMINISTRATOR = gql`
+    query GetActiveAdministrator {
+        activeAdministrator {
+            ...Administrator
+        }
+    }
+    ${ADMINISTRATOR_FRAGMENT}
+`;
+
 export const GET_ADMINISTRATORS = gql`
     query GetAdministrators($options: AdministratorListOptions) {
         administrators(options: $options) {

+ 13 - 0
packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts

@@ -359,3 +359,16 @@ export const TRANSITION_FULFILLMENT_TO_STATE = gql`
     ${FULFILLMENT_FRAGMENT}
     ${ERROR_RESULT_FRAGMENT}
 `;
+
+export const GET_ORDER_SUMMARY = gql`
+    query GetOrderSummary($start: DateTime!, $end: DateTime!) {
+        orders(options: { filter: { orderPlacedAt: { between: { start: $start, end: $end } } } }) {
+            totalItems
+            items {
+                id
+                total
+                currencyCode
+            }
+        }
+    }
+`;

+ 12 - 0
packages/admin-ui/src/lib/core/src/data/providers/administrator-data.service.ts

@@ -1,3 +1,5 @@
+import { FetchPolicy } from '@apollo/client';
+
 import {
     CreateAdministrator,
     CreateAdministratorInput,
@@ -5,6 +7,7 @@ import {
     CreateRoleInput,
     DeleteAdministrator,
     DeleteRole,
+    GetActiveAdministrator,
     GetAdministrator,
     GetAdministrators,
     GetRole,
@@ -19,6 +22,7 @@ import {
     CREATE_ROLE,
     DELETE_ADMINISTRATOR,
     DELETE_ROLE,
+    GET_ACTIVE_ADMINISTRATOR,
     GET_ADMINISTRATOR,
     GET_ADMINISTRATORS,
     GET_ROLE,
@@ -44,6 +48,14 @@ export class AdministratorDataService {
         );
     }
 
+    getActiveAdministrator(fetchPolicy: FetchPolicy = 'cache-first') {
+        return this.baseDataService.query<GetActiveAdministrator.Query>(
+            GET_ACTIVE_ADMINISTRATOR,
+            {},
+            fetchPolicy,
+        );
+    }
+
     getAdministrator(id: string) {
         return this.baseDataService.query<GetAdministrator.Query, GetAdministrator.Variables>(
             GET_ADMINISTRATOR,

+ 16 - 6
packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts

@@ -9,7 +9,9 @@ import {
     GetOrder,
     GetOrderHistory,
     GetOrderList,
+    GetOrderSummary,
     HistoryEntryListOptions,
+    OrderListOptions,
     RefundOrder,
     RefundOrderInput,
     SettlePayment,
@@ -28,8 +30,9 @@ import {
     CREATE_FULFILLMENT,
     DELETE_ORDER_NOTE,
     GET_ORDER,
-    GET_ORDER_HISTORY,
     GET_ORDERS_LIST,
+    GET_ORDER_HISTORY,
+    GET_ORDER_SUMMARY,
     REFUND_ORDER,
     SETTLE_PAYMENT,
     SETTLE_REFUND,
@@ -44,12 +47,9 @@ import { BaseDataService } from './base-data.service';
 export class OrderDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getOrders(take: number = 10, skip: number = 0) {
+    getOrders(options: OrderListOptions = { take: 10 }) {
         return this.baseDataService.query<GetOrderList.Query, GetOrderList.Variables>(GET_ORDERS_LIST, {
-            options: {
-                take,
-                skip,
-            },
+            options,
         });
     }
 
@@ -155,4 +155,14 @@ export class OrderDataService {
             input,
         });
     }
+
+    getOrderSummary(start: Date, end: Date) {
+        return this.baseDataService.query<GetOrderSummary.Query, GetOrderSummary.Variables>(
+            GET_ORDER_SUMMARY,
+            {
+                start: start.toISOString(),
+                end: end.toISOString(),
+            },
+        );
+    }
 }

+ 30 - 0
packages/admin-ui/src/lib/core/src/providers/dashboard-widget/dashboard-widget-types.ts

@@ -0,0 +1,30 @@
+import { Type } from '@angular/core';
+
+export interface DashboardWidgetConfig {
+    /**
+     * Used to specify the widget component. Supports both eager- and lazy-loading.
+     * @example
+     * ```TypeScript
+     * // eager-loading
+     * loadComponent: () => MyWidgetComponent,
+     *
+     * // lazy-loading
+     * loadComponent: () => import('./path-to/widget.component').then(m => m.MyWidgetComponent),
+     * ```
+     */
+    loadComponent: () => Promise<Type<any>> | Type<any>;
+    /**
+     * The title of the widget. Can be a translation token as it will get passed
+     * through the `translate` pipe.
+     */
+    title?: string;
+    /**
+     * The width of the widget, in terms of a Bootstrap-style 12-column grid.
+     */
+    width: 3 | 4 | 6 | 12;
+    /**
+     * If set, the widget will only be displayed if the current user has all the
+     * specified permissions.
+     */
+    requiresPermissions?: string[];
+}

+ 50 - 0
packages/admin-ui/src/lib/core/src/providers/dashboard-widget/dashboard-widget.service.ts

@@ -0,0 +1,50 @@
+import { Injectable } from '@angular/core';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+
+import { DashboardWidgetConfig } from './dashboard-widget-types';
+
+/**
+ * Registers a component to be used as a dashboard widget.
+ */
+@Injectable({
+    providedIn: 'root',
+})
+export class DashboardWidgetService {
+    private registry = new Map<string, DashboardWidgetConfig>();
+    private layout: ReadonlyArray<string> = [];
+
+    registerWidget(id: string, config: DashboardWidgetConfig) {
+        if (this.registry.has(id)) {
+            throw new Error(`A dashboard widget with the id "${id}" already exists`);
+        }
+
+        this.registry.set(id, config);
+    }
+
+    setDefaultLayout(ids: string[] | ReadonlyArray<string>) {
+        this.layout = ids;
+    }
+
+    getDefaultLayout(): ReadonlyArray<string> {
+        return this.layout;
+    }
+
+    getWidgets(): DashboardWidgetConfig[] {
+        return this.layout
+            .map(id => {
+                const config = this.registry.get(id);
+                if (!config) {
+                    // tslint:disable-next-line:no-console
+                    console.error(
+                        `No dashboard widget was found with the id "${id}"\nAvailable ids: ${[
+                            ...this.registry.keys(),
+                        ]
+                            .map(_id => `"${_id}"`)
+                            .join(', ')}`,
+                    );
+                }
+                return config;
+            })
+            .filter(notNullOrUndefined);
+    }
+}

+ 26 - 0
packages/admin-ui/src/lib/core/src/providers/dashboard-widget/register-dashboard-widget.ts

@@ -0,0 +1,26 @@
+import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
+
+import { DashboardWidgetConfig } from './dashboard-widget-types';
+import { DashboardWidgetService } from './dashboard-widget.service';
+
+export function registerDashboardWidget(id: string, config: DashboardWidgetConfig): FactoryProvider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (dashboardWidgetService: DashboardWidgetService) => () => {
+            dashboardWidgetService.registerWidget(id, config);
+        },
+        deps: [DashboardWidgetService],
+    };
+}
+
+export function setDashboardWidgetLayout(ids: string[] | ReadonlyArray<string>): FactoryProvider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (dashboardWidgetService: DashboardWidgetService) => () => {
+            dashboardWidgetService.setDefaultLayout(ids);
+        },
+        deps: [DashboardWidgetService],
+    };
+}

+ 3 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -68,6 +68,9 @@ export * from './data/utils/remove-readonly-custom-fields';
 export * from './providers/auth/auth.service';
 export * from './providers/component-registry/component-registry.service';
 export * from './providers/custom-field-component/custom-field-component.service';
+export * from './providers/dashboard-widget/dashboard-widget-types';
+export * from './providers/dashboard-widget/dashboard-widget.service';
+export * from './providers/dashboard-widget/register-dashboard-widget';
 export * from './providers/guard/auth.guard';
 export * from './providers/health-check/health-check.service';
 export * from './providers/i18n/custom-http-loader';

+ 32 - 2
packages/admin-ui/src/lib/core/src/shared/directives/if-permissions.directive.spec.ts

@@ -17,7 +17,7 @@ describe('vdrIfPermissions directive', () => {
         fixture.detectChanges(); // initial binding
     });
 
-    it('has permission', () => {
+    it('has permission (single)', () => {
         fixture.componentInstance.permissionToTest = 'ValidPermission';
         fixture.detectChanges();
 
@@ -27,6 +27,26 @@ describe('vdrIfPermissions directive', () => {
         expect(elseEl).toBeNull();
     });
 
+    it('has permission (array all match)', () => {
+        fixture.componentInstance.permissionToTest = ['ValidPermission'];
+        fixture.detectChanges();
+
+        const thenEl = fixture.nativeElement.querySelector('.then');
+        expect(thenEl).not.toBeNull();
+        const elseEl = fixture.nativeElement.querySelector('.else');
+        expect(elseEl).toBeNull();
+    });
+
+    it('has permission (array not all match)', () => {
+        fixture.componentInstance.permissionToTest = ['ValidPermission', 'InvalidPermission'];
+        fixture.detectChanges();
+
+        const thenEl = fixture.nativeElement.querySelector('.then');
+        expect(thenEl).toBeNull();
+        const elseEl = fixture.nativeElement.querySelector('.else');
+        expect(elseEl).not.toBeNull();
+    });
+
     it('does not have permission', () => {
         fixture.componentInstance.permissionToTest = 'InvalidPermission';
         fixture.detectChanges();
@@ -36,6 +56,16 @@ describe('vdrIfPermissions directive', () => {
         const elseEl = fixture.nativeElement.querySelector('.else');
         expect(elseEl).not.toBeNull();
     });
+
+    it('pass null', () => {
+        fixture.componentInstance.permissionToTest = null;
+        fixture.detectChanges();
+
+        const thenEl = fixture.nativeElement.querySelector('.then');
+        expect(thenEl).not.toBeNull();
+        const elseEl = fixture.nativeElement.querySelector('.else');
+        expect(elseEl).toBeNull();
+    });
 });
 
 @Component({
@@ -47,7 +77,7 @@ describe('vdrIfPermissions directive', () => {
     `,
 })
 export class TestComponent {
-    @Input() permissionToTest = '';
+    @Input() permissionToTest: string | string[] | null = '';
 }
 
 class MockDataService {

+ 22 - 11
packages/admin-ui/src/lib/core/src/shared/directives/if-permissions.directive.ts

@@ -23,12 +23,15 @@ import { IfDirectiveBase } from './if-directive-base';
  * <button *vdrIfPermissions="'DeleteCatalog'; else unauthorized">Delete Product</button>
  * <ng-template #unauthorized>Not allowed!</ng-template>
  * ```
+ *
+ * The permission can be a single string, or an array. If an array is passed, then _all_ of the permissions
+ * must match (logical AND)
  */
 @Directive({
     selector: '[vdrIfPermissions]',
 })
-export class IfPermissionsDirective extends IfDirectiveBase<[Permission | null]> {
-    private permissionToCheck: string | null = '__initial_value__';
+export class IfPermissionsDirective extends IfDirectiveBase<Array<Permission[] | null>> {
+    private permissionToCheck: string[] | null = ['__initial_value__'];
 
     constructor(
         _viewContainer: ViewContainerRef,
@@ -36,15 +39,22 @@ export class IfPermissionsDirective extends IfDirectiveBase<[Permission | null]>
         private dataService: DataService,
         private changeDetectorRef: ChangeDetectorRef,
     ) {
-        super(_viewContainer, templateRef, permission => {
-            if (permission == null) {
+        super(_viewContainer, templateRef, permissions => {
+            if (permissions == null) {
                 return of(true);
-            } else if (!permission) {
+            } else if (!permissions) {
                 return of(false);
             }
             return this.dataService.client
                 .userStatus()
-                .mapStream(({ userStatus }) => userStatus.permissions.includes(permission))
+                .mapStream(({ userStatus }) => {
+                    for (const permission of permissions) {
+                        if (!userStatus.permissions.includes(permission)) {
+                            return false;
+                        }
+                    }
+                    return true;
+                })
                 .pipe(tap(() => this.changeDetectorRef.markForCheck()));
         });
     }
@@ -53,17 +63,18 @@ export class IfPermissionsDirective extends IfDirectiveBase<[Permission | null]>
      * The permission to check to determine whether to show the template.
      */
     @Input()
-    set vdrIfPermissions(permission: string | null) {
-        this.permissionToCheck = permission;
-        this.updateArgs$.next([permission as Permission]);
+    set vdrIfPermissions(permission: string | string[] | null) {
+        this.permissionToCheck =
+            (permission && (Array.isArray(permission) ? permission : [permission])) || null;
+        this.updateArgs$.next([this.permissionToCheck as Permission[]]);
     }
 
     /**
-     * A template to show if the current user does not have the speicified permission.
+     * A template to show if the current user does not have the specified permission.
      */
     @Input()
     set vdrIfPermissionsElse(templateRef: TemplateRef<any> | null) {
         this.setElseTemplate(templateRef);
-        this.updateArgs$.next([this.permissionToCheck as Permission]);
+        this.updateArgs$.next([this.permissionToCheck as Permission[]]);
     }
 }

+ 8 - 0
packages/admin-ui/src/lib/dashboard/src/components/dashboard-widget/dashboard-widget.component.html

@@ -0,0 +1,8 @@
+<div class="card">
+    <div class="card-header" *ngIf="widgetConfig.title as title">
+        {{ title | translate }}
+    </div>
+    <div class="card-block">
+        <ng-template #portal></ng-template>
+    </div>
+</div>

+ 9 - 0
packages/admin-ui/src/lib/dashboard/src/components/dashboard-widget/dashboard-widget.component.scss

@@ -0,0 +1,9 @@
+@import "variables";
+
+:host {
+    display: block;
+}
+
+.card {
+    margin-top: 0;
+}

+ 50 - 0
packages/admin-ui/src/lib/dashboard/src/components/dashboard-widget/dashboard-widget.component.ts

@@ -0,0 +1,50 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    Component,
+    ComponentFactoryResolver,
+    ComponentRef,
+    Input,
+    OnDestroy,
+    OnInit,
+    ViewChild,
+    ViewContainerRef,
+} from '@angular/core';
+import { DashboardWidgetConfig } from '@vendure/admin-ui/core';
+
+@Component({
+    selector: 'vdr-dashboard-widget',
+    templateUrl: './dashboard-widget.component.html',
+    styleUrls: ['./dashboard-widget.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DashboardWidgetComponent implements AfterViewInit, OnDestroy {
+    @Input() widgetConfig: DashboardWidgetConfig;
+
+    @ViewChild('portal', { read: ViewContainerRef })
+    private portal: ViewContainerRef;
+
+    private componentRef: ComponentRef<any>;
+
+    constructor(private componentFactoryResolver: ComponentFactoryResolver) {}
+
+    ngAfterViewInit(): void {
+        this.loadWidget();
+    }
+
+    private async loadWidget() {
+        const loadComponentResult = this.widgetConfig.loadComponent();
+        const componentType =
+            loadComponentResult instanceof Promise ? await loadComponentResult : loadComponentResult;
+        this.componentRef = this.portal.createComponent(
+            this.componentFactoryResolver.resolveComponentFactory(componentType),
+        );
+        this.componentRef.changeDetectorRef.markForCheck();
+    }
+
+    ngOnDestroy() {
+        if (this.componentRef) {
+            this.componentRef.destroy();
+        }
+    }
+}

+ 4 - 3
packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.html

@@ -1,4 +1,5 @@
-<div class="placeholder">
-    <div class="version">vendure {{ version }}</div>
-    <clr-icon shape="line-chart" size="256"></clr-icon>
+<div class="clr-row dashboard-row" *ngFor="let row of widgetLayout">
+    <div *ngFor="let widget of row" [ngClass]="getClassForWidth(widget.width)">
+        <vdr-dashboard-widget *vdrIfPermissions="widget.requiresPermissions || null" [widgetConfig]="widget"></vdr-dashboard-widget>
+    </div>
 </div>

+ 4 - 0
packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.scss

@@ -12,3 +12,7 @@
         fill: $color-grey-200;
     }
 }
+
+vdr-dashboard-widget {
+    margin-bottom: 24px;
+}

+ 0 - 25
packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.spec.ts

@@ -1,25 +0,0 @@
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { DashboardComponent } from './dashboard.component';
-
-describe('DashboardComponent', () => {
-    /*   let component: DashboardComponent;
-    let fixture: ComponentFixture<DashboardComponent>;
-
-    beforeEach(async(() => {
-        TestBed.configureTestingModule({
-            declarations: [ DashboardComponent ]
-        })
-            .compileComponents();
-    }));
-
-    beforeEach(() => {
-        fixture = TestBed.createComponent(DashboardComponent);
-        component = fixture.componentInstance;
-        fixture.detectChanges();
-    });
-
-    it('should create', () => {
-        expect(component).toBeTruthy();
-    });*/
-});

+ 44 - 4
packages/admin-ui/src/lib/dashboard/src/components/dashboard/dashboard.component.ts

@@ -1,11 +1,51 @@
-import { Component } from '@angular/core';
-import { ADMIN_UI_VERSION } from '@vendure/admin-ui/core';
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { DashboardWidgetConfig, DashboardWidgetService } from '@vendure/admin-ui/core';
+import { assertNever } from '@vendure/common/lib/shared-utils';
+
+type WidgetLayout = DashboardWidgetConfig[][];
 
 @Component({
     selector: 'vdr-dashboard',
     templateUrl: './dashboard.component.html',
     styleUrls: ['./dashboard.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class DashboardComponent {
-    version = ADMIN_UI_VERSION;
+export class DashboardComponent implements OnInit {
+    widgetLayout: WidgetLayout;
+
+    constructor(private dashboardWidgetService: DashboardWidgetService) {}
+
+    ngOnInit() {
+        this.widgetLayout = this.buildLayout(this.dashboardWidgetService.getWidgets());
+    }
+
+    getClassForWidth(width: DashboardWidgetConfig['width']): string {
+        switch (width) {
+            case 3:
+                return `clr-col-12 clr-col-sm-6 clr-col-lg-3`;
+            case 4:
+                return `clr-col-12 clr-col-sm-6 clr-col-lg-4`;
+            case 6:
+                return `clr-col-12 clr-col-lg-6`;
+            case 12:
+                return `clr-col-12`;
+            default:
+                assertNever(width);
+        }
+    }
+
+    private buildLayout(widgetConfigs: DashboardWidgetConfig[]): WidgetLayout {
+        const layout: WidgetLayout = [];
+        let row: DashboardWidgetConfig[] = [];
+        for (const config of widgetConfigs) {
+            const rowSize = row.reduce((size, c) => size + c.width, 0);
+            if (12 < rowSize + config.width) {
+                layout.push(row);
+                row = [];
+            }
+            row.push(config);
+        }
+        layout.push(row);
+        return layout;
+    }
 }

+ 14 - 4
packages/admin-ui/src/lib/dashboard/src/dashboard.module.ts

@@ -1,13 +1,23 @@
-import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 import { RouterModule } from '@angular/router';
-import { SharedModule } from '@vendure/admin-ui/core';
+import { DashboardWidgetService, SharedModule } from '@vendure/admin-ui/core';
 
+import { DashboardWidgetComponent } from './components/dashboard-widget/dashboard-widget.component';
 import { DashboardComponent } from './components/dashboard/dashboard.component';
 import { dashboardRoutes } from './dashboard.routes';
+import { DEFAULT_DASHBOARD_WIDGET_LAYOUT, DEFAULT_WIDGETS } from './default-widgets';
 
 @NgModule({
     imports: [SharedModule, RouterModule.forChild(dashboardRoutes)],
-    declarations: [DashboardComponent],
+    declarations: [DashboardComponent, DashboardWidgetComponent],
 })
-export class DashboardModule {}
+export class DashboardModule {
+    constructor(dashboardWidgetService: DashboardWidgetService) {
+        Object.entries(DEFAULT_WIDGETS).map(([id, config]) =>
+            dashboardWidgetService.registerWidget(id, config),
+        );
+        if (dashboardWidgetService.getDefaultLayout().length === 0) {
+            dashboardWidgetService.setDefaultLayout(DEFAULT_DASHBOARD_WIDGET_LAYOUT);
+        }
+    }
+}

+ 34 - 0
packages/admin-ui/src/lib/dashboard/src/default-widgets.ts

@@ -0,0 +1,34 @@
+import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { DashboardWidgetConfig, DashboardWidgetService, Permission } from '@vendure/admin-ui/core';
+
+import { LatestOrdersWidgetComponent } from './widgets/latest-orders-widget/latest-orders-widget.component';
+import { OrderSummaryWidgetComponent } from './widgets/order-summary-widget/order-summary-widget.component';
+import { TestWidgetComponent } from './widgets/test-widget/test-widget.component';
+import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.component';
+
+export const DEFAULT_DASHBOARD_WIDGET_LAYOUT = ['welcome', 'orderSummary', 'latestOrders'] as const;
+
+export const DEFAULT_WIDGETS: { [id: string]: DashboardWidgetConfig } = {
+    welcome: {
+        loadComponent: () => WelcomeWidgetComponent,
+        width: 12,
+    },
+    orderSummary: {
+        title: _('dashboard.orders-summary'),
+        loadComponent: () => OrderSummaryWidgetComponent,
+        width: 6,
+        requiresPermissions: [Permission.ReadOrder],
+    },
+    latestOrders: {
+        title: _('dashboard.latest-orders'),
+        loadComponent: () => LatestOrdersWidgetComponent,
+        width: 6,
+        requiresPermissions: [Permission.ReadOrder],
+    },
+    testWidget: {
+        title: 'Test Widget',
+        loadComponent: () => TestWidgetComponent,
+        width: 4,
+    },
+};

+ 6 - 0
packages/admin-ui/src/lib/dashboard/src/public_api.ts

@@ -1,4 +1,10 @@
 // This file was generated by the build-public-api.ts script
 export * from './components/dashboard/dashboard.component';
+export * from './components/dashboard-widget/dashboard-widget.component';
 export * from './dashboard.module';
 export * from './dashboard.routes';
+export * from './default-widgets';
+export * from './widgets/latest-orders-widget/latest-orders-widget.component';
+export * from './widgets/order-summary-widget/order-summary-widget.component';
+export * from './widgets/test-widget/test-widget.component';
+export * from './widgets/welcome-widget/welcome-widget.component';

+ 20 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.html

@@ -0,0 +1,20 @@
+<vdr-data-table [items]="latestOrders$ | async">
+    <ng-template let-order="item">
+        <td class="left align-middle">
+            {{ order.code }}
+            <vdr-order-state-label [state]="order.state"></vdr-order-state-label>
+        </td>
+        <td class="left align-middle">
+            <vdr-customer-label [customer]="order.customer"></vdr-customer-label>
+        </td>
+        <td class="left align-middle">{{ order.total / 100 | currency: order.currencyCode }}</td>
+        <td class="left align-middle">{{ order.orderPlacedAt | timeAgo }}</td>
+        <td class="right align-middle">
+            <vdr-table-row-action
+                iconShape="shopping-cart"
+                [label]="'common.open' | translate"
+                [linkTo]="['/orders/', order.id]"
+            ></vdr-table-row-action>
+        </td>
+    </ng-template>
+</vdr-data-table>

+ 9 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.scss

@@ -0,0 +1,9 @@
+@import "variables";
+
+vdr-data-table {
+    ::ng-deep {
+        table {
+            margin-top: 0;
+        }
+    }
+}

+ 35 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/latest-orders-widget/latest-orders-widget.component.ts

@@ -0,0 +1,35 @@
+import { ChangeDetectionStrategy, Component, NgModule, OnInit } from '@angular/core';
+import { CoreModule, DataService, GetOrderList, SharedModule, SortOrder } from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+
+@Component({
+    selector: 'vdr-latest-orders-widget',
+    templateUrl: './latest-orders-widget.component.html',
+    styleUrls: ['./latest-orders-widget.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LatestOrdersWidgetComponent implements OnInit {
+    latestOrders$: Observable<GetOrderList.Items[]>;
+    constructor(private dataService: DataService) {}
+
+    ngOnInit(): void {
+        this.latestOrders$ = this.dataService.order
+            .getOrders({
+                take: 10,
+                filter: {
+                    active: { eq: false },
+                },
+                sort: {
+                    orderPlacedAt: SortOrder.DESC,
+                },
+            })
+            .refetchOnChannelChange()
+            .mapStream(data => data.orders.items);
+    }
+}
+
+@NgModule({
+    imports: [CoreModule, SharedModule],
+    declarations: [LatestOrdersWidgetComponent],
+})
+export class LatestOrdersWidgetModule {}

+ 29 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/order-summary-widget/order-summary-widget.component.html

@@ -0,0 +1,29 @@
+<div class="stats">
+    <div class="stat">
+        <div class="stat-figure">{{ totalOrderCount$ | async }}</div>
+        <div class="stat-label">{{ 'dashboard.total-orders' | translate }}</div>
+    </div>
+    <div class="stat">
+        <div class="stat-figure">
+            {{ totalOrderValue$ | async | currency: (currencyCode$ | async) || undefined }}
+        </div>
+        <div class="stat-label">{{ 'dashboard.total-order-value' | translate }}</div>
+    </div>
+</div>
+<div class="footer">
+    <div class="btn-group btn-outline-primary btn-sm" *ngIf="timeframe$ | async as timeframe">
+        <button class="btn" [class.btn-primary]="timeframe === 'day'" (click)="timeframe$.next('day')">
+            Day
+        </button>
+        <button class="btn" [class.btn-primary]="timeframe === 'week'" (click)="timeframe$.next('week')">
+            Week
+        </button>
+        <button class="btn" [class.btn-primary]="timeframe === 'month'" (click)="timeframe$.next('month')">
+            Month
+        </button>
+    </div>
+
+    <div class="date-range p5" *ngIf="dateRange$ | async as range">
+        {{ range.start | date }} - {{ range.end | date }}
+    </div>
+</div>

+ 29 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/order-summary-widget/order-summary-widget.component.scss

@@ -0,0 +1,29 @@
+@import "variables";
+
+.stats {
+    display: flex;
+    justify-content: space-evenly;
+}
+.stat {
+    text-align: center;
+}
+.stat-figure {
+    font-size: 2rem;
+    line-height: 3rem;
+}
+.stat-label {
+    color: $color-grey-700;
+    text-transform: uppercase;
+}
+.date-range {
+    margin-top: 0;
+}
+.footer {
+    margin-top: 24px;
+    display: flex;
+    flex-direction: column;
+    @media screen and (min-width: $breakpoint-small) {
+        flex-direction: row;
+    }
+    justify-content: space-between;
+}

+ 59 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/order-summary-widget/order-summary-widget.component.ts

@@ -0,0 +1,59 @@
+import { ChangeDetectionStrategy, Component, NgModule, OnInit } from '@angular/core';
+import { CoreModule, DataService } from '@vendure/admin-ui/core';
+import dayjs from 'dayjs';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { distinctUntilChanged, map, shareReplay, switchMap } from 'rxjs/operators';
+
+export type Timeframe = 'day' | 'week' | 'month';
+
+@Component({
+    selector: 'vdr-order-summary-widget',
+    templateUrl: './order-summary-widget.component.html',
+    styleUrls: ['./order-summary-widget.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderSummaryWidgetComponent implements OnInit {
+    totalOrderCount$: Observable<number>;
+    totalOrderValue$: Observable<number>;
+    currencyCode$: Observable<string | undefined>;
+    timeframe$ = new BehaviorSubject<Timeframe>('day');
+    dateRange$: Observable<{ start: Date; end: Date }>;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit(): void {
+        this.dateRange$ = this.timeframe$.pipe(
+            distinctUntilChanged(),
+            map(timeframe => {
+                return {
+                    start: dayjs().startOf(timeframe).toDate(),
+                    end: dayjs().endOf(timeframe).toDate(),
+                };
+            }),
+            shareReplay(1),
+        );
+        const orderSummary$ = this.dateRange$.pipe(
+            switchMap(({ start, end }) => {
+                return this.dataService.order
+                    .getOrderSummary(start, end)
+                    .refetchOnChannelChange()
+                    .mapStream(data => data.orders);
+            }),
+            shareReplay(1),
+        );
+        this.totalOrderCount$ = orderSummary$.pipe(map(res => res.totalItems));
+        this.totalOrderValue$ = orderSummary$.pipe(
+            map(res => res.items.reduce((total, order) => total + order.total, 0) / 100),
+        );
+        this.currencyCode$ = this.dataService.settings
+            .getActiveChannel()
+            .refetchOnChannelChange()
+            .mapStream(data => data.activeChannel.currencyCode || undefined);
+    }
+}
+
+@NgModule({
+    imports: [CoreModule],
+    declarations: [OrderSummaryWidgetComponent],
+})
+export class OrderSummaryWidgetModule {}

+ 1 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/test-widget/test-widget.component.html

@@ -0,0 +1 @@
+<p>This is a test widget!</p>

+ 0 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/test-widget/test-widget.component.scss


+ 14 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/test-widget/test-widget.component.ts

@@ -0,0 +1,14 @@
+import { ChangeDetectionStrategy, Component, NgModule } from '@angular/core';
+
+@Component({
+    selector: 'vdr-test-widget',
+    templateUrl: './test-widget.component.html',
+    styleUrls: ['./test-widget.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TestWidgetComponent {}
+
+@NgModule({
+    declarations: [TestWidgetComponent],
+})
+export class TestWidgetModule {}

+ 11 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/welcome-widget/welcome-widget.component.html

@@ -0,0 +1,11 @@
+<div *ngIf="administrator$ | async as administrator">
+    <h4 class="h4">
+        Welcome, {{ administrator.firstName }} {{ administrator.lastName }}<br />
+        <small class="p5">Last login: {{ administrator.user.lastLogin | timeAgo }}</small>
+    </h4>
+
+    <p class="p5">Vendure Admin UI v{{ version }}</p>
+</div>
+<div class="placeholder">
+    <clr-icon shape="line-chart" size="128"></clr-icon>
+</div>

+ 9 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/welcome-widget/welcome-widget.component.scss

@@ -0,0 +1,9 @@
+@import "variables";
+
+:host {
+    display: flex;
+    justify-content: space-between;
+}
+.placeholder {
+    color: $color-grey-200;
+}

+ 34 - 0
packages/admin-ui/src/lib/dashboard/src/widgets/welcome-widget/welcome-widget.component.ts

@@ -0,0 +1,34 @@
+import { ChangeDetectionStrategy, Component, NgModule, OnInit } from '@angular/core';
+import {
+    Administrator,
+    ADMIN_UI_VERSION,
+    CoreModule,
+    DataService,
+    GetActiveAdministrator,
+} from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+
+@Component({
+    selector: 'vdr-welcome-widget',
+    templateUrl: './welcome-widget.component.html',
+    styleUrls: ['./welcome-widget.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class WelcomeWidgetComponent implements OnInit {
+    version = ADMIN_UI_VERSION;
+    administrator$: Observable<GetActiveAdministrator.ActiveAdministrator | null>;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit(): void {
+        this.administrator$ = this.dataService.administrator
+            .getActiveAdministrator()
+            .mapStream(data => data.activeAdministrator || null);
+    }
+}
+
+@NgModule({
+    imports: [CoreModule],
+    declarations: [WelcomeWidgetComponent],
+})
+export class WelcomeWidgetModule {}

+ 2 - 1
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.ts

@@ -78,7 +78,8 @@ export class OrderListComponent
     ) {
         super(router, route);
         super.setQueryFn(
-            (...args: any[]) => this.dataService.order.getOrders(...args).refetchOnChannelChange(),
+            // tslint:disable-next-line:no-shadowed-variable
+            (take, skip) => this.dataService.order.getOrders({ take, skip }).refetchOnChannelChange(),
             data => data.orders,
             // tslint:disable-next-line:no-shadowed-variable
             (skip, take) =>

+ 7 - 1
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -281,6 +281,12 @@
     "verified": "Ověřený",
     "view-group-members": "Zobrazit členy skupiny"
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "před {count, plural, one {1 dnem} other {{count} dny}}",
     "ago-hours": "před {count, plural, one {1 hod} other {{count} hod}}",
@@ -719,4 +725,4 @@
     "job-result": "Výsledek úlohy",
     "job-state": "Stav úlohy"
   }
-}
+}

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -281,6 +281,12 @@
     "verified": "Bestätigt",
     "view-group-members": ""
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "{count, plural, one {Vor einem Tag} other {Vor {count} Tagen}}",
     "ago-hours": "{count, plural, one {Vor einer Stunde} other {Vor {count} Stunden}}",

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -281,6 +281,12 @@
     "verified": "Verified",
     "view-group-members": "View group members"
   },
+  "dashboard": {
+    "latest-orders": "Latest orders",
+    "orders-summary": "Orders summary",
+    "total-order-value": "Total value",
+    "total-orders": "Total orders"
+  },
   "datetime": {
     "ago-days": "{count, plural, one {1 day} other {{count} days}} ago",
     "ago-hours": "{count, plural, one {1 hr} other {{count} hrs}} ago",

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -281,6 +281,12 @@
     "verified": "Verificado",
     "view-group-members": ""
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "",
     "ago-hours": "",

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -281,6 +281,12 @@
     "verified": "Zweryfikowany",
     "view-group-members": ""
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "{count, plural, one {1 dzień} other {{count} dni}} temu",
     "ago-hours": "{count, plural, one {1 godzinę} other {{count} godzin}} temu",

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -281,6 +281,12 @@
     "verified": "Verificado",
     "view-group-members": "Visualizar membros do grupo"
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "{count, plural, one {1 day} other {{count} days}} atrás",
     "ago-hours": "{count, plural, one {1 hr} other {{count} hrs}} atrás",

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -281,6 +281,12 @@
     "verified": "已验证",
     "view-group-members": ""
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "",
     "ago-hours": "",

+ 6 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -281,6 +281,12 @@
     "verified": "已驗證",
     "view-group-members": ""
   },
+  "dashboard": {
+    "latest-orders": "",
+    "orders-summary": "",
+    "total-order-value": "",
+    "total-orders": ""
+  },
   "datetime": {
     "ago-days": "",
     "ago-hours": "",