From 55135f9d63a708d980edf5dd8b4ddd46c8be20d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E6=B9=9B?= <0x1304570@gmail.com> Date: Mon, 9 Sep 2024 14:54:55 +1200 Subject: [PATCH] refactor: use redux to rewrite onboard (#8012) --- config-ui/src/app/store.ts | 2 + .../context.tsx => features/onboard/index.ts} | 32 +---- config-ui/src/features/onboard/slice.ts | 131 ++++++++++++++++++ config-ui/src/routes/app/app.tsx | 10 +- config-ui/src/routes/app/loader.ts | 7 - config-ui/src/routes/layout/layout.tsx | 10 +- .../src/routes/onboard/components/card.tsx | 44 +++--- .../src/routes/onboard/components/tour.tsx | 8 +- config-ui/src/routes/onboard/index.tsx | 108 +++++---------- config-ui/src/routes/onboard/step-0.tsx | 40 ++---- config-ui/src/routes/onboard/step-1.tsx | 27 ++-- config-ui/src/routes/onboard/step-2.tsx | 54 +++----- config-ui/src/routes/onboard/step-3.tsx | 27 ++-- config-ui/src/routes/onboard/step-4.tsx | 52 ++----- 14 files changed, 264 insertions(+), 288 deletions(-) rename config-ui/src/{routes/onboard/context.tsx => features/onboard/index.ts} (55%) create mode 100644 config-ui/src/features/onboard/slice.ts diff --git a/config-ui/src/app/store.ts b/config-ui/src/app/store.ts index 8d2ce0c3429..8fec5046c6e 100644 --- a/config-ui/src/app/store.ts +++ b/config-ui/src/app/store.ts @@ -19,10 +19,12 @@ import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'; import { connectionsSlice } from '@/features'; +import { onboardSlice } from '@/features/onboard'; export const store = configureStore({ reducer: { connections: connectionsSlice.reducer, + onboard: onboardSlice.reducer, }, }); diff --git a/config-ui/src/routes/onboard/context.tsx b/config-ui/src/features/onboard/index.ts similarity index 55% rename from config-ui/src/routes/onboard/context.tsx rename to config-ui/src/features/onboard/index.ts index e21d12d2ba0..513ab48a7f8 100644 --- a/config-ui/src/routes/onboard/context.tsx +++ b/config-ui/src/features/onboard/index.ts @@ -16,34 +16,4 @@ * */ -import { createContext } from 'react'; - -export type Record = { - plugin: string; - connectionId: ID; - blueprintId: ID; - pipelineId: ID; - scopeName: string; -}; - -const initialValue: { - step: number; - records: Record[]; - done: boolean; - projectName?: string; - plugin?: string; - setStep: (value: number) => void; - setRecords: (value: Record[]) => void; - setProjectName: (value: string) => void; - setPlugin: (value: string) => void; -} = { - step: 0, - records: [], - done: false, - setStep: () => {}, - setRecords: () => {}, - setProjectName: () => {}, - setPlugin: () => {}, -}; - -export const Context = createContext(initialValue); +export * from './slice'; diff --git a/config-ui/src/features/onboard/slice.ts b/config-ui/src/features/onboard/slice.ts new file mode 100644 index 00000000000..1ddebf57605 --- /dev/null +++ b/config-ui/src/features/onboard/slice.ts @@ -0,0 +1,131 @@ +/* + * 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 { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; + +import API from '@/api'; +import type { RootState } from '@/app/store'; +import type { IStatus } from '@/types'; + +type DataType = { + initial: boolean; + step: number; + records: Array<{ + plugin: string; + connectionId: ID; + blueprintId: ID; + pipelineId: ID; + scopeName: string; + }>; + projectName: string; + plugin: string; + done: boolean; +}; + +export const request = createAsyncThunk('onboard/request', async () => { + const res = await API.store.get('onboard'); + return res; +}); + +export const update = createAsyncThunk('onboard/update', async (payload: Partial, { getState }) => { + const { data } = (getState() as RootState).onboard; + const res = await API.store.set('onboard', { + ...data, + ...payload, + step: payload.step ?? data.step + 1, + }); + return res; +}); + +export const done = createAsyncThunk('onboard/done', async (_, { getState }) => { + const { data } = (getState() as RootState).onboard; + await API.store.set('onboard', { + ...data, + done: true, + }); + return {}; +}); + +const initialState: { status: IStatus; data: DataType } = { + status: 'idle', + data: { + initial: false, + step: 0, + records: [], + projectName: '', + plugin: '', + done: false, + }, +}; + +export const onboardSlice = createSlice({ + name: 'onboard', + initialState, + reducers: { + previous: (state) => { + state.data.step -= 1; + }, + changeProjectName: (state, action) => { + state.data.projectName = action.payload; + }, + changePlugin: (state, action) => { + state.data.plugin = action.payload; + }, + changeRecords: (state, action) => { + state.data.records = action.payload; + }, + }, + extraReducers: (builder) => { + builder + .addCase(request.pending, (state) => { + state.status = 'loading'; + }) + .addCase(request.fulfilled, (state, action) => { + state.status = 'success'; + state.data = { + ...action.payload, + initial: action.payload?.initial ?? false, + step: action.payload?.step ?? 0, + records: action.payload?.records ?? [], + done: action.payload?.done ?? false, + }; + }) + .addCase(update.fulfilled, (state, action) => { + state.data = { + ...state.data, + ...action.payload, + }; + }) + .addCase(done.fulfilled, (state) => { + state.data.done = true; + }); + }, +}); + +export default onboardSlice.reducer; + +export const { previous, changeProjectName, changePlugin, changeRecords } = onboardSlice.actions; + +export const selectStatus = (state: RootState) => state.onboard.status; + +export const selectOnboard = (state: RootState) => state.onboard.data; + +export const selectRecord = (state: RootState) => { + const { plugin, records } = state.onboard.data; + return records.find((it) => it.plugin === plugin); +}; diff --git a/config-ui/src/routes/app/app.tsx b/config-ui/src/routes/app/app.tsx index 72a6b75aa19..51c3ce0c78f 100644 --- a/config-ui/src/routes/app/app.tsx +++ b/config-ui/src/routes/app/app.tsx @@ -19,8 +19,10 @@ import { useEffect } from 'react'; import { useNavigate, useLoaderData, Outlet } from 'react-router-dom'; +import { PageLoading } from '@/components'; import { init } from '@/features'; -import { useAppDispatch } from '@/hooks'; +import { request as requestOnboard, selectStatus } from '@/features/onboard'; +import { useAppDispatch, useAppSelector } from '@/hooks'; import { setUpRequestInterceptor } from '@/utils'; export const App = () => { @@ -29,11 +31,17 @@ export const App = () => { const { version, plugins } = useLoaderData() as { version: string; plugins: string[] }; const dispatch = useAppDispatch(); + const status = useAppSelector(selectStatus); useEffect(() => { setUpRequestInterceptor(navigate); dispatch(init({ version, plugins })); + dispatch(requestOnboard()); }, []); + if (status === 'loading') { + return ; + } + return ; }; diff --git a/config-ui/src/routes/app/loader.ts b/config-ui/src/routes/app/loader.ts index 51d532f6e99..dad6d126738 100644 --- a/config-ui/src/routes/app/loader.ts +++ b/config-ui/src/routes/app/loader.ts @@ -16,7 +16,6 @@ * */ -import { redirect } from 'react-router-dom'; import { intersection } from 'lodash'; import API from '@/api'; @@ -27,12 +26,6 @@ type Props = { }; export const appLoader = async ({ request }: Props) => { - const onboard = await API.store.get('onboard'); - - if (!onboard) { - return redirect('/onboard'); - } - let fePlugins = getRegisterPlugins(); const bePlugins = await API.plugin.list(); diff --git a/config-ui/src/routes/layout/layout.tsx b/config-ui/src/routes/layout/layout.tsx index 8e45dd96e4d..a67036a5c63 100644 --- a/config-ui/src/routes/layout/layout.tsx +++ b/config-ui/src/routes/layout/layout.tsx @@ -17,12 +17,13 @@ */ import { useState, useEffect, useMemo } from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import { Layout as AntdLayout, Menu, Divider } from 'antd'; import { PageLoading, Logo, ExternalLink } from '@/components'; -import { selectError, selectStatus, selectVersion } from '@/features'; +import { selectError, selectStatus, selectVersion } from '@/features/connections'; +import { selectOnboard } from '@/features/onboard'; import { OnboardCard } from '@/routes/onboard/components'; import { useAppSelector } from '@/hooks'; @@ -39,6 +40,7 @@ export const Layout = () => { const navigate = useNavigate(); const { pathname } = useLocation(); + const { initial } = useAppSelector(selectOnboard); const status = useAppSelector(selectStatus); const error = useAppSelector(selectError); const version = useAppSelector(selectVersion); @@ -77,6 +79,10 @@ export const Layout = () => { throw error.message; } + if (!initial) { + return ; + } + return ( diff --git a/config-ui/src/routes/onboard/components/card.tsx b/config-ui/src/routes/onboard/components/card.tsx index 3c59ee082d2..b73786a291d 100644 --- a/config-ui/src/routes/onboard/components/card.tsx +++ b/config-ui/src/routes/onboard/components/card.tsx @@ -16,14 +16,14 @@ * */ -import { useState, useMemo } from 'react'; +import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { CloseOutlined, LoadingOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons'; import { theme, Card, Flex, Progress, Space, Button, Modal } from 'antd'; import API from '@/api'; -import { useRefreshData, useAutoRefresh } from '@/hooks'; -import { operator } from '@/utils'; +import { selectOnboard, selectRecord, done as doneFuc } from '@/features/onboard'; +import { useAppDispatch, useAppSelector, useAutoRefresh } from '@/hooks'; import { DashboardURLMap } from '../step-4'; @@ -32,24 +32,21 @@ interface Props { } export const OnboardCard = ({ style }: Props) => { - const [oeprating, setOperating] = useState(false); - const [version, setVersion] = useState(0); - const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { step, plugin, done } = useAppSelector(selectOnboard); + const record = useAppSelector(selectRecord); + const { token: { green5, orange5, red5 }, } = theme.useToken(); const [modal, contextHolder] = Modal.useModal(); - const { ready, data } = useRefreshData(() => API.store.get('onboard'), [version]); - - const record = useMemo(() => (data ? data.records.find((it: any) => it.plugin === data.plugin) : null), [data]); - const tasksRes = useAutoRefresh( async () => { - if ((data && data.done) || !record) { + if (done || !record) { return; } @@ -64,7 +61,7 @@ export const OnboardCard = ({ style }: Props) => { ); const status = useMemo(() => { - if (!data || data.step !== 4) { + if (step !== 4) { return 'prepare'; } @@ -83,30 +80,21 @@ export const OnboardCard = ({ style }: Props) => { default: return 'running'; } - }, [data, tasksRes]); + }, [step, tasksRes]); const handleClose = async () => { modal.confirm({ width: 600, title: 'Permanently close this entry?', content: 'You will not be able to get back to the onboarding session again.', - okButtonProps: { - loading: oeprating, - }, okText: 'Confirm', - onOk: async () => { - const [success] = await operator(() => API.store.set('onboard', { ...data, done: true }), { - setOperating, - }); - - if (success) { - setVersion(version + 1); - } + onOk() { + dispatch(doneFuc()); }, }); }; - if (!ready || !data || data.done) { + if (done) { return null; } @@ -115,7 +103,7 @@ export const OnboardCard = ({ style }: Props) => { {status === 'prepare' && ( - `${data.step}/3`} percent={(data.step / 3) * 100} /> + `${step}/3`} percent={(step / 3) * 100} /> )} {status === 'running' && } {status === 'success' && } @@ -157,7 +145,7 @@ export const OnboardCard = ({ style }: Props) => { )} {status === 'success' && ( - @@ -168,7 +156,7 @@ export const OnboardCard = ({ style }: Props) => { - + )} diff --git a/config-ui/src/routes/onboard/components/tour.tsx b/config-ui/src/routes/onboard/components/tour.tsx index 875ce2e1857..bac65ce67e1 100644 --- a/config-ui/src/routes/onboard/components/tour.tsx +++ b/config-ui/src/routes/onboard/components/tour.tsx @@ -18,8 +18,8 @@ import { Tour } from 'antd'; -import API from '@/api'; -import { useRefreshData } from '@/hooks'; +import { selectOnboard } from '@/features/onboard'; +import { useAppSelector } from '@/hooks'; interface Props { nameRef: React.RefObject; @@ -28,7 +28,7 @@ interface Props { } export const OnboardTour = ({ nameRef, connectionRef, configRef }: Props) => { - const { ready, data } = useRefreshData(() => API.store.get('onboard'), []); + const { step, done } = useAppSelector(selectOnboard); const steps = [ { @@ -49,7 +49,7 @@ export const OnboardTour = ({ nameRef, connectionRef, configRef }: Props) => { }, ]; - if (!ready || !data || data.step !== 4 || data.done) { + if (step !== 4 || done) { return null; } diff --git a/config-ui/src/routes/onboard/index.tsx b/config-ui/src/routes/onboard/index.tsx index 061be20331f..239a4b973e8 100644 --- a/config-ui/src/routes/onboard/index.tsx +++ b/config-ui/src/routes/onboard/index.tsx @@ -16,19 +16,15 @@ * */ -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Navigate } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import { CloseOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import { theme, Layout, Modal } from 'antd'; -import API from '@/api'; -import { PageLoading } from '@/components'; +import { selectOnboard } from '@/features/onboard'; import { PATHS } from '@/config'; -import { useRefreshData } from '@/hooks'; +import { useAppSelector } from '@/hooks'; -import type { Record } from './context'; -import { Context } from './context'; import { Step0 } from './step-0'; import { Step1 } from './step-1'; import { Step2 } from './step-2'; @@ -59,30 +55,16 @@ interface Props { } export const Onboard = ({ logo, title }: Props) => { - const [step, setStep] = useState(0); - const [records, setRecords] = useState([]); - const [projectName, setProjectName] = useState(); - const [plugin, setPlugin] = useState(); - const navigate = useNavigate(); + const { step, done } = useAppSelector(selectOnboard); + const { token: { colorPrimary }, } = theme.useToken(); const [modal, contextHolder] = Modal.useModal(); - const { ready, data } = useRefreshData(() => API.store.get('onboard')); - - useEffect(() => { - if (ready && data) { - setStep(data.step); - setRecords(data.records); - setProjectName(data.projectName); - setPlugin(data.plugin); - } - }, [ready, data]); - const handleClose = () => { modal.confirm({ width: 820, @@ -94,58 +76,44 @@ export const Onboard = ({ logo, title }: Props) => { }); }; - if (!ready) { - return ; + if (done) { + return ; } return ( - + Onboard - {brandName} - - - {step === 0 ? ( - - ) : ( - <> - -

Connect to your first repository

- -
- - {[1, 2, 3].includes(step) && ( - - {steps.map((it) => ( - - {it.step} - {it.title} - - ))} - - )} - {step === 1 && } - {step === 2 && } - {step === 3 && } - {step === 4 && } - - - )} -
- {contextHolder} -
-
+ + {step === 0 ? ( + + ) : ( + <> + +

Connect to your first repository

+ +
+ + {[1, 2, 3].includes(step) && ( + + {steps.map((it) => ( + + {it.step} + {it.title} + + ))} + + )} + {step === 1 && } + {step === 2 && } + {step === 3 && } + {step === 4 && } + + + )} +
+ {contextHolder} + ); }; diff --git a/config-ui/src/routes/onboard/step-0.tsx b/config-ui/src/routes/onboard/step-0.tsx index 5e504bd42db..c015515f2b5 100644 --- a/config-ui/src/routes/onboard/step-0.tsx +++ b/config-ui/src/routes/onboard/step-0.tsx @@ -16,19 +16,17 @@ * */ -import { useState, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; import { ExclamationCircleOutlined, CloseOutlined } from '@ant-design/icons'; import { Modal, Flex, Button } from 'antd'; import styled from 'styled-components'; -import API from '@/api'; import { Logo } from '@/components'; import { PATHS } from '@/config'; +import { update } from '@/features/onboard'; +import { useAppDispatch } from '@/hooks'; import { operator } from '@/utils'; -import { Context } from './context'; - const Wrapper = styled.div` .logo { display: flex; @@ -65,14 +63,11 @@ interface Props { } export const Step0 = ({ logo = , title = 'DevLake' }: Props) => { - const [operating, setOperating] = useState(false); - const navigate = useNavigate(); + const dispatch = useAppDispatch(); const [modal, contextHolder] = Modal.useModal(); - const { step, records, done, projectName, plugin, setStep } = useContext(Context); - const handleClose = () => { modal.confirm({ width: 820, @@ -80,15 +75,10 @@ export const Step0 = ({ logo = , title = 'DevLake content: 'You can get back to this session via the card on top of the Projects page.', icon: , okText: 'Confirm', - onOk: async () => { - const [success] = await operator( - () => API.store.set('onboard', { step: 0, records, done, projectName, plugin }), - { - setOperating, - hideToast: true, - }, - ); - + async onOk() { + const [success] = await operator(() => dispatch(update({ initial: true, step: 0 })).unwrap(), { + hideToast: true, + }); if (success) { navigate(PATHS.ROOT()); } @@ -96,20 +86,6 @@ export const Step0 = ({ logo = , title = 'DevLake }); }; - const handleSubmit = async () => { - const [success] = await operator( - async () => API.store.set('onboard', { step: 1, records, done, projectName, plugin }), - { - setOperating, - hideToast: true, - }, - ); - - if (success) { - setStep(step + 1); - } - }; - return ( {contextHolder} @@ -123,7 +99,7 @@ export const Step0 = ({ logo = , title = 'DevLake

With just a few clicks, you can integrate your initial DevOps tool and observe engineering metrics.

-
diff --git a/config-ui/src/routes/onboard/step-1.tsx b/config-ui/src/routes/onboard/step-1.tsx index a6c16632aa8..a6582c4469d 100644 --- a/config-ui/src/routes/onboard/step-1.tsx +++ b/config-ui/src/routes/onboard/step-1.tsx @@ -16,24 +16,26 @@ * */ -import { useState, useEffect, useContext } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Input, Flex, Button, message } from 'antd'; import API from '@/api'; import { Block, Markdown } from '@/components'; import { PATHS } from '@/config'; +import { selectOnboard, update, previous, changeProjectName, changePlugin } from '@/features/onboard'; +import { useAppDispatch, useAppSelector } from '@/hooks'; import { ConnectionSelect } from '@/plugins'; import { operator } from '@/utils'; -import { Context } from './context'; import * as S from './styled'; export const Step1 = () => { const [QA, setQA] = useState(''); const [operating, setOperating] = useState(false); - const { step, records, done, projectName, plugin, setStep, setProjectName, setPlugin } = useContext(Context); + const dispatch = useAppDispatch(); + const { projectName, plugin } = useAppSelector(selectOnboard); useEffect(() => { fetch(`/onboard/step-1/${plugin ? plugin : 'default'}.md`) @@ -56,14 +58,7 @@ export const Step1 = () => { return; } - const [success] = await operator(() => API.store.set('onboard', { step: 2, records, done, projectName, plugin }), { - setOperating, - hideToast: true, - }); - - if (success) { - setStep(step + 1); - } + dispatch(update({})); }; return ( @@ -79,16 +74,16 @@ export const Step1 = () => { style={{ width: 386 }} placeholder="Your Project Name" value={projectName} - onChange={(e) => setProjectName(e.target.value)} + onChange={(e) => dispatch(changeProjectName(e.target.value))} /> + <> For self-managed GitLab/GitHub/Bitbucket, please skip the onboarding and configure via{' '} Data Connections. -

+ } required > @@ -117,14 +112,14 @@ export const Step1 = () => { }, ]} value={plugin} - onChange={setPlugin} + onChange={(p) => dispatch(changePlugin(p))} />
{QA} - - diff --git a/config-ui/src/routes/onboard/step-3.tsx b/config-ui/src/routes/onboard/step-3.tsx index 4ee900bfac9..58dcf85e9de 100644 --- a/config-ui/src/routes/onboard/step-3.tsx +++ b/config-ui/src/routes/onboard/step-3.tsx @@ -16,16 +16,17 @@ * */ -import { useState, useContext, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Flex, Button } from 'antd'; import dayjs from 'dayjs'; import API from '@/api'; import { Markdown } from '@/components'; +import { selectOnboard, previous, update } from '@/features/onboard'; +import { useAppDispatch, useAppSelector } from '@/hooks'; import { DataScopeRemote, getPluginScopeId } from '@/plugins'; import { operator, formatTime } from '@/utils'; -import { Context } from './context'; import * as S from './styled'; export const Step3 = () => { @@ -33,7 +34,8 @@ export const Step3 = () => { const [operating, setOperating] = useState(false); const [scopes, setScopes] = useState([]); - const { step, records, done, projectName, plugin, setStep, setRecords } = useContext(Context); + const dispatch = useAppDispatch(); + const { projectName, plugin, records } = useAppSelector(selectOnboard); useEffect(() => { fetch(`/onboard/step-3/${plugin}.md`) @@ -51,7 +53,7 @@ export const Step3 = () => { return; } - const [success] = await operator( + const [success, res] = await operator( async () => { // 1. create a new project const { blueprint } = await API.project.create({ @@ -89,7 +91,7 @@ export const Step3 = () => { // 5. get current run pipeline const pipeline = await API.blueprint.pipelines(blueprint.id); - const newRecords = records.map((it) => + return records.map((it) => it.plugin !== plugin ? it : { @@ -99,17 +101,6 @@ export const Step3 = () => { scopeName: scopes[0]?.fullName ?? scopes[0].name, }, ); - - setRecords(newRecords); - - // 6. update store - await API.store.set('onboard', { - step: 4, - records: newRecords, - done, - projectName, - plugin, - }); }, { setOperating, @@ -118,7 +109,7 @@ export const Step3 = () => { ); if (success) { - setStep(step + 1); + dispatch(update({ records: res })); } }; @@ -142,7 +133,7 @@ export const Step3 = () => { {QA} - -