You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by li...@apache.org on 2022/11/18 01:54:55 UTC

[incubator-devlake] branch main updated: feat(config-ui): github repo choose to support miller-columns component (#3750)

This is an automated email from the ASF dual-hosted git repository.

likyh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 679c83dec feat(config-ui): github repo choose to support miller-columns component (#3750)
679c83dec is described below

commit 679c83deca8fbca8f35c3ebadada6d49925e61d5
Author: 青湛 <0x...@gmail.com>
AuthorDate: Fri Nov 18 09:54:51 2022 +0800

    feat(config-ui): github repo choose to support miller-columns component (#3750)
    
    * fix(config-ui): miss depends in miller-columns component
    
    * feat(config-ui): support to show column count in miller-columns component
    
    * refactor(config-ui): support to flat data in miller-columns component
    
    * feat(config-ui): add test for miller-columns component
    
    * feat(config-ui): support to scroll load data in gitlab miller-columns
    
    * fix(config-ui): miss field in jira miller-columns component
    
    * feat(config-ui): github repo choose to support miller-columns component
    
    * fix(config-ui): github cannot choose repo on the miller-columns
    
    * feat(config-ui): github miller-columns component to support scroll load
    
    * fix(config-ui): gitlab miller-columns judgment condition error
    
    * chore(config-ui): review opinion updated
    
    * fix(config-ui): miss field in github miller-columns component
    
    * fix(config-ui): github repo select all will an additional content
---
 config-ui/package-lock.json                        |  13 +
 config-ui/package.json                             |   1 +
 .../blueprints/create-workflow/DataScopes.jsx      |  34 ++-
 .../{miller-columns/index.ts => github/config.ts}  |   6 +-
 .../components/{miller-columns => github}/index.ts |   3 -
 .../{jira => github}/miller-columns/index.tsx      |  37 ++-
 .../miller-columns/use-github-miller-columns.ts    | 234 ++++++++++++++++
 .../src/components/gitlab/miller-columns/index.tsx |  21 +-
 .../miller-columns/use-gitlab-miller-columns.ts    | 295 ++++++++++++++++++---
 .../src/components/jira/miller-columns/index.tsx   |   1 +
 .../jira/miller-columns/use-jira-miller-columns.ts |   5 +-
 .../miller-columns/components/column/column.tsx    |  17 +-
 .../miller-columns/components/column/styled.ts     |   8 +-
 .../src/components/miller-columns/hooks/index.ts   |   3 -
 .../components/miller-columns/hooks/use-columns.ts |  33 ++-
 .../styled.ts => hooks/use-convert-items.ts}       |  44 +--
 .../miller-columns/hooks/use-item-map.ts           |  92 ++++---
 .../miller-columns/hooks/use-load-items.ts         | 100 -------
 .../miller-columns/hooks/use-miller-columns.ts     |  20 +-
 config-ui/src/components/miller-columns/index.ts   |   3 +-
 .../components/miller-columns/miller-columns.tsx   |  18 +-
 .../column/styled.ts => test/index.tsx}            |  47 ++--
 .../styled.ts => test/mock/flat-data/first.ts}     |  63 +++--
 .../{ => test/mock/flat-data}/index.ts             |   7 +-
 .../{index.ts => test/mock/flat-data/second.ts}    |  31 ++-
 .../{index.ts => test/mock/flat-data/third.ts}     |  26 +-
 .../miller-columns/{ => test/mock}/index.ts        |   5 +-
 .../src/components/miller-columns/test/use-test.ts |  65 +++++
 config-ui/src/components/miller-columns/types.ts   |  35 +--
 config-ui/src/models/GithubProject.js              |   2 +
 30 files changed, 906 insertions(+), 363 deletions(-)

diff --git a/config-ui/package-lock.json b/config-ui/package-lock.json
index 3e867995e..73ddcbb82 100644
--- a/config-ui/package-lock.json
+++ b/config-ui/package-lock.json
@@ -15852,6 +15852,14 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
       "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
     },
+    "react-infinite-scroll-component": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz",
+      "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==",
+      "requires": {
+        "throttle-debounce": "^2.1.0"
+      }
+    },
     "react-is": {
       "version": "17.0.2",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -18281,6 +18289,11 @@
       "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==",
       "dev": true
     },
+    "throttle-debounce": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz",
+      "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ=="
+    },
     "throttleit": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
diff --git a/config-ui/package.json b/config-ui/package.json
index 4d08bda69..f0425b405 100644
--- a/config-ui/package.json
+++ b/config-ui/package.json
@@ -32,6 +32,7 @@
     "react": "17.0.2",
     "react-copy-to-clipboard": "^5.1.0",
     "react-dom": "17.0.2",
+    "react-infinite-scroll-component": "^6.1.0",
     "react-router-dom": "^5.3.0",
     "react-transition-group": "^2.9.0",
     "typeface-montserrat": "^1.1.13"
diff --git a/config-ui/src/components/blueprints/create-workflow/DataScopes.jsx b/config-ui/src/components/blueprints/create-workflow/DataScopes.jsx
index 6ee5d3124..7fb6f901e 100644
--- a/config-ui/src/components/blueprints/create-workflow/DataScopes.jsx
+++ b/config-ui/src/components/blueprints/create-workflow/DataScopes.jsx
@@ -34,6 +34,7 @@ import { GitLabMillerColumns, GitLabProjectSelector } from '@/components/gitlab'
 import GitlabProject from '@/models/GitlabProject'
 import { JIRAMillerColumns } from '@/components/jira'
 import JiraBoard from '@/models/JiraBoard'
+import { GitHubMillerColumns } from '@/components/github'
 import GitHubProject from '@/models/GithubProject'
 import JenkinsJobsSelector from '@/components/blueprints/JenkinsJobsSelector'
 
