Browse Source

feat(admin-ui): Create tree view for ProductCategoryList

Michael Bromley 7 years ago
parent
commit
e1b4d8781f

+ 1 - 0
admin-ui/package.json

@@ -13,6 +13,7 @@
   "private": true,
   "dependencies": {
     "@angular/animations": "^7.0.4",
+    "@angular/cdk": "^7.1.0",
     "@angular/common": "^7.0.4",
     "@angular/compiler": "^7.0.4",
     "@angular/core": "^7.0.4",

+ 6 - 1
admin-ui/src/app/catalog/catalog.module.ts

@@ -1,3 +1,4 @@
+import { DragDropModule } from '@angular/cdk/drag-drop';
 import { NgModule } from '@angular/core';
 import { RouterModule } from '@angular/router';
 
@@ -18,6 +19,8 @@ import { GenerateProductVariantsComponent } from './components/generate-product-
 import { ProductAssetsComponent } from './components/product-assets/product-assets.component';
 import { ProductCategoryDetailComponent } from './components/product-category-detail/product-category-detail.component';
 import { ProductCategoryListComponent } from './components/product-category-list/product-category-list.component';
+import { ProductCategoryTreeNodeComponent } from './components/product-category-tree/product-category-tree-node.component';
+import { ProductCategoryTreeComponent } from './components/product-category-tree/product-category-tree.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductVariantsListComponent } from './components/product-variants-list/product-variants-list.component';
@@ -30,7 +33,7 @@ import { ProductCategoryResolver } from './providers/routing/product-category-re
 import { ProductResolver } from './providers/routing/product-resolver';
 
 @NgModule({
-    imports: [SharedModule, RouterModule.forChild(catalogRoutes)],
+    imports: [SharedModule, RouterModule.forChild(catalogRoutes), DragDropModule],
     exports: [],
     declarations: [
         ProductListComponent,
@@ -54,6 +57,8 @@ import { ProductResolver } from './providers/routing/product-resolver';
         VariantPriceDetailComponent,
         ProductCategoryListComponent,
         ProductCategoryDetailComponent,
+        ProductCategoryTreeComponent,
+        ProductCategoryTreeNodeComponent,
     ],
     entryComponents: [
         AssetPickerDialogComponent,

+ 4 - 25
admin-ui/src/app/catalog/components/product-category-list/product-category-list.component.html

@@ -7,28 +7,7 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<vdr-data-table
-    [items]="items$ | async"
-    [itemsPerPage]="itemsPerPage$ | async"
-    [totalItems]="totalItems$ | async"
-    [currentPage]="currentPage$ | async"
-    (pageChange)="setPageNumber($event)"
-    (itemsPerPageChange)="setItemsPerPage($event)"
->
-    <vdr-dt-column>{{ 'common.ID' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'catalog.facet-values' | translate }}</vdr-dt-column>
-    <vdr-dt-column></vdr-dt-column>
-    <ng-template let-category="item">
-        <td class="left">{{ category.id }}</td>
-        <td class="left">{{ category.name }}</td>
-        <td class="left">{{ category.facetValues }}</td>
-        <td class="right">
-            <vdr-table-row-action
-                iconShape="edit"
-                [label]="'common.edit' | translate"
-                [linkTo]="['./', category.id]"
-            ></vdr-table-row-action>
-        </td>
-    </ng-template>
-</vdr-data-table>
+<vdr-product-category-tree
+    [productCategories]="categories$ | async"
+    (rearrange)="onRearrange($event)"
+></vdr-product-category-tree>

+ 42 - 6
admin-ui/src/app/catalog/components/product-category-list/product-category-list.component.ts

@@ -1,9 +1,12 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
+import { Observable, Subject } from 'rxjs';
+import { scan, startWith, switchMap } from 'rxjs/operators';
 import { GetProductCategoryList } from 'shared/generated-types';
 
 import { BaseListComponent } from '../../../common/base-list.component';
 import { DataService } from '../../../data/providers/data.service';
+import { RearrangeEvent } from '../product-category-tree/product-category-tree.component';
 
 @Component({
     selector: 'vdr-product-category-list',
@@ -11,15 +14,48 @@ import { DataService } from '../../../data/providers/data.service';
     styleUrls: ['./product-category-list.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ProductCategoryListComponent extends BaseListComponent<
-    GetProductCategoryList.Query,
-    GetProductCategoryList.Items
-> {
+export class ProductCategoryListComponent
+    extends BaseListComponent<GetProductCategoryList.Query, GetProductCategoryList.Items>
+    implements OnInit {
+    categories$: Observable<GetProductCategoryList.Items[]>;
+    private rearrange = new Subject<RearrangeEvent>();
+
     constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
         super(router, route);
         super.setQueryFn(
-            (...args: any[]) => this.dataService.product.getProductCategories(...args),
+            (...args: any[]) => this.dataService.product.getProductCategories(99999, 0),
             data => data.productCategories,
         );
     }
+
+    ngOnInit() {
+        super.ngOnInit();
+        this.categories$ = this.items$.pipe(
+            switchMap(items => {
+                return this.rearrange.pipe(
+                    startWith({} as any),
+                    scan<RearrangeEvent, GetProductCategoryList.Items[]>((acc, event) => {
+                        const itemIndex = acc.findIndex(item => item.id === event.categoryId);
+                        if (-1 < itemIndex) {
+                            let newIndex = 0;
+                            if (0 < event.index) {
+                                const priorItem = acc.filter(item => item.parent.id === event.parentId)[
+                                    event.index - 1
+                                ];
+                                newIndex = acc.indexOf(priorItem) + 1;
+                            }
+                            acc[itemIndex].parent = { ...acc[itemIndex].parent, id: event.parentId };
+                            acc.splice(newIndex, 0, acc.splice(itemIndex, 1)[0]);
+                            return [...acc];
+                        }
+                        return acc;
+                    }, items),
+                );
+            }),
+        );
+    }
+
+    onRearrange(event: RearrangeEvent) {
+        this.rearrange.next(event);
+    }
 }

+ 48 - 0
admin-ui/src/app/catalog/components/product-category-tree/array-to-tree.spec.ts

@@ -0,0 +1,48 @@
+import { arrayToTree, HasParent } from './array-to-tree';
+
+describe('arrayToTree()', () => {
+    it('preserves ordering', () => {
+        const result1 = arrayToTree([{ id: '13', parent: { id: '1' } }, { id: '12', parent: { id: '1' } }]);
+        expect(result1.children.map(i => i.id)).toEqual(['13', '12']);
+
+        const result2 = arrayToTree([{ id: '12', parent: { id: '1' } }, { id: '13', parent: { id: '1' } }]);
+        expect(result2.children.map(i => i.id)).toEqual(['12', '13']);
+    });
+
+    it('converts an array to a tree', () => {
+        const input: HasParent[] = [
+            { id: '12', parent: { id: '1' } },
+            { id: '13', parent: { id: '1' } },
+            { id: '132', parent: { id: '13' } },
+            { id: '131', parent: { id: '13' } },
+            { id: '1211', parent: { id: '121' } },
+            { id: '121', parent: { id: '12' } },
+        ];
+
+        const result = arrayToTree(input);
+        expect(result).toEqual({
+            id: '1',
+            children: [
+                {
+                    id: '12',
+                    parent: { id: '1' },
+                    children: [
+                        {
+                            id: '121',
+                            parent: { id: '12' },
+                            children: [{ id: '1211', parent: { id: '121' }, children: [] }],
+                        },
+                    ],
+                },
+                {
+                    id: '13',
+                    parent: { id: '1' },
+                    children: [
+                        { id: '132', parent: { id: '13' }, children: [] },
+                        { id: '131', parent: { id: '13' }, children: [] },
+                    ],
+                },
+            ],
+        });
+    });
+});

+ 36 - 0
admin-ui/src/app/catalog/components/product-category-tree/array-to-tree.ts

@@ -0,0 +1,36 @@
+export type HasParent = { id: string; parent: { id: string } };
+export type TreeNode<T extends HasParent> = T & { children: Array<TreeNode<T>> };
+export type RootNode<T extends HasParent> = { id?: string; children: Array<TreeNode<T>> };
+
+/**
+ * Builds a tree from an array of nodes which have a parent.
+ * Based on https://stackoverflow.com/a/31247960/772859, modified to preserve ordering.
+ */
+export function arrayToTree<T extends HasParent>(nodes: T[]): RootNode<T> {
+    const topLevelNodes: Array<TreeNode<T>> = [];
+    const mappedArr: { [id: string]: TreeNode<T> } = {};
+
+    // First map the nodes of the array to an object -> create a hash table.
+    for (const node of nodes) {
+        mappedArr[node.id] = { ...(node as any), children: [] };
+    }
+
+    for (const id of nodes.map(n => n.id)) {
+        if (mappedArr.hasOwnProperty(id)) {
+            const mappedElem = mappedArr[id];
+            // If the element is not at the root level, add it to its parent array of children.
+            const parentIsRoot = !mappedArr[mappedElem.parent.id];
+            if (!parentIsRoot) {
+                if (mappedArr[mappedElem.parent.id]) {
+                    mappedArr[mappedElem.parent.id].children.push(mappedElem);
+                } else {
+                    mappedArr[mappedElem.parent.id] = { children: [mappedElem] } as any;
+                }
+            } else {
+                topLevelNodes.push(mappedElem);
+            }
+        }
+    }
+    const rootId = topLevelNodes.length ? topLevelNodes[0].parent.id : undefined;
+    return { id: rootId, children: topLevelNodes };
+}

+ 42 - 0
admin-ui/src/app/catalog/components/product-category-tree/product-category-tree-node.component.html

@@ -0,0 +1,42 @@
+<div
+    cdkDropList
+    class="tree-node"
+    #dropList
+    [cdkDropListData]="categoryTree"
+    [cdkDropListDisabled]="true"
+    (cdkDropListDropped)="drop($event)"
+>
+    <div class="category" *ngFor="let category of categoryTree.children" cdkDrag [cdkDragData]="category">
+        <div class="category-detail" [ngClass]="'depth-' + depth">
+            <div class="name">{{ category.name }}</div>
+            <div class="flex-spacer"></div>
+            <a class="btn btn-link btn-sm" [routerLink]="['./', category.id]">
+                <clr-icon shape="edit"></clr-icon>
+                {{ 'common.edit' | translate }}
+            </a>
+            <!--
+                TODO: reenable once it is possible to drop into nested lists. See https://github.com/angular/material2/issues/14093
+                <div class="drag-handle" cdkDragHandle>
+                    <clr-icon shape="drag-handle" size="24"></clr-icon>
+                </div>
+            -->
+            <clr-dropdown>
+                <span class="trigger" clrDropdownTrigger>
+                    <clr-icon shape="ellipsis-vertical"></clr-icon>
+                </span>
+                <clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
+                    <h4 class="dropdown-header">Move to:</h4>
+                    <button
+                        type="button"
+                        class="dropdown-item"
+                        (click)="move(category, item.id)"
+                        *ngFor="let item of getMoveListItems(category)"
+                    >
+                        {{ item.path }}
+                    </button>
+                </clr-dropdown-menu>
+            </clr-dropdown>
+        </div>
+        <vdr-product-category-tree-node [categoryTree]="category"></vdr-product-category-tree-node>
+    </div>
+</div>

+ 59 - 0
admin-ui/src/app/catalog/components/product-category-tree/product-category-tree-node.component.scss

@@ -0,0 +1,59 @@
+@import "variables";
+
+:host {
+    display: block;
+}
+.category {
+    background-color: white;
+    color: rgba(0, 0, 0, 0.87);
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+
+    .category-detail {
+        padding: 12px;
+        display: flex;
+        justify-content: space-between;
+        border-bottom: 1px solid $color-grey-2;
+
+        &.depth-1 { padding-left: 12px + 24px; }
+        &.depth-2 { padding-left: 12px + 48px; }
+        &.depth-3 { padding-left: 12px + 72px; }
+        &.depth-4 { padding-left: 12px + 96px; }
+    }
+}
+
+.flex-spacer {
+    flex: 1;
+}
+
+.tree-node {
+    display: block;
+    background: white;
+    border-radius: 4px;
+    overflow: hidden;
+}
+
+.drag-placeholder {
+    min-height: 120px;
+    background-color: $color-grey-3;
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}
+
+.cdk-drag-preview {
+    box-sizing: border-box;
+    border-radius: 4px;
+    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
+    0 8px 10px 1px rgba(0, 0, 0, 0.14),
+    0 3px 14px 2px rgba(0, 0, 0, 0.12);
+}
+
+.cdk-drag-placeholder {
+    opacity: 0;
+}
+
+.cdk-drag-animating {
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}
+
+.example-list.cdk-drop-list-dragging .tree-node:not(.cdk-drag-placeholder) {
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}

+ 61 - 0
admin-ui/src/app/catalog/components/product-category-tree/product-category-tree-node.component.ts

@@ -0,0 +1,61 @@
+import { CdkDragDrop } from '@angular/cdk/drag-drop';
+import { ChangeDetectionStrategy, Component, Input, OnInit, Optional, SkipSelf } from '@angular/core';
+import { ProductCategory } from 'shared/generated-types';
+
+import { RootNode, TreeNode } from './array-to-tree';
+import { ProductCategoryTreeComponent } from './product-category-tree.component';
+
+@Component({
+    selector: 'vdr-product-category-tree-node',
+    templateUrl: './product-category-tree-node.component.html',
+    styleUrls: ['./product-category-tree-node.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductCategoryTreeNodeComponent implements OnInit {
+    depth = 0;
+    parentName: string;
+    @Input() categoryTree: TreeNode<ProductCategory.Fragment>;
+
+    constructor(
+        @SkipSelf() @Optional() private parent: ProductCategoryTreeNodeComponent,
+        private root: ProductCategoryTreeComponent,
+    ) {
+        if (parent) {
+            this.depth = parent.depth + 1;
+        }
+    }
+
+    ngOnInit() {
+        this.parentName = this.categoryTree.name || '<root>';
+    }
+
+    getMoveListItems(category: ProductCategory.Fragment): Array<{ path: string; id: string }> {
+        const visit = (
+            node: TreeNode<any>,
+            parentPath: string[],
+            output: Array<{ path: string; id: string }>,
+        ) => {
+            if (node.id !== category.id) {
+                const path = parentPath.concat(node.name);
+                if (node.id !== category.parent.id) {
+                    output.push({ path: path.join(' / ') || 'root', id: node.id });
+                }
+                node.children.forEach(child => visit(child, path, output));
+            }
+            return output;
+        };
+        return visit(this.root.categoryTree, [], []);
+    }
+
+    move(category: ProductCategory.Fragment, parentId: string) {
+        this.root.onMove({
+            index: 0,
+            parentId,
+            categoryId: category.id,
+        });
+    }
+
+    drop(event: CdkDragDrop<ProductCategory.Fragment | RootNode<ProductCategory.Fragment>>) {
+        this.root.onDrop(event);
+    }
+}

+ 5 - 0
admin-ui/src/app/catalog/components/product-category-tree/product-category-tree.component.html

@@ -0,0 +1,5 @@
+<vdr-product-category-tree-node
+    *ngIf="categoryTree"
+    cdkDropListGroup
+    [categoryTree]="categoryTree"
+></vdr-product-category-tree-node>

+ 0 - 0
admin-ui/src/app/catalog/components/product-category-tree/product-category-tree.component.scss


+ 55 - 0
admin-ui/src/app/catalog/components/product-category-tree/product-category-tree.component.ts

@@ -0,0 +1,55 @@
+import { CdkDragDrop } from '@angular/cdk/drag-drop';
+import {
+    ChangeDetectionStrategy,
+    Component,
+    EventEmitter,
+    Input,
+    OnChanges,
+    Output,
+    SimpleChanges,
+} from '@angular/core';
+import { ProductCategory } from 'shared/generated-types';
+
+import { arrayToTree, HasParent, RootNode } from './array-to-tree';
+
+export type RearrangeEvent = { categoryId: string; parentId: string; index: number };
+
+@Component({
+    selector: 'vdr-product-category-tree',
+    templateUrl: 'product-category-tree.component.html',
+    styleUrls: ['./product-category-tree.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ProductCategoryTreeComponent implements OnChanges {
+    @Input() productCategories: ProductCategory.Fragment[];
+    @Output() rearrange = new EventEmitter<RearrangeEvent>();
+    categoryTree: RootNode<ProductCategory.Fragment>;
+
+    ngOnChanges(changes: SimpleChanges) {
+        if ('productCategories' in changes && this.productCategories) {
+            this.categoryTree = arrayToTree(this.productCategories);
+        }
+    }
+
+    onDrop(event: CdkDragDrop<ProductCategory.Fragment | RootNode<ProductCategory.Fragment>>) {
+        const item = event.item.data as ProductCategory.Fragment;
+        const newParent = event.container.data;
+        const newParentId = newParent.id;
+        if (newParentId == null) {
+            throw new Error(`Could not determine the ID of the root ProductCategory`);
+        }
+        this.rearrange.emit({
+            categoryId: item.id,
+            parentId: newParentId,
+            index: event.currentIndex,
+        });
+    }
+
+    onMove(event: RearrangeEvent) {
+        this.rearrange.emit(event);
+    }
+
+    private isRootNode<T extends HasParent>(node: T | RootNode<T>): node is RootNode<T> {
+        return !node.hasOwnProperty('parent');
+    }
+}

+ 1 - 0
admin-ui/src/app/catalog/providers/routing/product-category-resolver.ts

@@ -19,6 +19,7 @@ export class ProductCategoryResolver extends BaseEntityResolver<ProductCategory.
                 assets: [],
                 translations: [],
                 facetValues: [],
+                parent: {} as any,
             },
             id => this.dataService.product.getProductCategory(id).mapStream(data => data.productCategory),
         );

+ 3 - 0
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -314,6 +314,9 @@ export const GET_PRODUCT_CATEGORY_LIST = gql`
                     code
                     name
                 }
+                parent {
+                    id
+                }
             }
             totalItems
         }

+ 15 - 1
admin-ui/yarn.lock

@@ -109,6 +109,15 @@
   dependencies:
     tslib "^1.9.0"
 
+"@angular/cdk@^7.1.0":
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-7.1.0.tgz#7e5c3e3631947ef91413a83997ec4edec92cdd1c"
+  integrity sha512-dY740pKcIRtKr6n6NomrgqfdEj988urTZ9I/bfJjxF5fdhnSjyhEvDlB55EHsrF+bTTZbZXRmv7AwOQ9GJnD9w==
+  dependencies:
+    tslib "^1.7.1"
+  optionalDependencies:
+    parse5 "^5.0.0"
+
 "@angular/cli@^7.0.6":
   version "7.0.6"
   resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-7.0.6.tgz#f97bc9ca92785c7ce2e5f20819c48265a1da5b53"
@@ -5019,6 +5028,11 @@ parse5@^3.0.1:
   dependencies:
     "@types/node" "*"
 
+parse5@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2"
+  integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==
+
 parseqs@0.0.5:
   version "0.0.5"
   resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
@@ -6646,7 +6660,7 @@ trix@^1.0.0:
   dependencies:
     glob "^6.0.4"
 
-tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
 

File diff suppressed because it is too large
+ 0 - 0
schema.json


+ 12 - 4
shared/generated-types.ts

@@ -486,7 +486,7 @@ export interface ProductCategory extends Node {
     description: string;
     featuredAsset?: Asset | null;
     assets: Asset[];
-    parent?: ProductCategory | null;
+    parent: ProductCategory;
     children?: ProductCategory[] | null;
     facetValues: FacetValue[];
     translations: ProductCategoryTranslation[];
@@ -1181,6 +1181,7 @@ export interface ConfigArgInput {
 export interface CreateProductCategoryInput {
     featuredAssetId?: string | null;
     assetIds?: string[] | null;
+    parentId?: string | null;
     translations: ProductCategoryTranslationInput[];
     customFields?: Json | null;
 }
@@ -1196,6 +1197,7 @@ export interface ProductCategoryTranslationInput {
 export interface UpdateProductCategoryInput {
     id: string;
     featuredAssetId?: string | null;
+    parentId?: string | null;
     assetIds?: string[] | null;
     translations: ProductCategoryTranslationInput[];
     customFields?: Json | null;
@@ -3489,7 +3491,7 @@ export namespace ProductCategoryResolvers {
         description?: DescriptionResolver<string, any, Context>;
         featuredAsset?: FeaturedAssetResolver<Asset | null, any, Context>;
         assets?: AssetsResolver<Asset[], any, Context>;
-        parent?: ParentResolver<ProductCategory | null, any, Context>;
+        parent?: ParentResolver<ProductCategory, any, Context>;
         children?: ChildrenResolver<ProductCategory[] | null, any, Context>;
         facetValues?: FacetValuesResolver<FacetValue[], any, Context>;
         translations?: TranslationsResolver<ProductCategoryTranslation[], any, Context>;
@@ -3512,7 +3514,7 @@ export namespace ProductCategoryResolvers {
         Context
     >;
     export type AssetsResolver<R = Asset[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type ParentResolver<R = ProductCategory | null, Parent = any, Context = any> = Resolver<
+    export type ParentResolver<R = ProductCategory, Parent = any, Context = any> = Resolver<
         R,
         Parent,
         Context
@@ -5315,6 +5317,7 @@ export namespace GetProductCategoryList {
         description: string;
         featuredAsset?: FeaturedAsset | null;
         facetValues: FacetValues[];
+        parent: Parent;
     };
 
     export type FeaturedAsset = Asset.Fragment;
@@ -5325,6 +5328,11 @@ export namespace GetProductCategoryList {
         code: string;
         name: string;
     };
+
+    export type Parent = {
+        __typename?: 'ProductCategory';
+        id: string;
+    };
 }
 
 export namespace GetProductCategory {
@@ -6292,7 +6300,7 @@ export namespace ProductCategory {
         assets: Assets[];
         facetValues: FacetValues[];
         translations: Translations[];
-        parent?: Parent | null;
+        parent: Parent;
         children?: Children[] | null;
     };
 

+ 1 - 1
shared/omit.ts

@@ -1,4 +1,4 @@
-export type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
+export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
 
 declare const File: any;
 

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