diff --git a/client/packages/lowcoder-core/lib/index.d.ts b/client/packages/lowcoder-core/lib/index.d.ts index a8d4702ec0..fceb837ce1 100644 --- a/client/packages/lowcoder-core/lib/index.d.ts +++ b/client/packages/lowcoder-core/lib/index.d.ts @@ -1,6 +1,6 @@ /// import * as react from 'react'; -import React, { ReactNode } from 'react'; +import { ReactNode, RefObject } from 'react'; import * as react_jsx_runtime from 'react/jsx-runtime'; type EvalMethods = Record>; @@ -388,6 +388,10 @@ interface Comp | undefined; } declare abstract class AbstractComp implements Comp { dispatch: DispatchType; @@ -403,6 +407,7 @@ declare abstract class AbstractComp | undefined; /** * don't override the function, override nodeWithout function instead * FIXME: node reference mustn't be changed if this object is changed @@ -616,7 +621,6 @@ declare abstract class MultiBaseComp ChildrenType[typeof childName]>): CompAction; - getRef(): React.RefObject; } declare function mergeExtra(e1: ExtraNodeType | undefined, e2: ExtraNodeType): ExtraNodeType; diff --git a/client/packages/lowcoder-core/lib/index.js b/client/packages/lowcoder-core/lib/index.js index 719756c00b..188e78e314 100644 --- a/client/packages/lowcoder-core/lib/index.js +++ b/client/packages/lowcoder-core/lib/index.js @@ -3529,6 +3529,9 @@ class AbstractComp { changeValueAction(value) { return changeValueAction(value, true); } + getRef() { + return undefined; + } /** * don't override the function, override nodeWithout function instead * FIXME: node reference mustn't be changed if this object is changed diff --git a/client/packages/lowcoder-core/src/baseComps/comp.tsx b/client/packages/lowcoder-core/src/baseComps/comp.tsx index 0808d50800..7ce9d3e2b1 100644 --- a/client/packages/lowcoder-core/src/baseComps/comp.tsx +++ b/client/packages/lowcoder-core/src/baseComps/comp.tsx @@ -1,6 +1,6 @@ import { changeValueAction, ChangeValueAction, CompAction, CompActionTypes } from "actions"; import { Node } from "eval"; -import { ReactNode } from "react"; +import { ReactNode, RefObject } from "react"; import { memo } from "util/cacheUtils"; import { JSONValue } from "util/jsonTypes"; import { setFieldsNoTypeCheck } from "util/objectUtils"; @@ -34,6 +34,11 @@ export interface Comp< changeDispatch(dispatch: DispatchType): this; changeValueAction(value: DataType): ChangeValueAction; + + /** + * Optional DOM ref for components that render a root element (e.g. UI comps, tour targets). + */ + getRef(): RefObject | undefined; } export abstract class AbstractComp< @@ -73,6 +78,10 @@ export abstract class AbstractComp< return changeValueAction(value, true); } + getRef(): RefObject | undefined { + return undefined; + } + /** * don't override the function, override nodeWithout function instead * FIXME: node reference mustn't be changed if this object is changed diff --git a/client/packages/lowcoder/src/api/commonSettingApi.ts b/client/packages/lowcoder/src/api/commonSettingApi.ts index 226617d96c..5ce5984bae 100644 --- a/client/packages/lowcoder/src/api/commonSettingApi.ts +++ b/client/packages/lowcoder/src/api/commonSettingApi.ts @@ -18,6 +18,7 @@ export interface CommonSettingResponseData { runJavaScriptInHost?: boolean | null; preloadLibs?: string[] | null; npmPlugins?: string[] | null; + npmTemplates?: string[] | null; applyPreloadCSSToHomePage?: boolean | null; defaultHomePage?: string | null; } diff --git a/client/packages/lowcoder/src/comps/comps/gridItemComp.tsx b/client/packages/lowcoder/src/comps/comps/gridItemComp.tsx index f6522d5835..40b011386f 100644 --- a/client/packages/lowcoder/src/comps/comps/gridItemComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/gridItemComp.tsx @@ -135,6 +135,10 @@ export class GridItemComp extends TmpComp { // Or add a node specifically for providing exposingData data } + override getRef() { + return this.children.comp.getRef?.(); + } + exposingInfo(): ExposingInfo { return this.children.comp.exposingInfo(); } diff --git a/client/packages/lowcoder/src/comps/comps/tourComp/tourComp.tsx b/client/packages/lowcoder/src/comps/comps/tourComp/tourComp.tsx index 292eb02b92..b1ddbb8c73 100644 --- a/client/packages/lowcoder/src/comps/comps/tourComp/tourComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tourComp/tourComp.tsx @@ -4,13 +4,6 @@ import { withMethodExposing } from "../../generators/withMethodExposing"; import { UICompBuilder } from "../../generators"; import { stringExposingStateControl } from "comps/controls/codeStateControl"; import { CommonNameConfig, NameConfig, withExposingConfigs } from "../../generators/withExposing"; -import { MultiBaseComp } from "lowcoder-core"; - -// FALK TODO: Check imports -// import { MultiBaseComp } from "lowcoder-core"; -// import { UICompBuilder } from "comps/generators/uiCompBuilder"; -// import { stringExposingStateControl } from "comps/controls/codeStateControl"; -// import { withMethodExposing } from "comps/generators/withMethodExposing"; import { TourChildrenMap, TourPropertyView } from "./tourPropertyView"; import { Tour, TourProps } from "antd"; @@ -39,12 +32,7 @@ let TourBasicComp = (function() { let target = undefined; const compListItem = compMap.find((compItem) => compItem.children.name.getView() === targetName); if (compListItem) { - // console.log(`setting selected comp to ${compListItem}`); - try { - target = ((compListItem as MultiBaseComp).children.comp as GridItemComp).getRef?.(); - } catch (e) { - target = ((compListItem as MultiBaseComp).children.comp as HookComp).getRef?.(); - } + target = compListItem.getRef(); } return { diff --git a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx index b3ce3126b9..dc841d5a9f 100644 --- a/client/packages/lowcoder/src/comps/hooks/hookComp.tsx +++ b/client/packages/lowcoder/src/comps/hooks/hookComp.tsx @@ -205,6 +205,10 @@ export class HookComp extends HookTmpComp { return this.children.comp.exposingInfo(); } + override getRef() { + return this.children.comp.getRef?.(); + } + override getView() { const view = this.children?.comp?.getView(); if (!view) { diff --git a/client/packages/lowcoder/src/constants/reduxActionConstants.ts b/client/packages/lowcoder/src/constants/reduxActionConstants.ts index 821470ac56..14eae5c70b 100644 --- a/client/packages/lowcoder/src/constants/reduxActionConstants.ts +++ b/client/packages/lowcoder/src/constants/reduxActionConstants.ts @@ -11,12 +11,6 @@ export const ReduxActionTypes = { FETCH_API_KEYS_SUCCESS: "FETCH_API_KEYS_SUCCESS", MOVE_TO_FOLDER2_SUCCESS: "MOVE_TO_FOLDER2_SUCCESS", - /* workspace RELATED */ - FETCH_WORKSPACES_INIT: "FETCH_WORKSPACES_INIT", - FETCH_WORKSPACES_SUCCESS: "FETCH_WORKSPACES_SUCCESS", - - - /* plugin RELATED */ FETCH_DATA_SOURCE_TYPES: "FETCH_DATA_SOURCE_TYPES", FETCH_DATA_SOURCE_TYPES_SUCCESS: "FETCH_DATA_SOURCE_TYPES_SUCCESS", diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 30407bb9ef..24859aab4c 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -145,8 +145,10 @@ export const en = { "extensionTab": "Extensions", "modulesTab": "Modules", "moduleListTitle": "Modules", + "templateListTitle": "Templates", "pluginListTitle": "Plugins", "emptyModules": "Modules are reusable Mikro-Apps. You can embed them in your App.", + "emptyTemplates": "No templates added yet. Add npm templates with layout drop-zones to enhance your project.", "searchNotFound": "Can't find the right component?", "emptyPlugins": "No plugins added yet. Add npm plugins to enhance your project.", "contactUs": "Contact Us", @@ -4672,13 +4674,18 @@ export const en = { "npm": { "invalidNpmPackageName": "Invalid npm Package Name or URL.", "pluginExisted": "This npm Plugin Already Existed", + "templateExisted": "This npm Template Already Existed", "compNotFound": "Component {compName} Not Found.", "addPluginModalTitle": "Add Plugin from a npm Repository", + "addTemplateModalTitle": "Add Template from a npm Repository", "pluginNameLabel": "npm Package's URL or Name", + "templateNameLabel": "npm Package's URL or Name", "noCompText": "No Components.", "compsLoading": "Loading...", "removePluginBtnText": "Remove", - "addPluginBtnText": "Add npm Plugin" + "removeTemplateBtnText": "Remove", + "addPluginBtnText": "Add npm Plugin", + "addTemplateBtnText": "Add npm Template" }, "toggleButton": { "valueDesc": "The Default Value of the Toggle Button, For Example: False", diff --git a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx index 0bd8a4c547..8b8180b4e8 100644 --- a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx +++ b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { Input, Pagination, Spin } from 'antd'; @@ -198,8 +198,19 @@ export default function WorkspaceSectionComponent({ handleSearchChange, handlePageChange, pageSize, + refetch, } = useWorkspaceManager({}); + // Refetch list when orgs change (create/delete) + const orgsCount = user.orgs.length; + const prevOrgsCount = useRef(orgsCount); + useEffect(() => { + if (prevOrgsCount.current !== orgsCount) { + prevOrgsCount.current = orgsCount; + refetch(); + } + }, [orgsCount, refetch]); + // Early returns for better performance if (!showSwitchOrg(user, sysConfig)) return null; diff --git a/client/packages/lowcoder/src/pages/editor/right/ExtensionPanel.tsx b/client/packages/lowcoder/src/pages/editor/right/ExtensionPanel.tsx index 05da6d68d1..f1af1bb52f 100644 --- a/client/packages/lowcoder/src/pages/editor/right/ExtensionPanel.tsx +++ b/client/packages/lowcoder/src/pages/editor/right/ExtensionPanel.tsx @@ -1,11 +1,13 @@ import ModulePanel from "./ModulePanel"; import PluginPanel from "./PluginPanel"; +import TemplatePanel from "./TemplatePanel"; import { RightPanelContentWrapper } from "./styledComponent"; export default function ExtensionPanel() { return ( + ); diff --git a/client/packages/lowcoder/src/pages/editor/right/TemplatePanel/TemplateItem.tsx b/client/packages/lowcoder/src/pages/editor/right/TemplatePanel/TemplateItem.tsx new file mode 100644 index 0000000000..a49aa75847 --- /dev/null +++ b/client/packages/lowcoder/src/pages/editor/right/TemplatePanel/TemplateItem.tsx @@ -0,0 +1,111 @@ +import axios from "axios"; +import { EmptyContent } from "components/EmptyContent"; +import { LinkButton } from "lowcoder-design"; +import { useApplicationId, useShallowEqualSelector } from "util/hooks"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { AppState } from "redux/reducers"; +import { packageMetaReadyAction } from "redux/reduxActions/npmPluginActions"; +import styled from "styled-components"; +import { NpmPackageMeta } from "types/remoteComp"; +import { PluginCompItem } from "../PluginPanel/PluginCompItem"; +import { NPM_REGISTRY_URL } from "constants/npmPlugins"; +import { trans } from "i18n"; +import { RightContext } from "../rightContext"; + +const TemplateViewWrapper = styled.div` + margin-bottom: 12px; + .remove-btn { + display: none; + } + &:hover { + .remove-btn { + display: block; + } + } +`; + +const TemplateViewTitle = styled.div` + height: 22px; + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 2px; +`; + +const TemplateViewTitleText = styled.div` + flex: 1; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #b8b9bf; +`; + +const TemplateViewContent = styled.div` + padding-top: 4px; + margin-bottom: 12px; +`; + +interface TemplateItemProps { + name: string; + onRemove: () => void; +} + +export function TemplateItem(props: TemplateItemProps) { + const { name, onRemove } = props; + const dispatch = useDispatch(); + const appId = useApplicationId(); + const { onDrag, searchValue } = useContext(RightContext); + const [loading, setLoading] = useState(false); + const packageMeta = useShallowEqualSelector( + (state: AppState) => state.npmPlugin.packageMeta[name] + ); + const currentVersion = useSelector((state: AppState) => state.npmPlugin.packageVersion[name]); + const versions = useMemo(() => packageMeta?.versions || {}, [packageMeta?.versions]); + const comps = versions[currentVersion]?.lowcoder?.comps || {}; + const compNames = Object.keys(comps); + + useEffect(() => { + setLoading(true); + axios.get(`${NPM_REGISTRY_URL}/${appId || 'none'}/${name}`).then((res) => { + if (res.status >= 400) { + return; + } + setLoading(false); + dispatch(packageMetaReadyAction(name, res.data)); + }); + }, [dispatch, name]); + + const filteredCompNames = compNames.filter( + (i) => !searchValue || i.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1 + ); + const hasComps = filteredCompNames.length > 0; + + return ( + + + {name} + + + + {!hasComps && } + {hasComps && + filteredCompNames.map((compName) => ( + + ))} + + + ); +} diff --git a/client/packages/lowcoder/src/pages/editor/right/TemplatePanel/index.tsx b/client/packages/lowcoder/src/pages/editor/right/TemplatePanel/index.tsx new file mode 100644 index 0000000000..57d5490885 --- /dev/null +++ b/client/packages/lowcoder/src/pages/editor/right/TemplatePanel/index.tsx @@ -0,0 +1,124 @@ +import { trans } from "i18n"; +import { useMemo, useState } from "react"; +import { TemplateItem } from "./TemplateItem"; +import { useDispatch, useSelector } from "react-redux"; +import { setCommonSettings } from "redux/reduxActions/commonSettingsActions"; +import { getUser } from "redux/selectors/usersSelectors"; +import { BluePlusIcon, CustomModal, TacoButton, TacoInput } from "lowcoder-design"; +import { getCommonSettings } from "redux/selectors/commonSettingSelectors"; +import styled from "styled-components"; +import { normalizeNpmPackage, validateNpmPackage } from "comps/utils/remote"; +import { ComListTitle, ExtensionContentWrapper } from "../styledComponent"; +import { EmptyContent } from "components/EmptyContent"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { isPublicApplication } from "@lowcoder-ee/redux/selectors/applicationSelector"; + +const Footer = styled.div` + display: flex; + justify-content: center; + margin-top: 24px; + margin-bottom: 24px; +`; + +export default function TemplatePanel() { + const dispatch = useDispatch(); + const [isAddModalShow, showAddModal] = useState(false); + const [newTemplateName, setNewTemplateName] = useState(""); + const user = useSelector(getUser); + const commonSettings = useSelector(getCommonSettings); + const isPublicApp = useSelector(isPublicApplication); + + const templates = useMemo( + () => + (commonSettings?.npmTemplates || []).map((i) => { + return { + name: normalizeNpmPackage(i), + raw: i, + }; + }), + [commonSettings?.npmTemplates] + ); + + const handleSetNpmTemplates = (nextNpmTemplates: string[]) => { + dispatch( + setCommonSettings({ + orgId: user.currentOrgId, + isPublicApp, + data: { + key: "npmTemplates", + value: nextNpmTemplates, + }, + }) + ); + }; + + const handleAddNewTemplate = () => { + if (!newTemplateName) { + return; + } + if (!validateNpmPackage(newTemplateName)) { + messageInstance.error(trans("npm.invalidNpmPackageName")); + return; + } + if ( + commonSettings.npmTemplates?.find( + (i) => normalizeNpmPackage(i) === normalizeNpmPackage(newTemplateName) + ) + ) { + messageInstance.error(trans("npm.templateExisted")); + return; + } + const nextNpmTemplates = (commonSettings?.npmTemplates || []).concat(newTemplateName); + handleSetNpmTemplates(nextNpmTemplates); + setNewTemplateName(""); + showAddModal(false); + }; + + const handleRemove = (name: string) => { + const nextNpmTemplates = commonSettings?.npmTemplates?.filter((i) => i !== name) || []; + handleSetNpmTemplates(nextNpmTemplates); + }; + + const items = templates.map((i) => ( + handleRemove(i.raw)} /> + )); + + const empty = ( + + ); + + return ( + <> + {trans("rightPanel.templateListTitle")} + {items.length > 0 ? items : empty} +
+ } buttonType="blue" onClick={() => showAddModal(true)}> + {trans("npm.addTemplateBtnText")} + +
+ showAddModal(false)} + > + + {trans("npm.templateNameLabel")} + + { + handleAddNewTemplate(); + }} + onChange={(e) => { + setNewTemplateName(e.target.value); + }} + value={newTemplateName} + /> + + + ); +} diff --git a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx index 78b48b185f..c9fb6e90a7 100644 --- a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx +++ b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx @@ -24,7 +24,7 @@ import { getUser } from "redux/selectors/usersSelectors"; import { getOrgCreateStatus } from "redux/selectors/orgSelectors"; import { useWorkspaceManager } from "util/useWorkspaceManager"; import { Org } from "constants/orgConstants"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { SwapOutlined } from "@ant-design/icons"; import dayjs from "dayjs"; @@ -195,10 +195,21 @@ function OrganizationSetting() { handleSearchChange, handlePageChange, pageSize, + refetch, } = useWorkspaceManager({ pageSize: 10 }); + // Refetch list when orgs change (create/delete) + const orgsCount = user.orgs.length; + const prevOrgsCount = useRef(orgsCount); + useEffect(() => { + if (prevOrgsCount.current !== orgsCount) { + prevOrgsCount.current = orgsCount; + refetch(); + } + }, [orgsCount, refetch]); + // Show all organizations with role information diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts index bf5369b869..686838a5ad 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts @@ -11,6 +11,7 @@ import { UpdateAppMetaPayload, UpdateAppPermissionPayload, } from "redux/reduxActions/applicationActions"; +import { UpdateOrgPayload } from "redux/reduxActions/orgActions"; import { createReducer } from "util/reducerUtils"; import { ApplicationDetail, @@ -70,6 +71,15 @@ const usersReducer = createReducer(initialState, { }, }, }), + [ReduxActionTypes.UPDATE_ORG_SUCCESS]: ( + state: ApplicationReduxState, + action: ReduxAction + ): ApplicationReduxState => ({ + ...state, + homeOrg: state.homeOrg && state.homeOrg.id === action.payload.id + ? { ...state.homeOrg, ...(action.payload.orgName && { name: action.payload.orgName }) } + : state.homeOrg, + }), [ReduxActionTypes.FETCH_HOME_DATA_SUCCESS]: ( state: ApplicationReduxState, action: ReduxAction diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/commonSettingsReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/commonSettingsReducer.ts index e4d8f8fd6d..dcfb28a28f 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/commonSettingsReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/commonSettingsReducer.ts @@ -20,6 +20,7 @@ export interface NpmRegistryConfigEntry { export interface CommonSettingsState { settings: { npmPlugins?: string[] | null; + npmTemplates?: string[] | null; npmRegistries?: NpmRegistryConfigEntry[] | null themeList?: ThemeType[] | null; defaultTheme?: string | null; diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts index f1a04ea2cc..be4b3a1dd0 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts @@ -1,4 +1,3 @@ -import { Org } from "@lowcoder-ee/constants/orgConstants"; import { ReduxAction, ReduxActionErrorTypes, @@ -22,14 +21,6 @@ const initialState: UsersReduxState = { rawCurrentUser: defaultCurrentUser, profileSettingModalVisible: false, apiKeys: [], - workspaces: { - items: [], - totalCount: 0, - currentOrg: null, - loading: false, - isFetched: false, - - } }; const usersReducer = createReducer(initialState, { @@ -199,31 +190,6 @@ const usersReducer = createReducer(initialState, { ...state, apiKeys: action.payload, }), - - [ReduxActionTypes.FETCH_WORKSPACES_INIT]: (state: UsersReduxState) => ({ - ...state, - workspaces: { - ...state.workspaces, - loading: true, - }, - }), - - - [ReduxActionTypes.FETCH_WORKSPACES_SUCCESS]: ( - state: UsersReduxState, - action: ReduxAction<{ items: Org[], totalCount: number, isLoadMore?: boolean }> - ) => ({ - ...state, - workspaces: { - items: action.payload.isLoadMore - ? [...state.workspaces.items, ...action.payload.items] // Append for load more - : action.payload.items, // Replace for new search/initial load - totalCount: action.payload.totalCount, - isFetched: true, - loading: false, - } - }), - }); export interface UsersReduxState { @@ -239,16 +205,6 @@ export interface UsersReduxState { error: string; profileSettingModalVisible: boolean; apiKeys: Array; - - // NEW state for workspaces - // NEW: Separate workspace state - workspaces: { - items: Org[]; // Current page of workspaces - totalCount: number; // Total workspaces available - currentOrg: Org | null; - loading: boolean; - isFetched: boolean; - }; } export default usersReducer; diff --git a/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts b/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts index 2bb8374ca9..d8e745df85 100644 --- a/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts +++ b/client/packages/lowcoder/src/redux/reduxActions/orgActions.ts @@ -161,7 +161,6 @@ export const updateOrgSuccess = (payload: UpdateOrgPayload) => { }; -// till now export type OrgAPIUsagePayload = { apiUsage: number, }; @@ -181,7 +180,6 @@ export const fetchAPIUsageActionSuccess = (payload: OrgAPIUsagePayload) => { }; }; -// last month export type OrgLastMonthAPIUsagePayload = { lastMonthApiUsage: number, }; @@ -199,14 +197,4 @@ export const fetchLastMonthAPIUsageActionSuccess = (payload: OrgLastMonthAPIUsag type: ReduxActionTypes.FETCH_ORG_LAST_MONTH_API_USAGE_SUCCESS, payload: payload, }; -}; - -export const fetchWorkspacesAction = (page: number = 1,pageSize: number = 20, search?: string, isLoadMore?: boolean) => ({ - type: ReduxActionTypes.FETCH_WORKSPACES_INIT, - payload: { page, pageSize, search, isLoadMore } -}); - -export const loadMoreWorkspacesAction = (page: number, search?: string) => ({ - type: ReduxActionTypes.FETCH_WORKSPACES_INIT, - payload: { page, search, isLoadMore: true } -}); \ No newline at end of file +}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts index 9ac80186f2..03bf406057 100644 --- a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts @@ -1,6 +1,6 @@ import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { ApiResponse, FetchGroupApiResponse, GenericApiResponse } from "api/apiResponses"; +import { ApiResponse, FetchGroupApiResponse } from "api/apiResponses"; import OrgApi, { CreateOrgResponse, GroupUsersResponse, OrgAPIUsageResponse, OrgUsersResponse } from "api/orgApi"; import { AxiosResponse } from "axios"; import { OrgGroup } from "constants/orgConstants"; @@ -25,14 +25,11 @@ import { fetchLastMonthAPIUsageActionSuccess, UpdateUserGroupRolePayload, UpdateUserOrgRolePayload, - fetchWorkspacesAction, } from "redux/reduxActions/orgActions"; import { getUser } from "redux/selectors/usersSelectors"; import { validateResponse } from "api/apiUtils"; import { User } from "constants/userConstants"; import { getUserSaga } from "redux/sagas/userSagas"; -import { GetMyOrgsResponse } from "@lowcoder-ee/api/userApi"; -import UserApi from "@lowcoder-ee/api/userApi"; export function* updateGroupSaga(action: ReduxAction) { try { @@ -264,14 +261,10 @@ export function* createOrgSaga(action: ReduxAction<{ orgName: string }>) { ); const isValidResponse: boolean = validateResponse(response); if (isValidResponse) { - // update org list yield call(getUserSaga); - // Refetch workspaces to update the profile dropdown - yield put(fetchWorkspacesAction(1, 10)); - yield put({ - type: ReduxActionTypes.CREATE_ORG_SUCCESS, - }); - + yield put({ + type: ReduxActionTypes.CREATE_ORG_SUCCESS, + }); } } catch (error: any) { yield put({ @@ -293,8 +286,6 @@ export function* deleteOrgSaga(action: ReduxAction<{ orgId: string }>) { orgId: action.payload.orgId, }, }); - // Refetch workspaces to update the profile dropdown - yield put(fetchWorkspacesAction(1, 10)); } } catch (error: any) { messageInstance.error(error.message); @@ -308,8 +299,6 @@ export function* updateOrgSaga(action: ReduxAction) { const isValidResponse: boolean = validateResponse(response); if (isValidResponse) { yield put(updateOrgSuccess(action.payload)); - // Refetch workspaces to update the profile dropdown - yield put(fetchWorkspacesAction(1, 10)); } } catch (error: any) { messageInstance.error(error.message); @@ -353,47 +342,6 @@ export function* fetchLastMonthAPIUsageSaga(action: ReduxAction<{ } } -// fetch my orgs -// In userSagas.ts -export function* fetchWorkspacesSaga(action: ReduxAction<{page: number, pageSize: number, search?: string, isLoadMore?: boolean}>) { - try { - const { page, pageSize, search, isLoadMore } = action.payload; - - const response: AxiosResponse = yield call( - UserApi.getMyOrgs, - page, // pageNum - pageSize, // pageSize (changed to 5 for testing) - search // orgName - ); - - if (validateResponse(response)) { - const apiData = response.data.data; - - // Transform orgId/orgName to match Org interface - const transformedItems = apiData.data - .filter(item => item.orgView && item.orgView.orgId) - .map(item => ({ - id: item.orgView.orgId, - name: item.orgView.orgName, - createdAt: item.orgView.createdAt, - updatedAt: item.orgView.updatedAt, - isCurrentOrg: item.isCurrentOrg, - })); - - yield put({ - type: ReduxActionTypes.FETCH_WORKSPACES_SUCCESS, - payload: { - items: transformedItems, - totalCount: apiData.total, - isLoadMore: isLoadMore || false - } - }); - } - } catch (error: any) { - console.error('Error fetching workspaces:', error); - } -} - export default function* orgSagas() { yield all([ takeLatest(ReduxActionTypes.UPDATE_GROUP_INFO, updateGroupSaga), @@ -414,8 +362,5 @@ export default function* orgSagas() { takeLatest(ReduxActionTypes.UPDATE_ORG, updateOrgSaga), takeLatest(ReduxActionTypes.FETCH_ORG_API_USAGE, fetchAPIUsageSaga), takeLatest(ReduxActionTypes.FETCH_ORG_LAST_MONTH_API_USAGE, fetchLastMonthAPIUsageSaga), - takeLatest(ReduxActionTypes.FETCH_WORKSPACES_INIT, fetchWorkspacesSaga), - - ]); } diff --git a/client/packages/lowcoder/src/redux/sagas/userSagas.ts b/client/packages/lowcoder/src/redux/sagas/userSagas.ts index b19e2b1a65..1e66f73095 100644 --- a/client/packages/lowcoder/src/redux/sagas/userSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/userSagas.ts @@ -25,7 +25,6 @@ import { messageInstance } from "lowcoder-design/src/components/GlobalInstances" import { AuthSearchParams } from "constants/authConstants"; import { saveAuthSearchParams } from "pages/userAuth/authUtils"; import { initTranslator } from "i18n"; -import { fetchWorkspacesAction } from "../reduxActions/orgActions"; function validResponseData(response: AxiosResponse) { return response && response.data && response.data.data; diff --git a/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts b/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts index 322f414f7b..99a7908a3b 100644 --- a/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts +++ b/client/packages/lowcoder/src/redux/selectors/orgSelectors.ts @@ -1,5 +1,4 @@ import { Org } from "@lowcoder-ee/constants/orgConstants"; -import { getUser } from "./usersSelectors"; import { AppState } from "redux/reducers"; import { getHomeOrg } from "./applicationSelector"; @@ -31,9 +30,6 @@ export const getOrgLastMonthApiUsage = (state: AppState) => { return state.ui.org.lastMonthApiUsage; } -// Add to usersSelectors.ts -export const getWorkspaces = (state: AppState) => state.ui.users.workspaces; - export const getCurrentOrg = (state: AppState): Pick | undefined => { const homeOrg = getHomeOrg(state); if (!homeOrg) { diff --git a/client/packages/lowcoder/src/util/useWorkspaceManager.ts b/client/packages/lowcoder/src/util/useWorkspaceManager.ts index fe2379769d..3383133489 100644 --- a/client/packages/lowcoder/src/util/useWorkspaceManager.ts +++ b/client/packages/lowcoder/src/util/useWorkspaceManager.ts @@ -1,201 +1,93 @@ -import { useReducer, useEffect, useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { debounce } from 'lodash'; import { Org } from 'constants/orgConstants'; -import { getWorkspaces } from 'redux/selectors/orgSelectors'; -import { fetchWorkspacesAction } from 'redux/reduxActions/orgActions'; import UserApi from 'api/userApi'; -// State interface for the workspace manager -interface WorkspaceState { - searchTerm: string; - currentPage: number; - currentPageWorkspaces: Org[]; - totalCount: number; - isLoading: boolean; -} - -// Action types for the reducer -type WorkspaceAction = - | { type: 'SET_SEARCH_TERM'; payload: string } - | { type: 'SET_PAGE'; payload: number } - | { type: 'SET_LOADING'; payload: boolean } - | { type: 'SET_WORKSPACES'; payload: { workspaces: Org[]; total: number } } - | { type: 'RESET'; payload: { totalCount: number } }; - -// Initial state -const initialState: WorkspaceState = { - searchTerm: '', - currentPage: 1, - currentPageWorkspaces: [], - totalCount: 0, - isLoading: false, -}; - -// Reducer function - handles state transitions -function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState { - switch (action.type) { - case 'SET_SEARCH_TERM': - return { - ...state, - searchTerm: action.payload, - currentPage: 1 , - isLoading: Boolean(action.payload.trim()) - }; - case 'SET_PAGE': - return { ...state, currentPage: action.payload }; - case 'SET_LOADING': - return { ...state, isLoading: action.payload }; - case 'SET_WORKSPACES': - return { - ...state, - currentPageWorkspaces: action.payload.workspaces, - totalCount: action.payload.total, - isLoading: false, - }; - case 'RESET': - return { - ...initialState, - totalCount: action.payload.totalCount, - }; - default: - return state; - } -} - -// Hook interface interface UseWorkspaceManagerOptions { pageSize?: number; } -// Main hook export function useWorkspaceManager({ pageSize = 10 }: UseWorkspaceManagerOptions) { - // Get workspaces from Redux - const workspaces = useSelector(getWorkspaces); - const reduxDispatch = useDispatch(); - - // Initialize reducer with Redux total count - const [state, dispatch] = useReducer(workspaceReducer, { - ...initialState, - totalCount: workspaces.totalCount, - }); - - - - /* ----- first-time fetch ------------------------------------------------ */ - useEffect(() => { - if (!workspaces.isFetched && !workspaces.loading) { - reduxDispatch(fetchWorkspacesAction(1, pageSize)); - } - }, [workspaces.isFetched, workspaces.loading, pageSize, reduxDispatch]); + const [searchTerm, setSearchTerm] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [workspaces, setWorkspaces] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); - // API call to fetch workspaces (memoized for stable reference) - const fetchWorkspacesPage = useCallback( + const fetchWorkspaces = useCallback( async (page: number, search?: string) => { - dispatch({ type: 'SET_LOADING', payload: true }); - + setIsLoading(true); try { const response = await UserApi.getMyOrgs(page, pageSize, search); if (response.data.success) { const apiData = response.data.data; - const transformedItems = apiData.data.map(item => ({ - id: item.orgView.orgId, - name: item.orgView.orgName, - createdAt: item.orgView.createdAt, - updatedAt: item.orgView.updatedAt, - isCurrentOrg: item.isCurrentOrg, - })); - - dispatch({ - type: 'SET_WORKSPACES', - payload: { - workspaces: transformedItems as Org[], - total: apiData.total, - }, - }); + const items = apiData.data + .filter(item => item.orgView && item.orgView.orgId) + .map(item => ({ + id: item.orgView.orgId, + name: item.orgView.orgName, + createdAt: item.orgView.createdAt, + updatedAt: item.orgView.updatedAt, + isCurrentOrg: item.isCurrentOrg, + })) as Org[]; + + setWorkspaces(items); + setTotalCount(apiData.total); } } catch (error) { console.error('Error fetching workspaces:', error); - dispatch({ type: 'SET_WORKSPACES', payload: { workspaces: [], total: 0 } }); + setWorkspaces([]); + setTotalCount(0); + } finally { + setIsLoading(false); } }, - [dispatch, pageSize] + [pageSize] ); - // Debounced search function (memoized to keep a single instance across renders) - const debouncedSearch = useMemo(() => - debounce(async (term: string) => { - if (!term.trim()) { - // Clear search - reset to Redux data - dispatch({ - type: 'SET_WORKSPACES', - payload: { workspaces: [], total: workspaces.totalCount }, - }); - return; - } - - // Perform search - await fetchWorkspacesPage(1, term); - }, 500) - , [dispatch, fetchWorkspacesPage, workspaces.totalCount]); + // Initial fetch + useEffect(() => { + fetchWorkspaces(1); + }, [fetchWorkspaces]); + + const debouncedSearch = useMemo( + () => debounce((term: string) => { + fetchWorkspaces(1, term || undefined); + }, 500), + [fetchWorkspaces] + ); - // Cleanup debounce on unmount useEffect(() => { - return () => { - debouncedSearch.cancel(); - }; + return () => { debouncedSearch.cancel(); }; }, [debouncedSearch]); - // Handle search input change const handleSearchChange = (value: string) => { - dispatch({ type: 'SET_SEARCH_TERM', payload: value }); + setSearchTerm(value); + setCurrentPage(1); debouncedSearch(value); }; - // Handle page change const handlePageChange = (page: number) => { - dispatch({ type: 'SET_PAGE', payload: page }); - - if (page === 1 && !state.searchTerm.trim()) { - // Page 1 + no search = use Redux data - dispatch({ - type: 'SET_WORKSPACES', - payload: { workspaces: [], total: workspaces.totalCount } - }); - } else { - // Other pages or search = fetch from API - fetchWorkspacesPage(page, state.searchTerm.trim() || undefined); - } + setCurrentPage(page); + fetchWorkspaces(page, searchTerm.trim() || undefined); }; - // Determine which workspaces to display - const displayWorkspaces = (() => { - if (state.searchTerm.trim() || state.currentPage > 1) { - return state.currentPageWorkspaces; // API results - } - return workspaces.items; // Redux data for page 1 - })(); - - // Determine current total count - const currentTotalCount = state.searchTerm.trim() - ? state.totalCount - : workspaces.totalCount; + const refetch = useCallback( + () => fetchWorkspaces(currentPage, searchTerm.trim() || undefined), + [fetchWorkspaces, currentPage, searchTerm] + ); return { - // State - searchTerm: state.searchTerm, - currentPage: state.currentPage, - isLoading: state.isLoading || workspaces.loading, - displayWorkspaces, - totalCount: currentTotalCount, - - // Actions + searchTerm, + currentPage, + isLoading, + displayWorkspaces: workspaces, + totalCount, handleSearchChange, handlePageChange, - - // Config pageSize, + refetch, }; -} \ No newline at end of file +} diff --git a/docs/.gitbook/assets/add-template.png b/docs/.gitbook/assets/add-template.png new file mode 100644 index 0000000000..4b0db148fc Binary files /dev/null and b/docs/.gitbook/assets/add-template.png differ diff --git a/docs/.gitbook/assets/template-list.png b/docs/.gitbook/assets/template-list.png new file mode 100644 index 0000000000..93f59d0e43 Binary files /dev/null and b/docs/.gitbook/assets/template-list.png differ diff --git a/docs/.gitbook/assets/use-template.png b/docs/.gitbook/assets/use-template.png new file mode 100644 index 0000000000..c3baff15d0 Binary files /dev/null and b/docs/.gitbook/assets/use-template.png differ diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e5346727fe..3520853ba4 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -54,6 +54,7 @@ * [Chat Controller](build-applications/app-editor/visual-components/chat-controller.md) * [Automator](build-applications/app-editor/automator.md) * [AI Help](build-applications/app-editor/ai-help.md) + * [NPM Template Plugins](build-applications/app-editor/npm-template-plugins.md) * [Date handling](build-applications/app-editor/date-handling.md) * [Bulk Editing](build-applications/app-editor/bulk-editing.md) * [Layers](build-applications/app-editor/layers.md) diff --git a/docs/build-applications/app-editor/README.md b/docs/build-applications/app-editor/README.md index c08cf4b69b..adc3c70139 100644 --- a/docs/build-applications/app-editor/README.md +++ b/docs/build-applications/app-editor/README.md @@ -68,4 +68,6 @@ The query editor is flexible and adapts the options to build queries to the data The component and property pane (red) is located on the right of the window. Drag components onto the canvas from the **Insert** tab and edit the properties of the components in the **Properties** tab. +Under the **Insert** tab, switch to **Extensions** to access **Modules**, **Templates** (npm layout packages), and **Plugins**. See [NPM Template Plugins](./npm-template-plugins.md). + When a component is selected on the canvas, the **Properties** tab will be activated automatically and display the properties of that component. diff --git a/docs/build-applications/app-editor/npm-template-plugins.md b/docs/build-applications/app-editor/npm-template-plugins.md new file mode 100644 index 0000000000..2ab225dda4 --- /dev/null +++ b/docs/build-applications/app-editor/npm-template-plugins.md @@ -0,0 +1,181 @@ +# NPM Template Plugins + +NPM Template Plugins let developers package reusable Lowcoder layouts as npm packages. Lowcoder users can add those packages from the editor, drag a prepared layout onto the canvas, and then place normal Lowcoder components into the editable areas of that layout. + +## Why this feature exists + +Complex layouts can already be created directly inside Lowcoder. For many apps, that is still the right approach. The problem starts when the same layout needs to be reused often and contains many fixed structural parts, such as sidebars, headers, tab areas, dashboard shells, card grids, landing pages, or admin panels. + +Building those structures natively in Lowcoder can require a lot of nested containers and repeated layout setup. Over time, that can make the app harder to maintain, harder to copy between apps, and heavier for the editor and runtime. + +NPM Template Plugins solve this by moving the fixed layout structure into a developer-maintained npm package. The package provides the layout shell, while Lowcoder keeps the important editable parts available as drop-zones. Users still build visually in Lowcoder, but they start from a prepared structure instead of rebuilding the same nested layout every time. + +In short: + +- Developers prepare and publish reusable layout packages. +- Lowcoder users install those packages from the editor. +- The fixed layout comes from the package. +- The editable areas remain available as Lowcoder drop-zones. + +## Templates vs npm component plugins + +Both npm component plugins and npm template plugins are installed from npm, but they solve different problems. + + +| Type | Use it for | +| -------------------- | ------------------------------------------------------------------------------------ | +| NPM Component Plugin | Adding one custom component, such as a chart, widget, map, viewer, or input control. | +| NPM Template Plugin | Adding a reusable page or section layout that contains editable Lowcoder drop-zones. | + + +Use a template plugin when the main value is the layout structure. Use a component plugin when the main value is a single reusable component. + +## For Lowcoder users + +### What you need + +Before adding a template package, you need: + +- the npm package name or npm package URL +- access to the Lowcoder workspace where the package should be available +- private npm registry settings, if the package is not published publicly + +For testing, you can use the demo package: + +```text +lowcoder-comp-template-demos +``` + +### Add a template package + +1. Open your app in the Lowcoder editor. +2. Go to **Insert > Extensions**. +3. Find the **Templates** section. +4. Click **Add npm Template**. +5. Enter the npm package name or URL. +6. Confirm the dialog. + +
Add npm template package from the Extensions panel