@@ -136,18 +137,45 @@ const DataScopes = (props) => {
                     configuredConnection.provider
                   ) && (
                     <>
-                      <h4>Projects *</h4>
-                      <p>Enter the project names you would like to sync.</p>
+                      <h4>Repositories *</h4>
+                      {!!activeStep && (
+                        <>
+                          <p>Select the repositories you would like to sync.</p>
+                          <GitHubMillerColumns
+                            connectionId={configuredConnection.connectionId}
+                            onChangeItems={(items) =>
+                              setScopeEntities([
+                                ...scopeEntitiesGroup[
+                                  configuredConnection.id
+                                ].filter((it) => it.type !== 'miller-columns'),
+                                ...items.map((it) => new GitHubProject(it))
+                              ])
+                            }
+                          />
+                        </>
+                      )}
+                      <div style={{ margin: '16px 0 8px' }}>
+                        Add repositories outside of your organizations
+                      </div>
+                      <p>
+                        Enter the repositories using the format “owner/repo” and
+                        separate multiple repos with a comma.
+                      </p>
                       <TagInput
                         id='project-id'
                         disabled={isRunning}
                         placeholder='username/repo, username/another-repo'
                         values={
-                          selectedScopeEntities?.map((p) => p.value) || []
+                          selectedScopeEntities
+                            ?.filter((it) => it.type !== 'miller-columns')
+                            .map((p) => p.value) || []
                         }
                         fill={true}
                         onChange={(values) =>
                           setScopeEntities([
+                            ...scopeEntitiesGroup[
+                              configuredConnection.id
+                            ].filter((it) => it.type === 'miller-columns'),
                             ...values.map(
                               (v, vIdx) =>
                                 new GitHubProject({
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/github/config.ts
similarity index 86%
copy from config-ui/src/components/miller-columns/index.ts
copy to config-ui/src/components/github/config.ts
index 3db209c1a..f5381cd4a 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/github/config.ts
@@ -16,7 +16,5 @@
  *
  */
 
-export * from './miller-columns'
-export * from './components'
-export * from './hooks'
-export * from './types'
+export const getGitHubProxyApiPrefix = (connectionId: string) =>
+  `/plugins/github/connections/${connectionId}/proxy/rest`
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/github/index.ts
similarity index 91%
copy from config-ui/src/components/miller-columns/index.ts
copy to config-ui/src/components/github/index.ts
index 3db209c1a..1c06557ce 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/github/index.ts
@@ -17,6 +17,3 @@
  */
 
 export * from './miller-columns'
-export * from './components'
-export * from './hooks'
-export * from './types'
diff --git a/config-ui/src/components/jira/miller-columns/index.tsx b/config-ui/src/components/github/miller-columns/index.tsx
similarity index 61%
copy from config-ui/src/components/jira/miller-columns/index.tsx
copy to config-ui/src/components/github/miller-columns/index.tsx
index dc40bc47e..9690ce643 100644
--- a/config-ui/src/components/jira/miller-columns/index.tsx
+++ b/config-ui/src/components/github/miller-columns/index.tsx
@@ -18,40 +18,53 @@
 
 import React, { useState, useEffect } from 'react'
 
-import type { ItemType } from '@/components/miller-columns'
+import { ItemType, ItemTypeEnum } from '@/components/miller-columns'
 import { MillerColumns } from '@/components/miller-columns'
 
 import {
-  useJIRAMillerColumns,
-  UseJIRAMillerColumnsProps
-} from './use-jira-miller-columns'
+  useGitHubMillerColumns,
+  UseGitHubMillerColumnsProps
+} from './use-github-miller-columns'
 
-interface Props extends UseJIRAMillerColumnsProps {
+interface Props extends UseGitHubMillerColumnsProps {
   onChangeItems: (items: Array<Pick<ItemType, 'id' | 'title'>>) => void
 }
 
-export const JIRAMillerColumns = ({ connectionId, onChangeItems }: Props) => {
+export const GitHubMillerColumns = ({ connectionId, onChangeItems }: Props) => {
   const [seletedIds, setSelectedIds] = useState<Array<ItemType['id']>>([])
 
-  const { items, hasMore, onScroll } = useJIRAMillerColumns({ connectionId })
+  const { items, onExpandItem, hasMore, onScroll } = useGitHubMillerColumns({
+    connectionId
+  })
 
   useEffect(() => {
     onChangeItems(
       items
-        .filter((it) => seletedIds.includes(it.id))
-        .map((it) => ({
-          id: it.id,
-          title: it.title
-        }))
+        .filter(
+          (it) => seletedIds.includes(it.id) && it.type !== ItemTypeEnum.BRANCH
+        )
+        .map((it: any) => {
+          return {
+            id: it.id,
+            title: `${it.owner}/${it.repo}`,
+            owner: it.owner,
+            repo: it.repo,
+            value: `${it.owner}/${it.repo}`,
+            type: 'miller-columns'
+          }
+        })
     )
   }, [seletedIds])
 
   return (
     <MillerColumns
       height={300}
+      columnCount={2}
+      firstColumnTitle='Organizations/Owners'
       items={items}
       selectedItemIds={seletedIds}
       onSelectedItemIds={setSelectedIds}
+      onExpandItem={onExpandItem}
       scrollProps={{
         hasMore,
         onScroll
diff --git a/config-ui/src/components/github/miller-columns/use-github-miller-columns.ts b/config-ui/src/components/github/miller-columns/use-github-miller-columns.ts
new file mode 100644
index 000000000..daf224cc9
--- /dev/null
+++ b/config-ui/src/components/github/miller-columns/use-github-miller-columns.ts
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { useState, useEffect, useMemo, useCallback } from 'react'
+
+import type { MillerColumnsItem, ItemType } from '@/components/miller-columns'
+import { ItemTypeEnum, ItemStatusEnum } from '@/components/miller-columns'
+import request from '@/components/utils/request'
+
+import { getGitHubProxyApiPrefix } from '../config'
+
+type MapType = Record<
+  MillerColumnsItem['id'],
+  {
+    page: number
+    pageSize: number
+    loaded: boolean
+  }
+>
+
+export interface UseGitHubMillerColumnsProps {
+  connectionId: string
+}
+
+export const useGitHubMillerColumns = ({
+  connectionId
+}: UseGitHubMillerColumnsProps) => {
+  const [user, setUser] = useState<any>({})
+  const [items, setItems] = useState<Array<MillerColumnsItem>>([])
+  const [hasMore, setHasMore] = useState(true)
+  const [map, setMap] = useState<MapType>({
+    root: {
+      page: 1,
+      pageSize: 30,
+      loaded: false
+    }
+  })
+
+  const prefix = useMemo(
+    () => getGitHubProxyApiPrefix(connectionId),
+    [connectionId]
+  )
+
+  const getUserOrgs = (username: string, page: number, pageSize: number) => {
+    return request(`${prefix}/users/${username}/orgs`, {
+      data: { page, per_page: pageSize }
+    })
+  }
+
+  const getUserRepos = (username: string, page: number, pageSize: number) => {
+    return request(`${prefix}/users/${username}/repos`, {
+      data: { page, per_page: pageSize }
+    })
+  }
+
+  const getOrgRepos = (org: string, page: number, pageSize: number) => {
+    return request(`${prefix}/orgs/${org}/repos`, {
+      data: { page, per_page: pageSize }
+    })
+  }
+
+  useEffect(() => {
+    ;(async () => {
+      const params = map.root
+
+      const user = await request(`${prefix}/user`)
+      const orgs = await getUserOrgs(user.login, params.page, params.pageSize)
+
+      if (orgs.length < params.pageSize) {
+        setHasMore(false)
+        params.loaded = true
+      } else {
+        params.page += 1
+      }
+
+      setUser(user)
+      setMap({
+        ...map,
+        root: params
+      })
+      setItems([
+        {
+          parentId: null,
+          id: user.login,
+          title: user.login,
+          type: ItemTypeEnum.BRANCH,
+          status: ItemStatusEnum.PENDING
+        },
+        ...orgs.map((it: any) => ({
+          parentId: null,
+          id: it.id,
+          title: it.login,
+          type: ItemTypeEnum.BRANCH,
+          status: ItemStatusEnum.PENDING
+        }))
+      ])
+    })()
+  }, [prefix])
+
+  const onExpandItem = useCallback(
+    async (item: ItemType) => {
+      if (map[item.id]) {
+        return
+      }
+
+      let params = {
+        page: 1,
+        pageSize: 30,
+        loaded: false
+      }
+
+      const isUser = item.id === user.login
+      const repos = isUser
+        ? await getUserRepos(item.id as string, params.page, params.pageSize)
+        : await getOrgRepos(item.title, params.page, params.pageSize)
+
+      if (repos.length < params.pageSize) {
+        params.loaded = true
+      } else {
+        params.page += 1
+      }
+
+      setMap({
+        ...map,
+        [`${item.id}`]: params
+      })
+      setItems([
+        ...items.map((it) =>
+          it.id !== item.id
+            ? it
+            : !params.loaded
+            ? it
+            : {
+                ...it,
+                status: ItemStatusEnum.READY
+              }
+        ),
+        ...repos.map((it: any) => ({
+          parentId: item.id,
+          id: it.id,
+          title: it.name,
+          type: ItemTypeEnum.LEAF,
+          status: ItemStatusEnum.READY,
+          owner: it.owner?.login,
+          repo: it.name
+        }))
+      ])
+    },
+    [items, prefix, map]
+  )
+
+  const onScroll = async (parentId: MillerColumnsItem['parentId']) => {
+    const params = map[parentId ?? 'root']
+
+    if (params.loaded) {
+      setItems(
+        items.map((it) =>
+          it.id !== parentId
+            ? it
+            : {
+                ...it,
+                status: ItemStatusEnum.READY
+              }
+        )
+      )
+    } else {
+      const isUser = parentId === user.login
+      const org = items.find((it) => it.id === parentId)
+      const repos = !parentId
+        ? await getUserOrgs(user.login, params.page, params.pageSize)
+        : isUser
+        ? await getUserRepos(org?.title as string, params.page, params.pageSize)
+        : await getOrgRepos(org?.title as string, params.page, params.pageSize)
+
+      if (!repos.length || repos.length < params.pageSize) {
+        setHasMore(false)
+        params.loaded = true
+      } else {
+        params.page += 1
+      }
+
+      setMap({
+        ...map,
+        [`${parentId ?? 'root'}`]: params
+      })
+      setItems([
+        ...items.map((it) =>
+          it.id !== parentId
+            ? it
+            : !params.loaded
+            ? it
+            : {
+                ...it,
+                status: ItemStatusEnum.READY
+              }
+        ),
+        ...repos.map((it: any) => ({
+          parentId,
+          id: it.id,
+          title: it.name,
+          type: ItemTypeEnum.LEAF,
+          status: ItemStatusEnum.READY,
+          owner: it.owner.login,
+          repo: it.name
+        }))
+      ])
+    }
+  }
+
+  return useMemo(
+    () => ({
+      items,
+      onExpandItem,
+      hasMore,
+      onScroll
+    }),
+    [items, hasMore]
+  )
+}
diff --git a/config-ui/src/components/gitlab/miller-columns/index.tsx b/config-ui/src/components/gitlab/miller-columns/index.tsx
index 5db575c50..a3242ab39 100644
--- a/config-ui/src/components/gitlab/miller-columns/index.tsx
+++ b/config-ui/src/components/gitlab/miller-columns/index.tsx
@@ -28,9 +28,7 @@ import {
 
 interface Props extends UseGitLabMillerColumnsProps {
   disabledItemIds?: Array<number>
-  onChangeItems: (
-    items: Array<Pick<ItemType, 'id' | 'title'> & { shortTitle: string }>
-  ) => void
+  onChangeItems: (items: Array<Pick<ItemType, 'id' | 'title'>>) => void
 }
 
 export const GitLabMillerColumns = ({
@@ -40,21 +38,12 @@ export const GitLabMillerColumns = ({
 }: Props) => {
   const [seletedIds, setSelectedIds] = useState<Array<ItemType['id']>>([])
 
-  const { items, itemTree, onExpandItem } = useGitLabMillerColumns<{
-    nameWithNameSpace?: string
-  }>({
+  const { items, onExpandItem, hasMore, onScroll } = useGitLabMillerColumns({
     connectionId
   })
 
   useEffect(() => {
-    const curItems = seletedIds
-      .filter((id) => itemTree[id].type === ItemTypeEnum.LEAF)
-      .map((id) => ({
-        id,
-        title: itemTree[id].nameWithNameSpace ?? '',
-        shortTitle: itemTree[id].title
-      }))
-
+    const curItems = items.filter((it) => seletedIds.includes(it.id))
     onChangeItems(curItems)
   }, [seletedIds])
 
@@ -67,6 +56,10 @@ export const GitLabMillerColumns = ({
       selectedItemIds={seletedIds}
       onSelectedItemIds={setSelectedIds}
       onExpandItem={onExpandItem}
+      scrollProps={{
+        hasMore,
+        onScroll
+      }}
     />
   )
 }
diff --git a/config-ui/src/components/gitlab/miller-columns/use-gitlab-miller-columns.ts b/config-ui/src/components/gitlab/miller-columns/use-gitlab-miller-columns.ts
index ea8c1dabd..bb4e982be 100644
--- a/config-ui/src/components/gitlab/miller-columns/use-gitlab-miller-columns.ts
+++ b/config-ui/src/components/gitlab/miller-columns/use-gitlab-miller-columns.ts
@@ -16,14 +16,30 @@
  *
  */
 
-import { useMemo, useCallback } from 'react'
+import { useState, useEffect, useMemo } from 'react'
 
-import type { ItemType } from '@/components/miller-columns'
-import { useLoadItems, ItemTypeEnum } from '@/components/miller-columns'
+import {
+  MillerColumnsItem,
+  ItemType,
+  ItemStatusEnum
+} from '@/components/miller-columns'
+import { ItemTypeEnum } from '@/components/miller-columns'
 import request from '@/components/utils/request'
 
 import { getGitLabProxyApiPrefix } from '../config'
 
+type MapType = Record<
+  MillerColumnsItem['id'],
+  {
+    groupPage: number
+    groupPageSize: number
+    groupLoaded: boolean
+    projectPage: number
+    projectPageSize: number
+    projectLoaded: boolean
+  }
+>
+
 export interface UseGitLabMillerColumnsProps {
   connectionId: string
 }
@@ -31,64 +47,259 @@ export interface UseGitLabMillerColumnsProps {
 export const useGitLabMillerColumns = <T>({
   connectionId
 }: UseGitLabMillerColumnsProps) => {
+  const [userId, setUserId] = useState(0)
+  const [items, setItems] = useState<Array<MillerColumnsItem>>([])
+  const [hasMore, setHasMore] = useState(true)
+  const [map, setMap] = useState<MapType>({
+    root: {
+      groupPage: 1,
+      groupPageSize: 20,
+      groupLoaded: false,
+      projectPage: 1,
+      projectPageSize: 20,
+      projectLoaded: false
+    }
+  })
+
   const prefix = useMemo(
     () => getGitLabProxyApiPrefix(connectionId),
     [connectionId]
   )
 
-  const upadateGroups = (arr: any): Array<ItemType> =>
+  const getRootGroups = (page: number, pageSize: number) => {
+    return request(`${prefix}/groups`, {
+      data: { top_level_only: 1, page, per_page: pageSize }
+    })
+  }
+
+  const getRootProjects = (id: number, page: number, pageSize: number) => {
+    return request(`${prefix}/users/${id}/projects`, {
+      data: { page, per_page: pageSize }
+    })
+  }
+
+  const getChildGroups = (
+    id: MillerColumnsItem['id'],
+    page: number,
+    pageSize: number
+  ) => {
+    return request(`${prefix}/groups/${id}/subgroups`, {
+      data: { page, per_page: pageSize }
+    })
+  }
+
+  const getChildProjects = (
+    id: MillerColumnsItem['id'],
+    page: number,
+    pageSize: number
+  ) => {
+    return request(`${prefix}/groups/${id}/projects`, {
+      data: { page, per_page: pageSize }
+    })
+  }
+
+  const updateGroups = (
+    arr: any,
+    parentId: MillerColumnsItem['parentId'] = null
+  ): Array<ItemType> =>
     arr.map((it: any) => ({
+      parentId,
       id: it.id,
       title: it.name,
       type: ItemTypeEnum.BRANCH,
-      items: []
+      status: ItemStatusEnum.PENDING
     }))
 
-  const updateProjects = (arr: any): Array<ItemType> =>
+  const updateProjects = (
+    arr: any,
+    parentId: MillerColumnsItem['parentId'] = null
+  ): Array<ItemType> =>
     arr.map((it: any) => ({
+      parentId,
       id: it.id,
-      title: it.name,
-      type: ItemTypeEnum.LEAF,
-      items: [],
-      nameWithNameSpace: it.name_with_namespace
+      title: it.name
     }))
 
-  const getInitItems = useCallback(async () => {
-    const user = await request(`${prefix}/user`)
-    const [groups, projects] = await Promise.all([
-      request(`${prefix}/groups`, {
-        data: { top_level_only: 1, per_page: 100 }
-      }),
-      request(`${prefix}/users/${user.id}/projects`, {
-        data: { per_page: 100 }
+  useEffect(() => {
+    ;(async () => {
+      const user = await request(`${prefix}/user`)
+      setUserId(user.id)
+
+      const target = map.root
+
+      let projects = []
+
+      const groups = await getRootGroups(target.groupPage, target.groupPageSize)
+
+      if (groups.length < target.groupPageSize) {
+        target.groupLoaded = true
+        projects = await getRootProjects(
+          user.id,
+          target.projectPage,
+          target.projectPageSize
+        )
+
+        if (projects.length < target.projectPageSize) {
+          target.projectLoaded = true
+          setHasMore(false)
+        } else {
+          target.projectPage += 1
+        }
+      } else {
+        target.groupPage += 1
+      }
+
+      setItems([...updateGroups(groups), ...updateProjects(projects)])
+      setMap({
+        ...map,
+        root: target
       })
-    ])
-    return [...upadateGroups(groups), ...updateProjects(projects)]
+    })()
   }, [prefix])
 
-  const loadMoreItems = useCallback(
-    async (item: ItemType) => {
-      const [groups, projects] = await Promise.all([
-        request(`${prefix}/groups/${item.id}/subgroups`, {
-          data: { per_page: 100 }
-        }),
-        request(`${prefix}/groups/${item.id}/projects`, {
-          data: { per_page: 100 }
-        })
-      ])
-      return [...upadateGroups(groups), ...updateProjects(projects)]
-    },
-    [prefix]
-  )
+  const onExpandItem = async (item: ItemType) => {
+    if (map[item.id]) {
+      return
+    }
 
-  const { items, itemTree, loadItems } = useLoadItems<T>({
-    getInitItems,
-    loadMoreItems
-  })
+    let target = {
+      groupPage: 1,
+      groupPageSize: 20,
+      groupLoaded: false,
+      projectPage: 1,
+      projectPageSize: 20,
+      projectLoaded: false
+    }
+
+    let groups = []
+    let projects = []
+
+    groups = await getChildGroups(
+      item.id,
+      target.groupPage,
+      target.groupPageSize
+    )
+
+    if (groups.length < target.groupPageSize) {
+      target.groupLoaded = true
 
-  return {
-    items,
-    itemTree,
-    onExpandItem: loadItems
+      projects = await getChildProjects(
+        item.id,
+        target.projectPage,
+        target.projectPageSize
+      )
+
+      if (projects.length < target.projectPageSize) {
+        target.projectLoaded = true
+      } else {
+        target.projectPage += 1
+      }
+    } else {
+      target.groupPage += 1
+    }
+
+    setItems([
+      ...items,
+      ...updateGroups(groups, item.id),
+      ...updateProjects(projects, item.id)
+    ])
+    setMap({
+      ...map,
+      [`${item.id}`]: target
+    })
   }
+
+  const onScroll = async (parentId: ItemType['parentId']) => {
+    const target = map[parentId ?? 'root']
+
+    let groups = []
+    let projects = []
+
+    // All children ready
+    if (target.groupLoaded && target.projectLoaded) {
+      setItems(
+        items.map((it) =>
+          it.id !== parentId ? it : { ...it, status: ItemStatusEnum.READY }
+        )
+      )
+      // groups ready
+    } else if (target.groupLoaded) {
+      projects = parentId
+        ? await getChildProjects(
+            parentId,
+            target.projectPage,
+            target.projectPageSize
+          )
+        : await getRootProjects(
+            userId,
+            target.projectPage,
+            target.projectPageSize
+          )
+
+      if (projects.length < target.projectPageSize) {
+        target.projectLoaded = true
+      } else {
+        target.projectPage += 1
+      }
+      // no group ready
+    } else {
+      groups = parentId
+        ? await getChildGroups(parentId, target.groupPage, target.groupPageSize)
+        : await getRootGroups(target.groupPage, target.groupPageSize)
+
+      if (!groups.length) {
+        target.groupLoaded = true
+        projects = parentId
+          ? await getChildProjects(
+              parentId,
+              target.projectPage,
+              target.projectPageSize
+            )
+          : await getRootProjects(
+              userId,
+              target.projectPage,
+              target.projectPageSize
+            )
+
+        if (projects.length < target.projectPageSize) {
+          target.projectLoaded = true
+        } else {
+          target.projectPage += 1
+        }
+      } else if (groups.length < target.groupPageSize) {
+        target.groupLoaded = true
+      } else {
+        target.groupPage += 1
+      }
+    }
+
+    setItems([
+      ...items.map((it) =>
+        it.id !== parentId
+          ? it
+          : !(target.groupLoaded && target.projectLoaded)
+          ? it
+          : {
+              ...it,
+              status: ItemStatusEnum.READY
+            }
+      ),
+      ...updateGroups(groups, parentId),
+      ...updateProjects(projects, parentId)
+    ])
+    setMap({
+      ...map,
+      [`${parentId}`]: target
+    })
+  }
+
+  return useMemo(
+    () => ({
+      items,
+      onExpandItem,
+      hasMore,
+      onScroll
+    }),
+    [items, map, hasMore]
+  )
 }
diff --git a/config-ui/src/components/jira/miller-columns/index.tsx b/config-ui/src/components/jira/miller-columns/index.tsx
index dc40bc47e..c2adb1d40 100644
--- a/config-ui/src/components/jira/miller-columns/index.tsx
+++ b/config-ui/src/components/jira/miller-columns/index.tsx
@@ -49,6 +49,7 @@ export const JIRAMillerColumns = ({ connectionId, onChangeItems }: Props) => {
   return (
     <MillerColumns
       height={300}
+      columnCount={1}
       items={items}
       selectedItemIds={seletedIds}
       onSelectedItemIds={setSelectedIds}
diff --git a/config-ui/src/components/jira/miller-columns/use-jira-miller-columns.ts b/config-ui/src/components/jira/miller-columns/use-jira-miller-columns.ts
index 212f3d60d..4e6d3d682 100644
--- a/config-ui/src/components/jira/miller-columns/use-jira-miller-columns.ts
+++ b/config-ui/src/components/jira/miller-columns/use-jira-miller-columns.ts
@@ -18,7 +18,7 @@
 
 import { useState, useEffect, useMemo } from 'react'
 
-import type { ItemType } from '@/components/miller-columns'
+import type { MillerColumnsItem } from '@/components/miller-columns'
 import { ItemTypeEnum, ItemStatusEnum } from '@/components/miller-columns'
 
 import request from '@/components/utils/request'
@@ -32,7 +32,7 @@ export interface UseJIRAMillerColumnsProps {
 export const useJIRAMillerColumns = ({
   connectionId
 }: UseJIRAMillerColumnsProps) => {
-  const [items, setItems] = useState<Array<ItemType>>([])
+  const [items, setItems] = useState<Array<MillerColumnsItem>>([])
   const [hasMore, setHasMore] = useState(true)
   const [page, setPage] = useState(1)
   const [pageSize] = useState(50)
@@ -41,6 +41,7 @@ export const useJIRAMillerColumns = ({
 
   const updateItems = (arr: Array<{ id: number; name: string }>) =>
     arr.map((it) => ({
+      parentId: null,
       id: it.id,
       title: it.name,
       type: ItemTypeEnum.LEAF,
diff --git a/config-ui/src/components/miller-columns/components/column/column.tsx b/config-ui/src/components/miller-columns/components/column/column.tsx
index 269fc2328..400072c38 100644
--- a/config-ui/src/components/miller-columns/components/column/column.tsx
+++ b/config-ui/src/components/miller-columns/components/column/column.tsx
@@ -24,24 +24,27 @@ import type { ItemType } from '../../types'
 import * as S from './styled'
 
 export interface ColumnsProps {
+  parentId: ItemType['parentId']
   items: Array<ItemType>
   renderItem: (item: ItemType) => React.ReactNode
   height?: number
   title?: string | React.ReactNode
-  bottom?: React.ReactNode
+  columnCount?: number
   scrollProps?: {
     hasMore: boolean
-    onScroll: () => void
+    onScroll: (parentId: ItemType['parentId']) => void
     renderLoader?: () => React.ReactNode
     renderBottom?: () => React.ReactNode
   }
 }
 
 export const Column = ({
+  parentId,
   items,
   renderItem,
   height,
   title,
+  columnCount = 3,
   scrollProps
 }: ColumnsProps) => {
   const [hasMore, setHasMore] = useState(true)
@@ -54,7 +57,7 @@ export const Column = ({
 
   const handleNext = useCallback(() => {
     if (scrollProps) {
-      scrollProps.onScroll()
+      scrollProps.onScroll(parentId)
     } else {
       setHasMore(false)
     }
@@ -65,18 +68,20 @@ export const Column = ({
   )
 
   const bottom = scrollProps?.renderBottom?.() ?? (
-    <S.StatusWrapper>All Data Loaded.</S.StatusWrapper>
+    <S.StatusWrapper>End.</S.StatusWrapper>
   )
 
+  const id = `miller-columns-column-${parentId ?? 'root'}`
+
   return (
-    <S.Container id='miller-columns-column-container' height={height}>
+    <S.Container id={id} height={height} columnCount={columnCount}>
       {title && <div className='title'>{title}</div>}
       <InfiniteScroll
         dataLength={items.length}
         hasMore={hasMore}
         next={handleNext}
         loader={loader}
-        scrollableTarget='miller-columns-column-container'
+        scrollableTarget={id}
         endMessage={bottom}
       >
         {items.map((it) => renderItem(it))}
diff --git a/config-ui/src/components/miller-columns/components/column/styled.ts b/config-ui/src/components/miller-columns/components/column/styled.ts
index e522a0693..93ce796e7 100644
--- a/config-ui/src/components/miller-columns/components/column/styled.ts
+++ b/config-ui/src/components/miller-columns/components/column/styled.ts
@@ -18,11 +18,13 @@
 
 import styled from '@emotion/styled'
 
-export const Container = styled.div<{ height?: number }>`
-  flex: 0 0 33.33%;
+export const Container = styled.div<{ height?: number; columnCount: number }>`
   margin: 0;
   padding: 0;
-  width: 33.33%;
+  ${({ columnCount }) => `
+    flex: 0 0 ${100 / columnCount}%;
+    width: ${100 / columnCount}%;
+  `}
   ${({ height }) => `height: ${height}px;`}
   list-style: none;
   border-left: 1px solid #dbe4fd;
diff --git a/config-ui/src/components/miller-columns/hooks/index.ts b/config-ui/src/components/miller-columns/hooks/index.ts
index 25195b787..e22a02a8c 100644
--- a/config-ui/src/components/miller-columns/hooks/index.ts
+++ b/config-ui/src/components/miller-columns/hooks/index.ts
@@ -16,7 +16,4 @@
  *
  */
 
-export * from './use-columns'
-export * from './use-item-map'
-export * from './use-load-items'
 export * from './use-miller-columns'
diff --git a/config-ui/src/components/miller-columns/hooks/use-columns.ts b/config-ui/src/components/miller-columns/hooks/use-columns.ts
index 9753e2f7e..5b3efa3fa 100644
--- a/config-ui/src/components/miller-columns/hooks/use-columns.ts
+++ b/config-ui/src/components/miller-columns/hooks/use-columns.ts
@@ -18,39 +18,48 @@
 
 import { useMemo } from 'react'
 
-import { ItemType, ItemMapType, ColumnType } from '../types'
+import type { ItemType, ItemMapType, ColumnType } from '../types'
+import { ItemStatusEnum } from '../types'
 
 interface Props {
-  items: ItemType[]
   itemMap: ItemMapType
   activeItemId?: ItemType['id']
 }
 
-export const useColumns = ({ items, itemMap, activeItemId }: Props) => {
+export const useColumns = ({ itemMap, activeItemId }: Props) => {
   return useMemo(() => {
-    const rootLeaf = { items, activeId: null, parentId: null }
+    const rootLeaf = {
+      parentId: null,
+      activeId: null,
+      items: Object.values(itemMap).filter((it) => it.parentId === null),
+      hasMore: false
+    }
 
     if (!activeItemId) {
       return [rootLeaf]
     }
 
-    const activeItem = itemMap.getItem(activeItemId)
+    const activeItem = itemMap[activeItemId]
 
     const columns: ColumnType[] = [
       {
         parentId: activeItem.id,
-        items: activeItem.items,
-        activeId: null
+        items: activeItem.items ?? [],
+        activeId: null,
+        hasMore: activeItem.status !== ItemStatusEnum.READY
       }
     ]
 
     const collect = (item: ItemType) => {
-      const parent = itemMap.getItemParent(item.id)
+      const parent = itemMap[item.parentId ?? '']
 
       columns.unshift({
-        parentId: parent?.id ?? null,
-        items: parent?.items ?? items,
-        activeId: item.id ?? null
+        parentId: item.parentId,
+        items: parent
+          ? parent.items
+          : Object.values(itemMap).filter((it) => it.parentId === null),
+        activeId: item.id ?? null,
+        hasMore: false
       })
 
       if (parent) {
@@ -61,5 +70,5 @@ export const useColumns = ({ items, itemMap, activeItemId }: Props) => {
     collect(activeItem)
 
     return columns
-  }, [items, itemMap, activeItemId])
+  }, [itemMap, activeItemId])
 }
diff --git a/config-ui/src/components/miller-columns/components/column/styled.ts b/config-ui/src/components/miller-columns/hooks/use-convert-items.ts
similarity index 53%
copy from config-ui/src/components/miller-columns/components/column/styled.ts
copy to config-ui/src/components/miller-columns/hooks/use-convert-items.ts
index e522a0693..acdbacc91 100644
--- a/config-ui/src/components/miller-columns/components/column/styled.ts
+++ b/config-ui/src/components/miller-columns/hooks/use-convert-items.ts
@@ -16,29 +16,31 @@
  *
  */
 
-import styled from '@emotion/styled'
+import { useState, useEffect, useMemo } from 'react'
 
-export const Container = styled.div<{ height?: number }>`
-  flex: 0 0 33.33%;
-  margin: 0;
-  padding: 0;
-  width: 33.33%;
-  ${({ height }) => `height: ${height}px;`}
-  list-style: none;
-  border-left: 1px solid #dbe4fd;
-  overflow-y: auto;
+import type { MillerColumnsItem } from '../types'
 
-  &:first-child {
-    border-left: none;
-  }
+export interface UseConvertItemsProps {
+  items: Array<MillerColumnsItem>
+}
+
+export const useConvertItems = ({ items }: UseConvertItemsProps) => {
+  const [convertItems, setConvertItems] = useState<Array<MillerColumnsItem>>([])
 
-  & > .title {
-    padding: 4px 12px;
-    font-weight: 700;
-    color: #292b3f;
+  const flatItems = (items: Array<MillerColumnsItem>) => {
+    let result: Array<MillerColumnsItem> = []
+    items.forEach((it) => {
+      result.push(it)
+      if (it.items) {
+        result.push(...flatItems(it.items))
+      }
+    })
+    return result
   }
-`
 
-export const StatusWrapper = styled.div`
-  padding: 4px 12px;
-`
+  useEffect(() => {
+    setConvertItems(flatItems(items))
+  }, [items])
+
+  return useMemo(() => convertItems, [convertItems])
+}
diff --git a/config-ui/src/components/miller-columns/hooks/use-item-map.ts b/config-ui/src/components/miller-columns/hooks/use-item-map.ts
index 89113d148..007490453 100644
--- a/config-ui/src/components/miller-columns/hooks/use-item-map.ts
+++ b/config-ui/src/components/miller-columns/hooks/use-item-map.ts
@@ -16,62 +16,72 @@
  *
  */
 
-import { useMemo } from 'react'
+import { useState, useMemo, useEffect } from 'react'
 
-import type { ItemType, ItemInfoType } from '../types'
-import { ItemStatusEnum } from '../types'
+import type { MillerColumnsItem, ItemType, ItemMapType } from '../types'
+import { ItemTypeEnum, ItemStatusEnum } from '../types'
 
 interface Props {
-  items: ItemType[]
+  items: Array<MillerColumnsItem>
 }
 
 export const useItemMap = ({ items }: Props) => {
-  const checkChildLoaded = (item: ItemType): boolean => {
+  const [itemMap, setItemMap] = useState<ItemMapType>({})
+
+  const checkChildLoaded = (item: MillerColumnsItem): boolean => {
     if (item.status === ItemStatusEnum.PENDING) {
       return false
     }
 
-    return item.items.every((it) => {
-      return checkChildLoaded(it)
-    })
+    return !items
+      .filter((it) => it.parentId === item.id)
+      .find((it) => it.status === ItemStatusEnum.PENDING)
   }
 
-  return useMemo(() => {
-    const itemMap = new Map<ItemType['id'], ItemInfoType>()
+  const covertItem = (item: MillerColumnsItem): ItemType => {
+    const type = item.type
+      ? item.type
+      : (item.items ?? []).length
+      ? ItemTypeEnum.BRANCH
+      : ItemTypeEnum.LEAF
+    const status = item.status ? item.status : ItemStatusEnum.READY
+    return {
+      ...item,
+      type,
+      status,
+      childLoaded: checkChildLoaded(item)
+    } as ItemType
+  }
 
-    const collect = ({
-      item,
-      parent
-    }: {
-      item: ItemType
-      parent?: ItemType
-    }) => {
-      if (!itemMap.has(item.id)) {
-        itemMap.set(item.id, {
-          item,
-          parentId: parent?.id,
-          childLoaded: checkChildLoaded(item)
+  const collectChildItems = (
+    items: Array<MillerColumnsItem>,
+    item: MillerColumnsItem
+  ): Array<ItemType> => {
+    return items
+      .filter((it) => {
+        return it.parentId === item.id
+      })
+      .map((it) =>
+        covertItem({
+          ...it,
+          items: collectChildItems(items, it)
         })
-      }
-
-      if (item.items) {
-        item.items.forEach((it) => collect({ item: it, parent: item }))
-      }
-    }
+      )
+  }
 
-    items.forEach((it) => collect({ item: it }))
+  const itemsToMap = (items: Array<MillerColumnsItem>): ItemMapType => {
+    return items.reduce((acc, cur) => {
+      acc[cur.id] = covertItem({
+        ...cur,
+        items: collectChildItems(items, cur)
+      })
+      return acc
+    }, {} as any)
+  }
 
-    return {
-      getItem(id: ItemType['id']) {
-        return (itemMap.get(id) as ItemInfoType).item
-      },
-      getItemParent(id: ItemType['id']) {
-        const parentId = itemMap.get(id)?.parentId
-        return parentId ? (itemMap.get(parentId) as ItemInfoType).item : null
-      },
-      getItemChildLoaded(id: ItemType['id']) {
-        return (itemMap.get(id) as ItemInfoType).childLoaded
-      }
-    }
+  useEffect(() => {
+    setItemMap(itemsToMap(items))
   }, [items])
+
+  return useMemo(() => itemMap, [itemMap])
 }
diff --git a/config-ui/src/components/miller-columns/hooks/use-load-items.ts b/config-ui/src/components/miller-columns/hooks/use-load-items.ts
deleted file mode 100644
index 1a75873b8..000000000
--- a/config-ui/src/components/miller-columns/hooks/use-load-items.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-
-import { useState, useEffect, useMemo } from 'react'
-
-import { ItemType, ItemTypeEnum, ItemStatusEnum } from '../types'
-
-interface Props {
-  getInitItems: () => Promise<Array<ItemType>>
-  loadMoreItems: (item: ItemType) => Promise<Array<ItemType>>
-}
-
-type TreeType<T> = Record<ItemType['id'], ItemType & T>
-
-export const useLoadItems = <T>({ getInitItems, loadMoreItems }: Props) => {
-  const [tree, setTree] = useState<TreeType<T>>({})
-
-  const itemsToTree = (items: Array<ItemType>) => {
-    return items.reduce((acc, cur) => {
-      acc[cur.id] = {
-        ...cur,
-        items: [],
-        status:
-          cur.type === ItemTypeEnum.BRANCH
-            ? ItemStatusEnum.PENDING
-            : ItemStatusEnum.READY
-      }
-      return acc
-    }, {} as any)
-  }
-
-  const treeToItems = (t: TreeType<T>) => {
-    if (!t.root) {
-      return []
-    }
-
-    const transform = (arr: Array<ItemType>): Array<ItemType> => {
-      return arr.map((it) => ({
-        ...it,
-        ...t[it.id],
-        items: transform(t[it.id].items)
-      }))
-    }
-
-    return transform(t.root.items)
-  }
-
-  useEffect(() => {
-    ;(async () => {
-      const initItems = await getInitItems()
-      setTree({
-        root: {
-          id: 'root',
-          title: 'root',
-          type: ItemTypeEnum.BRANCH,
-          status: ItemStatusEnum.READY,
-          items: initItems
-        },
-        ...itemsToTree(initItems)
-      })
-    })()
-  }, [])
-
-  return useMemo(() => {
-    return {
-      items: treeToItems(tree),
-      itemTree: tree,
-      async loadItems(item: ItemType) {
-        if (tree[item.id].status === ItemStatusEnum.READY) {
-          return
-        }
-        const items = await loadMoreItems(item)
-        setTree({
-          ...tree,
-          [`${item.id}`]: {
-            ...item,
-            items,
-            status: ItemStatusEnum.READY
-          },
-          ...itemsToTree(items)
-        })
-      }
-    }
-  }, [tree])
-}
diff --git a/config-ui/src/components/miller-columns/hooks/use-miller-columns.ts b/config-ui/src/components/miller-columns/hooks/use-miller-columns.ts
index 8f0f6b8fb..fa3e5c2bf 100644
--- a/config-ui/src/components/miller-columns/hooks/use-miller-columns.ts
+++ b/config-ui/src/components/miller-columns/hooks/use-miller-columns.ts
@@ -18,15 +18,16 @@
 
 import { useState, useMemo, useEffect } from 'react'
 
-import type { ItemType, ColumnType } from '../types'
+import type { MillerColumnsItem, ItemType, ColumnType } from '../types'
 import { ItemTypeEnum, RowStatus } from '../types'
 import { CheckStatus } from '../components'
 
+import { useConvertItems } from './use-convert-items'
 import { useItemMap } from './use-item-map'
 import { useColumns } from './use-columns'
 
 export interface UseMillerColumnsProps {
-  items: ItemType[]
+  items: Array<MillerColumnsItem>
   activeItemId?: ItemType['id']
   onActiveItemId?: (id: ItemType['id']) => void
   disabledItemIds?: Array<ItemType['id']>
@@ -48,8 +49,9 @@ export const useMillerColumns = ({
     []
   )
 
-  const itemMap = useItemMap({ items })
-  const columns = useColumns({ items, itemMap, activeItemId })
+  const covertItems = useConvertItems({ items })
+  const itemMap = useItemMap({ items: covertItems })
+  const columns = useColumns({ itemMap, activeItemId })
 
   useEffect(() => {
     setActiveItemId(props.activeItemId)
@@ -62,7 +64,7 @@ export const useMillerColumns = ({
   const collectAddParentIds = (item: ItemType) => {
     let result: Array<ItemType['id']> = []
 
-    const parentItem = itemMap.getItemParent(item.id)
+    const parentItem = itemMap[item.parentId ?? '']
 
     if (parentItem) {
       const childSelectedIds = parentItem.items
@@ -81,7 +83,7 @@ export const useMillerColumns = ({
   const collectRemoveParentIds = (item: ItemType) => {
     let result: Array<ItemType['id']> = []
 
-    const parentItem = itemMap.getItemParent(item.id)
+    const parentItem = itemMap[item.parentId ?? '']
 
     if (parentItem) {
       result.push(parentItem.id)
@@ -98,18 +100,18 @@ export const useMillerColumns = ({
       activeItemId,
       selectedItemIds,
       getStatus(item: ItemType, column: ColumnType) {
-        if (column.activeId === item.id) {
+        if (item.id === column.activeId) {
           return RowStatus.selected
         }
         return RowStatus.noselected
       },
       getChekecdStatus(item: ItemType) {
-        const childSelectedIds = item.items
+        const childSelectedIds = (item.items ?? [])
           .map((it) => it.id)
           .filter((id) => selectedItemIds.includes(id))
 
         switch (true) {
-          case !itemMap.getItemChildLoaded(item.id):
+          case !itemMap[item.id].childLoaded:
           case (disabledItemIds ?? []).includes(item.id):
             return CheckStatus.disabled
           case selectedItemIds.includes(item.id):
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/miller-columns/index.ts
index 3db209c1a..a3e6236e8 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/miller-columns/index.ts
@@ -17,6 +17,5 @@
  */
 
 export * from './miller-columns'
-export * from './components'
-export * from './hooks'
 export * from './types'
+export * from './test'
\ No newline at end of file
diff --git a/config-ui/src/components/miller-columns/miller-columns.tsx b/config-ui/src/components/miller-columns/miller-columns.tsx
index b07f2d621..f95efc403 100644
--- a/config-ui/src/components/miller-columns/miller-columns.tsx
+++ b/config-ui/src/components/miller-columns/miller-columns.tsx
@@ -18,20 +18,23 @@
 
 import React from 'react'
 
-import { useMillerColumns, UseMillerColumnsProps } from './hooks'
+import type { UseMillerColumnsProps } from './hooks'
+import { useMillerColumns } from './hooks'
 import { Column, ColumnsProps, Item } from './components'
 
 import * as S from './styled'
 
 interface Props extends UseMillerColumnsProps {
   height?: number
+  columnCount?: number
   firstColumnTitle?: React.ReactNode
   scrollProps?: ColumnsProps['scrollProps']
 }
 
 export const MillerColumns = ({
-  firstColumnTitle,
   height,
+  columnCount,
+  firstColumnTitle,
   scrollProps,
   ...props
 }: Props) => {
@@ -43,6 +46,7 @@ export const MillerColumns = ({
       {columns.map((col, i) => (
         <Column
           key={col.parentId}
+          parentId={col.parentId}
           items={col.items}
           renderItem={(item) => (
             <Item
@@ -56,7 +60,15 @@ export const MillerColumns = ({
           )}
           height={height}
           title={i === 0 && firstColumnTitle}
-          scrollProps={scrollProps}
+          columnCount={columnCount}
+          scrollProps={{
+            ...scrollProps,
+            hasMore: col.parentId ? col.hasMore : scrollProps?.hasMore ?? true,
+            onScroll:
+              scrollProps?.onScroll ??
+              ((parentId) =>
+                console.log(`column: ${parentId ?? 'root'} scroll`))
+          }}
         />
       ))}
     </S.Container>
diff --git a/config-ui/src/components/miller-columns/components/column/styled.ts b/config-ui/src/components/miller-columns/test/index.tsx
similarity index 55%
copy from config-ui/src/components/miller-columns/components/column/styled.ts
copy to config-ui/src/components/miller-columns/test/index.tsx
index e522a0693..412b972d9 100644
--- a/config-ui/src/components/miller-columns/components/column/styled.ts
+++ b/config-ui/src/components/miller-columns/test/index.tsx
@@ -16,29 +16,32 @@
  *
  */
 
-import styled from '@emotion/styled'
+import React, { useState } from 'react'
 
-export const Container = styled.div<{ height?: number }>`
-  flex: 0 0 33.33%;
-  margin: 0;
-  padding: 0;
-  width: 33.33%;
-  ${({ height }) => `height: ${height}px;`}
-  list-style: none;
-  border-left: 1px solid #dbe4fd;
-  overflow-y: auto;
+import { MillerColumns, MillerColumnsItem } from '..'
 
-  &:first-child {
-    border-left: none;
-  }
+import { useTest } from './use-test'
 
-  & > .title {
-    padding: 4px 12px;
-    font-weight: 700;
-    color: #292b3f;
-  }
-`
+export const TestMillerColumns = () => {
+  const [selectedIds, setSelectedIds] = useState<
+    Array<MillerColumnsItem['id']>
+  >([])
 
-export const StatusWrapper = styled.div`
-  padding: 4px 12px;
-`
+  const { items, onExpandItem, hasMore, onScroll } = useTest()
+
+  return (
+    <MillerColumns
+      height={100}
+      columnCount={4}
+      firstColumnTitle='TestMillerColumns'
+      items={items}
+      selectedItemIds={selectedIds}
+      onSelectedItemIds={setSelectedIds}
+      onExpandItem={onExpandItem}
+      scrollProps={{
+        hasMore,
+        onScroll
+      }}
+    />
+  )
+}
diff --git a/config-ui/src/components/miller-columns/components/column/styled.ts b/config-ui/src/components/miller-columns/test/mock/flat-data/first.ts
similarity index 58%
copy from config-ui/src/components/miller-columns/components/column/styled.ts
copy to config-ui/src/components/miller-columns/test/mock/flat-data/first.ts
index e522a0693..02107a4b6 100644
--- a/config-ui/src/components/miller-columns/components/column/styled.ts
+++ b/config-ui/src/components/miller-columns/test/mock/flat-data/first.ts
@@ -16,29 +16,44 @@
  *
  */
 
-import styled from '@emotion/styled'
+import { ItemTypeEnum, ItemStatusEnum } from '../../..'
 
-export const Container = styled.div<{ height?: number }>`
-  flex: 0 0 33.33%;
-  margin: 0;
-  padding: 0;
-  width: 33.33%;
-  ${({ height }) => `height: ${height}px;`}
-  list-style: none;
-  border-left: 1px solid #dbe4fd;
-  overflow-y: auto;
-
-  &:first-child {
-    border-left: none;
-  }
-
-  & > .title {
-    padding: 4px 12px;
-    font-weight: 700;
-    color: #292b3f;
+export const flatDataFirst = [
+  {
+    parentId: null,
+    id: '1',
+    title: '1'
+  },
+  {
+    parentId: null,
+    id: '2',
+    title: '2',
+    type: ItemTypeEnum.BRANCH,
+    status: ItemStatusEnum.PENDING
+  },
+  {
+    parentId: null,
+    id: '3',
+    title: '3'
+  },
+  {
+    parentId: '1',
+    id: '1-1',
+    title: '1-1'
+  },
+  {
+    parentId: '1',
+    id: '1-2',
+    title: '1-2'
+  },
+  {
+    parentId: '1-1',
+    id: '1-1-1',
+    title: '1-1-1'
+  },
+  {
+    parentId: '1-1-1',
+    id: '1-1-1-1',
+    title: '1-1-1-1'
   }
-`
-
-export const StatusWrapper = styled.div`
-  padding: 4px 12px;
-`
+]
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/miller-columns/test/mock/flat-data/index.ts
similarity index 87%
copy from config-ui/src/components/miller-columns/index.ts
copy to config-ui/src/components/miller-columns/test/mock/flat-data/index.ts
index 3db209c1a..cb9762c5f 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/miller-columns/test/mock/flat-data/index.ts
@@ -16,7 +16,6 @@
  *
  */
 
-export * from './miller-columns'
-export * from './components'
-export * from './hooks'
-export * from './types'
+export * from './first'
+export * from './second'
+export * from './third'
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/miller-columns/test/mock/flat-data/second.ts
similarity index 70%
copy from config-ui/src/components/miller-columns/index.ts
copy to config-ui/src/components/miller-columns/test/mock/flat-data/second.ts
index 3db209c1a..5128c66ae 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/miller-columns/test/mock/flat-data/second.ts
@@ -16,7 +16,30 @@
  *
  */
 
-export * from './miller-columns'
-export * from './components'
-export * from './hooks'
-export * from './types'
+export const flatDataSecond = [
+  {
+    parentId: '2',
+    id: '2-1',
+    title: '2-1'
+  },
+  {
+    parentId: '2',
+    id: '2-2',
+    title: '2-2'
+  },
+  {
+    parentId: '2',
+    id: '2-3',
+    title: '2-3'
+  },
+  {
+    parentId: '2',
+    id: '2-4',
+    title: '2-4'
+  },
+  {
+    parentId: '2',
+    id: '2-5',
+    title: '2-5'
+  }
+]
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/miller-columns/test/mock/flat-data/third.ts
similarity index 74%
copy from config-ui/src/components/miller-columns/index.ts
copy to config-ui/src/components/miller-columns/test/mock/flat-data/third.ts
index 3db209c1a..9b8f0cb7e 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/miller-columns/test/mock/flat-data/third.ts
@@ -16,7 +16,25 @@
  *
  */
 
-export * from './miller-columns'
-export * from './components'
-export * from './hooks'
-export * from './types'
+export const flatDataThird = [
+  {
+    parentId: '2',
+    id: '2-6',
+    title: '2-6'
+  },
+  {
+    parentId: '2',
+    id: '2-7',
+    title: '2-7'
+  },
+  {
+    parentId: '2',
+    id: '2-8',
+    title: '2-8'
+  },
+  {
+    parentId: '2',
+    id: '2-9',
+    title: '2-9'
+  }
+]
diff --git a/config-ui/src/components/miller-columns/index.ts b/config-ui/src/components/miller-columns/test/mock/index.ts
similarity index 87%
copy from config-ui/src/components/miller-columns/index.ts
copy to config-ui/src/components/miller-columns/test/mock/index.ts
index 3db209c1a..1739541d6 100644
--- a/config-ui/src/components/miller-columns/index.ts
+++ b/config-ui/src/components/miller-columns/test/mock/index.ts
@@ -16,7 +16,4 @@
  *
  */
 
-export * from './miller-columns'
-export * from './components'
-export * from './hooks'
-export * from './types'
+export * from './flat-data'
diff --git a/config-ui/src/components/miller-columns/test/use-test.ts b/config-ui/src/components/miller-columns/test/use-test.ts
new file mode 100644
index 000000000..36f805e34
--- /dev/null
+++ b/config-ui/src/components/miller-columns/test/use-test.ts
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { useState, useEffect, useMemo } from 'react'
+
+import type { MillerColumnsItem, ItemType } from '..'
+import { ItemStatusEnum } from '..'
+
+import { flatDataFirst, flatDataSecond, flatDataThird } from './mock'
+
+export const useTest = () => {
+  const [items, setItems] = useState<Array<MillerColumnsItem>>([])
+  const [hasMore, setHasMore] = useState(true)
+
+  // Get the initial items data
+  // And know whether the first column has completed all data loading
+  useEffect(() => {
+    setItems(flatDataFirst)
+    setHasMore(false)
+  }, [])
+
+  // Load more data when expanding
+  // And judge whether the data is loaded
+  const onExpandItem = (item: ItemType) => {
+    if (item.id === '2') {
+      setItems([...items, ...flatDataSecond])
+    }
+  }
+
+  const onScroll = (parentId: ItemType['parentId']) => {
+    if (parentId === '2') {
+      setItems([
+        ...items.map((it) =>
+          it.id !== '2' ? it : { ...it, status: ItemStatusEnum.READY }
+        ),
+        ...flatDataThird
+      ])
+    }
+  }
+
+  return useMemo(
+    () => ({
+      items,
+      onExpandItem,
+      hasMore,
+      onScroll
+    }),
+    [items, hasMore]
+  )
+}
diff --git a/config-ui/src/components/miller-columns/types.ts b/config-ui/src/components/miller-columns/types.ts
index 4f830f354..f8ae8c9dd 100644
--- a/config-ui/src/components/miller-columns/types.ts
+++ b/config-ui/src/components/miller-columns/types.ts
@@ -16,6 +16,15 @@
  *
  */
 
+export type MillerColumnsItem = {
+  parentId: string | number | null
+  id: string | number
+  title: string
+  items?: Array<MillerColumnsItem>
+  type?: ItemTypeEnum
+  status?: ItemStatusEnum
+}
+
 export enum ItemTypeEnum {
   LEAF = 'leaf',
   BRANCH = 'branch'
@@ -26,39 +35,23 @@ export enum ItemStatusEnum {
   READY = 'ready'
 }
 
-export type ItemType = {
-  id: string | number
-  title: string
+export type ItemType = Pick<MillerColumnsItem, 'parentId' | 'id' | 'title'> & {
+  items: Array<ItemType>
   type: ItemTypeEnum
   status: ItemStatusEnum
-  items: ItemType[]
-}
-
-export type ItemInfoType = {
-  item: ItemType
-  parentId?: ItemType['id']
   childLoaded: boolean
 }
 
-export type ItemMapType = {
-  getItem: (id: ItemType['id']) => ItemType
-  getItemParent: (id: ItemType['id']) => ItemType | null
-  getItemChildLoaded: (id: ItemType['id']) => boolean
-}
+export type ItemMapType = Record<ItemType['id'], ItemType>
 
 export type ColumnType = {
-  parentId: ItemType['id'] | null
+  parentId: ItemType['parentId']
   items: ItemType[]
   activeId: ItemType['id'] | null
+  hasMore: boolean
 }
 
 export enum RowStatus {
   selected = 'selected',
   noselected = 'noselected'
 }
-
-export enum CheckedStatus {
-  selected = 'selected',
-  noselected = 'noselected',
-  indeterminate = 'indeterminate'
-}
diff --git a/config-ui/src/models/GithubProject.js b/config-ui/src/models/GithubProject.js
index 7e0f50445..52959fb42 100644
--- a/config-ui/src/models/GithubProject.js
+++ b/config-ui/src/models/GithubProject.js
@@ -49,6 +49,8 @@ class GitHubProject extends Entity {
     this.useApi = data?.useApi || false
     this.variant = data?.variant || 'project'
     this.providerId = 'github'
+
+    this.type = data?.type
   }
 
   getConfiguredEntityId() {