diff --git a/.gitignore b/.gitignore index afe5fd86a..8758dff24 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ application-dev-localhost.yaml .vscode/launch.json server/api-service/lowcoder-server/src/main/resources/application-local-dev.yaml translations/locales/node_modules/ +.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 9cae45514..495ac31a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "titleBar.activeBackground": "#3B431A", "titleBar.activeForeground": "#F9FAF2" }, - "java.debug.settings.onBuildFailureProceed": true + "java.debug.settings.onBuildFailureProceed": true, + "java.configuration.updateBuildConfiguration": "automatic" } \ No newline at end of file diff --git a/client/packages/lowcoder/src/api/applicationApi.ts b/client/packages/lowcoder/src/api/applicationApi.ts index 827df5032..d38ee1843 100644 --- a/client/packages/lowcoder/src/api/applicationApi.ts +++ b/client/packages/lowcoder/src/api/applicationApi.ts @@ -9,6 +9,7 @@ import { PublishApplicationPayload, RecycleApplicationPayload, RestoreApplicationPayload, + SetAppEditingStatePayload, UpdateAppPermissionPayload, } from "redux/reduxActions/applicationActions"; import { ApiResponse, GenericApiResponse } from "./apiResponses"; @@ -96,7 +97,7 @@ class ApplicationApi extends Api { static publicToAllURL = (applicationId: string) => `/applications/${applicationId}/public-to-all`; static publicToMarketplaceURL = (applicationId: string) => `/applications/${applicationId}/public-to-marketplace`; static getMarketplaceAppURL = (applicationId: string) => `/applications/${applicationId}/view_marketplace`; - + static setAppEditingStateURL = (applicationId: string) => `/applications/editState/${applicationId}`; static fetchHomeData(request: HomeDataPayload): AxiosPromise { return Api.get(ApplicationApi.fetchHomeDataURL, request); @@ -232,6 +233,13 @@ class ApplicationApi extends Api { static getMarketplaceApp(appId: string) { return Api.get(ApplicationApi.getMarketplaceAppURL(appId)); } + + static setAppEditingState(request: SetAppEditingStatePayload): AxiosPromise { + const { applicationId, editingFinished } = request; + return Api.put(ApplicationApi.setAppEditingStateURL(applicationId), { + editingFinished, + }); + } } export default ApplicationApi; diff --git a/client/packages/lowcoder/src/constants/applicationConstants.ts b/client/packages/lowcoder/src/constants/applicationConstants.ts index 711bee4e6..22a134da6 100644 --- a/client/packages/lowcoder/src/constants/applicationConstants.ts +++ b/client/packages/lowcoder/src/constants/applicationConstants.ts @@ -91,6 +91,8 @@ export interface ApplicationMeta { folder: false; isLocalMarketplace?: boolean; applicationStatus: "NORMAL" | "RECYCLED" | "DELETED"; + editingUserId: string | null; + lastEditedAt: number; } export interface FolderMeta { diff --git a/client/packages/lowcoder/src/constants/reduxActionConstants.ts b/client/packages/lowcoder/src/constants/reduxActionConstants.ts index 9a3a49886..66178633c 100644 --- a/client/packages/lowcoder/src/constants/reduxActionConstants.ts +++ b/client/packages/lowcoder/src/constants/reduxActionConstants.ts @@ -144,6 +144,7 @@ export const ReduxActionTypes = { FETCH_ALL_MODULES_SUCCESS: "FETCH_ALL_MODULES_SUCCESS", FETCH_ALL_MARKETPLACE_APPS: "FETCH_ALL_MARKETPLACE_APPS", FETCH_ALL_MARKETPLACE_APPS_SUCCESS: "FETCH_ALL_MARKETPLACE_APPS_SUCCESS", + SET_APP_EDITING_STATE: "SET_APP_EDITING_STATE", /* user profile */ SET_USER_PROFILE_SETTING_MODAL_VISIBLE: "SET_USER_PROFILE_SETTING_MODAL_VISIBLE", diff --git a/client/packages/lowcoder/src/i18n/locales/de.ts b/client/packages/lowcoder/src/i18n/locales/de.ts index e85153aa5..fbf0ed6fe 100644 --- a/client/packages/lowcoder/src/i18n/locales/de.ts +++ b/client/packages/lowcoder/src/i18n/locales/de.ts @@ -2986,7 +2986,13 @@ export const de = { "editorMode_both": "Beide", "editorMode_layout_tooltip": "Passen Sie im rechten Fenster das Erscheinungsbild und Layout der Komponente an. Passen Sie Aussehen, Stil und Animationen an.", "editorMode_logic_tooltip": "Richten Sie im rechten Fenster ein, wie Ihre Komponente funktioniert und interagiert. Verwalten Sie den Inhalt und das interaktive Verhalten.", - }, + "AppEditingBlocked": "App-Bearbeitung blockiert für:", + "AppEditingBlockedHint": "Änderungen werden nicht gespeichert, während ein anderer Benutzer diese App bearbeitet.", + "AppEditingBlockedMessage": "Bitte warten Sie, bevor Sie den Bearbeitungsstatus der App überprüfen.", + "AppEditingBlockedCheckStatus": "App-Status prüfen", + "AppEditingBlockedSomeone": "Jemand", + "AppEditingBlockedMessageSnipped": "bearbeitet diese App", +}, "userAuth": { ...en.userAuth, diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 502468233..f2d94d0d3 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -2922,7 +2922,13 @@ export const en = { "editorMode_logic": "Logic", "editorMode_both": "Both", "editorMode_layout_tooltip": "Customise the component's appearance and layout, in the right window. Adjust how it looks, styles, and animates.", - "editorMode_logic_tooltip": "Set up how your component works and interacts, in the right window. Manage its content and interactive behaviours." + "editorMode_logic_tooltip": "Set up how your component works and interacts, in the right window. Manage its content and interactive behaviours.", + "AppEditingBlocked": "App Editing Blocked for:", + "AppEditingBlockedHint": "Changes will not be saved while another user is editing this app.", + "AppEditingBlockedMessage": "Please wait before check the editing status of the App.", + "AppEditingBlockedCheckStatus": "Check App Status", + "AppEditingBlockedSomeone": "Someone", + "AppEditingBlockedMessageSnipped": "is editing this app", }, "userAuth": { "registerByEmail": "Sign Up", diff --git a/client/packages/lowcoder/src/i18n/locales/es.ts b/client/packages/lowcoder/src/i18n/locales/es.ts index c87c4a7ce..73b07120b 100644 --- a/client/packages/lowcoder/src/i18n/locales/es.ts +++ b/client/packages/lowcoder/src/i18n/locales/es.ts @@ -2986,6 +2986,12 @@ export const es = { "editorMode_both": "Ambos", "editorMode_layout_tooltip": "Personalice la apariencia y el diseño del componente en la ventana derecha. Ajusta su aspecto, estilo y animación.", "editorMode_logic_tooltip": "Configure el funcionamiento y la interacción de su componente, en la ventana adecuada. Gestione su contenido y sus comportamientos interactivos.", + "AppEditingBlocked": "Edición de la App bloqueada para:", + "AppEditingBlockedHint": "Los cambios no se guardarán mientras otro usuario esté editando esta aplicación.", + "AppEditingBlockedMessage": "Por favor, espere antes de verificar el estado de edición de la aplicación.", + "AppEditingBlockedCheckStatus": "Verificar estado de la aplicación", + "AppEditingBlockedSomeone": "Alguien", + "AppEditingBlockedMessageSnipped": "está editando esta aplicación", }, "userAuth": { ...en.userAuth, diff --git a/client/packages/lowcoder/src/i18n/locales/it.ts b/client/packages/lowcoder/src/i18n/locales/it.ts index c0e56de33..8d66e9957 100644 --- a/client/packages/lowcoder/src/i18n/locales/it.ts +++ b/client/packages/lowcoder/src/i18n/locales/it.ts @@ -2986,6 +2986,12 @@ export const it = { "editorMode_both": "Entrambi", "editorMode_layout_tooltip": "Personalizzare l'aspetto e il layout del componente, nella finestra di destra. Regolate l'aspetto, gli stili e le animazioni.", "editorMode_logic_tooltip": "Impostate il funzionamento e l'interazione del vostro componente, nella finestra giusta. Gestite il suo contenuto e i suoi comportamenti interattivi.", + "AppEditingBlocked": "Modifica dell'app bloccata per:", + "AppEditingBlockedHint": "Le modifiche non verranno salvate mentre un altro utente sta modificando questa app.", + "AppEditingBlockedMessage": "Attendere prima di controllare lo stato di modifica dell'app.", + "AppEditingBlockedCheckStatus": "Controlla lo stato dell'app", + "AppEditingBlockedSomeone": "Qualcuno", + "AppEditingBlockedMessageSnipped": "sta modificando questa app", }, "userAuth": { ...en.userAuth, diff --git a/client/packages/lowcoder/src/i18n/locales/pt.ts b/client/packages/lowcoder/src/i18n/locales/pt.ts index 7be71ee23..69a17936b 100644 --- a/client/packages/lowcoder/src/i18n/locales/pt.ts +++ b/client/packages/lowcoder/src/i18n/locales/pt.ts @@ -2986,6 +2986,12 @@ export const pt = { "editorMode_both": "Ambos", "editorMode_layout_tooltip": "Personalize a aparência e o layout do componente, na janela direita. Ajuste sua aparência, estilo e animação.", "editorMode_logic_tooltip": "Configure como seu componente funciona e interage, na janela direita. Gerencie seu conteúdo e comportamentos interativos.", + "AppEditingBlocked": "Edição do aplicativo bloqueada para:", + "AppEditingBlockedHint": "As alterações não serão salvas enquanto outro usuário estiver editando este aplicativo.", + "AppEditingBlockedMessage": "Por favor, aguarde antes de verificar o status de edição do aplicativo.", + "AppEditingBlockedCheckStatus": "Verificar status do aplicativo", + "AppEditingBlockedSomeone": "Alguém", + "AppEditingBlockedMessageSnipped": "está editando este aplicativo", }, "userAuth": { ...en.userAuth, diff --git a/client/packages/lowcoder/src/i18n/locales/ru.ts b/client/packages/lowcoder/src/i18n/locales/ru.ts index fca96eed8..36a817c07 100644 --- a/client/packages/lowcoder/src/i18n/locales/ru.ts +++ b/client/packages/lowcoder/src/i18n/locales/ru.ts @@ -2986,6 +2986,12 @@ export const ru = { "editorMode_both": "Оба", "editorMode_layout_tooltip": "Настройте внешний вид и расположение компонента в правом окне. Настройте его внешний вид, стили и анимацию.", "editorMode_logic_tooltip": "Настройте работу и взаимодействие вашего компонента в нужном окне. Управляйте его содержимым и интерактивным поведением.", + "AppEditingBlocked": "Редактирование приложения заблокировано для:", + "AppEditingBlockedHint": "Изменения не будут сохранены, пока другой пользователь редактирует это приложение.", + "AppEditingBlockedMessage": "Пожалуйста, подождите, прежде чем проверить статус редактирования приложения.", + "AppEditingBlockedCheckStatus": "Проверить статус приложения", + "AppEditingBlockedSomeone": "Кто-то", + "AppEditingBlockedMessageSnipped": "редактирует это приложение", }, "userAuth": { ...en.userAuth, diff --git a/client/packages/lowcoder/src/i18n/locales/zh.ts b/client/packages/lowcoder/src/i18n/locales/zh.ts index 80fcbd7e9..78133ccda 100644 --- a/client/packages/lowcoder/src/i18n/locales/zh.ts +++ b/client/packages/lowcoder/src/i18n/locales/zh.ts @@ -2352,7 +2352,13 @@ export const zh: typeof en = { editorMode_logic: "应用程序逻辑", editorMode_both: "两者", editorMode_layout_tooltip: "在右侧窗口中自定义组件的外观和布局。调整它的外观、样式和动画。", - editorMode_logic_tooltip: "在右侧窗口中设置组件的工作和交互方式。管理其内容和交互行为。" + editorMode_logic_tooltip: "在右侧窗口中设置组件的工作和交互方式。管理其内容和交互行为。", + "AppEditingBlocked": "应用编辑已被阻止:", + "AppEditingBlockedHint": "在其他用户编辑此应用时,无法保存更改。", + "AppEditingBlockedMessage": "请稍候再检查应用的编辑状态。", + "AppEditingBlockedCheckStatus": "检查应用状态", + "AppEditingBlockedSomeone": "某人", + "AppEditingBlockedMessageSnipped": "正在编辑此应用", }, userAuth: { ...en.userAuth, diff --git a/client/packages/lowcoder/src/pages/common/header.tsx b/client/packages/lowcoder/src/pages/common/header.tsx index b37178659..21844023e 100644 --- a/client/packages/lowcoder/src/pages/common/header.tsx +++ b/client/packages/lowcoder/src/pages/common/header.tsx @@ -1,6 +1,10 @@ import { default as Dropdown } from "antd/es/dropdown"; import { default as Skeleton } from "antd/es/skeleton"; import { default as Radio, RadioChangeEvent } from "antd/es/radio"; +import { default as Statistic} from "antd/es/statistic"; +import { default as Flex} from "antd/es/flex"; +import { default as Popover } from "antd/es/popover"; +import { default as Typography } from "antd/es/typography"; import LayoutHeader from "components/layout/Header"; import { SHARE_TITLE } from "constants/apiConstants"; import { AppTypeEnum } from "constants/applicationConstants"; @@ -9,7 +13,7 @@ import { AUTH_LOGIN_URL, preview, } from "constants/routesURL"; -import { User } from "constants/userConstants"; +import { CurrentUser, User } from "constants/userConstants"; import { CommonTextLabel, CustomModal, @@ -20,12 +24,13 @@ import { Middle, ModuleIcon, PackUpIcon, + RefreshIcon, Right, TacoButton, } from "lowcoder-design"; import { trans } from "i18n"; import dayjs from "dayjs"; -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { publishApplication, @@ -54,6 +59,14 @@ import { getBrandingConfig } from "../../redux/selectors/configSelectors"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import { EditorContext } from "../../comps/editorState"; import Tooltip from "antd/es/tooltip"; +import { LockOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import Avatar from 'antd/es/avatar'; +import UserApi from "@lowcoder-ee/api/userApi"; +import { validateResponse } from "@lowcoder-ee/api/apiUtils"; +import ProfileImage from "./profileImage"; + +const { Countdown } = Statistic; +const { Text } = Typography; const StyledLink = styled.a` display: flex; @@ -181,6 +194,10 @@ const GrayBtn = styled(TacoButton)` color: #ffffff; border: none; } + + &[disabled] { + cursor: not-allowed; + } } `; @@ -234,12 +251,6 @@ const DropdownStyled = styled(Dropdown)` } `; -const DropdownMenuStyled = styled(DropdownMenu)` - .ant-dropdown-menu-item:hover { - background: #edf4fa; - } -`; - const Wrapper = styled.div` .taco-edit-text-wrapper { width: fit-content; @@ -262,6 +273,54 @@ const Prefix = styled.div` } `; +const EditingNoticeWrapper = styled.div` + display: flex; + align-items: center; + background-color: #ffe6e6; /* Light red background for warning */ + padding: 2px 8px; + border-radius: 5px; + margin-right: 8px; +`; + +const EditingHintText = styled.span` + margin-left: 8px; + font-size: 12px; + color: #ff4d4f; /* Red color to indicate warning */ +`; + +const WarningIcon = styled(ExclamationCircleOutlined)` + margin-left: 8px; + font-size: 16px; + color: #ff4d4f; /* Red color for the icon */ +`; + +const StyledCountdown = styled(Countdown)` + .ant-statistic-content { + color: #ff4d4f; + margin-top: 2px; + text-align: center; + } +`; + +const StyledRefreshIcon = styled(RefreshIcon)` + width: 16px !important; + height: 16px !important; + margin-right: -3px !important; + > g > g { + stroke: white; + } +`; + +// Add the lock icon logic for disabled options +const DropdownMenuStyled = styled(DropdownMenu)` + .ant-dropdown-menu-item:hover { + background: ${(props) => + props.disabled ? 'inherit' : '#edf4fa'}; + cursor: ${(props) => + props.disabled ? 'not-allowed' : 'pointer'}; + } +`; + function HeaderProfile(props: { user: User }) { const { user } = props; const fetchingUser = useSelector(isFetchingUser); @@ -284,6 +343,8 @@ function HeaderProfile(props: { user: User }) { ); } +const setCountdown = () => dayjs().add(3, 'minutes').toISOString(); + export type PanelStatus = { left: boolean; bottom: boolean; right: boolean }; export type TogglePanel = (panel?: keyof PanelStatus) => void; @@ -302,6 +363,7 @@ type HeaderProps = { // header in editor page export default function Header(props: HeaderProps) { const editorState = useContext(EditorContext); + const { blockEditing, fetchApplication } = useContext(ExternalEditorContext); const { togglePanel } = props; const { toggleEditorModeStatus } = props; const { left, bottom, right } = props.panelStatus; @@ -315,9 +377,25 @@ export default function Header(props: HeaderProps) { const [editName, setEditName] = useState(false); const [editing, setEditing] = useState(false); const [permissionDialogVisible, setPermissionDialogVisible] = useState(false); + const [editingUser, setEditingUser] = useState(); + const [enableCheckEditingStatus, setEnableCheckEditingStatus] = useState(false); + const editingCountdown = useRef(setCountdown()); const isModule = appType === AppTypeEnum.Module; + useEffect(() => { + if(blockEditing && application && Boolean(application?.editingUserId)) { + UserApi.getUserDetail(application.editingUserId!) + .then(resp => { + if (validateResponse(resp)) { + + console.log('editing user', resp.data.data); + setEditingUser(resp.data.data); + } + }); + } + }, [blockEditing]); + const editorModeOptions = [ { label: trans("header.editorMode_layout"), @@ -391,8 +469,6 @@ export default function Header(props: HeaderProps) { const headerMiddle = ( <> - <> - ) : ( <> + {/* Display a hint about who is editing the app */} + {blockEditing && ( + <> + { + return ( + + + {trans("header.AppEditingBlockedHint")} + + { + setEnableCheckEditingStatus(true) + }} + /> + + { + fetchApplication?.(); + setEnableCheckEditingStatus(false); + editingCountdown.current = setCountdown(); + }} + > + + {trans("header.AppEditingBlockedCheckStatus")} + + + + ) + }} + trigger="hover" + > + + + + {`${editingUser?.email || trans("header.AppEditingBlockedSomeone")}` + " " + trans("header.AppEditingBlockedMessageSnipped")} + + + + + + )} + {applicationId && ( )} {canManageApp(user, application) && ( - setPermissionDialogVisible(true)}> + setPermissionDialogVisible(true)} disabled={blockEditing}> {SHARE_TITLE} )} + preview(applicationId)}> {trans("header.preview")} - + { + if (blockEditing) return; // Prevent clicks if the app is being edited by someone else if (e.key === "deploy") { dispatch(publishApplication({ applicationId })); } else if (e.key === "snapshot") { @@ -494,24 +624,36 @@ export default function Header(props: HeaderProps) { { key: "deploy", label: ( - {trans("header.deploy")} +
+ {blockEditing && } + + {trans("header.deploy")} + +
), + disabled: blockEditing, }, { key: "snapshot", label: ( - {trans("header.snapshot")} +
+ {blockEditing && } + + {trans("header.snapshot")} + +
), + disabled: blockEditing, }, ]} /> )} > - +
- + ); @@ -520,6 +662,9 @@ export default function Header(props: HeaderProps) { showAppSnapshot, applicationId, permissionDialogVisible, + blockEditing, // Include the state in the dependency array + enableCheckEditingStatus, + editingUser?.name, ]); return ( diff --git a/client/packages/lowcoder/src/pages/editor/AppEditor.tsx b/client/packages/lowcoder/src/pages/editor/AppEditor.tsx index b11a4fce0..c9e03a701 100644 --- a/client/packages/lowcoder/src/pages/editor/AppEditor.tsx +++ b/client/packages/lowcoder/src/pages/editor/AppEditor.tsx @@ -32,6 +32,9 @@ import { ALL_APPLICATIONS_URL } from "@lowcoder-ee/constants/routesURL"; import history from "util/history"; import Flex from "antd/es/flex"; import React from "react"; +import dayjs from "dayjs"; +import { currentApplication } from "@lowcoder-ee/redux/selectors/applicationSelector"; +import { notificationInstance } from "components/GlobalInstances"; const AppSnapshot = lazy(() => { return import("pages/editor/appSnapshot") @@ -44,21 +47,39 @@ const AppEditorInternalView = lazy( ); const AppEditor = React.memo(() => { - const showAppSnapshot = useSelector(showAppSnapshotSelector); + const dispatch = useDispatch(); const params = useParams(); const isUserViewModeCheck = useUserViewMode(); - const isUserViewMode = params.viewMode ? isUserViewModeCheck : true; - const applicationId = params.applicationId || window.location.pathname.split("/")[2]; - const paramViewMode = params.viewMode || window.location.pathname.split("/")[3]; - const viewMode = (paramViewMode === "view" || paramViewMode === "admin") ? "published" : paramViewMode === "view_marketplace" ? "view_marketplace" : "editing"; + const showAppSnapshot = useSelector(showAppSnapshotSelector); const currentUser = useSelector(getUser); - const dispatch = useDispatch(); const fetchOrgGroupsFinished = useSelector(getFetchOrgGroupsFinished); const isCommonSettingsFetching = useSelector(getIsCommonSettingFetching); - const orgId = currentUser.currentOrgId; + const application = useSelector(currentApplication); + + const isUserViewMode = useMemo( + () => params.viewMode ? isUserViewModeCheck : true, + [params.viewMode, isUserViewModeCheck] + ); + const applicationId = useMemo( + () => params.applicationId || window.location.pathname.split("/")[2], + [params.applicationId, window.location.pathname] + ); + const paramViewMode = useMemo( + () => params.viewMode || window.location.pathname.split("/")[3], + [params.viewMode, window.location.pathname] + ); + const viewMode = useMemo( + () => (paramViewMode === "view" || paramViewMode === "admin") + ? "published" + : paramViewMode === "view_marketplace" ? "view_marketplace" : "editing", + [paramViewMode] + ); + const firstRendered = useRef(false); + const orgId = useMemo(() => currentUser.currentOrgId, [currentUser.currentOrgId]); const [isDataSourcePluginRegistered, setIsDataSourcePluginRegistered] = useState(false); const [appError, setAppError] = useState(''); + const [blockEditing, setBlockEditing] = useState(false); setGlobalSettings({ applicationId, isViewMode: paramViewMode === "view" }); @@ -83,7 +104,21 @@ const AppEditor = React.memo(() => { }); const readOnly = isUserViewMode; - const compInstance = useRootCompInstance(appInfo, readOnly, isDataSourcePluginRegistered); + const compInstance = useRootCompInstance( + appInfo, + readOnly, + isDataSourcePluginRegistered, + blockEditing, + ); + + useEffect(() => { + if (currentUser && application) { + const lastEditedAt = dayjs(application?.lastEditedAt); + const lastEditedDiff = dayjs().diff(lastEditedAt, 'minutes'); + const shouldBlockEditing = currentUser.id !== application?.editingUserId && lastEditedDiff < 3; + setBlockEditing(shouldBlockEditing); + } + }, [application, currentUser]); // fetch dataSource and plugin useEffect(() => { @@ -101,7 +136,7 @@ const AppEditor = React.memo(() => { } }, [dispatch, applicationId, paramViewMode]); - const fetchJSDataSourceByApp = () => { + const fetchJSDataSourceByApp = useCallback(() => { DatasourceApi.fetchJsDatasourceByApp(applicationId).then((res) => { res.data.data.forEach((i) => { registryDataSourcePlugin(i.type, i.id, i.pluginDefinition); @@ -109,7 +144,13 @@ const AppEditor = React.memo(() => { setIsDataSourcePluginRegistered(true); }); dispatch(setShowAppSnapshot(false)); - }; + }, [ + applicationId, + registryDataSourcePlugin, + setIsDataSourcePluginRegistered, + setShowAppSnapshot, + dispatch, + ]); useEffect(() => { if (!fetchOrgGroupsFinished) { @@ -117,7 +158,7 @@ const AppEditor = React.memo(() => { } }, [dispatch, fetchOrgGroupsFinished, orgId]); - useEffect(() => { + const fetchApplication = useCallback(() => { dispatch( fetchApplicationInfo({ type: viewMode, @@ -140,7 +181,11 @@ const AppEditor = React.memo(() => { } }) ); - }, [viewMode, applicationId, dispatch]); + }, [viewMode, applicationId, dispatch, fetchJSDataSourceByApp]); + + useEffect(() => { + fetchApplication(); + }, [fetchApplication]); const fallbackUI = useMemo(() => ( { )} diff --git a/client/packages/lowcoder/src/pages/editor/appEditorInternal.tsx b/client/packages/lowcoder/src/pages/editor/appEditorInternal.tsx index 1ece41909..f80f3fd47 100644 --- a/client/packages/lowcoder/src/pages/editor/appEditorInternal.tsx +++ b/client/packages/lowcoder/src/pages/editor/appEditorInternal.tsx @@ -35,7 +35,8 @@ import { isEqual } from "lodash"; function useSaveComp( applicationId: string, readOnly: boolean, - rootCompInstance: RootCompInstanceType | undefined + rootCompInstance: RootCompInstanceType | undefined, + blockEditing?: boolean, ) { const originalComp = rootCompInstance?.comp; // throttle comp change @@ -45,7 +46,7 @@ function useSaveComp( const [prevJsonStr, setPrevJsonStr] = useState(); useEffect(() => { - if (readOnly) { + if (readOnly || blockEditing) { return; } if (!comp || comp === prevComp) { @@ -74,16 +75,18 @@ function useSaveComp( interface AppEditorInternalViewProps { readOnly: boolean; + blockEditing?: boolean; appInfo: AppSummaryInfo; loading: boolean; compInstance: RootCompInstanceType; + fetchApplication?: () => void; } export const AppEditorInternalView = React.memo((props: AppEditorInternalViewProps) => { const isUserViewMode = useUserViewMode(); const extraExternalEditorState = useSelector(getExternalEditorState); const dispatch = useDispatch(); - const { readOnly, appInfo, compInstance } = props; + const { readOnly, blockEditing, appInfo, compInstance, fetchApplication } = props; const [externalEditorState, setExternalEditorState] = useState({ changeExternalState: (state: Partial) => { @@ -92,8 +95,7 @@ export const AppEditorInternalView = React.memo((props: AppEditorInternalViewPro applicationId: appInfo.id, appType: AppTypeEnum.Application, }); - useSaveComp(appInfo.id, readOnly, compInstance); - + useEffect(() => { setExternalEditorState((s) => ({ ...s, @@ -102,9 +104,18 @@ export const AppEditorInternalView = React.memo((props: AppEditorInternalViewPro appType: appInfo.appType, applicationId: appInfo.id, hideHeader: window.location.pathname.split("/")[3] === "admin", + blockEditing, + fetchApplication: fetchApplication, ...extraExternalEditorState, })); - }, [compInstance?.history, extraExternalEditorState, readOnly, appInfo.appType, appInfo.id]); + }, [ + compInstance?.history, + extraExternalEditorState, + readOnly, + appInfo.appType, appInfo.id, + blockEditing, + fetchApplication, + ]); useEffect(() => { message.config({ @@ -112,6 +123,8 @@ export const AppEditorInternalView = React.memo((props: AppEditorInternalViewPro }); }, [isUserViewMode]); + useSaveComp(appInfo.id, readOnly, compInstance, blockEditing); + const loading = !compInstance || !compInstance.comp || !compInstance.comp.preloaded || props.loading; diff --git a/client/packages/lowcoder/src/pages/editor/useRootCompInstance.tsx b/client/packages/lowcoder/src/pages/editor/useRootCompInstance.tsx index a289d3321..15411f8c8 100644 --- a/client/packages/lowcoder/src/pages/editor/useRootCompInstance.tsx +++ b/client/packages/lowcoder/src/pages/editor/useRootCompInstance.tsx @@ -7,7 +7,12 @@ import { useCompInstance } from "comps/utils/useCompInstance"; import { MarkAppInitialized, perfMark } from "util/perfUtils"; import { QueryApi } from "api/queryApi"; -export function useRootCompInstance(appInfo: AppSummaryInfo, readOnly: boolean, isReady: boolean) { +export function useRootCompInstance( + appInfo: AppSummaryInfo, + readOnly: boolean, + isReady: boolean, + blockEditing?: boolean, +) { const appId = appInfo.id; const params = useMemo(() => { return { @@ -28,7 +33,7 @@ export function useRootCompInstance(appInfo: AppSummaryInfo, readOnly: boolean, }; }, [appId, appInfo.dsl, appInfo.moduleDsl, isReady, readOnly]); const [comp, container] = useCompInstance(params); - const history = useAppHistory(container, readOnly, appId); + const history = useAppHistory(container, readOnly, appId, blockEditing); useUnmount(() => { comp?.clearPreload(); diff --git a/client/packages/lowcoder/src/redux/reduxActions/applicationActions.ts b/client/packages/lowcoder/src/redux/reduxActions/applicationActions.ts index 15a69d7fe..6d3a0310c 100644 --- a/client/packages/lowcoder/src/redux/reduxActions/applicationActions.ts +++ b/client/packages/lowcoder/src/redux/reduxActions/applicationActions.ts @@ -134,6 +134,12 @@ export type FetchAppInfoPayload = { onSuccess?: (info: AppSummaryInfo) => void; onError?: (error: string) => void; }; + +export type SetAppEditingStatePayload = { + applicationId: string; + editingFinished: boolean; +}; + export const fetchApplicationInfo = (payload: FetchAppInfoPayload) => ({ type: ReduxActionTypes.FETCH_APPLICATION_DETAIL, payload: payload, @@ -170,3 +176,8 @@ export const deleteAppPermission = (payload: DeleteAppPermissionPayload) => ({ type: ReduxActionTypes.DELETE_APP_PERMISSION, payload: payload, }); + +export const setAppEditingState = (payload: SetAppEditingStatePayload) => ({ + type: ReduxActionTypes.SET_APP_EDITING_STATE, + payload: payload, +}); diff --git a/client/packages/lowcoder/src/redux/sagas/applicationSagas.ts b/client/packages/lowcoder/src/redux/sagas/applicationSagas.ts index d5d1b5a31..b6299d626 100644 --- a/client/packages/lowcoder/src/redux/sagas/applicationSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/applicationSagas.ts @@ -22,6 +22,7 @@ import { PublishApplicationPayload, RecycleApplicationPayload, RestoreApplicationPayload, + SetAppEditingStatePayload, UpdateApplicationPayload, UpdateAppMetaPayload, UpdateAppPermissionPayload, @@ -391,6 +392,17 @@ function* fetchAllMarketplaceAppsSaga() { } } +function* setAppEditingStateSaga(action: ReduxAction) { + try { + yield call( + ApplicationApi.setAppEditingState, + action.payload + ); + } catch (error) { + log.debug("set app editing state: ", error); + } +} + export default function* applicationSagas() { yield all([ takeLatest(ReduxActionTypes.FETCH_HOME_DATA, fetchHomeDataSaga), @@ -416,5 +428,6 @@ export default function* applicationSagas() { ReduxActionTypes.FETCH_ALL_MARKETPLACE_APPS, fetchAllMarketplaceAppsSaga, ), + takeLatest(ReduxActionTypes.SET_APP_EDITING_STATE, setAppEditingStateSaga), ]); } diff --git a/client/packages/lowcoder/src/util/context/ExternalEditorContext.ts b/client/packages/lowcoder/src/util/context/ExternalEditorContext.ts index 21d1bf482..ab1ad3a5c 100644 --- a/client/packages/lowcoder/src/util/context/ExternalEditorContext.ts +++ b/client/packages/lowcoder/src/util/context/ExternalEditorContext.ts @@ -38,6 +38,15 @@ export interface ExternalEditorContextState { */ showScriptsAndStyleModal?: boolean; + /** + * whether to block editing if someone else is editing the app + */ + blockEditing?: boolean; + /** + * passing this function to refresh app from header + */ + fetchApplication?: () => void; + changeExternalState?: (state: Partial) => void; } diff --git a/client/packages/lowcoder/src/util/editoryHistory.ts b/client/packages/lowcoder/src/util/editoryHistory.ts index 421871c52..3f5a8496c 100644 --- a/client/packages/lowcoder/src/util/editoryHistory.ts +++ b/client/packages/lowcoder/src/util/editoryHistory.ts @@ -12,7 +12,12 @@ import { showCost } from "util/perfUtils"; type OperationType = AppSnapshotContext["operations"]; -export function useAppHistory(compContainer: CompContainer, readOnly: boolean, appId: string) { +export function useAppHistory( + compContainer: CompContainer, + readOnly: boolean, + appId: string, + blockEditing?: boolean, +) { const reduxDispatch = useDispatch(); return useMemo(() => { @@ -32,7 +37,7 @@ export function useAppHistory(compContainer: CompContainer, readOnly: boolean, a }; compContainer.addChangeListener((actions) => { - if (readOnly || !actions) { + if (readOnly || !actions || blockEditing) { return; } // maybe slow: comparing dsl by `toJson` @@ -41,7 +46,7 @@ export function useAppHistory(compContainer: CompContainer, readOnly: boolean, a showCost("addHistory", () => addHistory(actions)); }); return history; - }, [appId, compContainer, reduxDispatch, readOnly]); + }, [appId, compContainer, reduxDispatch, readOnly, blockEditing]); } /**