Add an npm template package from the Extensions panel.

+ +Lowcoder stores the package in the workspace settings, so the templates can be reused by apps in the same workspace. + +### Choose a template + +After the package loads, Lowcoder shows the available template designs under the package name. Drag the template you want onto the canvas. + +
Template package with available template designs

Template designs exposed by the installed npm package.

+ +The demo package includes layouts such as dashboard, landing page, sidebar, card grid, and admin panel templates. + +### Use the drop-zones + +The template package provides the fixed layout. The drop-zones are the editable areas where you add Lowcoder components such as tables, charts, forms, buttons, containers, and custom components. + +
Using a template by dropping Lowcoder components into the prepared drop-zone

Add Lowcoder components inside the template drop-zone.

+ +Components inside a drop-zone work like normal Lowcoder components. You can configure them, bind queries, connect data, and style them as usual. + +## For template plugin developers + +Template plugin developers create the npm package that Lowcoder users install. The reference implementation uses React, TypeScript, Vite, `lowcoder-sdk`, and `lowcoder-cli`, but the idea is not limited to React as a design choice. A developer can use another UI technology if the final package is compatible with Lowcoder's plugin loading model and exposes the required Lowcoder component/container behavior. + +For most teams, React is still the recommended path because the Lowcoder SDK examples and the current template demo are React-based. + +Developers should prepare: + +- a reusable layout shell, such as a dashboard, portal, landing page, sidebar layout, or admin panel +- one or more editable drop-zones where users can place Lowcoder components +- template names, descriptions, and icons for the editor +- `package.json` metadata under `lowcoder.comps` +- an export map from `src/index.ts` +- an npm package published publicly or available through a configured private registry + +The reference implementation is available in the Lowcoder component plugin examples: + +```text +lowcoder-create-component-plugin/lowcoder-comp-templates-demo/lowcoder-templates/ +``` + +The published demo package is: + +```text +lowcoder-comp-template-demos +``` + +## Development model + +A template package usually has two parts: + + +| Part | Responsibility | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Fixed layout | Built by the developer in the package. This can include navigation, headers, sidebars, cards, tabs, static sections, and other structure. | +| Drop-zones | Editable Lowcoder areas where users can drag components and continue building visually. | + + +The important rule is to keep the layout shell in code and expose only the parts that should remain editable as Lowcoder drop-zones. This avoids forcing users to manage a deeply nested native Lowcoder layout while still giving them control over the business-specific parts of the app. + +## Package registration + +Lowcoder discovers templates from the `lowcoder.comps` section in `package.json`. + +Example: + +```json +{ + "name": "my-template-package", + "version": "0.1.0", + "lowcoder": { + "description": "Reusable Lowcoder layout templates", + "comps": { + "dashboardTemplate": { + "name": "Dashboard Template", + "icon": "./icons/dashboard.svg", + "description": "Dashboard layout with a main content drop-zone", + "isContainer": true, + "layoutInfo": { + "w": 24, + "h": 80, + "delayCollision": true + } + } + } + } +} +``` + +Important fields: + + +| Field | Purpose | +| ------------- | -------------------------------------------------------- | +| `name` | The template name shown to Lowcoder users. | +| `icon` | The icon shown in the Templates list. | +| `description` | A short explanation of the layout. | +| `isContainer` | Required when the template contains editable drop-zones. | +| `layoutInfo` | Default canvas size and placement behavior. | + + +The key inside `lowcoder.comps`, for example `dashboardTemplate`, must match the component export in `src/index.ts`. + +Example: + +```ts +import DashboardTemplateComp from "./templates/dashboard/DashboardTemplateComp"; + +export default { + dashboardTemplate: DashboardTemplateComp, +}; +``` + +## Developer checklist + +Before publishing a template package, check that: + +- each template has a clear name, description, and icon +- each editable area is implemented as a Lowcoder drop-zone +- templates with drop-zones are marked with `"isContainer": true` +- every `lowcoder.comps` key is exported from `src/index.ts` +- the package builds successfully with the Lowcoder plugin build flow +- the package is published to npm or available from the configured private registry +- users know which package name or URL to add in Lowcoder