From 51de3a3cdd22f862c63057b0bf7a924a5c296f93 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Wed, 25 Jun 2025 13:40:15 +0500 Subject: [PATCH 001/352] Initialize tags component --- .../src/comps/comps/tagsComp/tagsCompView.tsx | 92 +++++++++++++++++++ .../src/comps/controls/optionsControl.tsx | 32 +++++++ client/packages/lowcoder/src/comps/index.tsx | 14 +++ .../lowcoder/src/comps/uiCompRegistry.ts | 1 + .../packages/lowcoder/src/i18n/locales/en.ts | 1 + .../src/pages/editor/editorConstants.tsx | 1 + 6 files changed, 141 insertions(+) create mode 100644 client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx diff --git a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx new file mode 100644 index 0000000000..9979c5a1b5 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx @@ -0,0 +1,92 @@ +import { AnimationStyle, BoolCodeControl, ButtonEventHandlerControl, CommonNameConfig, DropdownOptionControl, IconControl, LinkStyle, NameConfig, NameConfigDisabled, RefControl, Section, SelectOptionControl, StringControl, TabsOptionControl, TagsOptionControl, UICompBuilder, blurMethod, clickMethod, focusWithOptions, migrateOldData, refMethods, sectionNames, stringExposingStateControl, styleControl, withDefault, withExposingConfigs } from "@lowcoder-ee/index.sdk"; +import React from "react"; +import { trans } from "i18n"; +import { buttonRefMethods } from "../buttonComp/buttonCompConstants"; +import { Tag } from "antd"; +import { autoCompleteRefMethods } from "../autoCompleteComp/autoCompleteConstants"; + + +// const TagsCompView = (function () { +// // const childrenMap = { +// // text: withDefault(StringControl, trans("link.link")), +// // onEvent: ButtonEventHandlerControl, +// // disabled: BoolCodeControl, +// // loading: BoolCodeControl, + +// // // style: migrateOldData(styleControl(LinkStyle, 'style')), +// // animationStyle: styleControl(AnimationStyle, 'animationStyle'), +// // prefixIcon: IconControl, +// // suffixIcon: IconControl, +// // viewRef: RefControl, +// // }; + +// const childrenMap = { +// text: stringExposingStateControl("text", "world"), +// // options: TabsOptionControl, +// }; +// return new UICompBuilder(childrenMap, (props) => { +// return ( +// Tag 1 +// ) +// }) +// .setPropertyViewFn((children) => { +// return( +//
+// {/* {children.options.propertyView({})} */} +// {children.text.propertyView({ label: trans("text") })} +//
+// ) +// }) +// .build(); +// })(); + +const multiTags = (function () { + const childrenMap = { + text: stringExposingStateControl("text", "world"), + options: TagsOptionControl, + }; + + return new UICompBuilder(childrenMap, (props) => { + const text = props.text.value; + console.log(props.options) + return ( + <> + {props.options.map(tag => ( + {tag.label} + ))} + + ); + }) + .setPropertyViewFn((children: any) => { + return ( +
+ {children.options.propertyView({})} + {children.text.propertyView({ label: "Text" })} +
+ ) + }) + .build(); +})() + + +// const childrenMap = { +// text: stringExposingStateControl("text", "world"), +// options: TagsOptionControl, +// }; + +// const TagsCompView = new UICompBuilder(childrenMap, (props: any) => { +// const text = props.text.value; +// return
Hello {text}
; +// }) +// .setPropertyViewFn((children: any) => { +// return ( +//
+// {children.options.propertyView({})} +// {children.text.propertyView({ label: "Text" })} +//
+// ) +// }) +// .build(); + +export const MultiTagsComp = withExposingConfigs(multiTags, [new NameConfig("text", "")]); + diff --git a/client/packages/lowcoder/src/comps/controls/optionsControl.tsx b/client/packages/lowcoder/src/comps/controls/optionsControl.tsx index 1d36ec52c5..55e6554c63 100644 --- a/client/packages/lowcoder/src/comps/controls/optionsControl.tsx +++ b/client/packages/lowcoder/src/comps/controls/optionsControl.tsx @@ -557,6 +557,7 @@ const TabsOption = new MultiCompBuilder( )) .build(); + export const TabsOptionControl = manualOptionsControl(TabsOption, { initOptions: [ @@ -567,6 +568,37 @@ export const TabsOptionControl = manualOptionsControl(TabsOption, { autoIncField: "id", }); +const TagsOption = new MultiCompBuilder( + { + id: valueComp(-1), + label: StringControl, + icon: IconControl, + iconPosition: withDefault(LeftRightControl, "left"), + hidden: BoolCodeControl, + }, + (props) => props +) + .setPropertyViewFn((children) => ( + <> + {children.label.propertyView({ label: trans("label") })} + {children.icon.propertyView({ label: trans("icon") })} + {children.iconPosition.propertyView({ + label: trans("tabbedContainer.iconPosition"), + radioButton: true, + })} + {hiddenPropertyView(children)} + + )) + .build(); + +export const TagsOptionControl = optionsControl(TagsOption, { + initOptions: [ + { id: 0, label: "Option 1" }, + { id: 1, label: "Option 2" }, + ], + autoIncField: "id", +}); + const StyledIcon = styled.span` margin: 0 4px 0 14px; `; diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index 2395f4f290..00d54a9b2a 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -193,6 +193,7 @@ import { DrawerComp } from "./hooks/drawerComp"; import { ModalComp } from "./hooks/modalComp"; import { defaultCollapsibleContainerData } from "./comps/containerComp/collapsibleContainerComp"; import { ContainerComp as FloatTextContainerComp } from "./comps/containerComp/textContainerComp"; +import { MultiTagsComp } from "./comps/tagsComp/tagsCompView"; type Registry = { [key in UICompType]?: UICompManifest; @@ -709,6 +710,19 @@ export var uiCompMap: Registry = { }, defaultDataFn: defaultGridData, }, + multiTags: { + name: trans("tags"), + enName: "tags", + description: "Desc of Tags", + categories: ["layout"], + icon: FloatingButtonCompIcon, + keywords: trans("uiComp.floatButtonCompKeywords"), + comp: MultiTagsComp, + layoutInfo: { + w: 9, + h: 5, + }, + }, modal: { name: trans("uiComp.modalCompName"), enName: "Modal", diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 4c320de479..07f0e54b43 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -106,6 +106,7 @@ export type UICompType = | "container" | "pageLayout" // added by Falk Wolsky | "floatTextContainer" + | "multiTags" // Added by Kamal Qureshi | "tabbedContainer" | "modal" | "listView" diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 44f5f4b1dd..31750f7897 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -45,6 +45,7 @@ export const en = { "accessControl": "Access Control", "copySuccess": "Copied Successfully", "copyError": "Copy Error", + "tags": "Tags", "api": { "publishSuccess": "Published Successfully", diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index a931455d4b..9087b1e7d2 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -237,6 +237,7 @@ export const CompStateIcon: { step: , table: , text: , + multiTags: , timeline: , toggleButton: , tour: , From 1a2e96474e8bb60f3bb466aa45dbb884e6ace153 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 11:02:54 +0200 Subject: [PATCH 002/352] Update docker-images.yml Adding Enterprise Edition Docker Image --- .github/workflows/docker-images.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index d075f1fdce..97b1c85aa8 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -146,6 +146,24 @@ jobs: push: true tags: ${{ env.FRONTEND_IMAGE_NAMES }} + - name: Build and push the enterprise edition frontend image + if: ${{ env.BUILD_FRONTEND == 'true' }} + uses: docker/build-push-action@v6 + env: + NODE_ENV: production + with: + file: ./deploy/docker/Dockerfile + target: lowcoder-ce-frontend + build-args: | + REACT_APP_ENV=production + REACT_APP_EDITION=enterprise + REACT_APP_COMMIT_ID="dev #${{ env.SHORT_SHA }}" + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: ${{ env.FRONTEND_IMAGE_NAMES }} + - name: Build and push the node service image if: ${{ env.BUILD_NODESERVICE == 'true' }} uses: docker/build-push-action@v6 From 3ca7794a3b4ad5896928ed557b42da9a6e402830 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 15:14:40 +0500 Subject: [PATCH 003/352] added ee build command --- client/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/package.json b/client/package.json index 12f93a4aa7..08ebeac4ce 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "start:ee": "REACT_APP_EDITION=enterprise yarn workspace lowcoder start", "translate": "node --loader ts-node/esm ./scripts/translate.js", "build": "yarn node ./scripts/build.js", + "build:ee": "REACT_APP_EDITION=enterprise yarn node ./scripts/build.js", "test": "jest && yarn workspace lowcoder-comps test", "prepare": "yarn workspace lowcoder prepare", "build:core": "yarn workspace lowcoder-core build", From c6d018c7a62de99deea19bdbda475ccee1a343d9 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 15:27:52 +0500 Subject: [PATCH 004/352] updated Dockerfile to add separate lowcoder-ee-frontend image --- deploy/docker/Dockerfile | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 5ecbbd579d..2c26de9598 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -185,6 +185,84 @@ EXPOSE 3443 ############################################################################# +## +## Build lowcoder client (Enterprise) application +## +FROM node:20.2-slim AS build-client-ee + +# curl is required for yarn build to succeed, because it calls it while building client +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + +# Build client +COPY ./client /lowcoder-client-ee +WORKDIR /lowcoder-client-ee +RUN yarn --immutable + +ARG REACT_APP_COMMIT_ID=test +ARG REACT_APP_ENV=production +ARG REACT_APP_EDITION=community +ARG REACT_APP_DISABLE_JS_SANDBOX=true +RUN yarn build:ee + +# Build lowcoder-comps +WORKDIR /lowcoder-client-ee/packages/lowcoder-comps +RUN yarn install +RUN yarn build +RUN tar -zxf lowcoder-comps-*.tgz && mv package lowcoder-comps + +# Build lowcoder-sdk +WORKDIR /lowcoder-client-ee/packages/lowcoder-sdk +RUN yarn install +RUN yarn build + +WORKDIR /lowcoder-client-ee/packages/lowcoder-sdk-webpack-bundle +RUN yarn install +RUN yarn build + +## +## Intermediary Lowcoder client (Enterprise) image +## +## To create a separate image out of it, build it with: +## DOCKER_BUILDKIT=1 docker build -f deploy/docker/Dockerfile -t lowcoderorg/lowcoder-ee-frontend --target lowcoder-ee-frontend . +## +FROM nginx:1.27.1 AS lowcoder-ee-frontend +LABEL maintainer="lowcoder" + +# Change default nginx user into lowcoder user and remove default nginx config +RUN usermod --login lowcoder --uid 9001 nginx \ + && groupmod --new-name lowcoder --gid 9001 nginx \ + && rm -f /etc/nginx/nginx.conf \ + && mkdir -p /lowcoder/assets + +# Copy lowcoder client +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder/build/ /lowcoder/client +# Copy lowcoder components +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder-comps/lowcoder-comps /lowcoder/client-comps +# Copy lowcoder SDK +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder-sdk /lowcoder/client-sdk +# Copy lowcoder SDK webpack bundle +COPY --chown=lowcoder:lowcoder --from=build-client-ee /lowcoder-client-ee/packages/lowcoder-sdk-webpack-bundle/dist /lowcoder/client-embed + + +# Copy additional nginx init scripts +COPY deploy/docker/frontend/00-change-nginx-user.sh /docker-entrypoint.d/00-change-nginx-user.sh +COPY deploy/docker/frontend/01-update-nginx-conf.sh /docker-entrypoint.d/01-update-nginx-conf.sh + +RUN chmod +x /docker-entrypoint.d/00-change-nginx-user.sh && \ + chmod +x /docker-entrypoint.d/01-update-nginx-conf.sh + +COPY deploy/docker/frontend/server.conf /etc/nginx/server.conf +COPY deploy/docker/frontend/nginx-http.conf /etc/nginx/nginx-http.conf +COPY deploy/docker/frontend/nginx-https.conf /etc/nginx/nginx-https.conf +COPY deploy/docker/frontend/ssl-certificate.conf /etc/nginx/ssl-certificate.conf +COPY deploy/docker/frontend/ssl-params.conf /etc/nginx/ssl-params.conf + + +EXPOSE 3000 +EXPOSE 3444 + +############################################################################# + ## ## Build Lowcoder all-in-one image ## From b8211088d01141bf5a141e7427ca3fa9c3fb75f4 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 15:35:34 +0500 Subject: [PATCH 005/352] updated github workflow ee image --- .github/workflows/docker-images.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 97b1c85aa8..e26af7636b 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -80,18 +80,21 @@ jobs: # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} FRONTEND_IMAGE_NAMES=lowcoderorg/lowcoder-ce-frontend:${IMAGE_TAG} + FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-ee-frontend:${IMAGE_TAG} APISERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-api-service:${IMAGE_TAG} NODESERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-node-service:${IMAGE_TAG} if [[ "${IS_LATEST}" == "true" ]]; then ALLINONE_IMAGE_NAMES="lowcoderorg/lowcoder-ce:latest,${ALLINONE_IMAGE_NAMES}" FRONTEND_IMAGE_NAMES="lowcoderorg/lowcoder-ce-frontend:latest,${FRONTEND_IMAGE_NAMES}" + FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-ee-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" APISERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-api-service:latest,${APISERVICE_IMAGE_NAMES}" NODESERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-node-service:latest,${NODESERVICE_IMAGE_NAMES}" fi; echo "ALLINONE_IMAGE_NAMES=${ALLINONE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "FRONTEND_IMAGE_NAMES=${FRONTEND_IMAGE_NAMES}" >> "${GITHUB_ENV}" + echo "FRONTEND_EE_IMAGE_NAMES=${FRONTEND_EE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "APISERVICE_IMAGE_NAMES=${APISERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" echo "NODESERVICE_IMAGE_NAMES=${NODESERVICE_IMAGE_NAMES}" >> "${GITHUB_ENV}" @@ -153,7 +156,7 @@ jobs: NODE_ENV: production with: file: ./deploy/docker/Dockerfile - target: lowcoder-ce-frontend + target: lowcoder-ee-frontend build-args: | REACT_APP_ENV=production REACT_APP_EDITION=enterprise @@ -162,7 +165,7 @@ jobs: linux/amd64 linux/arm64 push: true - tags: ${{ env.FRONTEND_IMAGE_NAMES }} + tags: ${{ env.FRONTEND_EE_IMAGE_NAMES }} - name: Build and push the node service image if: ${{ env.BUILD_NODESERVICE == 'true' }} From 511f79ff13e101c374a7187147a6c863468f099b Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 2 Jul 2025 17:34:21 +0500 Subject: [PATCH 006/352] update env variable --- deploy/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 2c26de9598..611bad508b 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -200,7 +200,7 @@ RUN yarn --immutable ARG REACT_APP_COMMIT_ID=test ARG REACT_APP_ENV=production -ARG REACT_APP_EDITION=community +ARG REACT_APP_EDITION=enterprise ARG REACT_APP_DISABLE_JS_SANDBOX=true RUN yarn build:ee From bdcae5a11fd864297bb4e38c2ffac8ad3437cdec Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 14:44:57 +0200 Subject: [PATCH 007/352] Update docker-images.yml --- .github/workflows/docker-images.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index e26af7636b..9132a02c55 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -156,7 +156,7 @@ jobs: NODE_ENV: production with: file: ./deploy/docker/Dockerfile - target: lowcoder-ee-frontend + target: lowcoder-enterprise-frontend build-args: | REACT_APP_ENV=production REACT_APP_EDITION=enterprise From c16d1a4b366ceeed455a721beb03bd8e58abe7c5 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 15:15:21 +0200 Subject: [PATCH 008/352] Update docker-images.yml --- .github/workflows/docker-images.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 9132a02c55..439280b436 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -72,22 +72,22 @@ jobs: fi; # Control which images to build - echo "BUILD_ALLINONE=${{ inputs.build_allinone || true }}" >> "${GITHUB_ENV}" - echo "BUILD_FRONTEND=${{ inputs.build_frontend || true }}" >> "${GITHUB_ENV}" - echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || true }}" >> "${GITHUB_ENV}" - echo "BUILD_APISERVICE=${{ inputs.build_apiservice || true }}" >> "${GITHUB_ENV}" + echo "BUILD_ALLINONE=${{ inputs.build_allinone || false }}" >> "${GITHUB_ENV}" + echo "BUILD_FRONTEND=${{ inputs.build_frontend || false }}" >> "${GITHUB_ENV}" + echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || false }}" >> "${GITHUB_ENV}" + echo "BUILD_APISERVICE=${{ inputs.build_apiservice || false }}" >> "${GITHUB_ENV}" # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} FRONTEND_IMAGE_NAMES=lowcoderorg/lowcoder-ce-frontend:${IMAGE_TAG} - FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-ee-frontend:${IMAGE_TAG} + FRONTEND_EE_IMAGE_NAMES=lowcoderorg/lowcoder-enterprise-frontend:${IMAGE_TAG} APISERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-api-service:${IMAGE_TAG} NODESERVICE_IMAGE_NAMES=lowcoderorg/lowcoder-ce-node-service:${IMAGE_TAG} if [[ "${IS_LATEST}" == "true" ]]; then ALLINONE_IMAGE_NAMES="lowcoderorg/lowcoder-ce:latest,${ALLINONE_IMAGE_NAMES}" FRONTEND_IMAGE_NAMES="lowcoderorg/lowcoder-ce-frontend:latest,${FRONTEND_IMAGE_NAMES}" - FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-ee-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" + FRONTEND_EE_IMAGE_NAMES="lowcoderorg/lowcoder-enterprise-frontend:latest,${FRONTEND_EE_IMAGE_NAMES}" APISERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-api-service:latest,${APISERVICE_IMAGE_NAMES}" NODESERVICE_IMAGE_NAMES="lowcoderorg/lowcoder-ce-node-service:latest,${NODESERVICE_IMAGE_NAMES}" fi; From 42bb1104b8a797fe439ca8649f5418626d0c4f95 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Wed, 2 Jul 2025 15:40:53 +0200 Subject: [PATCH 009/352] Update Dockerfile --- deploy/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 611bad508b..e94ca2fa31 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -225,7 +225,7 @@ RUN yarn build ## To create a separate image out of it, build it with: ## DOCKER_BUILDKIT=1 docker build -f deploy/docker/Dockerfile -t lowcoderorg/lowcoder-ee-frontend --target lowcoder-ee-frontend . ## -FROM nginx:1.27.1 AS lowcoder-ee-frontend +FROM nginx:1.27.1 AS lowcoder-enterprise-frontend LABEL maintainer="lowcoder" # Change default nginx user into lowcoder user and remove default nginx config From 9d67db7d0fcc1a047a9cb428701f342c80f905ff Mon Sep 17 00:00:00 2001 From: th37star Date: Thu, 10 Jul 2025 05:38:31 -0400 Subject: [PATCH 010/352] Created test case for the ApplicationApiService. --- .../ApplicationApiServiceTest.java | 520 +++++++++++++++++- 1 file changed, 498 insertions(+), 22 deletions(-) diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java index 1069447772..0a4fdd3f64 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationApiServiceTest.java @@ -21,9 +21,12 @@ import org.lowcoder.domain.application.model.ApplicationStatus; import org.lowcoder.domain.application.model.ApplicationType; import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.solutions.TemplateSolutionService; import org.lowcoder.domain.permission.model.ResourceHolder; import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.model.ResourcePermission; import org.lowcoder.sdk.constants.FieldName; import org.lowcoder.sdk.exception.BizError; import org.lowcoder.sdk.exception.BizException; @@ -53,13 +56,507 @@ public class ApplicationApiServiceTest { @Autowired private DatasourceApiService datasourceApiService; @Autowired - private InitData initData; + private InitData initData = new InitData(); + @Autowired + private TemplateSolutionService templateSolutionService; @BeforeAll public void beforeAll() { initData.init(); } + @Test + @WithMockUser + public void testCreateApplication() { + CreateApplicationRequest request = new CreateApplicationRequest( + "org01", + null, + "test-app", + ApplicationType.APPLICATION.getValue(), + Map.of("comp", "list"), + null, + null, + null + ); + + Mono result = applicationApiService.create(request); + + StepVerifier.create(result) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("test-app", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetRecycledApplications() { + String appName = "recycled-app"; + Mono recycledAppIdMono = createApplication(appName, null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.recycle(appId)) + .cache(); + + String normalAppName = "normal-app"; + createApplication(normalAppName, null).block(); + + StepVerifier.create( + recycledAppIdMono.thenMany(applicationApiService.getRecycledApplications(null, null).collectList()) + ) + .assertNext(apps -> { + Assertions.assertTrue( + apps.stream().anyMatch(app -> appName.equals(app.getName()) && app.getApplicationStatus() == ApplicationStatus.RECYCLED), + "Expected recycled application not found" + ); + // Optionally, assert that normal-app is not in the recycled list + Assertions.assertTrue( + apps.stream().noneMatch(app -> normalAppName.equals(app.getName())), + "Normal app should not be in recycled list" + ); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testDeleteApplication() { + // Step 1: Create application + Mono appIdMono = createApplication("delete-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + // Step 2: Recycle the application + .delayUntil(appId -> applicationApiService.recycle(appId)) + .cache(); + + // Step 3: Delete the application and verify + StepVerifier.create( + appIdMono + .delayUntil(appId -> applicationApiService.delete(appId)) + .flatMap(appId -> applicationService.findById(appId)) + ) + .assertNext(app -> Assertions.assertEquals(ApplicationStatus.DELETED, app.getApplicationStatus())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testRecycleApplication() { + Mono appIdMono = createApplication("recycle-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono + .delayUntil(appId -> applicationApiService.recycle(appId)) + .flatMap(appId -> applicationService.findById(appId)) + ) + .assertNext(app -> Assertions.assertEquals(ApplicationStatus.RECYCLED, app.getApplicationStatus())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testRestoreApplication() { + // Create application and recycle it + Mono appIdMono = createApplication("restore-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.recycle(appId)) + .cache(); + + // Restore the application and verify status + StepVerifier.create( + appIdMono + .delayUntil(appId -> applicationApiService.restore(appId)) + .flatMap(appId -> applicationService.findById(appId)) + ) + .assertNext(app -> Assertions.assertNotEquals(ApplicationStatus.RECYCLED, app.getApplicationStatus())) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetEditingApplication() { + // Create a new application + Mono appIdMono = createApplication("editing-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Retrieve the editing application and verify its properties + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getEditingApplication(appId, false)) + ) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("editing-app-test", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetPublishedApplication() { + // Create a new application + Mono appIdMono = createApplication("published-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Publish the application + Mono publishedAppIdMono = appIdMono + .delayUntil(appId -> applicationApiService.publish(appId, new ApplicationPublishRequest("Initial Publish", "1.0.0"))) + .cache(); + + // Retrieve the published application and verify its properties + StepVerifier.create( + publishedAppIdMono.flatMap(appId -> + applicationApiService.getPublishedApplication(appId, ApplicationRequestType.PUBLIC_TO_ALL, false) + ) + ) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("published-app-test", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateUserApplicationLastViewTime() { + Mono appIdMono = createApplication("last-view-time-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.updateUserApplicationLastViewTime(appId)) + ) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateApplication() { + // Create a new application + Mono appIdMono = createApplication("update-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Update the application's name + Mono updatedAppMono = appIdMono + .flatMap(appId -> applicationApiService.update( + appId, + Application.builder().name("updated-app-name").build(), + false + )); + + // Verify the application's name is updated + StepVerifier.create(updatedAppMono) + .assertNext(applicationView -> + Assertions.assertEquals("updated-app-name", applicationView.getApplicationInfoView().getName()) + ) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testPublishFunction() { + // Step 1: Create a new application + Mono appIdMono = createApplication("publish-app-test", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Step 2: Publish the application + ApplicationPublishRequest publishRequest = new ApplicationPublishRequest("Initial Publish", "1.0.0"); + Mono publishedAppMono = appIdMono + .delayUntil(appId -> applicationApiService.publish(appId, publishRequest)) + .flatMap(appId -> applicationApiService.getPublishedApplication(appId, ApplicationRequestType.PUBLIC_TO_ALL, false)); + + // Step 3: Assert the result + StepVerifier.create(publishedAppMono) + .assertNext(applicationView -> { + Assertions.assertNotNull(applicationView); + Assertions.assertEquals("publish-app-test", applicationView.getApplicationInfoView().getName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateEditState() { + Mono appIdMono = createApplication("edit-state-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + ApplicationEndpoints.UpdateEditStateRequest request = + new ApplicationEndpoints.UpdateEditStateRequest(true); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.updateEditState(appId, request)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGrantPermission() { + // Create a new application + Mono appIdMono = createApplication("grant-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Grant permissions to user and group, then verify + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.grantPermission( + appId, + Set.of("user02"), + Set.of("group01"), + ResourceRole.EDITOR + ).then(applicationApiService.getApplicationPermissions(appId))) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(permissionItemView -> + permissionItemView.getType() == ResourceHolder.USER && + "user02".equals(permissionItemView.getId()) && + ResourceRole.EDITOR.getValue().equals(permissionItemView.getRole()) + )); + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(permissionItemView -> + permissionItemView.getType() == ResourceHolder.GROUP && + "group01".equals(permissionItemView.getId()) && + ResourceRole.EDITOR.getValue().equals(permissionItemView.getRole()) + )); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdatePermission() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("update-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Update the permission role for user02 to VIEWER and verify + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getApplicationPermissions(appId) + .map(applicationPermissionView -> applicationPermissionView.getPermissions().stream() + .filter(p -> p.getType() == ResourceHolder.USER && "user02".equals(p.getId())) + .findFirst() + .orElseThrow()) + .flatMap(permissionItemView -> applicationApiService.updatePermission( + appId, permissionItemView.getPermissionId(), ResourceRole.VIEWER)) + .then(applicationApiService.getApplicationPermissions(appId))) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(p -> p.getType() == ResourceHolder.USER + && "user02".equals(p.getId()) + && ResourceRole.VIEWER.getValue().equals(p.getRole()))); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testRemovePermission() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("remove-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Remove the permission for user02 and verify + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getApplicationPermissions(appId) + .map(applicationPermissionView -> applicationPermissionView.getPermissions().stream() + .filter(p -> p.getType() == ResourceHolder.USER && "user02".equals(p.getId())) + .findFirst() + .orElseThrow()) + .flatMap(permissionItemView -> applicationApiService.removePermission( + appId, permissionItemView.getPermissionId())) + .then(applicationApiService.getApplicationPermissions(appId))) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .noneMatch(p -> p.getType() == ResourceHolder.USER && "user02".equals(p.getId()))); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testGetApplicationPermissions() { + // Create a new application and grant permissions to user and group + Mono appIdMono = createApplication("get-permissions-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of("group01"), ResourceRole.EDITOR)) + .cache(); + + // Retrieve and verify permissions + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.getApplicationPermissions(appId)) + ) + .assertNext(applicationPermissionView -> { + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(p -> p.getType() == ResourceHolder.USER + && "user02".equals(p.getId()) + && ResourceRole.EDITOR.getValue().equals(p.getRole()))); + Assertions.assertTrue(applicationPermissionView.getPermissions().stream() + .anyMatch(p -> p.getType() == ResourceHolder.GROUP + && "group01".equals(p.getId()) + && ResourceRole.EDITOR.getValue().equals(p.getRole()))); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testCreateFromTemplate() { + String templateId = "test-template-id"; + Mono result = applicationApiService.createFromTemplate(templateId); + + StepVerifier.create(result) + .expectErrorMatches(throwable -> + throwable instanceof BizException && + throwable.getMessage().contains("template does not exist") + ) + .verify(); + } + + @Test + @WithMockUser + public void testCheckPermissionWithReadableErrorMsg() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("check-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Check permission for an EDIT_APPLICATIONS action + StepVerifier.create( + appIdMono.flatMap(appId -> + applicationApiService.checkPermissionWithReadableErrorMsg(appId, ResourceAction.EDIT_APPLICATIONS) + ) + ) + .assertNext(resourcePermission -> { + Assertions.assertNotNull(resourcePermission); + Assertions.assertTrue(resourcePermission.getResourceRole().canDo(ResourceAction.EDIT_APPLICATIONS)); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testCheckApplicationPermissionWithReadableErrorMsg() { + // Create a new application and grant EDITOR permission to user02 + Mono appIdMono = createApplication("check-app-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of(), ResourceRole.EDITOR)) + .cache(); + + // Check permission for an EDIT_APPLICATIONS action with PUBLIC_TO_ALL request type + StepVerifier.create( + appIdMono.flatMap(appId -> + applicationApiService.checkApplicationPermissionWithReadableErrorMsg( + appId, ResourceAction.EDIT_APPLICATIONS, ApplicationRequestType.PUBLIC_TO_ALL) + ) + ) + .assertNext(resourcePermission -> { + Assertions.assertNotNull(resourcePermission); + Assertions.assertTrue(resourcePermission.getResourceRole().canDo(ResourceAction.EDIT_APPLICATIONS)); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testSetApplicationPublicToAll() { + Mono appIdMono = createApplication("public-to-all-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.setApplicationPublicToAll(appId, true)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testSetApplicationPublicToMarketplace() { + Mono appIdMono = createApplication("public-to-marketplace-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + ApplicationEndpoints.ApplicationPublicToMarketplaceRequest request = + new ApplicationEndpoints.ApplicationPublicToMarketplaceRequest(true); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.setApplicationPublicToMarketplace(appId, request)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testSetApplicationAsAgencyProfile() { + Mono appIdMono = createApplication("agency-profile-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + StepVerifier.create( + appIdMono.flatMap(appId -> applicationApiService.setApplicationAsAgencyProfile(appId, true)) + ) + .expectNext(true) + .verifyComplete(); + } + + @Test + @WithMockUser + public void testUpdateSlug() { + String uniqueAppName = "SlugTestApp-" + System.currentTimeMillis(); + String uniqueSlug = "new-slug-" + System.currentTimeMillis(); + + createApplication(uniqueAppName, null) + .map(applicationView -> applicationView.getApplicationInfoView().getApplicationId()) + .flatMap(applicationId -> applicationApiService.updateSlug(applicationId, uniqueSlug)) + .as(StepVerifier::create) + .expectComplete() // Expect no value, just completion + .verify(); + } + + @Test + @WithMockUser + public void testGetGroupsOrMembersWithoutPermissions() { + // Create a new application + Mono appIdMono = createApplication("no-permission-test-app", null) + .map(appView -> appView.getApplicationInfoView().getApplicationId()) + .cache(); + + // Grant permission to user02 and group01 + Mono> resultMono = appIdMono + .delayUntil(appId -> applicationApiService.grantPermission( + appId, Set.of("user02"), Set.of("group01"), ResourceRole.EDITOR)) + .flatMap(appId -> applicationApiService.getGroupsOrMembersWithoutPermissions(appId)); + + StepVerifier.create(resultMono) + .assertNext(list -> { + // Should contain users/groups except user02 and group01 + Assertions.assertTrue(list.stream().noneMatch(obj -> obj.toString().contains("user02"))); + Assertions.assertTrue(list.stream().noneMatch(obj -> obj.toString().contains("group01"))); + }) + .verifyComplete(); + } + @Test @WithMockUser public void testAutoInheritFoldersPermissionsOnAppCreate() { @@ -334,25 +831,4 @@ public void testAppCreateAndRetrievalByGID() { }) .verifyComplete(); } - - // Skipping this test as it requires a database setup that's not available in the test environment - @Test - @WithMockUser - @Disabled("This test requires a database setup that's not available in the test environment") - public void testUpdateSlug() { - // Create a dummy application with a unique name to avoid conflicts - String uniqueAppName = "SlugTestApp-" + System.currentTimeMillis(); - String uniqueSlug = "new-slug-" + System.currentTimeMillis(); - - // Create the application and then update its slug - createApplication(uniqueAppName, null) - .map(applicationView -> applicationView.getApplicationInfoView().getApplicationId()) - .flatMap(applicationId -> applicationApiService.updateSlug(applicationId, uniqueSlug)) - .as(StepVerifier::create) - .assertNext(application -> { - Assertions.assertNotNull(application.getSlug(), "Slug should not be null"); - Assertions.assertEquals(uniqueSlug, application.getSlug(), "Slug should be updated to the new value"); - }) - .verifyComplete(); - } } \ No newline at end of file From f9128ea2a881a111c54cca22355b25e5cf3f78cc Mon Sep 17 00:00:00 2001 From: th37star Date: Thu, 10 Jul 2025 14:06:08 -0400 Subject: [PATCH 011/352] Created test case for the ApplicationEndpointsTest. --- .../application/ApplicationEndpointsTest.java | 1442 +++++++++++++++++ 1 file changed, 1442 insertions(+) create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java new file mode 100644 index 0000000000..c09bc9d63a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationEndpointsTest.java @@ -0,0 +1,1442 @@ +package org.lowcoder.api.application; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.application.ApplicationEndpoints.*; +import org.lowcoder.api.application.view.*; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.home.UserHomeApiService; +import org.lowcoder.api.home.UserHomepageView; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.api.util.GidService; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationRequestType; +import org.lowcoder.domain.application.model.ApplicationStatus; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.core.publisher.Flux; + +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +class ApplicationEndpointsTest { + + private UserHomeApiService userHomeApiService; + private ApplicationApiService applicationApiService; + private BusinessEventPublisher businessEventPublisher; + private GidService gidService; + private ApplicationRecordService applicationRecordService; + private ApplicationController controller; + + private static final String TEST_APPLICATION_ID = "test-app-id"; + private static final String TEST_ORGANIZATION_ID = "test-org-id"; + private static final String TEST_TEMPLATE_ID = "template-123"; + + @BeforeEach + void setUp() { + // Create mocks manually + userHomeApiService = Mockito.mock(UserHomeApiService.class); + applicationApiService = Mockito.mock(ApplicationApiService.class); + businessEventPublisher = Mockito.mock(BusinessEventPublisher.class); + gidService = Mockito.mock(GidService.class); + applicationRecordService = Mockito.mock(ApplicationRecordService.class); + + // Setup common mocks + when(businessEventPublisher.publishApplicationCommonEvent(any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationCommonEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationPublishEvent(any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationVersionChangeEvent(any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationPermissionEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishApplicationSharingEvent(any(), any(), any())).thenReturn(Mono.empty()); + + // Mock gidService to return the same ID that was passed to it + when(gidService.convertApplicationIdToObjectId(any())).thenAnswer(invocation -> { + String appId = invocation.getArgument(0); + return Mono.just(appId); + }); + when(gidService.convertLibraryQueryIdToObjectId(any())).thenAnswer(invocation -> { + String appId = invocation.getArgument(0); + return Mono.just(appId); + }); + + // Mock getApplicationPermissions to prevent null pointer exceptions + ApplicationPermissionView mockPermissionView = Mockito.mock(ApplicationPermissionView.class); + when(applicationApiService.getApplicationPermissions(any())).thenReturn(Mono.just(mockPermissionView)); + + // Mock setApplicationPublicToMarketplace to return a proper Mono + when(applicationApiService.setApplicationPublicToMarketplace(any(), any())).thenReturn(Mono.just(true)); + + // Mock setApplicationAsAgencyProfile to return a proper Mono + when(applicationApiService.setApplicationAsAgencyProfile(any(), anyBoolean())).thenReturn(Mono.just(true)); + + // Mock setApplicationPublicToAll to return a proper Mono + when(applicationApiService.setApplicationPublicToAll(any(), anyBoolean())).thenReturn(Mono.just(true)); + + // Mock getGroupsOrMembersWithoutPermissions to return a proper Mono + when(applicationApiService.getGroupsOrMembersWithoutPermissions(any())).thenReturn(Mono.just(List.of())); + + // Create controller with all required dependencies + controller = new ApplicationController( + userHomeApiService, + applicationApiService, + businessEventPublisher, + gidService, + applicationRecordService + ); + } + + @Test + void testCreateApplication_success() { + // Prepare request data + CreateApplicationRequest request = new CreateApplicationRequest( + TEST_ORGANIZATION_ID, + null, + "Test App", + 1, + new HashMap<>(), + null, + null, + null + ); + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.create(any(CreateApplicationRequest.class))) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + assert TEST_APPLICATION_ID.equals(response.getData().getApplicationInfoView().getApplicationId()); + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateApplication_withAllFields() { + // Prepare request data with all fields populated + HashMap dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + CreateApplicationRequest request = new CreateApplicationRequest( + TEST_ORGANIZATION_ID, + "test-gid", + "Test Application with All Fields", + 1, + dsl, + "folder-123", + true, + false + ); + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.create(any(CreateApplicationRequest.class))) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateApplication_serviceError() { + // Prepare request data + CreateApplicationRequest request = new CreateApplicationRequest( + TEST_ORGANIZATION_ID, + null, + "Error App", + 1, + new HashMap<>(), + null, + false, + false + ); + + when(applicationApiService.create(any(CreateApplicationRequest.class))) + .thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testCreateFromTemplate_success() { + // Mock the service response + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.createFromTemplate(TEST_TEMPLATE_ID)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(TEST_TEMPLATE_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + assert TEST_APPLICATION_ID.equals(response.getData().getApplicationInfoView().getApplicationId()); + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateFromTemplate_withDifferentTemplateId() { + // Test with a different template ID + String differentTemplateId = "template-456"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.createFromTemplate(differentTemplateId)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(differentTemplateId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreateFromTemplate_serviceError() { + // Mock service error + when(applicationApiService.createFromTemplate(TEST_TEMPLATE_ID)) + .thenReturn(Mono.error(new RuntimeException("Template not found"))); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(TEST_TEMPLATE_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testCreateFromTemplate_withEmptyTemplateId() { + // Test with empty template ID + String emptyTemplateId = ""; + + when(applicationApiService.createFromTemplate(emptyTemplateId)) + .thenReturn(Mono.error(new IllegalArgumentException("Template ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(emptyTemplateId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void testCreateFromTemplate_withNullTemplateId() { + // Test with null template ID + when(applicationApiService.createFromTemplate(null)) + .thenReturn(Mono.error(new IllegalArgumentException("Template ID cannot be null"))); + + // Test the controller method directly + Mono> result = controller.createFromTemplate(null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void testRecycle_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.recycle(TEST_APPLICATION_ID)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.recycle(TEST_APPLICATION_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRecycle_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-456"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.recycle(differentAppId)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.recycle(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRecycle_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.recycle(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRecycle_recycleServiceError() { + // Mock successful get but failed recycle + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.recycle(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Recycle operation failed"))); + + // Test the controller method directly + Mono> result = controller.recycle(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRestore_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.restore(TEST_APPLICATION_ID)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.restore(TEST_APPLICATION_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRestore_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-789"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.restore(differentAppId)) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.restore(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRestore_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.restore(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRestore_restoreServiceError() { + // Mock successful get but failed restore + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.restore(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Restore operation failed"))); + + // Test the controller method directly + Mono> result = controller.restore(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRecycle_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, true)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.recycle(emptyAppId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testRestore_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, true)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.restore(emptyAppId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetRecycledApplications_success() { + // Mock the service response + List mockRecycledApps = List.of( + createMockApplicationInfoView(), + createMockApplicationInfoView() + ); + when(applicationApiService.getRecycledApplications(null, null)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, null); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 2; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_withNameFilter() { + // Mock the service response with name filter + String nameFilter = "test-app"; + List mockRecycledApps = List.of(createMockApplicationInfoView()); + when(applicationApiService.getRecycledApplications(nameFilter, null)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(nameFilter, null); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 1; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_withCategoryFilter() { + // Mock the service response with category filter + String categoryFilter = "business"; + List mockRecycledApps = List.of(createMockApplicationInfoView()); + when(applicationApiService.getRecycledApplications(null, categoryFilter)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, categoryFilter); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 1; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_withNameAndCategoryFilter() { + // Mock the service response with both filters + String nameFilter = "test-app"; + String categoryFilter = "business"; + List mockRecycledApps = List.of(createMockApplicationInfoView()); + when(applicationApiService.getRecycledApplications(nameFilter, categoryFilter)) + .thenReturn(Flux.fromIterable(mockRecycledApps)); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(nameFilter, categoryFilter); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().size() == 1; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_emptyResult() { + // Mock empty service response + when(applicationApiService.getRecycledApplications(null, null)) + .thenReturn(Flux.empty()); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, null); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().isEmpty(); + return true; + }) + .verifyComplete(); + } + + @Test + void testGetRecycledApplications_serviceError() { + // Mock service error + when(applicationApiService.getRecycledApplications(null, null)) + .thenReturn(Flux.error(new RuntimeException("Database error"))); + + // Test the controller method directly + Mono>> result = controller.getRecycledApplications(null, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testDelete_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.delete(TEST_APPLICATION_ID)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.delete(TEST_APPLICATION_ID); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testDelete_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-999"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.delete(differentAppId)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.delete(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testDelete_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.delete(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testDelete_deleteServiceError() { + // Mock successful get but failed delete + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.delete(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Delete operation failed"))); + + // Test the controller method directly + Mono> result = controller.delete(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testDelete_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, true)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.delete(emptyAppId); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetEditingApplication_success() { + // Mock the service response + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(TEST_APPLICATION_ID, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetEditingApplication_withDeleted() { + // Mock the service response with withDeleted=true + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(TEST_APPLICATION_ID, true); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetEditingApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-123"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getEditingApplication(differentAppId, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(differentAppId, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetEditingApplication_serviceError() { + // Mock service error + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(TEST_APPLICATION_ID, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetEditingApplication_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getEditingApplication(emptyAppId, false)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.getEditingApplication(emptyAppId, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetPublishedApplication_success() { + // Mock the service responses + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(TEST_APPLICATION_ID, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationInfoView() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedApplication_withDeleted() { + // Mock the service responses with withDeleted=true + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_ALL, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(TEST_APPLICATION_ID, true); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-456"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(differentAppId, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(differentAppId, false); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedApplication_serviceError() { + // Mock service error + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(TEST_APPLICATION_ID, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetPublishedApplication_withEmptyApplicationId() { + // Test with empty application ID + String emptyAppId = ""; + + when(applicationApiService.getPublishedApplication(emptyAppId, ApplicationRequestType.PUBLIC_TO_ALL, false)) + .thenReturn(Mono.error(new RuntimeException("Application ID cannot be empty"))); + + // Test the controller method directly + Mono> result = controller.getPublishedApplication(emptyAppId, false); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetPublishedMarketPlaceApplication_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_MARKETPLACE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + Mono> result = controller.getPublishedMarketPlaceApplication(TEST_APPLICATION_ID); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedMarketPlaceApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-789"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(differentAppId, ApplicationRequestType.PUBLIC_TO_MARKETPLACE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getPublishedMarketPlaceApplication(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetPublishedMarketPlaceApplication_serviceError() { + // Mock service error + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.PUBLIC_TO_MARKETPLACE, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getPublishedMarketPlaceApplication(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetAgencyProfileApplication_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.AGENCY_PROFILE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + + Mono> result = controller.getAgencyProfileApplication(TEST_APPLICATION_ID); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetAgencyProfileApplication_withDifferentApplicationId() { + // Test with a different application ID + String differentAppId = "app-999"; + + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationApiService.getPublishedApplication(differentAppId, ApplicationRequestType.AGENCY_PROFILE, false)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.updateUserApplicationLastViewTime(differentAppId)) + .thenReturn(Mono.empty()); + + // Test the controller method directly + Mono> result = controller.getAgencyProfileApplication(differentAppId); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetAgencyProfileApplication_serviceError() { + // Mock service error + when(applicationApiService.getPublishedApplication(TEST_APPLICATION_ID, ApplicationRequestType.AGENCY_PROFILE, false)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.getAgencyProfileApplication(TEST_APPLICATION_ID); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testUpdate_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.update(TEST_APPLICATION_ID, mockApplication, null)) + .thenReturn(Mono.just(mockApplicationView)); + + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdate_withUpdateStatus() { + // Mock the service responses with updateStatus=true + ApplicationView mockApplicationView = createMockApplicationView(); + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.update(TEST_APPLICATION_ID, mockApplication, true)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, true); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdate_serviceError() { + // Mock service error + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.error(new RuntimeException("Application not found"))); + + // Test the controller method directly + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testUpdate_updateServiceError() { + // Mock successful get but failed update + ApplicationView mockApplicationView = createMockApplicationView(); + Application mockApplication = createMockApplication(); + when(applicationApiService.getEditingApplication(TEST_APPLICATION_ID, true)) + .thenReturn(Mono.just(mockApplicationView)); + when(applicationApiService.update(TEST_APPLICATION_ID, mockApplication, null)) + .thenReturn(Mono.error(new RuntimeException("Update operation failed"))); + + // Test the controller method directly + Mono> result = controller.update(TEST_APPLICATION_ID, mockApplication, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testPublish_success() { + ApplicationView mockApplicationView = createMockApplicationView(); + when(applicationRecordService.getLatestRecordByApplicationId(any())) + .thenReturn(Mono.empty()); + when(applicationApiService.publish(any(), any(ApplicationPublishRequest.class))) + .thenReturn(Mono.just(mockApplicationView)); + + Mono> result = controller.publish(TEST_APPLICATION_ID, null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testPublish_withPublishRequest() { + // Mock the service responses with publish request + ApplicationView mockApplicationView = createMockApplicationView(); + ApplicationPublishRequest publishRequest = new ApplicationPublishRequest("test-tag", "1.0.0"); + when(applicationRecordService.getLatestRecordByApplicationId(TEST_APPLICATION_ID)) + .thenReturn(Mono.empty()); + when(applicationApiService.publish(TEST_APPLICATION_ID, publishRequest)) + .thenReturn(Mono.just(mockApplicationView)); + + // Test the controller method directly + Mono> result = controller.publish(TEST_APPLICATION_ID, publishRequest); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testPublish_serviceError() { + // Mock service error + when(applicationRecordService.getLatestRecordByApplicationId(TEST_APPLICATION_ID)) + .thenReturn(Mono.error(new RuntimeException("Application record not found"))); + + // Test the controller method directly + Mono> result = controller.publish(TEST_APPLICATION_ID, null); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testUpdateEditState_success() { + UpdateEditStateRequest updateRequest = new UpdateEditStateRequest(true); + when(applicationApiService.updateEditState(TEST_APPLICATION_ID, updateRequest)) + .thenReturn(Mono.just(true)); + + Mono> result = controller.updateEditState(TEST_APPLICATION_ID, updateRequest); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdateSlug_success() { + String newSlug = "new-app-slug"; + Application mockApplication = createMockApplication(); + when(applicationApiService.updateSlug(TEST_APPLICATION_ID, newSlug)) + .thenReturn(Mono.just(mockApplication)); + + Mono> result = controller.updateSlug(TEST_APPLICATION_ID, newSlug); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetUserHomePage_success() { + UserHomepageView mockHomepageView = Mockito.mock(UserHomepageView.class); + when(userHomeApiService.getUserHomePageView(any())) + .thenReturn(Mono.just(mockHomepageView)); + + Mono> result = controller.getUserHomePage(0); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetApplications_success() { + List mockApps = List.of(createMockApplicationInfoView()); + when(userHomeApiService.getAllAuthorisedApplications4CurrentOrgMember(any(), any(), anyBoolean(), any(), any())) + .thenReturn(Flux.fromIterable(mockApps)); + + Mono>> result = controller.getApplications(null, null, true, null, null, 1, 10); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetMarketplaceApplications_success() { + List mockApps = List.of(Mockito.mock(MarketplaceApplicationInfoView.class)); + when(userHomeApiService.getAllMarketplaceApplications(any())) + .thenReturn(Flux.fromIterable(mockApps)); + + Mono>> result = controller.getMarketplaceApplications(null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetAgencyProfileApplications_success() { + List mockApps = List.of(Mockito.mock(MarketplaceApplicationInfoView.class)); + when(userHomeApiService.getAllAgencyProfileApplications(any())) + .thenReturn(Flux.fromIterable(mockApps)); + + Mono>> result = controller.getAgencyProfileApplications(null); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testUpdatePermission_success() { + UpdatePermissionRequest updateRequest = new UpdatePermissionRequest("editor"); + when(applicationApiService.updatePermission(eq(TEST_APPLICATION_ID), eq("permission-123"), any())) + .thenReturn(Mono.just(true)); + + Mono> result = controller.updatePermission(TEST_APPLICATION_ID, "permission-123", updateRequest); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testRemovePermission_success() { + when(applicationApiService.removePermission(TEST_APPLICATION_ID, "permission-123")) + .thenReturn(Mono.just(true)); + + Mono> result = controller.removePermission(TEST_APPLICATION_ID, "permission-123"); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testGrantPermission_success() { + BatchAddPermissionRequest grantRequest = new BatchAddPermissionRequest("editor", Set.of("user1"), Set.of("group1")); + when(applicationApiService.grantPermission(TEST_APPLICATION_ID, Set.of("user1"), Set.of("group1"), ResourceRole.EDITOR)) + .thenReturn(Mono.just(true)); + + Mono> result = controller.grantPermission(TEST_APPLICATION_ID, grantRequest); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetApplicationPermissions_success() { + Mono> result = controller.getApplicationPermissions(TEST_APPLICATION_ID); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetGroupsOrMembersWithoutPermissions_success() { + Mono>> result = controller.getGroupsOrMembersWithoutPermissions(TEST_APPLICATION_ID, null, 1, 1000); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testSetApplicationPublicToAll_success() { + ApplicationPublicToAllRequest request = new ApplicationPublicToAllRequest(true); + + Mono> result = controller.setApplicationPublicToAll(TEST_APPLICATION_ID, request); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testSetApplicationPublicToMarketplace_success() { + ApplicationPublicToMarketplaceRequest request = new ApplicationPublicToMarketplaceRequest(true); + + Mono> result = controller.setApplicationPublicToMarketplace(TEST_APPLICATION_ID, request); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testSetApplicationAsAgencyProfile_success() { + ApplicationAsAgencyProfileRequest request = new ApplicationAsAgencyProfileRequest(true); + + Mono> result = controller.setApplicationAsAgencyProfile(TEST_APPLICATION_ID, request); + + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + // Helper methods to create mock objects + private ApplicationView createMockApplicationView() { + ApplicationView view = Mockito.mock(ApplicationView.class); + ApplicationInfoView infoView = createMockApplicationInfoView(); + when(view.getApplicationInfoView()).thenReturn(infoView); + return view; + } + + private ApplicationInfoView createMockApplicationInfoView() { + ApplicationInfoView view = Mockito.mock(ApplicationInfoView.class); + when(view.getApplicationId()).thenReturn(TEST_APPLICATION_ID); + when(view.getName()).thenReturn("Test Application"); + when(view.getApplicationType()).thenReturn(1); // ApplicationType.APPLICATION.getValue() + when(view.getApplicationStatus()).thenReturn(ApplicationStatus.NORMAL); + return view; + } + + private Application createMockApplication() { + Application application = Mockito.mock(Application.class); + when(application.getId()).thenReturn(TEST_APPLICATION_ID); + when(application.getName()).thenReturn("Test Application"); + when(application.getApplicationType()).thenReturn(1); // ApplicationType.APPLICATION.getValue() + when(application.getApplicationStatus()).thenReturn(ApplicationStatus.NORMAL); + return application; + } +} From 1a587df9a2400f5ec9737c54cff1744da5b6fcc4 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 11 Jul 2025 17:57:33 +0500 Subject: [PATCH 012/352] fix workspaces updated myorg endpoint --- client/packages/lowcoder/src/api/userApi.ts | 11 +++++++---- .../packages/lowcoder/src/constants/orgConstants.ts | 1 + .../lowcoder/src/pages/common/WorkspaceSection.tsx | 4 ++-- .../src/pages/setting/organization/orgList.tsx | 2 +- .../src/redux/reducers/uiReducers/usersReducer.ts | 3 +++ client/packages/lowcoder/src/redux/sagas/orgSagas.ts | 9 +++++---- .../packages/lowcoder/src/util/useWorkspaceManager.ts | 9 +++++---- 7 files changed, 24 insertions(+), 15 deletions(-) diff --git a/client/packages/lowcoder/src/api/userApi.ts b/client/packages/lowcoder/src/api/userApi.ts index 5955071a84..a65a72338c 100644 --- a/client/packages/lowcoder/src/api/userApi.ts +++ b/client/packages/lowcoder/src/api/userApi.ts @@ -63,10 +63,13 @@ export type GetCurrentUserResponse = GenericApiResponse; export interface GetMyOrgsResponse extends ApiResponse { data: { data: Array<{ - orgId: string; - orgName: string; - createdAt?: number; - updatedAt?: number; + isCurrentOrg: boolean; + orgView: { + orgId: string; + orgName: string; + createdAt?: number; + updatedAt?: number; + }; }>; pageNum: number; pageSize: number; diff --git a/client/packages/lowcoder/src/constants/orgConstants.ts b/client/packages/lowcoder/src/constants/orgConstants.ts index d46d9957bc..e2afb5c5fe 100644 --- a/client/packages/lowcoder/src/constants/orgConstants.ts +++ b/client/packages/lowcoder/src/constants/orgConstants.ts @@ -56,6 +56,7 @@ export type Org = { createTime?: string; createdAt?: number; updatedAt?: number; + isCurrentOrg?: boolean; }; export type OrgAndRole = { diff --git a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx index f1cb0709f2..0bd8a4c547 100644 --- a/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx +++ b/client/packages/lowcoder/src/pages/common/WorkspaceSection.tsx @@ -242,11 +242,11 @@ export default function WorkspaceSectionComponent({ displayWorkspaces.map((org: Org) => ( handleOrgSwitch(org.id)} > {org.name} - {user.currentOrgId === org.id && } + {org.isCurrentOrg && } )) ) : ( diff --git a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx index c60f492ead..a3cad40430 100644 --- a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx +++ b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx @@ -262,7 +262,7 @@ function OrganizationSetting() { dataIndex: "orgName", ellipsis: true, render: (_, record: any) => { - const isActiveOrg = record.id === user.currentOrgId; + const isActiveOrg = record.isCurrentOrg; return ( diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts index 4146dfd625..3142ca13d3 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts @@ -25,6 +25,8 @@ const initialState: UsersReduxState = { workspaces: { items: [], totalCount: 0, + currentOrg: null + } }; @@ -231,6 +233,7 @@ export interface UsersReduxState { workspaces: { items: Org[]; // Current page of workspaces totalCount: number; // Total workspaces available + currentOrg: Org | null; }; } diff --git a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts index f427eeb92b..dd64bf1c9f 100644 --- a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts @@ -368,10 +368,11 @@ export function* fetchWorkspacesSaga(action: ReduxAction<{page: number, pageSize // Transform orgId/orgName to match Org interface const transformedItems = apiData.data.map(item => ({ - id: item.orgId, - name: item.orgName, - createdAt: item.createdAt, - updatedAt: item.updatedAt, + id: item.orgView.orgId, + name: item.orgView.orgName, + createdAt: item.orgView.createdAt, + updatedAt: item.orgView.updatedAt, + isCurrentOrg: item.isCurrentOrg, })); yield put({ diff --git a/client/packages/lowcoder/src/util/useWorkspaceManager.ts b/client/packages/lowcoder/src/util/useWorkspaceManager.ts index 59732ac539..5c5cafee07 100644 --- a/client/packages/lowcoder/src/util/useWorkspaceManager.ts +++ b/client/packages/lowcoder/src/util/useWorkspaceManager.ts @@ -91,10 +91,11 @@ export function useWorkspaceManager({ if (response.data.success) { const apiData = response.data.data; const transformedItems = apiData.data.map(item => ({ - id: item.orgId, - name: item.orgName, - createdAt: item.createdAt, - updatedAt: item.updatedAt, + id: item.orgView.orgId, + name: item.orgView.orgName, + createdAt: item.orgView.createdAt, + updatedAt: item.orgView.updatedAt, + isCurrentOrg: item.isCurrentOrg, })); dispatch({ From 53fb65d20e3ca1a1cfab04ed0305b714f445e1fc Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 11 Jul 2025 18:04:46 +0500 Subject: [PATCH 013/352] fix orglist active org icon --- .../lowcoder/src/pages/setting/organization/orgList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx index a3cad40430..0e9c8a01c0 100644 --- a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx +++ b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx @@ -211,6 +211,7 @@ function OrganizationSetting() { logoUrl: org.logoUrl || "", createdAt: org.createdAt, updatedAt: org.updatedAt, + isCurrentOrg: org.isCurrentOrg, })); @@ -307,7 +308,7 @@ function OrganizationSetting() { key: i, operation: ( - {item.id !== user.currentOrgId && ( + {!item.isCurrentOrg && ( Date: Fri, 11 Jul 2025 19:41:29 +0500 Subject: [PATCH 014/352] Updated CustomSelector UI --- .../lowcoder-design/src/components/customSelect.tsx | 9 +++++---- .../src/components/PermissionDialog/Permission.tsx | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/packages/lowcoder-design/src/components/customSelect.tsx b/client/packages/lowcoder-design/src/components/customSelect.tsx index 2f13f0db8e..72864178ad 100644 --- a/client/packages/lowcoder-design/src/components/customSelect.tsx +++ b/client/packages/lowcoder-design/src/components/customSelect.tsx @@ -20,7 +20,8 @@ const SelectWrapper = styled.div<{ $border?: boolean }>` padding: ${(props) => (props.$border ? "0px" : "0 0 0 12px")}; height: 100%; align-items: center; - margin-right: 8px; + margin-right: 10px; + padding-right: 5px; background-color: #fff; .ant-select-selection-item { @@ -46,9 +47,9 @@ const SelectWrapper = styled.div<{ $border?: boolean }>` } .ant-select-arrow { - width: 20px; - height: 20px; - right: 8px; + width: 17px; + height: 17px; + right: 10px; top: 0; bottom: 0; margin: auto; diff --git a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx index 6425d3afc6..adb9e9ffb3 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx @@ -274,7 +274,7 @@ function PermissionTagRender(props: CustomTagProps) { color={value} closable={closable} onClose={onClose} - style={{ marginRight: 3 }} + style={{ marginRight: 3, display: "flex", alignItems: "center" }} > {label} From d2f2a4c59fc800c8b14fc260b0857017dd720ffb Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Fri, 11 Jul 2025 19:51:22 +0500 Subject: [PATCH 015/352] Adding Tooltip to Multiselect custom tags --- .../src/comps/comps/selectInputComp/selectInputConstants.tsx | 3 ++- client/packages/lowcoder/src/i18n/locales/en.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx index 290f3628d5..d7b5648e17 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectInputConstants.tsx @@ -128,7 +128,8 @@ export const SelectInputValidationSection = (children: ValidationComp) => ( label: trans("prop.showEmptyValidation"), })} {children.allowCustomTags.propertyView({ - label: trans("prop.customTags") + label: trans("prop.customTags"), + tooltip: trans("prop.customTagsTooltip") })} {children.customRule.propertyView({})} diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 43bcb39868..f644df665a 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -235,7 +235,8 @@ export const en = { "verticalGridCells": "Vertical Grid Cells", "timeZone": "TimeZone", "pickerMode": "Picker Mode", - "customTags": "Custom Tags" + "customTags": "Allow Custom Tags", + "customTagsTooltip": "Allow users to enter custom tags that are not in the options list." }, "autoHeightProp": { "auto": "Auto", From cc2e8d53434ec786cc35ab915573be4846efb9d7 Mon Sep 17 00:00:00 2001 From: th37star Date: Fri, 11 Jul 2025 02:20:01 -0400 Subject: [PATCH 016/352] Created test case for the ApplicationHistorySnapshotEndpoints. --- ...storySnapshotEndpointsIntegrationTest.java | 450 ++++++++++++++ ...plicationHistorySnapshotEndpointsTest.java | 570 ++++++++++++++++++ 2 files changed, 1020 insertions(+) create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java new file mode 100644 index 0000000000..45a93a61b1 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java @@ -0,0 +1,450 @@ +package org.lowcoder.api.application; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints.ApplicationHistorySnapshotRequest; +import org.lowcoder.api.application.view.HistorySnapshotDslView; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.service.ApplicationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@SpringBootTest +@ActiveProfiles("test") // Uses embedded MongoDB +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ApplicationHistorySnapshotEndpointsIntegrationTest { + + @Autowired + private ApplicationHistorySnapshotController controller; + + @Autowired + private ApplicationController applicationController; + + @Autowired + private InitData initData; + + @BeforeAll + public void beforeAll() { + initData.init(); // Initialize test database with data + } + + @Test + @WithMockUser(id = "test-user") + public void testCreateHistorySnapshotWithDatabase() { + // First create an application + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for History", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application and then create history snapshot + Mono> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create history snapshot request + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + appId, + createTestDsl(), + createTestContext() + ); + + return controller.create(snapshotRequest); + }); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "test-user") + public void testListHistorySnapshotsWithDatabase() { + // First create an application and snapshot + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for List", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application, snapshot, and then list snapshots + Mono>> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create history snapshot + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + appId, + createTestDsl(), + createTestContext() + ); + + return controller.create(snapshotRequest) + .then(Mono.just(appId)); + }) + .flatMap(appId -> controller.listAllHistorySnapshotBriefInfo( + appId, + 1, + 10, + null, + null, + null, + null + )); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + Assertions.assertTrue((Long) response.getData().get("count") >= 1L); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "test-user") + public void testGetHistorySnapshotDslWithDatabase() { + // First create an application and snapshot + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for DSL", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application, snapshot, and then get snapshot DSL + Mono> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create history snapshot + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + appId, + createTestDsl(), + createTestContext() + ); + + return controller.create(snapshotRequest) + .then(Mono.just(appId)); + }) + .flatMap(appId -> controller.listAllHistorySnapshotBriefInfo( + appId, + 1, + 10, + null, + null, + null, + null + )) + .flatMap(listResponse -> { + @SuppressWarnings("unchecked") + java.util.List snapshots = + (java.util.List) listResponse.getData().get("list"); + + if (!snapshots.isEmpty()) { + String snapshotId = snapshots.get(0).snapshotId(); + String appId = snapshots.get(0).userId(); // This is actually the appId in the test context + return controller.getHistorySnapshotDsl(appId, snapshotId); + } else { + return Mono.error(new RuntimeException("No snapshots found")); + } + }); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertNotNull(response.getData().getApplicationsDsl()); + Assertions.assertNotNull(response.getData().getModuleDSL()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "test-user") + public void testListArchivedHistorySnapshotsWithDatabase() { + // First create an application and snapshot + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for Archived", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application, snapshot, and then list archived snapshots + Mono>> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create history snapshot + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + appId, + createTestDsl(), + createTestContext() + ); + + return controller.create(snapshotRequest) + .then(Mono.just(appId)); + }) + .flatMap(appId -> controller.listAllHistorySnapshotBriefInfoArchived( + appId, + 1, + 10, + null, + null, + null, + null + )); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + // Archived snapshots might be empty in test environment + Assertions.assertNotNull(response.getData().get("count")); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "test-user") + public void testCreateMultipleSnapshotsWithDatabase() { + // First create an application + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for Multiple Snapshots", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application and multiple snapshots + Mono>> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create multiple history snapshots + ApplicationHistorySnapshotRequest snapshotRequest1 = new ApplicationHistorySnapshotRequest( + appId, + createTestDsl(), + createTestContext("snapshot1") + ); + + ApplicationHistorySnapshotRequest snapshotRequest2 = new ApplicationHistorySnapshotRequest( + appId, + createTestDsl(), + createTestContext("snapshot2") + ); + + return controller.create(snapshotRequest1) + .then(controller.create(snapshotRequest2)) + .then(Mono.just(appId)); + }) + .flatMap(appId -> controller.listAllHistorySnapshotBriefInfo( + appId, + 1, + 10, + null, + null, + null, + null + )); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + Assertions.assertTrue((Long) response.getData().get("count") >= 2L); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "test-user") + public void testCreateSnapshotWithEmptyDsl() { + // First create an application + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for Empty DSL", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application and snapshot with empty DSL + Mono> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create history snapshot with empty DSL + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + appId, + new HashMap<>(), + createTestContext() + ); + + return controller.create(snapshotRequest); + }); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "test-user") + public void testCreateSnapshotWithComplexDsl() { + // First create an application + ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( + "org01", + null, + "Test App for Complex DSL", + 1, + createTestDsl(), + null, + null, + null + ); + + // Create application and snapshot with complex DSL + Mono> result = applicationController.create(createRequest) + .flatMap(appView -> { + String appId = appView.getData().getApplicationInfoView().getApplicationId(); + + // Create complex DSL + Map complexDsl = createComplexTestDsl(); + + // Create history snapshot with complex DSL + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + appId, + complexDsl, + createTestContext("complex-snapshot") + ); + + return controller.create(snapshotRequest); + }); + + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + // Helper methods + private Map createTestDsl() { + Map dsl = new HashMap<>(); + Map components = new HashMap<>(); + Map layout = new HashMap<>(); + + components.put("test-component", new HashMap<>()); + layout.put("type", "grid"); + + dsl.put("components", components); + dsl.put("layout", layout); + + return dsl; + } + + private Map createComplexTestDsl() { + Map dsl = new HashMap<>(); + Map components = new HashMap<>(); + Map layout = new HashMap<>(); + + // Create complex component structure + Map component1 = new HashMap<>(); + component1.put("type", "button"); + component1.put("text", "Click me"); + component1.put("style", Map.of("backgroundColor", "#007bff")); + + Map component2 = new HashMap<>(); + component2.put("type", "input"); + component2.put("placeholder", "Enter text"); + component2.put("style", Map.of("border", "1px solid #ccc")); + + components.put("button-1", component1); + components.put("input-1", component2); + + layout.put("type", "flex"); + layout.put("direction", "column"); + layout.put("items", java.util.List.of("button-1", "input-1")); + + dsl.put("components", components); + dsl.put("layout", layout); + + return dsl; + } + + private Map createTestContext() { + return createTestContext("test-snapshot"); + } + + private Map createTestContext(String snapshotName) { + Map context = new HashMap<>(); + context.put("action", "save"); + context.put("timestamp", Instant.now().toEpochMilli()); + context.put("name", snapshotName); + context.put("description", "Test snapshot created during integration test"); + return context; + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java new file mode 100644 index 0000000000..7e1190e4e3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsTest.java @@ -0,0 +1,570 @@ +package org.lowcoder.api.application; + +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.application.ApplicationHistorySnapshotEndpoints.ApplicationHistorySnapshotRequest; +import org.lowcoder.api.application.view.HistorySnapshotDslView; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.util.Pagination; +import org.lowcoder.domain.application.model.Application; +import org.lowcoder.domain.application.model.ApplicationHistorySnapshot; +import org.lowcoder.domain.application.model.ApplicationHistorySnapshotTS; +import org.lowcoder.domain.application.service.ApplicationHistorySnapshotService; +import org.lowcoder.domain.application.service.ApplicationRecordService; +import org.lowcoder.domain.application.service.ApplicationService; +import org.lowcoder.domain.permission.model.ResourceAction; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +class ApplicationHistorySnapshotEndpointsTest { + + private ResourcePermissionService resourcePermissionService; + private ApplicationHistorySnapshotService applicationHistorySnapshotService; + private SessionUserService sessionUserService; + private UserService userService; + private ApplicationService applicationService; + private ApplicationRecordService applicationRecordService; + private ApplicationHistorySnapshotController controller; + + private static final String TEST_APPLICATION_ID = "test-app-id"; + private static final String TEST_SNAPSHOT_ID = "test-snapshot-id"; + private static final String TEST_USER_ID = "test-user-id"; + private static final String TEST_USER_NAME = "Test User"; + private static final String TEST_USER_AVATAR = "https://example.com/avatar.jpg"; + + @BeforeEach + void setUp() { + // Create mocks manually + resourcePermissionService = Mockito.mock(ResourcePermissionService.class); + applicationHistorySnapshotService = Mockito.mock(ApplicationHistorySnapshotService.class); + sessionUserService = Mockito.mock(SessionUserService.class); + userService = Mockito.mock(UserService.class); + applicationService = Mockito.mock(ApplicationService.class); + applicationRecordService = Mockito.mock(ApplicationRecordService.class); + + // Setup common mocks + when(sessionUserService.getVisitorId()).thenReturn(Mono.just(TEST_USER_ID)); + when(resourcePermissionService.checkResourcePermissionWithError(anyString(), anyString(), any(ResourceAction.class))) + .thenReturn(Mono.empty()); + + // Create controller with all required dependencies + controller = new ApplicationHistorySnapshotController( + resourcePermissionService, + applicationHistorySnapshotService, + sessionUserService, + userService, + applicationService, + applicationRecordService + ); + } + + @Test + void testCreate_success() { + // Prepare request data + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + Map context = new HashMap<>(); + context.put("action", "save"); + context.put("timestamp", Instant.now().toEpochMilli()); + + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + dsl, + context + ); + + when(applicationHistorySnapshotService.createHistorySnapshot( + eq(TEST_APPLICATION_ID), + eq(dsl), + eq(context), + eq(TEST_USER_ID) + )).thenReturn(Mono.just(true)); + + when(applicationService.updateLastEditedAt(eq(TEST_APPLICATION_ID), any(Instant.class), eq(TEST_USER_ID))) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData() == true; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreate_withEmptyDsl() { + // Prepare request data with empty DSL + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + new HashMap<>(), + new HashMap<>() + ); + + when(applicationHistorySnapshotService.createHistorySnapshot( + eq(TEST_APPLICATION_ID), + any(Map.class), + any(Map.class), + eq(TEST_USER_ID) + )).thenReturn(Mono.just(true)); + + when(applicationService.updateLastEditedAt(eq(TEST_APPLICATION_ID), any(Instant.class), eq(TEST_USER_ID))) + .thenReturn(Mono.just(true)); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testCreate_serviceError() { + // Prepare request data + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + new HashMap<>(), + new HashMap<>() + ); + + when(applicationHistorySnapshotService.createHistorySnapshot( + anyString(), + any(Map.class), + any(Map.class), + anyString() + )).thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testListAllHistorySnapshotBriefInfo_success() { + // Prepare test data + ApplicationHistorySnapshot snapshot1 = createMockApplicationHistorySnapshot("snapshot-1", "user1"); + ApplicationHistorySnapshot snapshot2 = createMockApplicationHistorySnapshot("snapshot-2", "user2"); + List snapshotList = List.of(snapshot1, snapshot2); + + User user1 = createMockUser("user1", "User One", "avatar1.jpg"); + User user2 = createMockUser("user2", "User Two", "avatar2.jpg"); + + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfo( + eq(TEST_APPLICATION_ID), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.just(snapshotList)); + + when(userService.getByIds(anyList())).thenReturn(Mono.just(Map.of("user1", user1, "user2", user2))); + when(applicationHistorySnapshotService.countByApplicationId(eq(TEST_APPLICATION_ID))) + .thenReturn(Mono.just(2L)); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfo( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().containsKey("list"); + assert response.getData().containsKey("count"); + assert (Long) response.getData().get("count") == 2L; + return true; + }) + .verifyComplete(); + } + + @Test + void testListAllHistorySnapshotBriefInfo_withNullFilters() { + // Prepare test data + List snapshotList = List.of(); + User user = createMockUser("user1", "User One", "avatar1.jpg"); + + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfo( + eq(TEST_APPLICATION_ID), + isNull(), + isNull(), + isNull(), + isNull(), + any() + )).thenReturn(Mono.just(snapshotList)); + + when(userService.getByIds(anyList())).thenReturn(Mono.just(Map.of())); + when(applicationHistorySnapshotService.countByApplicationId(eq(TEST_APPLICATION_ID))) + .thenReturn(Mono.just(0L)); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfo( + TEST_APPLICATION_ID, + 1, + 10, + null, + null, + null, + null + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().containsKey("list"); + assert response.getData().containsKey("count"); + assert (Long) response.getData().get("count") == 0L; + return true; + }) + .verifyComplete(); + } + + @Test + void testListAllHistorySnapshotBriefInfo_serviceError() { + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfo( + anyString(), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfo( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testListAllHistorySnapshotBriefInfoArchived_success() { + // Prepare test data + ApplicationHistorySnapshotTS snapshot1 = createMockApplicationHistorySnapshotTS("snapshot-1", "user1"); + ApplicationHistorySnapshotTS snapshot2 = createMockApplicationHistorySnapshotTS("snapshot-2", "user2"); + List snapshotList = List.of(snapshot1, snapshot2); + + User user1 = createMockUser("user1", "User One", "avatar1.jpg"); + User user2 = createMockUser("user2", "User Two", "avatar2.jpg"); + + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfoArchived( + eq(TEST_APPLICATION_ID), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.just(snapshotList)); + + when(userService.getByIds(anyList())).thenReturn(Mono.just(Map.of("user1", user1, "user2", user2))); + when(applicationHistorySnapshotService.countByApplicationIdArchived(eq(TEST_APPLICATION_ID))) + .thenReturn(Mono.just(2L)); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfoArchived( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().containsKey("list"); + assert response.getData().containsKey("count"); + assert (Long) response.getData().get("count") == 2L; + return true; + }) + .verifyComplete(); + } + + @Test + void testListAllHistorySnapshotBriefInfoArchived_serviceError() { + when(applicationHistorySnapshotService.listAllHistorySnapshotBriefInfoArchived( + anyString(), + anyString(), + anyString(), + any(Instant.class), + any(Instant.class), + any() + )).thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono>> result = controller.listAllHistorySnapshotBriefInfoArchived( + TEST_APPLICATION_ID, + 1, + 10, + "test-component", + "dark", + Instant.now().minusSeconds(3600), + Instant.now() + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetHistorySnapshotDsl_success() { + // Prepare test data + ApplicationHistorySnapshot snapshot = createMockApplicationHistorySnapshot(TEST_SNAPSHOT_ID, TEST_USER_ID); + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + List dependentModules = List.of(); + + when(applicationHistorySnapshotService.getHistorySnapshotDetail(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.just(snapshot)); + when(applicationService.getAllDependentModulesFromDsl(any(Map.class))) + .thenReturn(Mono.just(dependentModules)); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDsl( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationsDsl() != null; + assert response.getData().getModuleDSL() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetHistorySnapshotDsl_withDependentModules() { + // Prepare test data + ApplicationHistorySnapshot snapshot = createMockApplicationHistorySnapshot(TEST_SNAPSHOT_ID, TEST_USER_ID); + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + Application dependentApp = createMockApplication("dependent-app-id"); + List dependentModules = List.of(dependentApp); + + when(applicationHistorySnapshotService.getHistorySnapshotDetail(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.just(snapshot)); + when(applicationService.getAllDependentModulesFromDsl(any(Map.class))) + .thenReturn(Mono.just(dependentModules)); + when(dependentApp.getLiveApplicationDsl(applicationRecordService)) + .thenReturn(Mono.just(new HashMap<>())); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDsl( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationsDsl() != null; + assert response.getData().getModuleDSL() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetHistorySnapshotDsl_serviceError() { + when(applicationHistorySnapshotService.getHistorySnapshotDetail(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDsl( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testGetHistorySnapshotDslArchived_success() { + // Prepare test data + ApplicationHistorySnapshotTS snapshot = createMockApplicationHistorySnapshotTS(TEST_SNAPSHOT_ID, TEST_USER_ID); + Map dsl = new HashMap<>(); + dsl.put("components", new HashMap<>()); + dsl.put("layout", new HashMap<>()); + + List dependentModules = List.of(); + + when(applicationHistorySnapshotService.getHistorySnapshotDetailArchived(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.just(snapshot)); + when(applicationService.getAllDependentModulesFromDsl(any(Map.class))) + .thenReturn(Mono.just(dependentModules)); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDslArchived( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the result + StepVerifier.create(result) + .expectNextMatches(response -> { + assert response != null; + assert response.isSuccess(); + assert response.getData() != null; + assert response.getData().getApplicationsDsl() != null; + assert response.getData().getModuleDSL() != null; + return true; + }) + .verifyComplete(); + } + + @Test + void testGetHistorySnapshotDslArchived_serviceError() { + when(applicationHistorySnapshotService.getHistorySnapshotDetailArchived(eq(TEST_SNAPSHOT_ID))) + .thenReturn(Mono.error(new RuntimeException("Service error"))); + + // Test the controller method directly + Mono> result = controller.getHistorySnapshotDslArchived( + TEST_APPLICATION_ID, + TEST_SNAPSHOT_ID + ); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void testPermissionCheck_failure() { + // Prepare request data + ApplicationHistorySnapshotRequest request = new ApplicationHistorySnapshotRequest( + TEST_APPLICATION_ID, + new HashMap<>(), + new HashMap<>() + ); + + when(resourcePermissionService.checkResourcePermissionWithError( + eq(TEST_USER_ID), + eq(TEST_APPLICATION_ID), + eq(ResourceAction.EDIT_APPLICATIONS) + )).thenReturn(Mono.error(new RuntimeException("Permission denied"))); + + // Test the controller method directly + Mono> result = controller.create(request); + + // Verify the error is propagated + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + // Helper methods to create mock objects + private ApplicationHistorySnapshot createMockApplicationHistorySnapshot(String snapshotId, String userId) { + ApplicationHistorySnapshot snapshot = Mockito.mock(ApplicationHistorySnapshot.class); + when(snapshot.getId()).thenReturn(snapshotId); + when(snapshot.getApplicationId()).thenReturn(TEST_APPLICATION_ID); + when(snapshot.getCreatedBy()).thenReturn(userId); + when(snapshot.getCreatedAt()).thenReturn(Instant.now()); + when(snapshot.getDsl()).thenReturn(new HashMap<>()); + when(snapshot.getContext()).thenReturn(new HashMap<>()); + return snapshot; + } + + private ApplicationHistorySnapshotTS createMockApplicationHistorySnapshotTS(String snapshotId, String userId) { + ApplicationHistorySnapshotTS snapshot = Mockito.mock(ApplicationHistorySnapshotTS.class); + when(snapshot.getId()).thenReturn(snapshotId); + when(snapshot.getApplicationId()).thenReturn(TEST_APPLICATION_ID); + when(snapshot.getCreatedBy()).thenReturn(userId); + when(snapshot.getCreatedAt()).thenReturn(Instant.now()); + when(snapshot.getDsl()).thenReturn(new HashMap<>()); + when(snapshot.getContext()).thenReturn(new HashMap<>()); + return snapshot; + } + + private User createMockUser(String userId, String userName, String avatarUrl) { + User user = Mockito.mock(User.class); + when(user.getId()).thenReturn(userId); + when(user.getName()).thenReturn(userName); + when(user.getAvatarUrl()).thenReturn(avatarUrl); + return user; + } + + private Application createMockApplication(String appId) { + Application app = Mockito.mock(Application.class); + when(app.getId()).thenReturn(appId); + return app; + } +} \ No newline at end of file From ab233a57709716c0ad287359aba5825a4a89631c Mon Sep 17 00:00:00 2001 From: th37star Date: Fri, 11 Jul 2025 14:45:37 -0400 Subject: [PATCH 017/352] Created test case for the ApplicationHistorySnapshotEndpoints. --- ...storySnapshotEndpointsIntegrationTest.java | 418 +++++++++--------- 1 file changed, 219 insertions(+), 199 deletions(-) diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java index 45a93a61b1..373945d340 100644 --- a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/application/ApplicationHistorySnapshotEndpointsIntegrationTest.java @@ -21,6 +21,9 @@ import java.util.HashMap; import java.util.Map; +import static org.lowcoder.sdk.constants.GlobalContext.VISITOR_TOKEN; +import org.lowcoder.api.application.view.ApplicationView; + @SpringBootTest @ActiveProfiles("test") // Uses embedded MongoDB @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -41,34 +44,30 @@ public void beforeAll() { } @Test - @WithMockUser(id = "test-user") - public void testCreateHistorySnapshotWithDatabase() { - // First create an application - ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( - "org01", - null, - "Test App for History", - 1, + @WithMockUser(id = "user01") + public void testCreateHistorySnapshotWithExistingApplication() { + // Use an existing application from test data instead of creating a new one + String existingAppId = "app01"; // This exists in the test data + + // Create history snapshot request for existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, createTestDsl(), - null, - null, - null + createTestContext() ); - - // Create application and then create history snapshot - Mono> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create history snapshot request - ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( - appId, - createTestDsl(), - createTestContext() - ); - - return controller.create(snapshotRequest); - }); + + System.out.println("Creating history snapshot for existing app: " + existingAppId); + + // Create history snapshot + Mono> result = controller.create(snapshotRequest) + .doOnNext(response -> { + System.out.println("History snapshot creation response: " + response); + }) + .doOnError(error -> { + System.err.println("History snapshot creation error: " + error.getMessage()); + error.printStackTrace(); + }) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) @@ -81,44 +80,30 @@ public void testCreateHistorySnapshotWithDatabase() { } @Test - @WithMockUser(id = "test-user") - public void testListHistorySnapshotsWithDatabase() { - // First create an application and snapshot - ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( - "org01", - null, - "Test App for List", - 1, + @WithMockUser(id = "user01") + public void testListHistorySnapshotsWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // First create a history snapshot for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, createTestDsl(), - null, - null, - null + createTestContext() ); - - // Create application, snapshot, and then list snapshots - Mono>> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create history snapshot - ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( - appId, - createTestDsl(), - createTestContext() - ); - - return controller.create(snapshotRequest) - .then(Mono.just(appId)); - }) - .flatMap(appId -> controller.listAllHistorySnapshotBriefInfo( - appId, + + // Create snapshot and then list snapshots + Mono>> result = controller.create(snapshotRequest) + .then(controller.listAllHistorySnapshotBriefInfo( + existingAppId, 1, 10, null, null, null, null - )); + )) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) @@ -133,37 +118,22 @@ public void testListHistorySnapshotsWithDatabase() { } @Test - @WithMockUser(id = "test-user") - public void testGetHistorySnapshotDslWithDatabase() { - // First create an application and snapshot - ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( - "org01", - null, - "Test App for DSL", - 1, + @WithMockUser(id = "user01") + public void testGetHistorySnapshotDslWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // First create a history snapshot for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, createTestDsl(), - null, - null, - null + createTestContext() ); - - // Create application, snapshot, and then get snapshot DSL - Mono> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create history snapshot - ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( - appId, - createTestDsl(), - createTestContext() - ); - - return controller.create(snapshotRequest) - .then(Mono.just(appId)); - }) - .flatMap(appId -> controller.listAllHistorySnapshotBriefInfo( - appId, + + // Create snapshot and then get snapshot DSL + Mono> result = controller.create(snapshotRequest) + .then(controller.listAllHistorySnapshotBriefInfo( + existingAppId, 1, 10, null, @@ -178,12 +148,12 @@ public void testGetHistorySnapshotDslWithDatabase() { if (!snapshots.isEmpty()) { String snapshotId = snapshots.get(0).snapshotId(); - String appId = snapshots.get(0).userId(); // This is actually the appId in the test context - return controller.getHistorySnapshotDsl(appId, snapshotId); + return controller.getHistorySnapshotDsl(existingAppId, snapshotId); } else { return Mono.error(new RuntimeException("No snapshots found")); } - }); + }) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) @@ -197,44 +167,30 @@ public void testGetHistorySnapshotDslWithDatabase() { } @Test - @WithMockUser(id = "test-user") - public void testListArchivedHistorySnapshotsWithDatabase() { - // First create an application and snapshot - ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( - "org01", - null, - "Test App for Archived", - 1, + @WithMockUser(id = "user01") + public void testListArchivedHistorySnapshotsWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // First create a history snapshot for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, createTestDsl(), - null, - null, - null + createTestContext() ); - - // Create application, snapshot, and then list archived snapshots - Mono>> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create history snapshot - ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( - appId, - createTestDsl(), - createTestContext() - ); - - return controller.create(snapshotRequest) - .then(Mono.just(appId)); - }) - .flatMap(appId -> controller.listAllHistorySnapshotBriefInfoArchived( - appId, + + // Create snapshot and then list archived snapshots + Mono>> result = controller.create(snapshotRequest) + .then(controller.listAllHistorySnapshotBriefInfoArchived( + existingAppId, 1, 10, null, null, null, null - )); + )) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) @@ -250,51 +206,69 @@ public void testListArchivedHistorySnapshotsWithDatabase() { } @Test - @WithMockUser(id = "test-user") - public void testCreateMultipleSnapshotsWithDatabase() { - // First create an application - ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( - "org01", - null, - "Test App for Multiple Snapshots", + @WithMockUser(id = "user01") + public void testListArchivedHistorySnapshotsEmptyList() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Test the archived endpoint structure - in test environment, there are no archived snapshots + // so we test that the endpoint responds correctly with an empty list + Mono>> listResult = controller.listAllHistorySnapshotBriefInfoArchived( + existingAppId, 1, - createTestDsl(), + 10, + null, null, null, null - ); + ) + .contextWrite(setupTestContext()); - // Create application and multiple snapshots - Mono>> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create multiple history snapshots - ApplicationHistorySnapshotRequest snapshotRequest1 = new ApplicationHistorySnapshotRequest( - appId, - createTestDsl(), - createTestContext("snapshot1") - ); - - ApplicationHistorySnapshotRequest snapshotRequest2 = new ApplicationHistorySnapshotRequest( - appId, - createTestDsl(), - createTestContext("snapshot2") - ); - - return controller.create(snapshotRequest1) - .then(controller.create(snapshotRequest2)) - .then(Mono.just(appId)); + // Verify that the archived list endpoint works correctly + StepVerifier.create(listResult) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData().containsKey("list")); + Assertions.assertTrue(response.getData().containsKey("count")); + // In test environment, count should be 0 since no snapshots are archived + Assertions.assertEquals(0L, response.getData().get("count")); }) - .flatMap(appId -> controller.listAllHistorySnapshotBriefInfo( - appId, + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testCreateMultipleSnapshotsWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Create multiple history snapshots for the existing application + ApplicationHistorySnapshotRequest snapshotRequest1 = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext("snapshot1") + ); + + ApplicationHistorySnapshotRequest snapshotRequest2 = new ApplicationHistorySnapshotRequest( + existingAppId, + createTestDsl(), + createTestContext("snapshot2") + ); + + // Create multiple snapshots and then list them + Mono>> result = controller.create(snapshotRequest1) + .then(controller.create(snapshotRequest2)) + .then(controller.listAllHistorySnapshotBriefInfo( + existingAppId, 1, 10, null, null, null, null - )); + )) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) @@ -309,34 +283,51 @@ public void testCreateMultipleSnapshotsWithDatabase() { } @Test - @WithMockUser(id = "test-user") - public void testCreateSnapshotWithEmptyDsl() { - // First create an application - ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( - "org01", - null, - "Test App for Empty DSL", - 1, - createTestDsl(), - null, - null, - null + @WithMockUser(id = "user01") + public void testCreateSnapshotWithEmptyDslWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Create history snapshot with empty DSL for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + new HashMap<>(), + createTestContext() ); + + // Create snapshot + Mono> result = controller.create(snapshotRequest) + .contextWrite(setupTestContext()); - // Create application and snapshot with empty DSL - Mono> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create history snapshot with empty DSL - ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( - appId, - new HashMap<>(), - createTestContext() - ); - - return controller.create(snapshotRequest); - }); + // Verify the result + StepVerifier.create(result) + .assertNext(response -> { + Assertions.assertTrue(response.isSuccess()); + Assertions.assertNotNull(response.getData()); + Assertions.assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + public void testCreateSnapshotWithComplexDslWithExistingApplication() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + + // Create complex DSL + Map complexDsl = createComplexTestDsl(); + + // Create history snapshot with complex DSL for the existing application + ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( + existingAppId, + complexDsl, + createTestContext("complex-snapshot") + ); + + // Create snapshot + Mono> result = controller.create(snapshotRequest) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) @@ -349,13 +340,13 @@ public void testCreateSnapshotWithEmptyDsl() { } @Test - @WithMockUser(id = "test-user") - public void testCreateSnapshotWithComplexDsl() { - // First create an application + @WithMockUser(id = "user01") + public void testApplicationCreationWorks() { + // Test that application creation works independently ApplicationEndpoints.CreateApplicationRequest createRequest = new ApplicationEndpoints.CreateApplicationRequest( "org01", null, - "Test App for Complex DSL", + "Test App for Creation", 1, createTestDsl(), null, @@ -363,34 +354,63 @@ public void testCreateSnapshotWithComplexDsl() { null ); - // Create application and snapshot with complex DSL - Mono> result = applicationController.create(createRequest) - .flatMap(appView -> { - String appId = appView.getData().getApplicationInfoView().getApplicationId(); - - // Create complex DSL - Map complexDsl = createComplexTestDsl(); - - // Create history snapshot with complex DSL - ApplicationHistorySnapshotRequest snapshotRequest = new ApplicationHistorySnapshotRequest( - appId, - complexDsl, - createTestContext("complex-snapshot") - ); - - return controller.create(snapshotRequest); - }); + System.out.println("Creating application with request: " + createRequest); + + Mono> result = applicationController.create(createRequest) + .doOnNext(response -> { + System.out.println("Application creation response: " + response); + if (response.isSuccess() && response.getData() != null) { + System.out.println("Application created successfully with ID: " + response.getData().getApplicationInfoView().getApplicationId()); + } else { + System.out.println("Application creation failed: " + response.getMessage()); + } + }) + .doOnError(error -> { + System.err.println("Application creation error: " + error.getMessage()); + error.printStackTrace(); + }) + .contextWrite(setupTestContext()); // Verify the result StepVerifier.create(result) .assertNext(response -> { Assertions.assertTrue(response.isSuccess()); Assertions.assertNotNull(response.getData()); - Assertions.assertTrue(response.getData()); + Assertions.assertNotNull(response.getData().getApplicationInfoView()); + Assertions.assertNotNull(response.getData().getApplicationInfoView().getApplicationId()); + System.out.println("Successfully created application with ID: " + response.getData().getApplicationInfoView().getApplicationId()); }) .verifyComplete(); } + @Test + @WithMockUser(id = "user01") + public void testGetHistorySnapshotDslArchivedWithNonExistentSnapshot() { + // Use an existing application from test data + String existingAppId = "app01"; // This exists in the test data + String nonExistentSnapshotId = "non-existent-snapshot-id"; + + // Test that trying to get a non-existent archived snapshot returns an appropriate error + Mono> result = controller.getHistorySnapshotDslArchived( + existingAppId, + nonExistentSnapshotId + ) + .contextWrite(setupTestContext()); + + // Verify that the endpoint handles non-existent snapshots appropriately + StepVerifier.create(result) + .expectError() + .verify(); + } + + // Helper method to set up Reactor context for tests + private reactor.util.context.Context setupTestContext() { + return reactor.util.context.Context.of( + VISITOR_TOKEN, "test-token-" + System.currentTimeMillis(), + "headers", new HashMap() + ); + } + // Helper methods private Map createTestDsl() { Map dsl = new HashMap<>(); From 6560014040e3cec389e8618946dbfe575938ff8c Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Sat, 12 Jul 2025 14:50:38 +0500 Subject: [PATCH 018/352] fixed text input glitch --- .../comps/comps/textInputComp/textInputConstants.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx index bacb892bd8..97af00711b 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx @@ -179,15 +179,10 @@ export const useTextInputProps = (props: RecordConstructorToView { + setLocalInputValue(defaultValue); props.value.onChange(defaultValue) }, [defaultValue]); - useEffect(() => { - if (inputValue !== localInputValue) { - setLocalInputValue(inputValue); - } - }, [inputValue]); - useEffect(() => { if (!changeRef.current) return; @@ -220,8 +215,7 @@ export const useTextInputProps = (props: RecordConstructorToView) => { const value = e.target.value; From bf68435ce8f43d5d4901c12c5dabb8c70523fac7 Mon Sep 17 00:00:00 2001 From: FalkWolsky Date: Sun, 13 Jul 2025 13:23:39 +0200 Subject: [PATCH 019/352] Adding function Calls to Timer Component --- .../lowcoder/src/comps/comps/timerComp.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/client/packages/lowcoder/src/comps/comps/timerComp.tsx b/client/packages/lowcoder/src/comps/comps/timerComp.tsx index a749cb0687..e9fc26ad05 100644 --- a/client/packages/lowcoder/src/comps/comps/timerComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/timerComp.tsx @@ -299,6 +299,42 @@ let TimerCompBasic = (function () { comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('reset')) }, }, + { + method: { + name: "start", + description: trans("timer.start"), + params: [], + }, + execute: async (comp, params) => { + if (comp.children.timerState.value === 'stoped') { + comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('start')) + } + }, + }, + { + method: { + name: "pause", + description: trans("timer.pause"), + params: [], + }, + execute: async (comp, params) => { + if (comp.children.timerState.value === 'started') { + comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('pause')) + } + }, + }, + { + method: { + name: "resume", + description: trans("timer.resume"), + params: [], + }, + execute: async (comp, params) => { + if (comp.children.timerState.value === 'paused') { + comp.children.actionHandler.dispatch(comp.children.actionHandler.changeValueAction('resume')) + } + } + } ]) .build(); })(); From f60c3e60ece509bd1c05a7cc2106f6adabf74553 Mon Sep 17 00:00:00 2001 From: th37star Date: Mon, 14 Jul 2025 15:11:04 -0400 Subject: [PATCH 020/352] Created test case for the AuthenticationEndpoints. --- ...uthenticationEndpointsIntegrationTest.java | 647 ++++++++++++++++++ .../AuthenticationEndpointsUnitTest.java | 384 +++++++++++ 2 files changed, 1031 insertions(+) create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsIntegrationTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsUnitTest.java diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsIntegrationTest.java new file mode 100644 index 0000000000..3c103dd74e --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsIntegrationTest.java @@ -0,0 +1,647 @@ +package org.lowcoder.api.authentication; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.authentication.dto.APIKeyRequest; +import org.lowcoder.api.authentication.dto.AuthConfigRequest; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.usermanagement.view.APIKeyVO; +import org.lowcoder.domain.authentication.AuthenticationService; +import org.lowcoder.domain.authentication.FindAuthConfig; +import org.lowcoder.domain.user.model.APIKey; +import org.lowcoder.domain.user.repository.UserRepository; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.auth.EmailAuthConfig; +import org.lowcoder.sdk.constants.AuthSourceConstants; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseCookie; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.lowcoder.sdk.exception.BizError.INVALID_PASSWORD; +import org.lowcoder.domain.user.model.Connection; + +@SpringBootTest +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AuthenticationEndpointsIntegrationTest { + + @Autowired + private AuthenticationController authenticationController; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthenticationService authenticationService; + + @Autowired + private InitData initData; + + private ServerWebExchange mockExchange; + + @BeforeEach + void setUp() { + try { + initData.init(); + } catch (RuntimeException e) { + // Handle duplicate key errors gracefully - this happens when test data already exists + if (e.getCause() instanceof DuplicateKeyException) { + // Data already exists, continue with test + System.out.println("Test data already exists, continuing with test..."); + } else { + // Re-throw other exceptions + throw e; + } + } + MockServerHttpRequest request = MockServerHttpRequest.post("").build(); + mockExchange = MockServerWebExchange.builder(request).build(); + } + + @Test + @WithMockUser(id = "user01") + void testFormLogin_Integration_Success() { + // Arrange + String email = "integration_test@example.com"; + String password = "testPassword123"; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + email, password, true, source, authId + ); + + // Act + Mono> result = authenticationController.formLogin( + formLoginRequest, null, null, mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + // Verify user was created in database + StepVerifier.create(userRepository.findByConnections_SourceAndConnections_RawId(source, email)) + .assertNext(user -> { + assertNotNull(user); + // Since connections is a Set, we need to find the connection by source + // Fixed: Changed from get(0) to stream().filter().findFirst() approach + Connection connection = user.getConnections().stream() + .filter(conn -> source.equals(conn.getSource())) + .findFirst() + .orElse(null); + assertNotNull(connection); + assertEquals(email, connection.getRawId()); + assertEquals(source, connection.getSource()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testFormLogin_Integration_LoginExistingUser() { + // Arrange - First register a user + String email = "existing_user@example.com"; + String password = "testPassword123"; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + + AuthenticationEndpoints.FormLoginRequest registerRequest = new AuthenticationEndpoints.FormLoginRequest( + email, password, true, source, authId + ); + + // Register the user first + authenticationController.formLogin(registerRequest, null, null, mockExchange).block(); + + // Now try to login with the same credentials + AuthenticationEndpoints.FormLoginRequest loginRequest = new AuthenticationEndpoints.FormLoginRequest( + email, password, false, source, authId + ); + + // Act + Mono> result = authenticationController.formLogin( + loginRequest, null, null, mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testFormLogin_Integration_InvalidCredentials() { + // Arrange + String email = "nonexistent@example.com"; + String password = "wrongPassword"; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + email, password, false, source, authId + ); + + // Act & Assert + StepVerifier.create(authenticationController.formLogin(formLoginRequest, null, null, mockExchange)) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof BizException); + BizException bizException = (BizException) throwable; + assertEquals(INVALID_PASSWORD, bizException.getError()); + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testLogout_Integration_Success() { + // Arrange - Set up a mock session token + MockServerHttpRequest request = MockServerHttpRequest.post("") + .cookie(ResponseCookie.from("token", "test-session-token").build()) + .build(); + ServerWebExchange exchangeWithCookie = MockServerWebExchange.builder(request).build(); + + // Act + Mono> result = authenticationController.logout(exchangeWithCookie); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testEnableAuthConfig_Integration_Success() { + // Arrange + AuthConfigRequest authConfigRequest = new AuthConfigRequest(); + authConfigRequest.put("authType", "FORM"); + authConfigRequest.put("source", "test-email"); + authConfigRequest.put("sourceName", "Test Email Auth"); + authConfigRequest.put("enableRegister", true); + + // Act + Mono> result = authenticationController.enableAuthConfig(authConfigRequest); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetAllConfigs_Integration_Success() { + // Act + Mono>> result = authenticationController.getAllConfigs(); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + // Should have at least the default email config + assertTrue(response.getData().size() >= 1); + + // Verify at least one config is an EmailAuthConfig + boolean hasEmailConfig = response.getData().stream() + .anyMatch(config -> config instanceof EmailAuthConfig); + assertTrue(hasEmailConfig); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testCreateAPIKey_Integration_Success() { + // Arrange + APIKeyRequest apiKeyRequest = new APIKeyRequest(); + apiKeyRequest.put("name", "Integration Test API Key"); + apiKeyRequest.put("description", "API Key created during integration test"); + + // Act + Mono> result = authenticationController.createAPIKey(apiKeyRequest); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getId()); + assertNotNull(response.getData().getToken()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetAllAPIKeys_Integration_Success() { + // Arrange - Create an API key first + APIKeyRequest apiKeyRequest = new APIKeyRequest(); + apiKeyRequest.put("name", "Test API Key for List"); + apiKeyRequest.put("description", "Test Description"); + + authenticationController.createAPIKey(apiKeyRequest).block(); + + // Act + Mono>> result = authenticationController.getAllAPIKeys(); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertTrue(response.getData().size() >= 1); + + // Verify the created API key is in the list + boolean foundCreatedKey = response.getData().stream() + .anyMatch(key -> "Test API Key for List".equals(key.getName())); + assertTrue(foundCreatedKey); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testDeleteAPIKey_Integration_Success() { + // Arrange - Create an API key first + APIKeyRequest apiKeyRequest = new APIKeyRequest(); + apiKeyRequest.put("name", "API Key to Delete"); + apiKeyRequest.put("description", "This key will be deleted"); + + APIKeyVO createdKey = authenticationController.createAPIKey(apiKeyRequest).block().getData(); + + // Act + Mono> result = authenticationController.deleteAPIKey(createdKey.getId()); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + // Verify the key was actually deleted + StepVerifier.create(authenticationController.getAllAPIKeys()) + .assertNext(response -> { + assertTrue(response.isSuccess()); + boolean keyStillExists = response.getData().stream() + .anyMatch(key -> createdKey.getId().equals(key.getId())); + assertFalse(keyStillExists); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testBindEmail_Integration_Success() { + // Arrange + String emailToBind = "bound_email@example.com"; + + // Act + Mono> result = authenticationController.bindEmail(emailToBind); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testFormLogin_Integration_WithInvitationId() { + // Arrange - Test registration without invitation ID (invitation ID is optional) + String email = "invited_user@example.com"; + String password = "testPassword123"; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + String invitationId = null; // No invitation ID - should work fine + + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + email, password, true, source, authId + ); + + // Act + Mono> result = authenticationController.formLogin( + formLoginRequest, invitationId, null, mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testFormLogin_Integration_WithOrgId() { + // Arrange + String email = "org_user@example.com"; + String password = "testPassword123"; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + String orgId = null; // Use null to get default EMAIL auth config + + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + email, password, true, source, authId + ); + + // Act + Mono> result = authenticationController.formLogin( + formLoginRequest, null, orgId, mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testDisableAuthConfig_Integration_Success() { + // Arrange - First enable an auth config + AuthConfigRequest authConfigRequest = new AuthConfigRequest(); + authConfigRequest.put("authType", "FORM"); + authConfigRequest.put("source", "disable-test"); + authConfigRequest.put("sourceName", "Test Auth to Disable"); + authConfigRequest.put("enableRegister", true); + + authenticationController.enableAuthConfig(authConfigRequest).block(); + + // Get the config ID (this is a simplified approach - in real scenario you'd get it from the response) + String configId = "disable-test"; // Simplified for test + + // Act + Mono> result = authenticationController.disableAuthConfig(configId, false); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testLoginWithThirdParty_Integration_Success() { + // Arrange - Use the existing Google OAuth config from test data + String authId = "106e4f4a4f6a48e5aa23cca6757c29e4"; // Google OAuth config ID from organization.json + String source = "GOOGLE"; + String code = "mock-oauth-code"; + String redirectUrl = "http://localhost:8080/auth/callback"; + String orgId = "org01"; + + // Act & Assert - Expect network error since auth.google.com doesn't exist + StepVerifier.create(authenticationController.loginWithThirdParty( + authId, source, code, null, redirectUrl, orgId, mockExchange + )) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof BizException); + BizException bizException = (BizException) throwable; + // Should fail due to network error when trying to reach auth.google.com + assertTrue(bizException.getMessage().contains("Failed to get OIDC information") || + bizException.getMessage().contains("Failed to resolve")); + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testLoginWithThirdParty_Integration_WithInvitationId() { + // Arrange - Test with invitation ID + String authId = "106e4f4a4f6a48e5aa23cca6757c29e4"; + String source = "GOOGLE"; + String code = "mock-oauth-code"; + String redirectUrl = "http://localhost:8080/auth/callback"; + String orgId = "org01"; + String invitationId = "test-invitation-id"; + + // Act & Assert - Expect network error since auth.google.com doesn't exist + StepVerifier.create(authenticationController.loginWithThirdParty( + authId, source, code, invitationId, redirectUrl, orgId, mockExchange + )) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof BizException); + BizException bizException = (BizException) throwable; + // Should fail due to network error when trying to reach auth.google.com + assertTrue(bizException.getMessage().contains("Failed to get OIDC information") || + bizException.getMessage().contains("Failed to resolve")); + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testLinkAccountWithThirdParty_Integration_Success() { + // Arrange - Use the existing Google OAuth config from test data + String authId = "106e4f4a4f6a48e5aa23cca6757c29e4"; // Google OAuth config ID from organization.json + String source = "GOOGLE"; + String code = "mock-oauth-code"; + String redirectUrl = "http://localhost:8080/auth/callback"; + String orgId = "org01"; + + // Act & Assert - Expect network error since auth.google.com doesn't exist + StepVerifier.create(authenticationController.linkAccountWithThirdParty( + authId, source, code, redirectUrl, orgId, mockExchange + )) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof BizException); + BizException bizException = (BizException) throwable; + // Should fail due to network error when trying to reach auth.google.com + assertTrue(bizException.getMessage().contains("Failed to get OIDC information") || + bizException.getMessage().contains("Failed to resolve")); + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testLoginWithThirdParty_Integration_InvalidAuthConfig() { + // Arrange - Test with non-existent auth config + String authId = "non-existent-auth-id"; + String source = "GOOGLE"; + String code = "mock-oauth-code"; + String redirectUrl = "http://localhost:8080/auth/callback"; + String orgId = "org01"; + + // Act & Assert + StepVerifier.create(authenticationController.loginWithThirdParty( + authId, source, code, null, redirectUrl, orgId, mockExchange + )) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof BizException); + // Should fail due to invalid auth config + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testLinkAccountWithThirdParty_Integration_InvalidAuthConfig() { + // Arrange - Test with non-existent auth config + String authId = "non-existent-auth-id"; + String source = "GOOGLE"; + String code = "mock-oauth-code"; + String redirectUrl = "http://localhost:8080/auth/callback"; + String orgId = "org01"; + + // Act & Assert + StepVerifier.create(authenticationController.linkAccountWithThirdParty( + authId, source, code, redirectUrl, orgId, mockExchange + )) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof BizException); + // Should fail due to invalid auth config + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testFormLoginRequest_Record_Integration() { + // Arrange - Test FormLoginRequest record creation and validation + String loginId = "test@example.com"; + String password = "testPassword123"; + boolean register = true; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + + // Act - Create FormLoginRequest record + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, register, source, authId + ); + + // Assert - Verify record properties + assertEquals(loginId, formLoginRequest.loginId()); + assertEquals(password, formLoginRequest.password()); + assertEquals(register, formLoginRequest.register()); + assertEquals(source, formLoginRequest.source()); + assertEquals(authId, formLoginRequest.authId()); + + // Test record immutability and equality + AuthenticationEndpoints.FormLoginRequest sameRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, register, source, authId + ); + assertEquals(formLoginRequest, sameRequest); + assertEquals(formLoginRequest.hashCode(), sameRequest.hashCode()); + + // Test different request + AuthenticationEndpoints.FormLoginRequest differentRequest = new AuthenticationEndpoints.FormLoginRequest( + "different@example.com", password, register, source, authId + ); + assertNotEquals(formLoginRequest, differentRequest); + + // Test toString method + String toString = formLoginRequest.toString(); + assertTrue(toString.contains(loginId)); + assertTrue(toString.contains(source)); + assertTrue(toString.contains(String.valueOf(register))); + + // Test with null values (should work for optional fields) + AuthenticationEndpoints.FormLoginRequest nullAuthIdRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, register, source, null + ); + assertNull(nullAuthIdRequest.authId()); + assertEquals(loginId, nullAuthIdRequest.loginId()); + } + + @Test + @WithMockUser(id = "user01") + void testFormLoginRequest_Record_WithDifferentSources() { + // Arrange - Test FormLoginRequest with different auth sources + String loginId = "test@example.com"; + String password = "testPassword123"; + boolean register = false; + String authId = getEmailAuthConfigId(); + + // Test with EMAIL source + AuthenticationEndpoints.FormLoginRequest emailRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, register, AuthSourceConstants.EMAIL, authId + ); + assertEquals(AuthSourceConstants.EMAIL, emailRequest.source()); + + // Test with PHONE source + AuthenticationEndpoints.FormLoginRequest phoneRequest = new AuthenticationEndpoints.FormLoginRequest( + "1234567890", password, register, AuthSourceConstants.PHONE, authId + ); + assertEquals(AuthSourceConstants.PHONE, phoneRequest.source()); + assertEquals("1234567890", phoneRequest.loginId()); + + // Test with GOOGLE source + AuthenticationEndpoints.FormLoginRequest googleRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, register, "GOOGLE", authId + ); + assertEquals("GOOGLE", googleRequest.source()); + } + + @Test + @WithMockUser(id = "user01") + void testFormLoginRequest_Record_WithDifferentRegisterModes() { + // Arrange - Test FormLoginRequest with different register modes + String loginId = "test@example.com"; + String password = "testPassword123"; + String source = AuthSourceConstants.EMAIL; + String authId = getEmailAuthConfigId(); + + // Test register mode (true) + AuthenticationEndpoints.FormLoginRequest registerRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, true, source, authId + ); + assertTrue(registerRequest.register()); + + // Test login mode (false) + AuthenticationEndpoints.FormLoginRequest loginRequest = new AuthenticationEndpoints.FormLoginRequest( + loginId, password, false, source, authId + ); + assertFalse(loginRequest.register()); + + // Verify they are different + assertNotEquals(registerRequest, loginRequest); + } + + private String getEmailAuthConfigId() { + return authenticationService.findAuthConfigBySource(null, AuthSourceConstants.EMAIL) + .map(FindAuthConfig::authConfig) + .map(AbstractAuthConfig::getId) + .block(); + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsUnitTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsUnitTest.java new file mode 100644 index 0000000000..b744836840 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/AuthenticationEndpointsUnitTest.java @@ -0,0 +1,384 @@ +package org.lowcoder.api.authentication; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.api.authentication.dto.APIKeyRequest; +import org.lowcoder.api.authentication.dto.AuthConfigRequest; +import org.lowcoder.api.authentication.service.AuthenticationApiService; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.home.SessionUserService; +import org.lowcoder.api.usermanagement.view.APIKeyVO; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.domain.user.model.APIKey; +import org.lowcoder.domain.user.model.AuthUser; +import org.lowcoder.domain.user.model.User; +import org.lowcoder.domain.user.service.UserService; +import org.lowcoder.sdk.auth.AbstractAuthConfig; +import org.lowcoder.sdk.auth.EmailAuthConfig; +import org.lowcoder.sdk.util.CookieHelper; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthenticationEndpointsUnitTest { + + @Mock + private AuthenticationApiService authenticationApiService; + + @Mock + private SessionUserService sessionUserService; + + @Mock + private CookieHelper cookieHelper; + + @Mock + private BusinessEventPublisher businessEventPublisher; + + @Mock + private UserService userService; + + private AuthenticationController authenticationController; + private ServerWebExchange mockExchange; + + @BeforeEach + void setUp() { + authenticationController = new AuthenticationController( + authenticationApiService, + sessionUserService, + cookieHelper, + businessEventPublisher, + userService + ); + + MockServerHttpRequest request = MockServerHttpRequest.post("").build(); + mockExchange = MockServerWebExchange.builder(request).build(); + } + + @Test + void testFormLogin_Success() { + // Arrange + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + "test@example.com", "password", false, "email", "authId" + ); + AuthUser mockAuthUser = mock(AuthUser.class); + + when(authenticationApiService.authenticateByForm( + eq("test@example.com"), eq("password"), eq("email"), + eq(false), eq("authId"), eq("orgId") + )).thenReturn(Mono.just(mockAuthUser)); + + when(authenticationApiService.loginOrRegister( + eq(mockAuthUser), eq(mockExchange), eq("invitationId"), eq(false) + )).thenReturn(Mono.empty()); + + // Act + Mono> result = authenticationController.formLogin( + formLoginRequest, "invitationId", "orgId", mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(authenticationApiService).authenticateByForm( + "test@example.com", "password", "email", false, "authId", "orgId" + ); + verify(authenticationApiService).loginOrRegister(mockAuthUser, mockExchange, "invitationId", false); + } + + @Test + void testFormLogin_RegisterMode() { + // Arrange + AuthenticationEndpoints.FormLoginRequest formLoginRequest = new AuthenticationEndpoints.FormLoginRequest( + "new@example.com", "password", true, "email", "authId" + ); + AuthUser mockAuthUser = mock(AuthUser.class); + + when(authenticationApiService.authenticateByForm( + eq("new@example.com"), eq("password"), eq("email"), + eq(true), eq("authId"), isNull() + )).thenReturn(Mono.just(mockAuthUser)); + + when(authenticationApiService.loginOrRegister( + eq(mockAuthUser), eq(mockExchange), isNull(), eq(false) + )).thenReturn(Mono.empty()); + + // Act + Mono> result = authenticationController.formLogin( + formLoginRequest, null, null, mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + void testLoginWithThirdParty_Success() { + // Arrange + AuthUser mockAuthUser = mock(AuthUser.class); + + when(authenticationApiService.authenticateByOauth2( + eq("authId"), eq("google"), eq("code"), eq("redirectUrl"), eq("orgId") + )).thenReturn(Mono.just(mockAuthUser)); + + when(authenticationApiService.loginOrRegister( + eq(mockAuthUser), eq(mockExchange), eq("invitationId"), eq(false) + )).thenReturn(Mono.empty()); + + // Act + Mono> result = authenticationController.loginWithThirdParty( + "authId", "google", "code", "invitationId", "redirectUrl", "orgId", mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + void testLinkAccountWithThirdParty_Success() { + // Arrange + AuthUser mockAuthUser = mock(AuthUser.class); + + when(authenticationApiService.authenticateByOauth2( + eq("authId"), eq("github"), eq("code"), eq("redirectUrl"), eq("orgId") + )).thenReturn(Mono.just(mockAuthUser)); + + when(authenticationApiService.loginOrRegister( + eq(mockAuthUser), eq(mockExchange), isNull(), eq(true) + )).thenReturn(Mono.empty()); + + // Act + Mono> result = authenticationController.linkAccountWithThirdParty( + "authId", "github", "code", "redirectUrl", "orgId", mockExchange + ); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + void testLogout_Success() { + // Arrange + when(cookieHelper.getCookieToken(mockExchange)).thenReturn("sessionToken"); + when(sessionUserService.removeUserSession("sessionToken")).thenReturn(Mono.empty()); + when(businessEventPublisher.publishUserLogoutEvent()).thenReturn(Mono.empty()); + + // Act + Mono> result = authenticationController.logout(mockExchange); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(cookieHelper).getCookieToken(mockExchange); + verify(sessionUserService).removeUserSession("sessionToken"); + verify(businessEventPublisher).publishUserLogoutEvent(); + } + + @Test + void testEnableAuthConfig_Success() { + // Arrange + AuthConfigRequest authConfigRequest = new AuthConfigRequest(); + authConfigRequest.put("authType", "FORM"); + authConfigRequest.put("source", "email"); + + when(authenticationApiService.enableAuthConfig(authConfigRequest)).thenReturn(Mono.just(true)); + + // Act + Mono> result = authenticationController.enableAuthConfig(authConfigRequest); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(authenticationApiService).enableAuthConfig(authConfigRequest); + } + + @Test + void testDisableAuthConfig_Success() { + // Arrange + when(authenticationApiService.disableAuthConfig("authId", true)).thenReturn(Mono.just(true)); + + // Act + Mono> result = authenticationController.disableAuthConfig("authId", true); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(authenticationApiService).disableAuthConfig("authId", true); + } + + @Test + void testGetAllConfigs_Success() { + // Arrange + EmailAuthConfig emailConfig = new EmailAuthConfig("email", true, true); + List configs = List.of(emailConfig); + + when(authenticationApiService.findAuthConfigs(false)) + .thenReturn(reactor.core.publisher.Flux.fromIterable( + configs.stream().map(config -> new org.lowcoder.domain.authentication.FindAuthConfig(config, null)).toList() + )); + + // Act + Mono>> result = authenticationController.getAllConfigs(); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(1, response.getData().size()); + assertEquals(emailConfig, response.getData().get(0)); + }) + .verifyComplete(); + } + + @Test + void testCreateAPIKey_Success() { + // Arrange + APIKeyRequest apiKeyRequest = new APIKeyRequest(); + apiKeyRequest.put("name", "Test API Key"); + apiKeyRequest.put("description", "Test Description"); + + APIKeyVO mockApiKeyVO = mock(APIKeyVO.class); + when(authenticationApiService.createAPIKey(apiKeyRequest)).thenReturn(Mono.just(mockApiKeyVO)); + + // Act + Mono> result = authenticationController.createAPIKey(apiKeyRequest); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(mockApiKeyVO, response.getData()); + }) + .verifyComplete(); + + verify(authenticationApiService).createAPIKey(apiKeyRequest); + } + + @Test + void testDeleteAPIKey_Success() { + // Arrange + when(authenticationApiService.deleteAPIKey("apiKeyId")).thenReturn(Mono.empty()); + + // Act + Mono> result = authenticationController.deleteAPIKey("apiKeyId"); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(authenticationApiService).deleteAPIKey("apiKeyId"); + } + + @Test + void testGetAllAPIKeys_Success() { + // Arrange + APIKey apiKey1 = APIKey.builder().id("key1").name("Key 1").build(); + APIKey apiKey2 = APIKey.builder().id("key2").name("Key 2").build(); + List apiKeys = List.of(apiKey1, apiKey2); + + when(authenticationApiService.findAPIKeys()) + .thenReturn(reactor.core.publisher.Flux.fromIterable(apiKeys)); + + // Act + Mono>> result = authenticationController.getAllAPIKeys(); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(2, response.getData().size()); + assertEquals(apiKey1, response.getData().get(0)); + assertEquals(apiKey2, response.getData().get(1)); + }) + .verifyComplete(); + } + + @Test + void testBindEmail_Success() { + // Arrange + User mockUser = mock(User.class); + when(sessionUserService.getVisitor()).thenReturn(Mono.just(mockUser)); + when(userService.bindEmail(mockUser, "test@example.com")).thenReturn(Mono.just(true)); + + // Act + Mono> result = authenticationController.bindEmail("test@example.com"); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(true, response.getData()); + }) + .verifyComplete(); + + verify(sessionUserService).getVisitor(); + verify(userService).bindEmail(mockUser, "test@example.com"); + } + + @Test + void testFormLoginRequest_Record() { + // Arrange & Act + AuthenticationEndpoints.FormLoginRequest request = new AuthenticationEndpoints.FormLoginRequest( + "test@example.com", "password", false, "email", "authId" + ); + + // Assert + assertEquals("test@example.com", request.loginId()); + assertEquals("password", request.password()); + assertFalse(request.register()); + assertEquals("email", request.source()); + assertEquals("authId", request.authId()); + } +} \ No newline at end of file From fc128f4584699c9842bf3938a881ef923931fc50 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Tue, 15 Jul 2025 21:49:22 +0500 Subject: [PATCH 021/352] fix: Form child element not updating --- .../src/comps/comps/formComp/formComp.tsx | 17 +++++++++-------- .../lowcoder/src/redux/sagas/orgSagas.ts | 16 +++++++++------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx index e0fce300cf..84a7b71564 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx @@ -208,13 +208,17 @@ const FormBaseComp = (function () { ); }) .setPropertyViewFn((children) => { + const editorContext = useContext(EditorContext); + const isLogicMode = editorContext.editorModeStatus === "logic" || editorContext.editorModeStatus === "both"; + const isLayoutMode = editorContext.editorModeStatus === "layout" || editorContext.editorModeStatus === "both"; + return ( <>
{children.resetAfterSubmit.propertyView({ label: trans("formComp.resetAfterSubmit") })}
- {(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && ( + {isLogicMode && ( <>
{children.onEvent.getPropertyView()} {disabledPropertyView(children)} @@ -225,7 +229,7 @@ const FormBaseComp = (function () { )} - {(useContext(EditorContext).editorModeStatus === "layout" || useContext(EditorContext).editorModeStatus === "both") && ( + {isLayoutMode && ( <>
{children.container.getPropertyView()} @@ -233,14 +237,14 @@ const FormBaseComp = (function () { )} - {(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && ( + {isLogicMode && (
{children.initialData.propertyView({ label: trans("formComp.initialData") })} {children.invalidFormMessage.propertyView({ label: trans("formComp.invalidFormMessage") })}
)} - {(useContext(EditorContext).editorModeStatus === "layout" || useContext(EditorContext).editorModeStatus === "both") && ( + {isLayoutMode && ( <>
{children.container.stylePropertyView()} @@ -383,9 +387,7 @@ let FormTmpComp = class extends FormBaseComp implements IForm { case CompActionTypes.UPDATE_NODES_V2: { const ret = super.reduce(action); // When the initial value changes, update the form - if (ret.children.initialData !== this.children.initialData) { - // FIXME: kill setTimeout ? - setTimeout(() => { + requestAnimationFrame(() => { this.dispatch( customAction( { @@ -396,7 +398,6 @@ let FormTmpComp = class extends FormBaseComp implements IForm { ) ); }); - } return ret; } case CompActionTypes.CUSTOM: diff --git a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts index dd64bf1c9f..e4157abde7 100644 --- a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts @@ -367,13 +367,15 @@ export function* fetchWorkspacesSaga(action: ReduxAction<{page: number, pageSize const apiData = response.data.data; // Transform orgId/orgName to match Org interface - 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, - })); + 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, From d4c148f7b813ad4cfdd9d054c8d4e8874d4fa58d Mon Sep 17 00:00:00 2001 From: th37star Date: Tue, 15 Jul 2025 16:12:49 -0400 Subject: [PATCH 022/352] Created test case for the ConfigEndpoints. --- .../ConfigEndpointsIntegrationTest.java | 518 ++++++++++++++++++ .../api/config/ConfigEndpointsUnitTest.java | 430 +++++++++++++++ 2 files changed, 948 insertions(+) create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsIntegrationTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsUnitTest.java diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsIntegrationTest.java new file mode 100644 index 0000000000..c42f7a429d --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsIntegrationTest.java @@ -0,0 +1,518 @@ +package org.lowcoder.api.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.infra.config.model.ServerConfig; +import org.lowcoder.infra.config.repository.ServerConfigRepository; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.*; +import org.lowcoder.api.config.ConfigView; + +@SpringBootTest +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ConfigEndpointsIntegrationTest { + + @Autowired + private ConfigController configController; + + @Autowired + private ServerConfigRepository serverConfigRepository; + + @Autowired + private ConfigCenter configCenter; + + @Autowired + private InitData initData; + + private ServerWebExchange mockExchange; + + @BeforeEach + void setUp() { + try { + initData.init(); + } catch (RuntimeException e) { + // Handle duplicate key errors gracefully - this happens when test data already exists + if (e.getCause() instanceof DuplicateKeyException) { + // Data already exists, continue with test + System.out.println("Test data already exists, continuing with test..."); + } else { + // Re-throw other exceptions + throw e; + } + } + MockServerHttpRequest request = MockServerHttpRequest.get("").build(); + mockExchange = MockServerWebExchange.builder(request).build(); + } + + @Test + @WithMockUser(id = "user01") + void testGetDeploymentId_Integration_Success() { + // Act + Mono result = configController.getDeploymentId(); + + // Assert + StepVerifier.create(result) + .assertNext(deploymentId -> { + assertNotNull(deploymentId); + // Deployment ID can be empty string in test environment + assertTrue(deploymentId.length() >= 0); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetServerConfig_Integration_ExistingConfig() { + // Arrange - Create a test config first + String key = "integration-test-key"; + String value = "integration-test-value"; + + ServerConfig testConfig = ServerConfig.builder() + .key(key) + .value(value) + .build(); + + serverConfigRepository.save(testConfig).block(); + + // Act + Mono> result = configController.getServerConfig(key); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + + // Cleanup + serverConfigRepository.deleteById(testConfig.getId()).block(); + } + + @Test + @WithMockUser(id = "user01") + void testGetServerConfig_Integration_NonExistingConfig() { + // Arrange + String key = "non-existing-integration-key"; + + // Act + Mono> result = configController.getServerConfig(key); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertNull(response.getData().getValue()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetServerConfig_Integration_EmptyKey() { + // Arrange + String key = ""; + + // Act + Mono> result = configController.getServerConfig(key); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertNull(response.getData().getValue()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateServerConfig_Integration_Success() { + // Arrange + String key = "update-test-key"; + String value = "update-test-value"; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + + // Verify the config was actually saved in database + StepVerifier.create(serverConfigRepository.findByKey(key)) + .assertNext(savedConfig -> { + assertNotNull(savedConfig); + assertEquals(key, savedConfig.getKey()); + assertEquals(value, savedConfig.getValue()); + }) + .verifyComplete(); + + // Cleanup + serverConfigRepository.findByKey(key) + .flatMap(config -> serverConfigRepository.deleteById(config.getId())) + .block(); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateServerConfig_Integration_UpdateExistingConfig() { + // Arrange - Create a config first + String key = "update-existing-test-key"; + String initialValue = "initial-value"; + + ServerConfig initialConfig = ServerConfig.builder() + .key(key) + .value(initialValue) + .build(); + + serverConfigRepository.save(initialConfig).block(); + + // Update the config + String newValue = "updated-value"; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(newValue); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(newValue, response.getData().getValue()); + }) + .verifyComplete(); + + // Verify the config was actually updated in database + StepVerifier.create(serverConfigRepository.findByKey(key)) + .assertNext(savedConfig -> { + assertNotNull(savedConfig); + assertEquals(key, savedConfig.getKey()); + assertEquals(newValue, savedConfig.getValue()); + }) + .verifyComplete(); + + // Cleanup + serverConfigRepository.findByKey(key) + .flatMap(config -> serverConfigRepository.deleteById(config.getId())) + .block(); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateServerConfig_Integration_NullValue() { + // Arrange + String key = "null-value-test-key"; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(null); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertNull(response.getData().getValue()); + }) + .verifyComplete(); + + // Verify the config was actually saved in database + StepVerifier.create(serverConfigRepository.findByKey(key)) + .assertNext(savedConfig -> { + assertNotNull(savedConfig); + assertEquals(key, savedConfig.getKey()); + assertNull(savedConfig.getValue()); + }) + .verifyComplete(); + + // Cleanup + serverConfigRepository.findByKey(key) + .flatMap(config -> serverConfigRepository.deleteById(config.getId())) + .block(); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateServerConfig_Integration_EmptyValue() { + // Arrange + String key = "empty-value-test-key"; + String value = ""; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + + // Verify the config was actually saved in database + StepVerifier.create(serverConfigRepository.findByKey(key)) + .assertNext(savedConfig -> { + assertNotNull(savedConfig); + assertEquals(key, savedConfig.getKey()); + assertEquals(value, savedConfig.getValue()); + }) + .verifyComplete(); + + // Cleanup + serverConfigRepository.findByKey(key) + .flatMap(config -> serverConfigRepository.deleteById(config.getId())) + .block(); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateServerConfig_Integration_SpecialCharacters() { + // Arrange + String key = "special-chars-test-key"; + String value = "test-value-with-special-chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + + // Verify the config was actually saved in database + StepVerifier.create(serverConfigRepository.findByKey(key)) + .assertNext(savedConfig -> { + assertNotNull(savedConfig); + assertEquals(key, savedConfig.getKey()); + assertEquals(value, savedConfig.getValue()); + }) + .verifyComplete(); + + // Cleanup + serverConfigRepository.findByKey(key) + .flatMap(config -> serverConfigRepository.deleteById(config.getId())) + .block(); + } + + @Test + @WithMockUser(id = "user01") + void testGetConfig_Integration_Success() { + // Act + Mono> result = configController.getConfig(mockExchange, null); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + // Verify ConfigView properties + assertNotNull(response.getData().getAuthConfigs()); + assertNotNull(response.getData().getWorkspaceMode()); + assertNotNull(response.getData().getCookieName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetConfig_Integration_WithNullOrgId() { + // Act + Mono> result = configController.getConfig(mockExchange, null); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + // Verify ConfigView properties + assertNotNull(response.getData().getAuthConfigs()); + assertNotNull(response.getData().getWorkspaceMode()); + assertNotNull(response.getData().getCookieName()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetConfig_Integration_WithEmptyOrgId() { + // Act & Assert - Expect BizException for empty org ID + StepVerifier.create(configController.getConfig(mockExchange, "")) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof org.lowcoder.sdk.exception.BizException); + org.lowcoder.sdk.exception.BizException bizException = (org.lowcoder.sdk.exception.BizException) throwable; + // Should fail due to empty organization ID + assertTrue(bizException.getMessage().contains("Illegal workspace ID") || + bizException.getMessage().contains("does not exist")); + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testGetConfig_Integration_WithNonExistingOrgId() { + // Act & Assert - Expect BizException for non-existing org ID + StepVerifier.create(configController.getConfig(mockExchange, "non-existing-org-id")) + .verifyErrorMatches(throwable -> { + assertTrue(throwable instanceof org.lowcoder.sdk.exception.BizException); + org.lowcoder.sdk.exception.BizException bizException = (org.lowcoder.sdk.exception.BizException) throwable; + // Should fail due to non-existing organization + assertTrue(bizException.getMessage().contains("Illegal workspace ID") || + bizException.getMessage().contains("does not exist")); + return true; + }); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateConfigRequest_Record_Integration() { + // Arrange - Test UpdateConfigRequest record creation and validation + String value = "integration-test-value"; + + // Act - Create UpdateConfigRequest record + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Assert - Verify record properties + assertEquals(value, request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(value); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test different request + ConfigEndpoints.UpdateConfigRequest differentRequest = new ConfigEndpoints.UpdateConfigRequest("different-value"); + assertNotEquals(request, differentRequest); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains(value)); + + // Test with null values + ConfigEndpoints.UpdateConfigRequest nullRequest = new ConfigEndpoints.UpdateConfigRequest(null); + assertNull(nullRequest.value()); + assertEquals(value, request.value()); // Original request should remain unchanged + } + + @Test + @WithMockUser(id = "user01") + void testUpdateConfigRequest_Record_Integration_WithNullValue() { + // Arrange & Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(null); + + // Assert + assertNull(request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(null); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains("null")); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateConfigRequest_Record_Integration_WithEmptyValue() { + // Arrange + String value = ""; + + // Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Assert + assertEquals(value, request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(value); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains(value)); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateConfigRequest_Record_Integration_WithSpecialCharacters() { + // Arrange + String value = "integration-test-value-with-special-chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"; + + // Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Assert + assertEquals(value, request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(value); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains(value)); + } + + @Test + @WithMockUser(id = "user01") + void testConfigView_Integration_Properties() { + // Act + Mono> result = configController.getConfig(mockExchange, null); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + // Verify ConfigView properties + assertNotNull(response.getData().getAuthConfigs()); + assertNotNull(response.getData().getWorkspaceMode()); + assertNotNull(response.getData().getCookieName()); + }) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsUnitTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsUnitTest.java new file mode 100644 index 0000000000..30dc9c3edb --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/config/ConfigEndpointsUnitTest.java @@ -0,0 +1,430 @@ +package org.lowcoder.api.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.usermanagement.OrgApiService; +import org.lowcoder.infra.config.model.ServerConfig; +import org.lowcoder.infra.config.repository.ServerConfigRepository; +import org.lowcoder.sdk.config.dynamic.Conf; +import org.lowcoder.sdk.config.dynamic.ConfigCenter; +import org.lowcoder.sdk.config.dynamic.ConfigInstance; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ConfigEndpointsUnitTest { + + @Mock + private ServerConfigRepository serverConfigRepository; + + @Mock + private OrgApiService orgApiService; + + @Mock + private ConfigCenter configCenter; + + @Mock + private ConfigInstance configInstance; + + @Mock + private Conf deploymentIdConf; + + @InjectMocks + private ConfigController configController; + + private ServerWebExchange mockExchange; + + @BeforeEach + void setUp() { + MockServerHttpRequest request = MockServerHttpRequest.get("").build(); + mockExchange = MockServerWebExchange.builder(request).build(); + } + + @Test + void testGetDeploymentId_Success() { + // Arrange + String expectedDeploymentId = "test-deployment-id"; + when(configCenter.deployment()).thenReturn(configInstance); + when(configInstance.ofString("id", "")).thenReturn(deploymentIdConf); + when(deploymentIdConf.get()).thenReturn(expectedDeploymentId); + + // Initialize the controller since @PostConstruct is not called with @InjectMocks + configController.init(); + + // Act + Mono result = configController.getDeploymentId(); + + // Assert + StepVerifier.create(result) + .expectNext(expectedDeploymentId) + .verifyComplete(); + } + + @Test + void testGetDeploymentId_EmptyId() { + // Arrange + when(configCenter.deployment()).thenReturn(configInstance); + when(configInstance.ofString("id", "")).thenReturn(deploymentIdConf); + when(deploymentIdConf.get()).thenReturn(""); + + // Initialize the controller since @PostConstruct is not called with @InjectMocks + configController.init(); + + // Act + Mono result = configController.getDeploymentId(); + + // Assert + StepVerifier.create(result) + .expectNext("") + .verifyComplete(); + } + + @Test + void testGetServerConfig_ExistingConfig() { + // Arrange + String key = "test-key"; + String value = "test-value"; + ServerConfig expectedConfig = ServerConfig.builder() + .key(key) + .value(value) + .build(); + + when(serverConfigRepository.findByKey(key)).thenReturn(Mono.just(expectedConfig)); + + // Act + Mono> result = configController.getServerConfig(key); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + } + + @Test + void testGetServerConfig_NonExistingConfig() { + // Arrange + String key = "non-existing-key"; + when(serverConfigRepository.findByKey(key)).thenReturn(Mono.empty()); + + // Act + Mono> result = configController.getServerConfig(key); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertNull(response.getData().getValue()); + }) + .verifyComplete(); + } + + @Test + void testGetServerConfig_NullKey() { + // Arrange + String key = null; + when(serverConfigRepository.findByKey(key)).thenReturn(Mono.empty()); + + // Act + Mono> result = configController.getServerConfig(key); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNull(response.getData().getKey()); + assertNull(response.getData().getValue()); + }) + .verifyComplete(); + } + + @Test + void testUpdateServerConfig_Success() { + // Arrange + String key = "test-key"; + String value = "new-value"; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + ServerConfig expectedConfig = ServerConfig.builder() + .key(key) + .value(value) + .build(); + + when(serverConfigRepository.upsert(key, value)).thenReturn(Mono.just(expectedConfig)); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + + verify(serverConfigRepository).upsert(key, value); + } + + @Test + void testUpdateServerConfig_NullValue() { + // Arrange + String key = "test-key"; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(null); + + ServerConfig expectedConfig = ServerConfig.builder() + .key(key) + .value(null) + .build(); + + when(serverConfigRepository.upsert(key, null)).thenReturn(Mono.just(expectedConfig)); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertNull(response.getData().getValue()); + }) + .verifyComplete(); + + verify(serverConfigRepository).upsert(key, null); + } + + @Test + void testUpdateServerConfig_EmptyValue() { + // Arrange + String key = "test-key"; + String value = ""; + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + ServerConfig expectedConfig = ServerConfig.builder() + .key(key) + .value(value) + .build(); + + when(serverConfigRepository.upsert(key, value)).thenReturn(Mono.just(expectedConfig)); + + // Act + Mono> result = configController.updateServerConfig(key, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(key, response.getData().getKey()); + assertEquals(value, response.getData().getValue()); + }) + .verifyComplete(); + + verify(serverConfigRepository).upsert(key, value); + } + + @Test + void testGetConfig_Success() { + // Arrange + String orgId = "test-org-id"; + ConfigView expectedConfigView = ConfigView.builder() + .isCloudHosting(false) + .workspaceMode(org.lowcoder.sdk.constants.WorkspaceMode.SAAS) + .selfDomain(false) + .cookieName("test-cookie") + .build(); + + when(orgApiService.getOrganizationConfigs(orgId)).thenReturn(Mono.just(expectedConfigView)); + + // Act + Mono> result = configController.getConfig(mockExchange, orgId); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(expectedConfigView.isCloudHosting(), response.getData().isCloudHosting()); + assertEquals(expectedConfigView.getWorkspaceMode(), response.getData().getWorkspaceMode()); + assertEquals(expectedConfigView.isSelfDomain(), response.getData().isSelfDomain()); + assertEquals(expectedConfigView.getCookieName(), response.getData().getCookieName()); + }) + .verifyComplete(); + + verify(orgApiService).getOrganizationConfigs(orgId); + } + + @Test + void testGetConfig_NullOrgId() { + // Arrange + String orgId = null; + ConfigView expectedConfigView = ConfigView.builder() + .isCloudHosting(true) + .workspaceMode(org.lowcoder.sdk.constants.WorkspaceMode.ENTERPRISE) + .selfDomain(true) + .cookieName("default-cookie") + .build(); + + when(orgApiService.getOrganizationConfigs(orgId)).thenReturn(Mono.just(expectedConfigView)); + + // Act + Mono> result = configController.getConfig(mockExchange, orgId); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(expectedConfigView.isCloudHosting(), response.getData().isCloudHosting()); + assertEquals(expectedConfigView.getWorkspaceMode(), response.getData().getWorkspaceMode()); + assertEquals(expectedConfigView.isSelfDomain(), response.getData().isSelfDomain()); + assertEquals(expectedConfigView.getCookieName(), response.getData().getCookieName()); + }) + .verifyComplete(); + + verify(orgApiService).getOrganizationConfigs(orgId); + } + + @Test + void testGetConfig_EmptyOrgId() { + // Arrange + String orgId = ""; + ConfigView expectedConfigView = ConfigView.builder() + .isCloudHosting(false) + .workspaceMode(org.lowcoder.sdk.constants.WorkspaceMode.SAAS) + .selfDomain(false) + .cookieName("empty-org-cookie") + .build(); + + when(orgApiService.getOrganizationConfigs(orgId)).thenReturn(Mono.just(expectedConfigView)); + + // Act + Mono> result = configController.getConfig(mockExchange, orgId); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals(expectedConfigView.isCloudHosting(), response.getData().isCloudHosting()); + assertEquals(expectedConfigView.getWorkspaceMode(), response.getData().getWorkspaceMode()); + assertEquals(expectedConfigView.isSelfDomain(), response.getData().isSelfDomain()); + assertEquals(expectedConfigView.getCookieName(), response.getData().getCookieName()); + }) + .verifyComplete(); + + verify(orgApiService).getOrganizationConfigs(orgId); + } + + @Test + void testUpdateConfigRequest_Record() { + // Arrange + String value = "test-value"; + + // Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Assert + assertEquals(value, request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(value); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test different request + ConfigEndpoints.UpdateConfigRequest differentRequest = new ConfigEndpoints.UpdateConfigRequest("different-value"); + assertNotEquals(request, differentRequest); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains(value)); + + // Test with null value + ConfigEndpoints.UpdateConfigRequest nullRequest = new ConfigEndpoints.UpdateConfigRequest(null); + assertNull(nullRequest.value()); + } + + @Test + void testUpdateConfigRequest_Record_WithNullValue() { + // Arrange & Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(null); + + // Assert + assertNull(request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(null); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains("null")); + } + + @Test + void testUpdateConfigRequest_Record_WithEmptyValue() { + // Arrange + String value = ""; + + // Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Assert + assertEquals(value, request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(value); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains(value)); + } + + @Test + void testUpdateConfigRequest_Record_WithSpecialCharacters() { + // Arrange + String value = "test-value-with-special-chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"; + + // Act + ConfigEndpoints.UpdateConfigRequest request = new ConfigEndpoints.UpdateConfigRequest(value); + + // Assert + assertEquals(value, request.value()); + + // Test record immutability and equality + ConfigEndpoints.UpdateConfigRequest sameRequest = new ConfigEndpoints.UpdateConfigRequest(value); + assertEquals(request, sameRequest); + assertEquals(request.hashCode(), sameRequest.hashCode()); + + // Test toString method + String toString = request.toString(); + assertTrue(toString.contains(value)); + } +} \ No newline at end of file From 8ff5f78bc650d9dd8bbaf8aa49c59e6978af9b01 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Wed, 16 Jul 2025 12:15:56 +0500 Subject: [PATCH 023/352] Dropdown in User Groups to filter roles --- .../packages/lowcoder/src/i18n/locales/en.ts | 1 + .../permission/groupUsersPermission.tsx | 40 +++++++++++++++---- .../lowcoder/src/util/pagination/type.ts | 3 +- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index f644df665a..62470a286b 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -3021,6 +3021,7 @@ export const en = { "joinTimeColumn": "Joining Time", "actionColumn": "Operation", "roleColumn": "Role", + "filterByRole": "Filter by role", "exitGroup": "Exit Group", "moveOutGroup": "Remove from Group", "inviteUser": "Invite Members", diff --git a/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx b/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx index 1ce0155300..70d736c903 100644 --- a/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx +++ b/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx @@ -1,6 +1,6 @@ -import { GroupRoleInfo, GroupUser, OrgGroup, TacoRoles } from "constants/orgConstants"; +import { GroupRoleInfo, GroupUser, OrgGroup, TacoRoles, RoleIdType } from "constants/orgConstants"; import { User } from "constants/userConstants"; -import { AddIcon, ArrowIcon, CustomSelect, PackUpIcon, Search, SuperUserIcon } from "lowcoder-design"; +import { AddIcon, ArrowIcon, CustomSelect, Dropdown, PackUpIcon, Search, SuperUserIcon } from "lowcoder-design"; import { trans } from "i18n"; import ProfileImage from "pages/common/profileImage"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -84,6 +84,7 @@ const GroupUsersPermission: React.FC = (props) => { setElements } = props; const [searchValue, setSearchValue] = useState("") + const [roleFilter, setRoleFilter] = useState("") const dispatch = useDispatch(); const adminCount = groupUsers.filter((user) => isGroupAdmin(user.role)).length; @@ -99,9 +100,23 @@ const GroupUsersPermission: React.FC = (props) => { }); }, [groupUsers]); + const roleFilterOptions = useCallback(() => { + const filterOptions = [ + ...TacoRoles.map(role => ({ + label: GroupRoleInfo[role].name, + value: role as RoleIdType | "" + })), + { + label: "All", + value: "" as RoleIdType | "" + } + ] + return filterOptions; + }, []) + const debouncedFetchPotentialMembers = useCallback( - debounce((searchVal: string) => { - fetchGroupUsrPagination({groupId: group.groupId, search: searchVal}) + debounce((searchVal: string, roleFilter: string) => { + fetchGroupUsrPagination({groupId: group.groupId, search: searchVal, role: roleFilter}) .then(result => { if (result.success) { setElements({ @@ -115,13 +130,13 @@ const GroupUsersPermission: React.FC = (props) => { ); useEffect(() => { - if (searchValue.length > 2 || searchValue === "") { - debouncedFetchPotentialMembers(searchValue); + if (searchValue.length > 2 || searchValue === "" || roleFilter) { + debouncedFetchPotentialMembers(searchValue, roleFilter); } return () => { debouncedFetchPotentialMembers.cancel(); }; - }, [searchValue, debouncedFetchPotentialMembers]); + }, [searchValue, roleFilter, debouncedFetchPotentialMembers]); return ( <> @@ -137,6 +152,17 @@ const GroupUsersPermission: React.FC = (props) => { {isGroupAdmin(currentUserGroupRole) && !group.syncGroup && ( + { + setRoleFilter(value); + }} + style={{ + minWidth: "100px" + }} + placeholder={trans("memberSettings.filterByRole")} + /> Date: Thu, 17 Jul 2025 17:07:43 +0500 Subject: [PATCH 024/352] remove myorg call on initial load --- .../redux/reducers/uiReducers/usersReducer.ts | 18 ++++++++++++++++-- .../lowcoder/src/redux/sagas/userSagas.ts | 3 +-- .../lowcoder/src/util/useWorkspaceManager.ts | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts index 3142ca13d3..f1a04ea2cc 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts @@ -25,7 +25,9 @@ const initialState: UsersReduxState = { workspaces: { items: [], totalCount: 0, - currentOrg: null + currentOrg: null, + loading: false, + isFetched: false, } }; @@ -198,6 +200,14 @@ const usersReducer = createReducer(initialState, { apiKeys: action.payload, }), + [ReduxActionTypes.FETCH_WORKSPACES_INIT]: (state: UsersReduxState) => ({ + ...state, + workspaces: { + ...state.workspaces, + loading: true, + }, + }), + [ReduxActionTypes.FETCH_WORKSPACES_SUCCESS]: ( state: UsersReduxState, @@ -208,7 +218,9 @@ const usersReducer = createReducer(initialState, { 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 + totalCount: action.payload.totalCount, + isFetched: true, + loading: false, } }), @@ -234,6 +246,8 @@ export interface UsersReduxState { items: Org[]; // Current page of workspaces totalCount: number; // Total workspaces available currentOrg: Org | null; + loading: boolean; + isFetched: boolean; }; } diff --git a/client/packages/lowcoder/src/redux/sagas/userSagas.ts b/client/packages/lowcoder/src/redux/sagas/userSagas.ts index d0dfdba068..b19e2b1a65 100644 --- a/client/packages/lowcoder/src/redux/sagas/userSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/userSagas.ts @@ -77,8 +77,7 @@ export function* getUserSaga() { type: ReduxActionTypes.FETCH_USER_DETAILS_SUCCESS, payload: user, }); - // fetch all workspaces and store in redux - yield put(fetchWorkspacesAction(1, 10)); + } } catch (error: any) { yield put({ diff --git a/client/packages/lowcoder/src/util/useWorkspaceManager.ts b/client/packages/lowcoder/src/util/useWorkspaceManager.ts index 5c5cafee07..fe2379769d 100644 --- a/client/packages/lowcoder/src/util/useWorkspaceManager.ts +++ b/client/packages/lowcoder/src/util/useWorkspaceManager.ts @@ -1,8 +1,9 @@ import { useReducer, useEffect, useCallback, useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; 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 @@ -73,6 +74,7 @@ export function useWorkspaceManager({ }: UseWorkspaceManagerOptions) { // Get workspaces from Redux const workspaces = useSelector(getWorkspaces); + const reduxDispatch = useDispatch(); // Initialize reducer with Redux total count const [state, dispatch] = useReducer(workspaceReducer, { @@ -81,6 +83,14 @@ export function useWorkspaceManager({ }); + + /* ----- first-time fetch ------------------------------------------------ */ + useEffect(() => { + if (!workspaces.isFetched && !workspaces.loading) { + reduxDispatch(fetchWorkspacesAction(1, pageSize)); + } + }, [workspaces.isFetched, workspaces.loading, pageSize, reduxDispatch]); + // API call to fetch workspaces (memoized for stable reference) const fetchWorkspacesPage = useCallback( async (page: number, search?: string) => { @@ -177,7 +187,7 @@ export function useWorkspaceManager({ // State searchTerm: state.searchTerm, currentPage: state.currentPage, - isLoading: state.isLoading, + isLoading: state.isLoading || workspaces.loading, displayWorkspaces, totalCount: currentTotalCount, From c9e00c519d587af51e1cfc2cfdc91e53c6dbd7f9 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 17 Jul 2025 17:41:11 +0500 Subject: [PATCH 025/352] remove api call from DS home page --- client/packages/lowcoder/src/pages/datasource/index.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/packages/lowcoder/src/pages/datasource/index.tsx b/client/packages/lowcoder/src/pages/datasource/index.tsx index fe965aba6c..3983a40697 100644 --- a/client/packages/lowcoder/src/pages/datasource/index.tsx +++ b/client/packages/lowcoder/src/pages/datasource/index.tsx @@ -16,18 +16,11 @@ import { getDataSource, getDataSourceTypes } from "../../redux/selectors/datasou export const DatasourceHome = () => { const dispatch = useDispatch(); - const datasourceList = useSelector(getDataSource); const datasourceTypes = useSelector(getDataSourceTypes); const currentUser = useSelector(getUser); const orgId = currentUser.currentOrgId; - useEffect(() => { - if (isEmpty(orgId) || datasourceList.length !== 0) { - return; - } - dispatch(fetchDatasource({ organizationId: orgId })); - }, [dispatch, datasourceList.length, orgId]); useEffect(() => { if (isEmpty(orgId) || datasourceTypes.length !== 0) { From 1c6f8bd15c56a1fa1a810c4e7514ea0fb29c71f0 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Thu, 17 Jul 2025 17:58:38 +0500 Subject: [PATCH 026/352] Tags Component Completed --- .../lowcoder-design/src/icons/index.tsx | 2 + .../lowcoder-design/src/icons/v2/tags-l.svg | 10 + .../lowcoder-design/src/icons/v2/tags-s.svg | 10 + .../comps/selectInputComp/multiSelectComp.tsx | 2 +- .../comps/selectInputComp/selectComp.tsx | 2 +- .../src/comps/comps/tagsComp/tagsCompView.tsx | 229 ++++++++++++------ .../src/comps/controls/optionsControl.tsx | 79 +++++- client/packages/lowcoder/src/comps/index.tsx | 4 +- .../packages/lowcoder/src/i18n/locales/en.ts | 12 + 9 files changed, 270 insertions(+), 80 deletions(-) create mode 100644 client/packages/lowcoder-design/src/icons/v2/tags-l.svg create mode 100644 client/packages/lowcoder-design/src/icons/v2/tags-s.svg diff --git a/client/packages/lowcoder-design/src/icons/index.tsx b/client/packages/lowcoder-design/src/icons/index.tsx index b033d52e92..9c00866feb 100644 --- a/client/packages/lowcoder-design/src/icons/index.tsx +++ b/client/packages/lowcoder-design/src/icons/index.tsx @@ -355,6 +355,7 @@ export { ReactComponent as VideoCameraStreamCompIconSmall } from "./v2/camera-st export { ReactComponent as VideoScreenshareCompIconSmall } from "./v2/screen-share-stream-s.svg"; // new export { ReactComponent as SignatureCompIconSmall } from "./v2/signature-s.svg"; export { ReactComponent as StepCompIconSmall } from "./v2/steps-s.svg"; +export { ReactComponent as TagsCompIconSmall } from "./v2/tags-s.svg" export { ReactComponent as CandlestickChartCompIconSmall } from "./v2/candlestick-chart-s.svg"; // new @@ -468,6 +469,7 @@ export { ReactComponent as SignatureCompIcon } from "./v2/signature-m.svg"; export { ReactComponent as GanttCompIcon } from "./v2/gantt-chart-m.svg"; export { ReactComponent as KanbanCompIconSmall } from "./v2/kanban-s.svg"; export { ReactComponent as KanbanCompIcon } from "./v2/kanban-m.svg"; +export { ReactComponent as TagsCompIcon } from "./v2/tags-l.svg"; export { ReactComponent as CandlestickChartCompIcon } from "./v2/candlestick-chart-m.svg"; export { ReactComponent as FunnelChartCompIcon } from "./v2/funnel-chart-m.svg"; diff --git a/client/packages/lowcoder-design/src/icons/v2/tags-l.svg b/client/packages/lowcoder-design/src/icons/v2/tags-l.svg new file mode 100644 index 0000000000..cd1d0368c3 --- /dev/null +++ b/client/packages/lowcoder-design/src/icons/v2/tags-l.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/client/packages/lowcoder-design/src/icons/v2/tags-s.svg b/client/packages/lowcoder-design/src/icons/v2/tags-s.svg new file mode 100644 index 0000000000..d45fcb0aa8 --- /dev/null +++ b/client/packages/lowcoder-design/src/icons/v2/tags-s.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx index 8380c56722..2527d57bd4 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/multiSelectComp.tsx @@ -30,7 +30,7 @@ let MultiSelectBasicComp = (function () { padding: PaddingControl, }; return new UICompBuilder(childrenMap, (props, dispatch) => { - const valueSet = new Set(props.options.map((o) => o.value)); // Filter illegal default values entered by the user + const valueSet = new Set((props.options as any[]).map((o: any) => o.value)); // Filter illegal default values entered by the user const [ validateState, handleChange, diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx index eef8cad608..a2415b4a58 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectComp.tsx @@ -39,7 +39,7 @@ let SelectBasicComp = (function () { const propsRef = useRef>(props); propsRef.current = props; - const valueSet = new Set(props.options.map((o) => o.value)); // Filter illegal default values entered by the user + const valueSet = new Set((props.options as any[]).map((o: any) => o.value)); // Filter illegal default values entered by the user return props.label({ required: props.required, diff --git a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx index 9979c5a1b5..f59898964c 100644 --- a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx @@ -1,92 +1,179 @@ -import { AnimationStyle, BoolCodeControl, ButtonEventHandlerControl, CommonNameConfig, DropdownOptionControl, IconControl, LinkStyle, NameConfig, NameConfigDisabled, RefControl, Section, SelectOptionControl, StringControl, TabsOptionControl, TagsOptionControl, UICompBuilder, blurMethod, clickMethod, focusWithOptions, migrateOldData, refMethods, sectionNames, stringExposingStateControl, styleControl, withDefault, withExposingConfigs } from "@lowcoder-ee/index.sdk"; -import React from "react"; +import { + BoolCodeControl, + ButtonEventHandlerControl, + InputLikeStyle, + NameConfig, + Section, + UICompBuilder, + hiddenPropertyView, + sectionNames, + showDataLoadingIndicatorsPropertyView, + styleControl, + withExposingConfigs +} from "@lowcoder-ee/index.sdk"; +import styled from "styled-components"; +import React, { useContext } from "react"; import { trans } from "i18n"; -import { buttonRefMethods } from "../buttonComp/buttonCompConstants"; import { Tag } from "antd"; -import { autoCompleteRefMethods } from "../autoCompleteComp/autoCompleteConstants"; +import { EditorContext } from "comps/editorState"; +import { PresetStatusColorTypes } from "antd/es/_util/colors"; +import { hashToNum } from "util/stringUtils"; +import { TagsCompOptionsControl } from "comps/controls/optionsControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +const colors = PresetStatusColorTypes; -// const TagsCompView = (function () { -// // const childrenMap = { -// // text: withDefault(StringControl, trans("link.link")), -// // onEvent: ButtonEventHandlerControl, -// // disabled: BoolCodeControl, -// // loading: BoolCodeControl, - -// // // style: migrateOldData(styleControl(LinkStyle, 'style')), -// // animationStyle: styleControl(AnimationStyle, 'animationStyle'), -// // prefixIcon: IconControl, -// // suffixIcon: IconControl, -// // viewRef: RefControl, -// // }; +// These functions are used for individual tag styling +function getTagColor(tagText : any, tagOptions: any[]) { + const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + if (foundOption.colorType === "preset") { + return foundOption.presetColor; + } else if (foundOption.colorType === "custom") { + return undefined; + } + return foundOption.color; + } + const index = Math.abs(hashToNum(tagText)) % colors.length; + return colors[index]; +} -// const childrenMap = { -// text: stringExposingStateControl("text", "world"), -// // options: TabsOptionControl, -// }; -// return new UICompBuilder(childrenMap, (props) => { -// return ( -// Tag 1 -// ) -// }) -// .setPropertyViewFn((children) => { -// return( -//
-// {/* {children.options.propertyView({})} */} -// {children.text.propertyView({ label: trans("text") })} -//
-// ) -// }) -// .build(); -// })(); +const getTagStyle = (tagText: any, tagOptions: any[], baseStyle: any = {}) => { + const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + const style: any = { ...baseStyle }; + + if (foundOption.colorType === "custom") { + style.backgroundColor = foundOption.color; + style.color = foundOption.textColor; + style.border = `1px solid ${foundOption.color}`; + } + + if (foundOption.border) { + style.borderColor = foundOption.border; + if (!foundOption.colorType || foundOption.colorType !== "custom") { + style.border = `1px solid ${foundOption.border}`; + } + } + + if (foundOption.radius) { + style.borderRadius = foundOption.radius; + } + + if (foundOption.margin) { + style.margin = foundOption.margin; + } + + if (foundOption.padding) { + style.padding = foundOption.padding; + } + + return style; + } + return baseStyle; +}; + +function getTagIcon(tagText: any, tagOptions: any[]) { + const foundOption = tagOptions.find(option => option.label === tagText); + return foundOption ? foundOption.icon : undefined; +} const multiTags = (function () { + + const StyledTag = styled(Tag)<{ $style: any, $bordered: boolean, $customStyle: any }>` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + background: ${(props) => props.$customStyle?.backgroundColor || props.$style?.background}; + color: ${(props) => props.$customStyle?.color || props.$style?.text}; + border-radius: ${(props) => props.$customStyle?.borderRadius || props.$style?.borderRadius}; + border: ${(props) => { + if (props.$customStyle?.border) return props.$customStyle.border; + return props.$bordered ? `${props.$style?.borderStyle} ${props.$style?.borderWidth} ${props.$style?.border}` : 'none'; + }}; + padding: ${(props) => props.$customStyle?.padding || props.$style?.padding}; + margin: ${(props) => props.$customStyle?.margin || props.$style?.margin}; + font-size: ${(props) => props.$style?.textSize}; + font-weight: ${(props) => props.$style?.fontWeight}; + cursor: pointer; + `; + + const StyledTagContainer = styled.div` + display: flex; + gap: 5px; + padding: 5px; + `; + const childrenMap = { - text: stringExposingStateControl("text", "world"), - options: TagsOptionControl, + options: TagsCompOptionsControl, + style: styleControl(InputLikeStyle, 'style'), + onEvent: ButtonEventHandlerControl, + borderless: BoolCodeControl, + enableIndividualStyling: BoolCodeControl, }; return new UICompBuilder(childrenMap, (props) => { - const text = props.text.value; - console.log(props.options) + const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent}); + return ( - <> - {props.options.map(tag => ( - {tag.label} - ))} - - ); + + {props.options.map((tag, index) => { + + // Use individual styling only if enableIndividualStyling is true + const tagColor = props.enableIndividualStyling ? getTagColor(tag.label, props.options) : undefined; + const tagIcon = props.enableIndividualStyling ? getTagIcon(tag.label, props.options) : tag.icon; + const tagStyle = props.enableIndividualStyling ? getTagStyle(tag.label, props.options, props.style) : {}; + + return ( + handleClickEvent()} + > + {tag.label} + + ); + })} + + ); }) .setPropertyViewFn((children: any) => { return ( -
- {children.options.propertyView({})} - {children.text.propertyView({ label: "Text" })} -
+ <> +
+ {children.options.propertyView({})} +
+ + {["logic", "both"].includes(useContext(EditorContext).editorModeStatus) && ( +
+ {children.onEvent.getPropertyView()} + {hiddenPropertyView(children)} + {showDataLoadingIndicatorsPropertyView(children)} +
+ )} + + {["layout", "both"].includes( + useContext(EditorContext).editorModeStatus + ) && ( +
+ {children.enableIndividualStyling.propertyView({ + label: trans("style.individualStyling"), + tooltip: trans("style.individualStylingTooltip") + })} + {children.borderless.propertyView({ label: trans("style.borderless") })} + {children.style.getPropertyView()} +
+ )} + ) }) .build(); })() - -// const childrenMap = { -// text: stringExposingStateControl("text", "world"), -// options: TagsOptionControl, -// }; - -// const TagsCompView = new UICompBuilder(childrenMap, (props: any) => { -// const text = props.text.value; -// return
Hello {text}
; -// }) -// .setPropertyViewFn((children: any) => { -// return ( -//
-// {children.options.propertyView({})} -// {children.text.propertyView({ label: "Text" })} -//
-// ) -// }) -// .build(); - -export const MultiTagsComp = withExposingConfigs(multiTags, [new NameConfig("text", "")]); +export const MultiTagsComp = withExposingConfigs(multiTags, [new NameConfig("options", "")]); diff --git a/client/packages/lowcoder/src/comps/controls/optionsControl.tsx b/client/packages/lowcoder/src/comps/controls/optionsControl.tsx index 55e6554c63..1186057d9c 100644 --- a/client/packages/lowcoder/src/comps/controls/optionsControl.tsx +++ b/client/packages/lowcoder/src/comps/controls/optionsControl.tsx @@ -782,14 +782,83 @@ export const StepOptionControl = optionsControl(StepOption, { uniqField: "label", }); +let TagsCompOptions = new MultiCompBuilder( + { + label: StringControl, + icon: IconControl, + colorType: withDefault(dropdownControl([ + { label: trans("style.preset"), value: "preset" }, + { label: trans("style.custom"), value: "custom" }, + ] as const, "preset"), "preset"), + presetColor: withDefault(dropdownControl(TAG_PRESET_COLORS, "blue"), "blue"), + color: withDefault(ColorControl, "#1890ff"), + textColor: withDefault(ColorControl, "#ffffff"), + border: withDefault(ColorControl, ""), + radius: withDefault(RadiusControl, ""), + margin: withDefault(StringControl, ""), + padding: withDefault(StringControl, ""), + }, + (props) => props +).build(); + +TagsCompOptions = class extends TagsCompOptions implements OptionCompProperty { + propertyView(param: { autoMap?: boolean }) { + const colorType = this.children.colorType.getView(); + return ( + <> + {this.children.label.propertyView({ label: trans("coloredTagOptionControl.tag") })} + {this.children.icon.propertyView({ label: trans("coloredTagOptionControl.icon") })} + {this.children.colorType.propertyView({ + label: trans("style.colorType"), + radioButton: true + })} + {colorType === "preset" && this.children.presetColor.propertyView({ + label: trans("style.presetColor") + })} + {colorType === "custom" && ( + <> + {this.children.color.propertyView({ label: trans("coloredTagOptionControl.color") })} + {this.children.textColor.propertyView({ label: trans("style.textColor") })} + + )} + {this.children.border.propertyView({ + label: trans('style.border') + })} + {this.children.radius.propertyView({ + label: trans('style.borderRadius'), + preInputNode: , + placeholder: '3px', + })} + {this.children.margin.propertyView({ + label: trans('style.margin'), + preInputNode: , + placeholder: '3px', + })} + {this.children.padding.propertyView({ + label: trans('style.padding'), + preInputNode: , + placeholder: '3px', + })} + + ); + } +}; + +export const TagsCompOptionsControl = optionsControl(TagsCompOptions, { + initOptions: [ + { label: "Option 1", colorType: "preset", presetColor: "blue" }, + { label: "Option 2", colorType: "preset", presetColor: "green" } + ], + uniqField: "label", +}); let ColoredTagOption = new MultiCompBuilder( { label: StringControl, icon: IconControl, colorType: withDefault(dropdownControl([ - { label: "Preset", value: "preset" }, - { label: "Custom", value: "custom" }, + { label: trans("style.preset"), value: "preset" }, + { label: trans("style.custom"), value: "custom" }, ] as const, "preset"), "preset"), presetColor: withDefault(dropdownControl(TAG_PRESET_COLORS, "blue"), "blue"), color: withDefault(ColorControl, "#1890ff"), @@ -811,16 +880,16 @@ ColoredTagOption = class extends ColoredTagOption implements OptionCompProperty {this.children.label.propertyView({ label: trans("coloredTagOptionControl.tag") })} {this.children.icon.propertyView({ label: trans("coloredTagOptionControl.icon") })} {this.children.colorType.propertyView({ - label: "Color Type", + label: trans("style.colorType"), radioButton: true })} {colorType === "preset" && this.children.presetColor.propertyView({ - label: "Preset Color" + label: trans("style.presetColor") })} {colorType === "custom" && ( <> {this.children.color.propertyView({ label: trans("coloredTagOptionControl.color") })} - {this.children.textColor.propertyView({ label: "Text Color" })} + {this.children.textColor.propertyView({ label: trans("style.textColor") })} )} {this.children.border.propertyView({ diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index 00d54a9b2a..609ddf5b0a 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -94,7 +94,7 @@ import { TourCompIcon, StepCompIcon, ShapesCompIcon, - + TagsCompIcon, CandlestickChartCompIcon, FunnelChartCompIcon, HeatmapChartCompIcon, @@ -715,7 +715,7 @@ export var uiCompMap: Registry = { enName: "tags", description: "Desc of Tags", categories: ["layout"], - icon: FloatingButtonCompIcon, + icon: TagsCompIcon, keywords: trans("uiComp.floatButtonCompKeywords"), comp: MultiTagsComp, layoutInfo: { diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 95fd8671a2..1ff980d532 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -572,6 +572,10 @@ export const en = { "headerText": "Header Text Color", "labelColor": "Label Color", "label": "Label Color", + "colorType": "Color Type", + "presetColor": "Preset Color", + "preset": "Preset", + "custom": "Custom", "lineHeight":"Line Height", "subTitleColor": "SubTitle Color", "titleText": "Title Color", @@ -601,6 +605,14 @@ export const en = { "chartTextColor": "Text Color", "detailSize": "Detail Size", "hideColumn": "Hide Column", + "height": "Height", + "gap": "Gap", + "flexWrap": "Flex Wrap", + "justifyContent": "Justify Content", + "alignItems": "Align Items", + "borderless": "Borderless", + "individualStyling": "Individual Styling", + "individualStylingTooltip": "When enabled, each tag can have its own colors, borders, and spacing. When disabled, all tags use the general style settings.", "radiusTip": "Specifies the radius of the element's corners. Example: 5px, 50%, or 1em.", "gapTip": "Specifies the gap between rows and columns in a grid or flex container. Example: 10px, 1rem, or 5%.", From 3ede19e1d84bbcb25089b9ecb529333476790d2c Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Thu, 17 Jul 2025 18:05:28 +0500 Subject: [PATCH 027/352] Tags icon --- client/packages/lowcoder/src/pages/editor/editorConstants.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index 9087b1e7d2..beea9cae7a 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -104,6 +104,7 @@ import { TurnstileCaptchaCompIconSmall, PivotTableCompIconSmall, GraphChartCompIconSmall, + TagsCompIconSmall, } from "lowcoder-design"; // Memoize icon components to prevent unnecessary re-renders @@ -237,7 +238,7 @@ export const CompStateIcon: { step: , table: , text: , - multiTags: , + multiTags: , timeline: , toggleButton: , tour: , From 5fb6c52e05ff7169728b1d463b07a494f5377130 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 17 Jul 2025 19:53:04 +0500 Subject: [PATCH 028/352] fix edit DS page and add loading states --- .../lowcoder/src/api/datasourceApi.ts | 4 ++ .../pages/datasource/datasourceEditPage.tsx | 69 ++++++++++++++++--- .../src/pages/datasource/datasourceList.tsx | 11 +-- .../lowcoder/src/pages/datasource/index.tsx | 4 +- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/client/packages/lowcoder/src/api/datasourceApi.ts b/client/packages/lowcoder/src/api/datasourceApi.ts index 1be29e6469..278015a124 100644 --- a/client/packages/lowcoder/src/api/datasourceApi.ts +++ b/client/packages/lowcoder/src/api/datasourceApi.ts @@ -187,6 +187,10 @@ export class DatasourceApi extends Api { return Api.get(DatasourceApi.url + `/listByOrg?orgId=${orgId}`, {...res}); } + static getDatasourceById(id: string): AxiosPromise> { + return Api.get(`${DatasourceApi.url}/${id}`); + } + static createDatasource( datasourceConfig: Partial ): AxiosPromise> { diff --git a/client/packages/lowcoder/src/pages/datasource/datasourceEditPage.tsx b/client/packages/lowcoder/src/pages/datasource/datasourceEditPage.tsx index 7e033cb7a5..9f8de01aa2 100644 --- a/client/packages/lowcoder/src/pages/datasource/datasourceEditPage.tsx +++ b/client/packages/lowcoder/src/pages/datasource/datasourceEditPage.tsx @@ -1,20 +1,25 @@ import styled from "styled-components"; import history from "../../util/history"; import { default as Button } from "antd/es/button"; -import { useCallback, useMemo, useState } from "react"; +import { Spin } from "antd"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CopyTextButton, DocIcon, PackUpIcon, TacoButton } from "lowcoder-design"; import { useDatasourceForm } from "./form/useDatasourceForm"; import { useParams } from "react-router-dom"; import { DATASOURCE_URL } from "../../constants/routesURL"; import { useSelector } from "react-redux"; -import { getDataSource, getDataSourceTypes } from "../../redux/selectors/datasourceSelectors"; +import { getDataSourceTypes } from "../../redux/selectors/datasourceSelectors"; import { trans } from "i18n"; import { DatasourceType } from "@lowcoder-ee/constants/queryConstants"; import { getDatasourceTutorial } from "@lowcoder-ee/util/tutorialUtils"; import { getDataSourceFormManifest } from "./getDataSourceFormManifest"; import DataSourceIcon from "components/DataSourceIcon"; import { Helmet } from "react-helmet"; - +import { DatasourceApi } from "@lowcoder-ee/api/datasourceApi"; +import { DatasourceInfo } from "@lowcoder-ee/api/datasourceApi"; +import { GenericApiResponse } from "../../api/apiResponses"; +import { Datasource } from "@lowcoder-ee/constants/datasourceConstants"; +import { AxiosResponse } from "axios"; const Wrapper = styled.div` display: flex; justify-content: center; @@ -154,16 +159,44 @@ type DatasourcePathParams = { export const DatasourceEditPage = () => { const { datasourceId, datasourceType } = useParams(); - const datasourceList = useSelector(getDataSource); const datasourceTypes = useSelector(getDataSourceTypes); const [isReady, setIsReady] = useState(true); - const datasourceInfo = useMemo(() => { + + const [datasourceInfo, setDatasourceInfo] = useState(); + const [loading, setLoading] = useState(false); + + // Fetch individual datasource when editing + useEffect(() => { if (!datasourceId) { - return undefined; + setDatasourceInfo(undefined); + return; } - return datasourceList.find((info) => info.datasource.id === datasourceId); - }, [datasourceId, datasourceList]); + + const fetchDatasource = async () => { + setLoading(true); + try { + const response: AxiosResponse> = await DatasourceApi.getDatasourceById(datasourceId); + if (response.data.success) { + // Transform to DatasourceInfo format + setDatasourceInfo({ + datasource: response.data.data, + edit: true, // Assume editable since user reached edit page + }); + } else { + console.error('API returned error:', response.data); + setDatasourceInfo(undefined); + } + } catch (error: any) { + console.error('Failed to fetch datasource:', error); + setDatasourceInfo(undefined); + } finally { + setLoading(false); + } + }; + + fetchDatasource(); + }, [datasourceId]); const dataSourceTypeInfo = useMemo(() => { if (datasourceId) { @@ -181,6 +214,26 @@ export const DatasourceEditPage = () => { setIsReady(isReady); }, []); + // Show loading state while fetching datasource + if (loading) { + return ( + + +
+ +
+
+
+ ); + } + if (!finalDataSourceType) { return null; } diff --git a/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx b/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx index 2d7386fa09..61eb621b23 100644 --- a/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx +++ b/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx @@ -1,8 +1,8 @@ import styled from "styled-components"; import { EditPopover, PointIcon, Search, TacoButton } from "lowcoder-design"; -import React, {useEffect, useState} from "react"; +import {useEffect, useState} from "react"; import { useDispatch, useSelector } from "react-redux"; -import { getDataSource, getDataSourceLoading, getDataSourceTypesMap } from "../../redux/selectors/datasourceSelectors"; +import { getDataSourceTypesMap } from "../../redux/selectors/datasourceSelectors"; import { deleteDatasource } from "../../redux/reduxActions/datasourceActions"; import { isEmpty } from "lodash"; import history from "../../util/history"; @@ -113,7 +113,6 @@ export const DatasourceList = () => { const [modify, setModify] = useState(false); const currentUser = useSelector(getUser); const orgId = currentUser.currentOrgId; - const datasourceLoading = useSelector(getDataSourceLoading); const plugins = useSelector(getDataSourceTypesMap); interface ElementsState { elements: DatasourceInfo[]; @@ -123,6 +122,7 @@ export const DatasourceList = () => { const [elements, setElements] = useState({ elements: [], total: 0 }); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const [paginationLoading, setPaginationLoading] = useState(false); useEffect(()=> { const timer = setTimeout(() => { @@ -133,6 +133,7 @@ export const DatasourceList = () => { }, [searchValue]) useEffect( () => { + setPaginationLoading(true); fetchDatasourcePagination( { orgId: orgId, @@ -146,6 +147,8 @@ export const DatasourceList = () => { } else console.error("ERROR: fetchFolderElements", result.error) + }).finally(() => { + setPaginationLoading(false); }) }, [currentPage, pageSize, searchValues, modify] ) @@ -195,7 +198,7 @@ export const DatasourceList = () => { }} rowClassName={(record: any) => (!record.edit ? "datasource-can-not-edit" : "")} diff --git a/client/packages/lowcoder/src/pages/datasource/index.tsx b/client/packages/lowcoder/src/pages/datasource/index.tsx index 3983a40697..9364af3919 100644 --- a/client/packages/lowcoder/src/pages/datasource/index.tsx +++ b/client/packages/lowcoder/src/pages/datasource/index.tsx @@ -8,10 +8,10 @@ import { } from "../../constants/routesURL"; import React, { useEffect } from "react"; import { isEmpty } from "lodash"; -import { fetchDatasource, fetchDataSourceTypes } from "../../redux/reduxActions/datasourceActions"; +import { fetchDataSourceTypes } from "../../redux/reduxActions/datasourceActions"; import { useDispatch, useSelector } from "react-redux"; import { getUser } from "../../redux/selectors/usersSelectors"; -import { getDataSource, getDataSourceTypes } from "../../redux/selectors/datasourceSelectors"; +import { getDataSourceTypes } from "../../redux/selectors/datasourceSelectors"; export const DatasourceHome = () => { const dispatch = useDispatch(); From c8f9e3d22548cbef3d6f8bae25c38a9d53de5d75 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 17 Jul 2025 23:59:10 +0500 Subject: [PATCH 029/352] #1848 remove duplicate environments call --- .../lowcoder/src/util/context/EnterpriseContext.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/util/context/EnterpriseContext.tsx b/client/packages/lowcoder/src/util/context/EnterpriseContext.tsx index ba8e911c97..61b3097dc6 100644 --- a/client/packages/lowcoder/src/util/context/EnterpriseContext.tsx +++ b/client/packages/lowcoder/src/util/context/EnterpriseContext.tsx @@ -26,13 +26,12 @@ export const EnterpriseProvider: React.FC = ({ children }) => { // Fetch the enterprise license only if we're in an EE environment dispatch(fetchEnterpriseLicense()); dispatch(fetchEnvironments()); - dispatch(fetchBrandingSetting({ orgId: user.currentOrgId, fallbackToGlobal: true })) } else { // Set the state to false for non-EE environments // setEEActiveState(false); setIsEnterpriseActive(false); } - }, [dispatch, user.currentOrgId]); + }, [dispatch]); useEffect(() => { if (isEEEnvironment()) { @@ -41,6 +40,12 @@ export const EnterpriseProvider: React.FC = ({ children }) => { setIsEnterpriseActive(isEnterpriseActiveRedux); } }, [isEnterpriseActiveRedux]); + + useEffect(() => { + if (isEEEnvironment()) { + dispatch(fetchBrandingSetting({ orgId: user.currentOrgId, fallbackToGlobal: true })) + } + }, [dispatch, user.currentOrgId]); return ( From 9e10673be0da50d1bfc1d979a98898a52c1bb2ca Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 18 Jul 2025 18:26:08 +0500 Subject: [PATCH 030/352] [Fix]: #1848 remove duplicate folderelements request from Home --- .../lowcoder/src/pages/ApplicationV2/FolderView.tsx | 10 ++++++++++ .../lowcoder/src/pages/ApplicationV2/index.tsx | 9 --------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx index 695b932a98..887a74cc1d 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx @@ -9,6 +9,8 @@ import { Helmet } from "react-helmet"; import { trans } from "i18n"; import {ApplicationPaginationType} from "@lowcoder-ee/util/pagination/type"; import {fetchFolderElements} from "@lowcoder-ee/util/pagination/axios"; +import { fetchFolderElements as fetchFolderElementsRedux } from "../../redux/reduxActions/folderActions"; +import { getUser } from "../../redux/selectors/usersSelectors"; function getBreadcrumbs( folder: FolderMeta, @@ -52,6 +54,7 @@ export function FolderView() { const element = useSelector(folderElementsSelector); const allFolders = useSelector(foldersSelector); + const user = useSelector(getUser); const folder = allFolders.filter((f) => f.folderId === folderId)[0] || {}; const breadcrumbs = getBreadcrumbs(folder, allFolders, [ @@ -61,6 +64,13 @@ export function FolderView() { }, ]); + // Fetch folder data for breadcrumbs if not available + useEffect(() => { + if (allFolders.length === 0 && user.currentOrgId) { + dispatch(fetchFolderElementsRedux({})); + } + }, [allFolders.length, user.currentOrgId, dispatch]); + useEffect( () => { try{ fetchFolderElements({ diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx index 5987d097d0..14bf9df2e1 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx @@ -124,15 +124,6 @@ export default function ApplicationHome() { setIsPreloadCompleted(true); }, [org, orgHomeId]); - useEffect(() => { - // Check if we need to fetch data (either no folders or no applications) - if (allFoldersCount !== 0 && allAppCount !== 0) { - return; - } - - user.currentOrgId && dispatch(fetchFolderElements({})); - }, [dispatch, allFoldersCount, allAppCount, user.currentOrgId]); - if (fetchingUser || !isPreloadCompleted) { return ; } From 2df0a8fed3739498e14afd1edb6e99fb97440db7 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 18 Jul 2025 23:08:33 +0500 Subject: [PATCH 031/352] [Fix]: #1848 delete/recycled and movetofolder --- .../pages/ApplicationV2/HomeResOptions.tsx | 67 ++++++++++--------- .../pages/ApplicationV2/TrashTableView.tsx | 45 ++++++------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx index 99244d7fcd..38e4b68953 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx @@ -3,8 +3,6 @@ import { HomeResTypeEnum } from "../../types/homeRes"; import { exportApplicationAsJSONFile } from "./components/AppImport"; import { CustomModal, EditPopover, EditPopoverItemType, PointIcon } from "lowcoder-design"; import { HomeResInfo } from "../../util/homeResUtils"; -import { recycleApplication } from "../../redux/reduxActions/applicationActions"; -import { deleteFolder } from "../../redux/reduxActions/folderActions"; import { useDispatch } from "react-redux"; import React, { useState } from "react"; import styled from "styled-components"; @@ -13,6 +11,9 @@ import { useParams } from "react-router-dom"; import { AppTypeEnum } from "constants/applicationConstants"; import { CopyModal } from "pages/common/copyModal"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import ApplicationApi from "../../api/applicationApi"; +import { FolderApi } from "../../api/folderApi"; +import { ReduxActionTypes } from "constants/reduxActionConstants"; const PopoverIcon = styled(PointIcon)` cursor: pointer; @@ -80,23 +81,20 @@ export const HomeResOptions = (props: { type: HomeResInfo[res.type].name, name: {res.name}, }), - onConfirm: () =>{ - new Promise((resolve, reject) => { - dispatch( - recycleApplication( - { applicationId: res.id, folderId: folderId }, - () => { - messageInstance.success(trans("success")); - resolve(true); - }, - () => reject() - ) - ); + onConfirm: async () => { + try { + await ApplicationApi.recycleApplication({ + applicationId: res.id, + folderId: folderId || "" + }); + messageInstance.success(trans("success")); setTimeout(() => { setModify(!modify); }, 200); - }) - + } catch (error) { + console.error("Failed to recycle application:", error); + messageInstance.error("Failed to delete application"); + } }, confirmBtnType: "delete", okText: trans("home.moveToTrash"), @@ -122,22 +120,27 @@ export const HomeResOptions = (props: { type: HomeResInfo[res.type].name.toLowerCase(), name: {res.name}, }), - onConfirm: () =>{ - new Promise((resolve, reject) => { - dispatch( - deleteFolder( - { folderId: res.id, parentFolderId: folderId }, - () => { - messageInstance.success(trans("home.deleteSuccessMsg")); - resolve(true); - }, - () => reject() - ) - ); - }) - setTimeout(() => { - setModify(!modify); - }, 200); + onConfirm: async () => { + try { + await FolderApi.deleteFolder({ + folderId: res.id, + parentFolderId: folderId || "" + }); + + // Update Redux state to remove deleted folder from dropdown + dispatch({ + type: ReduxActionTypes.DELETE_FOLDER_SUCCESS, + payload: { folderId: res.id, parentFolderId: folderId || "" } + }); + + messageInstance.success(trans("home.deleteSuccessMsg")); + setTimeout(() => { + setModify(!modify); + }, 200); + } catch (error) { + console.error("Failed to delete folder:", error); + messageInstance.error("Failed to delete folder"); + } }, confirmBtnType: "delete", okText: trans("delete"), diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/TrashTableView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/TrashTableView.tsx index 11323e56dc..54d93835df 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/TrashTableView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/TrashTableView.tsx @@ -5,11 +5,11 @@ import styled from "styled-components"; import { useDispatch } from "react-redux"; import { HomeResInfo } from "../../util/homeResUtils"; import { HomeResTypeEnum } from "../../types/homeRes"; -import { deleteApplication, restoreApplication } from "../../redux/reduxActions/applicationActions"; import { HomeRes } from "./HomeLayout"; import { trans, transToNode } from "../../i18n"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import { BrandedIcon } from "@lowcoder-ee/components/BrandedIcon"; +import ApplicationApi from "../../api/applicationApi"; const OperationWrapper = styled.div` display: flex; @@ -123,17 +123,18 @@ export const TrashTableView = (props: { resources: HomeRes[] , setModify: any, m style={{ padding: "0 8px", width: "fit-content", minWidth: "52px" }} buttonType={"blue"} className={"home-datasource-edit-button"} - onClick={() =>{ - dispatch( - restoreApplication({ applicationId: item.id }, () => { - messageInstance.success(trans("home.recoverSuccessMsg")); - }) - ) + onClick={async () => { + try { + await ApplicationApi.restoreApplication({ applicationId: item.id }); + messageInstance.success(trans("home.recoverSuccessMsg")); setTimeout(() => { - setModify(!modify); + setModify(!modify); }, 200); - } - } + } catch (error) { + console.error("Failed to restore application:", error); + messageInstance.error("Failed to restore application"); + } + }} > {trans("recover")} @@ -148,27 +149,21 @@ export const TrashTableView = (props: { resources: HomeRes[] , setModify: any, m type: HomeResInfo[item.type].name.toLowerCase(), name: {item.name}, }), - onConfirm: () =>{ - new Promise((resolve, reject) => { - dispatch( - deleteApplication( - { applicationId: item.id }, - () => { - messageInstance.success(trans("home.deleteSuccessMsg")); - resolve(true); - }, - () => reject() - ) - ); - }) + onConfirm: async () => { + try { + await ApplicationApi.deleteApplication({ applicationId: item.id }); + messageInstance.success(trans("home.deleteSuccessMsg")); setTimeout(() => { - setModify(!modify); + setModify(!modify); }, 200); + } catch (error) { + console.error("Failed to delete application:", error); + messageInstance.error("Failed to delete application permanently"); + } }, confirmBtnType: "delete", okText: trans("delete"), }) - } style={{ marginLeft: "12px", width: "76px" }} > From 59097467fc61e73f50c3b293b1633063ad06d18d Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Sun, 20 Jul 2025 22:38:47 +0500 Subject: [PATCH 032/352] remove unrelated changes --- .../src/comps/comps/formComp/formComp.tsx | 34 ++++++++++++------- .../textInputComp/textInputConstants.tsx | 7 ++++ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx index 84a7b71564..02ca347ff0 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx @@ -208,17 +208,13 @@ const FormBaseComp = (function () { ); }) .setPropertyViewFn((children) => { - const editorContext = useContext(EditorContext); - const isLogicMode = editorContext.editorModeStatus === "logic" || editorContext.editorModeStatus === "both"; - const isLayoutMode = editorContext.editorModeStatus === "layout" || editorContext.editorModeStatus === "both"; - return ( <>
{children.resetAfterSubmit.propertyView({ label: trans("formComp.resetAfterSubmit") })}
- {isLogicMode && ( + {(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && ( <>
{children.onEvent.getPropertyView()} {disabledPropertyView(children)} @@ -229,7 +225,7 @@ const FormBaseComp = (function () { )} - {isLayoutMode && ( + {(useContext(EditorContext).editorModeStatus === "layout" || useContext(EditorContext).editorModeStatus === "both") && ( <>
{children.container.getPropertyView()} @@ -237,14 +233,14 @@ const FormBaseComp = (function () { )} - {isLogicMode && ( + {(useContext(EditorContext).editorModeStatus === "logic" || useContext(EditorContext).editorModeStatus === "both") && (
{children.initialData.propertyView({ label: trans("formComp.initialData") })} {children.invalidFormMessage.propertyView({ label: trans("formComp.invalidFormMessage") })}
)} - {isLayoutMode && ( + {(useContext(EditorContext).editorModeStatus === "layout" || useContext(EditorContext).editorModeStatus === "both") && ( <>
{children.container.stylePropertyView()} @@ -289,7 +285,8 @@ let FormTmpComp = class extends FormBaseComp implements IForm { } traverseFormItems(consumer: (item: GridItemComp) => boolean) { return traverseCompTree(this.getCompTree(), (item) => { - return item.children.comp.children.formDataKey ? consumer(item as GridItemComp) : true; + const hasFormDataKey = item.children.comp.children.hasOwnProperty("formDataKey"); + return hasFormDataKey ? consumer(item as GridItemComp) : true; }); } validateFormItems() { @@ -333,12 +330,19 @@ let FormTmpComp = class extends FormBaseComp implements IForm { // For the properties, first find in data, then initialData, subcomponent default value (resetValue), empty value (clearValue) const newData = { ...(initialData ?? this.children.initialData.getView()), ...data }; + // Only proceed if we have data to set + if (!Object.keys(newData).length) { + return Promise.resolve(); + } + return this.runMethodOfItems( { name: "setValue", getParams: (t) => { // use component name when formDataKey is empty - const key = t.children.comp.children.formDataKey?.getView() || t.children.name.getView(); + const formDataKey = t.children.comp.children.formDataKey?.getView(); + const componentName = t.children.name.getView(); + const key = formDataKey || componentName; const value = newData[key]; return value !== undefined ? [value as EvalParamType] : undefined; }, @@ -347,7 +351,9 @@ let FormTmpComp = class extends FormBaseComp implements IForm { name: "setRange", getParams: (t) => { // use component name when formDataKey is empty - const key = t.children.comp.children.formDataKey?.getView() || t.children.name.getView(); + const formDataKey = t.children.comp.children.formDataKey?.getView(); + const componentName = t.children.name.getView(); + const key = formDataKey || componentName; const value = newData[key] ? newData[key] : undefined; return value !== undefined ? [value as EvalParamType] : undefined; }, @@ -387,7 +393,8 @@ let FormTmpComp = class extends FormBaseComp implements IForm { case CompActionTypes.UPDATE_NODES_V2: { const ret = super.reduce(action); // When the initial value changes, update the form - requestAnimationFrame(() => { + if (action.value["initialData"] !== undefined) { + queueMicrotask(() => { this.dispatch( customAction( { @@ -398,6 +405,7 @@ let FormTmpComp = class extends FormBaseComp implements IForm { ) ); }); + } return ret; } case CompActionTypes.CUSTOM: @@ -548,4 +556,4 @@ export function defaultFormData(compName: string, nameGenerator: NameGenerator): showFooter: true, }, }; -} +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx index 97af00711b..2d1761f1df 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx @@ -183,6 +183,12 @@ export const useTextInputProps = (props: RecordConstructorToView { + if (!changeRef.current) { + setLocalInputValue(inputValue); + } + }, [inputValue]); + useEffect(() => { if (!changeRef.current) return; @@ -214,6 +220,7 @@ export const useTextInputProps = (props: RecordConstructorToView Date: Mon, 21 Jul 2025 11:06:22 +0500 Subject: [PATCH 033/352] Updated useCallack to useMemo for optimization --- .../pages/setting/permission/groupUsersPermission.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx b/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx index 70d736c903..c2d260fe49 100644 --- a/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx +++ b/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx @@ -100,8 +100,7 @@ const GroupUsersPermission: React.FC = (props) => { }); }, [groupUsers]); - const roleFilterOptions = useCallback(() => { - const filterOptions = [ + const roleFilterOptions = useMemo(() => [ ...TacoRoles.map(role => ({ label: GroupRoleInfo[role].name, value: role as RoleIdType | "" @@ -110,9 +109,7 @@ const GroupUsersPermission: React.FC = (props) => { label: "All", value: "" as RoleIdType | "" } - ] - return filterOptions; - }, []) + ], []); const debouncedFetchPotentialMembers = useCallback( debounce((searchVal: string, roleFilter: string) => { @@ -153,7 +150,7 @@ const GroupUsersPermission: React.FC = (props) => { {isGroupAdmin(currentUserGroupRole) && !group.syncGroup && ( { setRoleFilter(value); From 9a1295e45a1b3532fcd4286fd40184b5811023c9 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Mon, 21 Jul 2025 12:35:14 +0500 Subject: [PATCH 034/352] added currentExpandedRow, currentExpandedRows and setExpandedRows in table comp --- .../src/comps/comps/tableComp/tableComp.tsx | 37 +++++++++++++++++++ .../comps/comps/tableComp/tableCompView.tsx | 23 +++++++++++- .../src/comps/comps/tableComp/tableTypes.tsx | 1 + 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx index 721f645657..d3386360c0 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx @@ -724,6 +724,24 @@ TableTmpComp = withMethodExposing(TableTmpComp, [ comp.children.columns.dispatchClearInsertSet(); }, }, + { + method: { + name: "setExpandedRows", + description: "", + params: [ + { name: "expandedRows", type: "arrayString"}, + ], + }, + execute: (comp, values) => { + const expandedRows = values[0]; + if (!isArray(expandedRows)) { + return Promise.reject("setExpandedRows function only accepts array of string i.e. ['1', '2', '3']") + } + if (expandedRows && isArray(expandedRows)) { + comp.children.currentExpandedRows.dispatchChangeValueAction(expandedRows as string[]); + } + }, + } ]); // exposing data @@ -978,5 +996,24 @@ export const TableComp = withExposingConfigs(TableTmpComp, [ }, trans("table.selectedCellDesc") ), + depsConfig({ + name: "currentExpandedRow", + desc: trans("table.sortDesc"), + depKeys: ["currentExpandedRows"], + func: (input) => { + if (input.currentExpandedRows.length > 0) { + return input.currentExpandedRows[input.currentExpandedRows.length - 1]; + } + return ""; + }, + }), + depsConfig({ + name: "currentExpandedRows", + desc: trans("table.sortDesc"), + depKeys: ["currentExpandedRows"], + func: (input) => { + return input.currentExpandedRows; + }, + }), new NameConfig("data", trans("table.dataDesc")), ]); diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx index fa53d2f506..139cc396d0 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx @@ -28,7 +28,7 @@ import { CompNameContext, EditorContext } from "comps/editorState"; import { BackgroundColorContext } from "comps/utils/backgroundColorContext"; import { PrimaryColor } from "constants/style"; import { trans } from "i18n"; -import _ from "lodash"; +import _, { isEqual } from "lodash"; import { darkenColor, isDarkColor, isValidColor, ScrollBar } from "lowcoder-design"; import React, { Children, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Resizable } from "react-resizable"; @@ -48,6 +48,7 @@ import { TableSummary } from "./tableSummaryComp"; import Skeleton from "antd/es/skeleton"; import { SkeletonButtonProps } from "antd/es/skeleton/Button"; import { ThemeContext } from "@lowcoder-ee/comps/utils/themeContext"; +import { useUpdateEffect } from "react-use"; export const EMPTY_ROW_KEY = 'empty_row'; @@ -814,6 +815,7 @@ export const TableCompView = React.memo((props: { onRefresh: (allQueryNames: Array, setLoading: (loading: boolean) => void) => void; onDownload: (fileName: string) => void; }) => { + const [expandedRowKeys, setExpandedRowKeys] = useState([]); const [emptyRowsMap, setEmptyRowsMap] = useState>({}); const editorState = useContext(EditorContext); const currentTheme = useContext(ThemeContext)?.theme; @@ -856,6 +858,7 @@ export const TableCompView = React.memo((props: { const size = useMemo(() => compChildren.size.getView(), [compChildren.size]); const editModeClicks = useMemo(() => compChildren.editModeClicks.getView(), [compChildren.editModeClicks]); const onEvent = useMemo(() => compChildren.onEvent.getView(), [compChildren.onEvent]); + const currentExpandedRows = useMemo(() => compChildren.currentExpandedRows.getView(), [compChildren.currentExpandedRows]); const dynamicColumn = compChildren.dynamicColumn.getView(); const dynamicColumnConfig = useMemo( () => compChildren.dynamicColumnConfig.getView(), @@ -955,6 +958,18 @@ export const TableCompView = React.memo((props: { updateEmptyRows(); }, [updateEmptyRows]); + useUpdateEffect(() => { + if (!isEqual(currentExpandedRows, expandedRowKeys)) { + compChildren.currentExpandedRows.dispatchChangeValueAction(expandedRowKeys); + } + }, [expandedRowKeys]); + + useUpdateEffect(() => { + if (!isEqual(currentExpandedRows, expandedRowKeys)) { + setExpandedRowKeys(currentExpandedRows); + } + }, [currentExpandedRows]); + const pageDataInfo = useMemo(() => { // Data pagination let pagedData = data; @@ -1104,7 +1119,11 @@ export const TableCompView = React.memo((props: { } else { handleChangeEvent('rowShrink') } - } + }, + onExpandedRowsChange: (expandedRowKeys) => { + setExpandedRowKeys(expandedRowKeys as unknown as string[]); + }, + expandedRowKeys: expandedRowKeys, }} // rowKey={OB_ROW_ORI_INDEX} rowColorFn={compChildren.rowColor.getView() as any} diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx index f40f18c73d..f80bcf3b58 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx @@ -265,6 +265,7 @@ const tableChildrenMap = { selectedCell: stateComp({}), inlineAddNewRow: BoolControl, editModeClicks: dropdownControl(editModeClickOptions, "single"), + currentExpandedRows: stateComp([]), }; export const TableInitComp = (function () { From fca7adb13865d17db7e35a525d5fa8f847b89a5a Mon Sep 17 00:00:00 2001 From: th37star Date: Fri, 18 Jul 2025 09:58:00 -0400 Subject: [PATCH 035/352] Fixed app publish version. --- .../ApplicationApiServiceImpl.java | 79 +++++++++++-------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationApiServiceImpl.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationApiServiceImpl.java index ce40d6cc12..66b62b258b 100644 --- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationApiServiceImpl.java +++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/application/ApplicationApiServiceImpl.java @@ -92,7 +92,7 @@ public class ApplicationApiServiceImpl implements ApplicationApiService { private final OrgDevChecker orgDevChecker; private final TemplateSolutionService templateSolutionService; private final SuggestAppAdminSolutionService suggestAppAdminSolutionService; - + private final FolderApiService folderApiService; private final UserHomeApiService userHomeApiService; private final UserApplicationInteractionService userApplicationInteractionService; @@ -240,8 +240,8 @@ private Mono checkApplicationViewRequest(Application application, Applicat if (expected == ApplicationRequestType.PUBLIC_TO_MARKETPLACE && application.isPublicToMarketplace() && application.isPublicToAll()) { return Mono.empty(); } - - // + + // // Falk: application.agencyProfile() & isPublicToAll must be both true if (expected == ApplicationRequestType.AGENCY_PROFILE && application.agencyProfile() && application.isPublicToAll()) { return Mono.empty(); @@ -560,9 +560,9 @@ public Mono checkApplicationPermissionWithReadableErrorMsg(S return Mono.just(permissionStatus.getPermission()); }); } - - - + + + private Mono buildView(Application application, String role) { return buildView(application, role, null).delayUntil(applicationInfoView -> { String applicationId = applicationInfoView.getApplicationId(); @@ -574,32 +574,47 @@ private Mono buildView(Application application, String role } private Mono buildView(Application application, String role, @Nullable String folderId) { - return application.getCategory(applicationRecordService) - .zipWith(application.getDescription(applicationRecordService)) - .zipWith(application.getTitle(applicationRecordService), TupleUtils::merge) - .map(tuple -> - ApplicationInfoView.builder() - .applicationId(application.getId()) - .applicationGid(application.getGid()) - .orgId(application.getOrganizationId()) - .name(application.getName()) - .createBy(application.getCreatedBy()) - .createAt(application.getCreatedAt().toEpochMilli()) - .role(role) - .applicationType(application.getApplicationType()) - .applicationStatus(application.getApplicationStatus()) - .folderId(folderId) - .publicToAll(application.isPublicToAll()) - .publicToMarketplace(application.isPublicToMarketplace()) - .agencyProfile(application.agencyProfile()) - .editingUserId(application.getEditingUserId()) - .lastModifyTime(application.getUpdatedAt()) - .lastEditedAt(application.getLastEditedAt()) - .category(tuple.getT1()) - .description(tuple.getT2()) - .title(tuple.getT3()) - .build() - ); + Mono categoryMono = application.getCategory(applicationRecordService); + Mono descriptionMono = application.getDescription(applicationRecordService); + Mono latestRecordMono = applicationRecordService + .getLatestRecordByApplicationId(application.getId()) + .defaultIfEmpty(new ApplicationVersion() ); + Mono titleMono = application.getTitle(applicationRecordService); + + return Mono.zip(categoryMono, descriptionMono, latestRecordMono, titleMono) + .map(tuple -> { + String category = tuple.getT1(); + String description = tuple.getT2(); + ApplicationVersion latestRecord = tuple.getT3(); + String title = tuple.getT4(); + boolean hasPublished = latestRecord.getTag() != null && !latestRecord.getTag().isEmpty(); + return ApplicationInfoView.builder() + .category(category) + .description(description) + .published(hasPublished) + .publishedVersion(hasPublished ? latestRecord.getTag() : null) + .lastPublishedTime(hasPublished && latestRecord.getCreateTime() != 0 + ? Instant.ofEpochMilli(latestRecord.getCreateTime()) + : null) + .title(title) + .applicationId(application.getId()) + .applicationGid(application.getGid()) + .orgId(application.getOrganizationId()) + .name(application.getName()) + .createBy(application.getCreatedBy()) + .createAt(application.getCreatedAt().toEpochMilli()) + .role(role) + .applicationType(application.getApplicationType()) + .applicationStatus(application.getApplicationStatus()) + .folderId(folderId) + .publicToAll(application.isPublicToAll()) + .publicToMarketplace(application.isPublicToMarketplace()) + .agencyProfile(application.agencyProfile()) + .editingUserId(application.getEditingUserId()) + .lastModifyTime(application.getUpdatedAt()) + .lastEditedAt(application.getLastEditedAt()) + .build(); + }); } private Mono buildView(Application application) { From c130120278256527aab3ea10a7ca80f59c2e2616 Mon Sep 17 00:00:00 2001 From: th37star Date: Fri, 18 Jul 2025 16:53:10 -0400 Subject: [PATCH 036/352] Created test case for the BundleEndpoints. --- .../BundleEndpointsIntegrationTest.java | 751 +++++++++++++++ .../api/bundle/BundleEndpointsUnitTest.java | 886 ++++++++++++++++++ 2 files changed, 1637 insertions(+) create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsIntegrationTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsUnitTest.java diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsIntegrationTest.java new file mode 100644 index 0000000000..b63cafb95a --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsIntegrationTest.java @@ -0,0 +1,751 @@ +package org.lowcoder.api.bundle; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.bundle.view.BundleInfoView; +import org.lowcoder.api.bundle.view.BundlePermissionView; +import org.lowcoder.api.bundle.view.MarketplaceBundleInfoView; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.api.framework.view.PageResponseView; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.domain.application.model.ApplicationType; +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.model.BundleStatus; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.sdk.constants.FieldName; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.lowcoder.sdk.constants.GlobalContext.VISITOR_TOKEN; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("BundleEndpointsIntegrationTest") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BundleEndpointsIntegrationTest { + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + public BusinessEventPublisher mockBusinessEventPublisher() { + BusinessEventPublisher mockPublisher = mock(BusinessEventPublisher.class); + + // Mock all BusinessEventPublisher methods to return Mono.empty() + when(mockPublisher.publishBundleCommonEvent(any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishBundleCommonEvent(any(String.class), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishApplicationCommonEvent(any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishApplicationCommonEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishApplicationPermissionEvent(any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishApplicationSharingEvent(any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishApplicationPublishEvent(any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishApplicationVersionChangeEvent(any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishFolderCommonEvent(any(), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishDatasourceEvent(any(String.class), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishDatasourceEvent(any(org.lowcoder.domain.datasource.model.Datasource.class), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishDatasourcePermissionEvent(any(), any(), any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishDatasourceResourcePermissionEvent(any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupCreateEvent(any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupUpdateEvent(any(Boolean.class), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupDeleteEvent(any(Boolean.class), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupMemberAddEvent(any(Boolean.class), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupMemberRoleUpdateEvent(any(Boolean.class), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupMemberLeaveEvent(any(Boolean.class), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishGroupMemberRemoveEvent(any(Boolean.class), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishUserLoginEvent(any())).thenReturn(Mono.empty()); + when(mockPublisher.publishUserLogoutEvent()).thenReturn(Mono.empty()); + when(mockPublisher.publishLibraryQueryEvent(any(), any(), any(), any())).thenReturn(Mono.empty()); + when(mockPublisher.publishLibraryQueryPublishEvent(any(), any(), any(), any())).thenReturn(Mono.empty()); + + return mockPublisher; + } + } + + @Autowired + private BundleController bundleController; + + @Autowired + private BundleApiService bundleApiService; + + @Autowired + private InitData initData; + + @Autowired + private WebTestClient webTestClient; + + private String testBundleId; + private String testBundleId2; + + @BeforeAll + public void beforeAll() { + initData.init(); + } + + // Helper method to clean up test data + private void cleanupTestBundle(String bundleId) { + if (bundleId != null) { + try { + bundleController.delete(bundleId) + .contextWrite(setupTestContext()) + .block(); + } catch (Exception e) { + // Ignore cleanup errors + System.out.println("Cleanup failed for bundle " + bundleId + ": " + e.getMessage()); + } + } + } + + // Helper method to create a unique bundle name + private String createUniqueBundleName(String baseName) { + return baseName + "_" + System.currentTimeMillis() + "_" + Thread.currentThread().getId(); + } + + // Helper method to create a unique user ID + private String createUniqueUserId(String baseUserId) { + return baseUserId + "_" + System.currentTimeMillis() + "_" + Thread.currentThread().getId(); + } + + // Helper method to set up Reactor context for tests + private reactor.util.context.Context setupTestContext() { + return reactor.util.context.Context.of( + VISITOR_TOKEN, "test-token-" + System.currentTimeMillis(), + "headers", new HashMap(), + "visitorId", "user01", + "currentOrgMember", Mono.just(org.lowcoder.domain.organization.model.OrgMember.builder() + .orgId("org01") + .userId("user01") + .role(org.lowcoder.domain.organization.model.MemberRole.ADMIN) + .state(org.lowcoder.domain.organization.model.OrgMemberState.CURRENT.getValue()) + .joinTime(System.currentTimeMillis()) + .build()) + ); + } + + @Test + @WithMockUser + void testCreateBundle_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("integration-test-bundle"); + BundleEndpoints.CreateBundleRequest request = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Integration Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + // When + Mono> result = bundleController.create(request) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + BundleInfoView bundle = response.getData(); + assertNotNull(bundle.getBundleId()); + assertEquals(uniqueBundleName, bundle.getName()); + assertEquals("Integration Test Bundle", bundle.getTitle()); + assertEquals("Test Description", bundle.getDescription()); + assertEquals("test-category", bundle.getCategory()); + assertEquals("test-image", bundle.getImage()); + assertFalse(bundle.getPublicToAll()); + assertFalse(bundle.getPublicToMarketplace()); + assertFalse(bundle.getAgencyProfile()); + assertNull(bundle.getFolderId()); + assertNotNull(bundle.getBundleGid()); + assertTrue(FieldName.isGID(bundle.getBundleGid())); + + // Store for other tests + testBundleId = bundle.getBundleId(); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + void testCreateBundleWithFolder_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("folder-test-bundle"); + BundleEndpoints.CreateBundleRequest request = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Folder Test Bundle", + "Test Description", "test-category", "test-image", "folder01" + ); + + // When + Mono> result = bundleController.create(request) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + BundleInfoView bundle = response.getData(); + assertNotNull(bundle.getBundleId()); + assertEquals(uniqueBundleName, bundle.getName()); + assertEquals("folder01", bundle.getFolderId()); + + // Store for other tests + testBundleId2 = bundle.getBundleId(); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + void testUpdateBundle_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("update-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Update Test Bundle", + "Original Description", "test-category", "test-image", null + ); + + // First create a bundle + BundleInfoView createdBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(createdBundle); + + // Update the bundle + Bundle updateRequest = Bundle.builder() + .id(createdBundle.getBundleId()) + .name("updated-bundle-name") + .title("Updated Bundle Title") + .description("Updated Description") + .build(); + + // When + Mono> result = bundleController.update(updateRequest) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + BundleInfoView updatedBundle = response.getData(); + assertEquals(createdBundle.getBundleId(), updatedBundle.getBundleId()); + assertEquals("updated-bundle-name", updatedBundle.getName()); + assertEquals("Updated Bundle Title", updatedBundle.getTitle()); + assertEquals("Updated Description", updatedBundle.getDescription()); + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(createdBundle.getBundleId()); + } + + @Test + @WithMockUser + void testPublishBundle_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("publish-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Publish Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView createdBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(createdBundle); + + // When + Mono> result = bundleController.publish(createdBundle.getBundleId()) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + BundleInfoView publishedBundle = response.getData(); + // Note: publishedBundle might be null if publish fails, which is acceptable for testing + if (publishedBundle != null) { + assertEquals(createdBundle.getBundleId(), publishedBundle.getBundleId()); + } + // Note: publishedBundleDSL might be null if the bundle has no DSL content + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(createdBundle.getBundleId()); + } + + @Test + @WithMockUser + void testRecycleAndRestoreBundle_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("recycle-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Recycle Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView createdBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(createdBundle); + + // When - Recycle the bundle + Mono> recycleResult = bundleController.recycle(createdBundle.getBundleId()) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(recycleResult) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + // Verify it appears in recycled bundles + Mono>> recycledBundlesResult = bundleController.getRecycledBundles() + .contextWrite(setupTestContext()); + StepVerifier.create(recycledBundlesResult) + .assertNext(response -> { + assertTrue(response.isSuccess()); + List recycledBundles = response.getData(); + assertTrue(recycledBundles.stream() + .anyMatch(bundle -> bundle.getBundleId().equals(createdBundle.getBundleId()))); + }) + .verifyComplete(); + + // When - Restore the bundle + Mono> restoreResult = bundleController.restore(createdBundle.getBundleId()) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(restoreResult) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(createdBundle.getBundleId()); + } + + @Test + @WithMockUser + void testGetElements_Integration() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("elements-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Elements Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + // When + Mono> result = bundleController.getElements( + testBundle.getBundleId(), + ApplicationType.APPLICATION, + 1, + 10 + ).contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + // Elements might be empty for a new bundle + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testAddAppToBundle_Integration() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("add-app-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Add App Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + // Note: We can't add a non-existent app, so we'll just test the bundle creation and elements retrieval + // In a real scenario, you would need to create an application first + + // Test that we can get elements from the bundle + Mono> getElementsResult = bundleController.getElements( + testBundle.getBundleId(), + ApplicationType.APPLICATION, + 1, + 10 + ).contextWrite(setupTestContext()); + + StepVerifier.create(getElementsResult) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testReorder_Integration() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("reorder-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Reorder Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + List elementIds = List.of("element1", "element2", "element3"); + + // When + Mono> result = bundleController.reorder(testBundle.getBundleId(), elementIds) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testGetPublishedBundle_Integration() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("published-bundle-test"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Published Bundle Test", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + // Publish the bundle first + bundleController.publish(testBundle.getBundleId()) + .contextWrite(setupTestContext()) + .block(); + + // When + Mono> result = bundleController.getPublishedBundle(testBundle.getBundleId()) + .contextWrite(setupTestContext()) + .onErrorReturn(ResponseView.error(500, "Bundle not accessible")); // Handle potential null pointer exceptions + + // Then + StepVerifier.create(result) + .assertNext(response -> { + // The operation should either succeed or return an error response, not throw an exception + assertTrue(response.isSuccess() || response.getCode() == 500); + if (response.isSuccess() && response.getData() != null) { + BundleInfoView publishedBundle = response.getData(); + assertEquals(testBundle.getBundleId(), publishedBundle.getBundleId()); + } + // Note: publishedBundleDSL might be null if the bundle has no DSL content + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testGetPublishedMarketPlaceBundle_Integration() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("marketplace-bundle-test"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Marketplace Bundle Test", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + // When + Mono> result = bundleController.getPublishedMarketPlaceBundle(testBundle.getBundleId()) + .contextWrite(setupTestContext()) + .onErrorReturn(ResponseView.error(500, "Bundle not accessible")); // Handle potential null pointer exceptions + + // Then + StepVerifier.create(result) + .assertNext(response -> { + // The operation should either succeed or return an error response, not throw an exception + assertTrue(response.isSuccess() || response.getCode() == 500); + if (response.isSuccess() && response.getData() != null) { + BundleInfoView marketplaceBundle = response.getData(); + assertEquals(testBundle.getBundleId(), marketplaceBundle.getBundleId()); + } + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testGetAgencyProfileBundle_Integration() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("agency-profile-bundle-test"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Agency Profile Bundle Test", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + // When + Mono> result = bundleController.getAgencyProfileBundle(testBundle.getBundleId()) + .contextWrite(setupTestContext()) + .onErrorReturn(ResponseView.error(500, "Bundle not accessible")); // Handle potential null pointer exceptions + + // Then + StepVerifier.create(result) + .assertNext(response -> { + // The operation should either succeed or return an error response, not throw an exception + assertTrue(response.isSuccess() || response.getCode() == 500); + if (response.isSuccess() && response.getData() != null) { + BundleInfoView agencyBundle = response.getData(); + assertEquals(testBundle.getBundleId(), agencyBundle.getBundleId()); + } + }) + .verifyComplete(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testGetBundles_Integration() { + // Given + BundleStatus bundleStatus = BundleStatus.NORMAL; + + // When + Mono>> result = bundleController.getBundles(bundleStatus) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + List bundles = response.getData(); + assertNotNull(bundles); + // Should contain at least the bundles created in previous tests + }) + .verifyComplete(); + } + + @Test + @WithMockUser + void testGetMarketplaceBundles_Integration() { + // When + Mono>> result = bundleController.getMarketplaceBundles() + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + List marketplaceBundles = response.getData(); + assertNotNull(marketplaceBundles); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + void testGetAgencyProfileBundles_Integration() { + // When + Mono>> result = bundleController.getAgencyProfileBundles() + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + List agencyBundles = response.getData(); + assertNotNull(agencyBundles); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + void testDeleteBundle_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("delete-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Delete Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView createdBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(createdBundle); + + // First recycle the bundle to put it in the correct status for deletion + bundleController.recycle(createdBundle.getBundleId()) + .contextWrite(setupTestContext()) + .block(); + + // When + Mono> result = bundleController.delete(createdBundle.getBundleId()) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser + void testInvalidBundleId_ReturnsError() { + // Given - Use a valid format but non-existent bundle ID + String invalidBundleId = "507f1f77bcf86cd799439011"; // Valid MongoDB ObjectId format but doesn't exist + + // When + Mono> result = bundleController.delete(invalidBundleId) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + @WithMockUser + void testInvalidPermissionRole_ReturnsError() { + // Given - Create a new bundle for testing + String uniqueBundleName = createUniqueBundleName("invalid-permission-test-bundle"); + BundleEndpoints.CreateBundleRequest createRequest = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Invalid Permission Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + BundleInfoView testBundle = bundleController.create(createRequest) + .contextWrite(setupTestContext()) + .map(ResponseView::getData) + .block(); + + assertNotNull(testBundle); + + BundleEndpoints.BatchAddPermissionRequest invalidRequest = new BundleEndpoints.BatchAddPermissionRequest( + "INVALID_ROLE", + Set.of("user01"), + Set.of() + ); + + // When + Mono> result = bundleController.grantPermission(testBundle.getBundleId(), invalidRequest) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + + // Cleanup + cleanupTestBundle(testBundle.getBundleId()); + } + + @Test + @WithMockUser + void testSimpleBundleCreation_Integration() { + // Given + String uniqueBundleName = createUniqueBundleName("simple-test-bundle"); + BundleEndpoints.CreateBundleRequest request = new BundleEndpoints.CreateBundleRequest( + "org01", "", uniqueBundleName, "Simple Test Bundle", + "Test Description", "test-category", "test-image", null + ); + + // When + Mono> result = bundleController.create(request) + .contextWrite(setupTestContext()); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + BundleInfoView bundle = response.getData(); + assertNotNull(bundle.getBundleId()); + assertEquals(uniqueBundleName, bundle.getName()); + assertEquals("Simple Test Bundle", bundle.getTitle()); + }) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsUnitTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsUnitTest.java new file mode 100644 index 0000000000..cea147d385 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/bundle/BundleEndpointsUnitTest.java @@ -0,0 +1,886 @@ +package org.lowcoder.api.bundle; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.bundle.view.BundleInfoView; +import org.lowcoder.api.bundle.view.BundlePermissionView; +import org.lowcoder.api.bundle.view.MarketplaceBundleInfoView; +import org.lowcoder.api.framework.view.PageResponseView; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.home.UserHomeApiService; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.api.util.GidService; +import org.lowcoder.domain.application.model.ApplicationType; +import org.lowcoder.domain.bundle.model.Bundle; +import org.lowcoder.domain.bundle.model.BundleStatus; +import org.lowcoder.domain.bundle.service.BundleService; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BundleEndpointsUnitTest { + + @Mock + private BundleService bundleService; + + @Mock + private BundleApiService bundleApiService; + + @Mock + private BusinessEventPublisher businessEventPublisher; + + @Mock + private UserHomeApiService userHomeApiService; + + @Mock + private GidService gidService; + + private BundleController bundleController; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + bundleController = new BundleController( + bundleService, + bundleApiService, + businessEventPublisher, + userHomeApiService, + gidService + ); + + // Default mock behaviors + when(businessEventPublisher.publishBundleCommonEvent(any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishBundleCommonEvent(anyString(), any(), any(), any())).thenReturn(Mono.empty()); + when(gidService.convertBundleIdToObjectId(anyString())).thenReturn(Mono.just("objectId")); + when(gidService.convertApplicationIdToObjectId(anyString())).thenReturn(Mono.just("appObjectId")); + } + + @Test + void testCreateBundle_Success() { + // Given + BundleEndpoints.CreateBundleRequest request = new BundleEndpoints.CreateBundleRequest( + "org01", "", "test-bundle", "Test Bundle", "Description", "category", "image", null + ); + + BundleInfoView expectedView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("test-bundle") + .title("Test Bundle") + .description("Description") + .category("category") + .image("image") + .build(); + + when(bundleApiService.create(any(BundleEndpoints.CreateBundleRequest.class))) + .thenReturn(Mono.just(expectedView)); + + // When + Mono> result = bundleController.create(request); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(expectedView, response.getData()); + }) + .verifyComplete(); + + verify(bundleApiService).create(eq(request)); + verify(businessEventPublisher).publishBundleCommonEvent(eq(expectedView), any()); + } + + @Test + void testCreateBundle_Error() { + // Given + BundleEndpoints.CreateBundleRequest request = new BundleEndpoints.CreateBundleRequest( + "org01", "", "test-bundle", "Test Bundle", "Description", "category", "image", null + ); + + when(bundleApiService.create(any(BundleEndpoints.CreateBundleRequest.class))) + .thenReturn(Mono.error(new BizException(BizError.INVALID_PARAMETER, "Invalid request"))); + + // When + Mono> result = bundleController.create(request); + + // Then + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + void testDeleteBundle_Success() { + // Given + String bundleId = "bundle-123"; + Bundle deletedBundle = Bundle.builder() + .id("objectId") + .gid(bundleId) + .name("test-bundle") + .title("Test Bundle") + .description("Test Description") + .category("test-category") + .image("test-image") + .editingBundleDSL(Map.of()) + .publishedBundleDSL(Map.of()) + .publicToMarketplace(false) + .publicToAll(false) + .agencyProfile(false) + .createdAt(Instant.now()) + .createdBy("test-user") + .build(); + + when(bundleApiService.delete(anyString())) + .thenReturn(Mono.just(deletedBundle)); + + // When + Mono> result = bundleController.delete(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).delete(eq("objectId")); + verify(businessEventPublisher).publishBundleCommonEvent(any(BundleInfoView.class), any()); + } + + @Test + void testUpdateBundle_Success() { + // Given + Bundle bundle = Bundle.builder() + .id("bundle-123") + .name("updated-bundle") + .title("Updated Bundle") + .build(); + + Bundle existingBundle = Bundle.builder() + .id("bundle-123") + .name("old-bundle") + .build(); + + BundleInfoView updatedView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("updated-bundle") + .build(); + + when(bundleService.findById(anyString())) + .thenReturn(Mono.just(existingBundle)); + when(bundleApiService.update(any(Bundle.class))) + .thenReturn(Mono.just(updatedView)); + + // When + Mono> result = bundleController.update(bundle); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(updatedView, response.getData()); + }) + .verifyComplete(); + + verify(bundleService).findById(eq("bundle-123")); + verify(bundleApiService).update(eq(bundle)); + verify(businessEventPublisher).publishBundleCommonEvent(eq(updatedView), any()); + } + + @Test + void testPublishBundle_Success() { + // Given + String bundleId = "bundle-123"; + BundleInfoView publishedView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("published-bundle") + .build(); + + when(bundleApiService.publish(anyString())) + .thenReturn(Mono.just(publishedView)); + + // When + Mono> result = bundleController.publish(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(publishedView, response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).publish(eq("objectId")); + } + + @Test + void testRecycleBundle_Success() { + // Given + String bundleId = "bundle-123"; + when(bundleApiService.recycle(anyString())) + .thenReturn(Mono.just(true)); + + // When + Mono> result = bundleController.recycle(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).recycle(eq("objectId")); + verify(businessEventPublisher).publishBundleCommonEvent(eq(bundleId), eq(null), eq(null), any()); + } + + @Test + void testRestoreBundle_Success() { + // Given + String bundleId = "bundle-123"; + when(bundleApiService.restore(anyString())) + .thenReturn(Mono.just(true)); + + // When + Mono> result = bundleController.restore(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).restore(eq("objectId")); + verify(businessEventPublisher).publishBundleCommonEvent(eq(bundleId), eq(null), eq(null), any()); + } + + @Test + void testGetRecycledBundles_Success() { + // Given + List recycledBundles = List.of( + BundleInfoView.builder().bundleId("bundle-1").name("recycled-1").build(), + BundleInfoView.builder().bundleId("bundle-2").name("recycled-2").build() + ); + + when(bundleApiService.getRecycledBundles()) + .thenReturn(Flux.fromIterable(recycledBundles)); + + // When + Mono>> result = bundleController.getRecycledBundles(); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(recycledBundles, response.getData()); + }) + .verifyComplete(); + + verify(bundleApiService).getRecycledBundles(); + } + + @Test + void testGetElements_Success() { + // Given + String bundleId = "bundle-123"; + ApplicationType applicationType = ApplicationType.APPLICATION; + Integer pageNum = 1; + Integer pageSize = 10; + + when(bundleApiService.getElements(anyString(), any(ApplicationType.class))) + .thenReturn(Flux.empty()); + + // When + Mono> result = bundleController.getElements(bundleId, applicationType, pageNum, pageSize); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).getElements(eq("objectId"), eq(applicationType)); + } + + @Test + void testMoveApp_Success() { + // Given + String applicationId = "app-123"; + String fromBundleId = "bundle-1"; + String toBundleId = "bundle-2"; + + when(bundleApiService.moveApp(anyString(), anyString(), anyString())) + .thenReturn(Mono.empty()); + + // When + Mono> result = bundleController.moveApp(applicationId, fromBundleId, toBundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(fromBundleId)); + verify(gidService).convertBundleIdToObjectId(eq(toBundleId)); + verify(gidService).convertApplicationIdToObjectId(eq(applicationId)); + verify(bundleApiService).moveApp(eq("appObjectId"), eq("objectId"), eq("objectId")); + verify(businessEventPublisher).publishBundleCommonEvent(eq(applicationId), eq(fromBundleId), eq(toBundleId), any()); + } + + @Test + void testAddApp_Success() { + // Given + String applicationId = "app-123"; + String toBundleId = "bundle-1"; + + when(bundleApiService.addApp(anyString(), anyString())) + .thenReturn(Mono.empty()); + + // When + Mono> result = bundleController.addApp(applicationId, toBundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(toBundleId)); + verify(gidService).convertApplicationIdToObjectId(eq(applicationId)); + verify(bundleApiService).addApp(eq("appObjectId"), eq("objectId")); + } + + @Test + void testReorder_Success() { + // Given + String bundleId = "bundle-123"; + List elementIds = List.of("element1", "element2", "element3"); + + when(bundleApiService.reorder(anyString(), anyList())) + .thenReturn(Mono.empty()); + + // When + Mono> result = bundleController.reorder(bundleId, elementIds); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).reorder(eq("objectId"), eq(elementIds)); + } + + @Test + void testUpdatePermission_Success() { + // Given + String bundleId = "bundle-123"; + String permissionId = "perm-123"; + BundleEndpoints.UpdatePermissionRequest request = new BundleEndpoints.UpdatePermissionRequest("owner"); + + when(bundleApiService.updatePermission(anyString(), anyString(), any(ResourceRole.class))) + .thenReturn(Mono.empty()); + + // When + Mono> result = bundleController.updatePermission(bundleId, permissionId, request); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).updatePermission(eq("objectId"), eq(permissionId), eq(ResourceRole.OWNER)); + } + + @Test + void testUpdatePermission_InvalidRole() { + // Given + String bundleId = "bundle-123"; + String permissionId = "perm-123"; + BundleEndpoints.UpdatePermissionRequest request = new BundleEndpoints.UpdatePermissionRequest("INVALID_ROLE"); + + // When + Mono> result = bundleController.updatePermission(bundleId, permissionId, request); + + // Then + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + + verify(gidService, never()).convertBundleIdToObjectId(anyString()); + verify(bundleApiService, never()).updatePermission(anyString(), anyString(), any()); + } + + @Test + void testRemovePermission_Success() { + // Given + String bundleId = "bundle-123"; + String permissionId = "perm-123"; + + when(bundleApiService.removePermission(anyString(), anyString())) + .thenReturn(Mono.empty()); + + // When + Mono> result = bundleController.removePermission(bundleId, permissionId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).removePermission(eq("objectId"), eq(permissionId)); + } + + @Test + void testGrantPermission_Success() { + // Given + String bundleId = "bundle-123"; + BundleEndpoints.BatchAddPermissionRequest request = new BundleEndpoints.BatchAddPermissionRequest( + "editor", + Set.of("user1", "user2"), + Set.of("group1") + ); + + when(bundleApiService.grantPermission(anyString(), anySet(), anySet(), any(ResourceRole.class))) + .thenReturn(Mono.empty()); + + // When + Mono> result = bundleController.grantPermission(bundleId, request); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNull(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).grantPermission(eq("objectId"), eq(request.userIds()), eq(request.groupIds()), eq(ResourceRole.EDITOR)); + } + + @Test + void testGrantPermission_InvalidRole() { + // Given + String bundleId = "bundle-123"; + BundleEndpoints.BatchAddPermissionRequest request = new BundleEndpoints.BatchAddPermissionRequest( + "INVALID_ROLE", + Set.of("user1"), + Set.of() + ); + + // When + Mono> result = bundleController.grantPermission(bundleId, request); + + // Then + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + + verify(gidService, never()).convertBundleIdToObjectId(anyString()); + verify(bundleApiService, never()).grantPermission(anyString(), anySet(), anySet(), any()); + } + + @Test + void testGetBundlePermissions_Success() { + // Given + String bundleId = "bundle-123"; + BundlePermissionView permissionView = BundlePermissionView.builder() + .groupPermissions(List.of()) + .userPermissions(List.of()) + .build(); + + when(bundleApiService.getPermissions(anyString())) + .thenReturn(Mono.just(permissionView)); + + // When + Mono> result = bundleController.getBundlePermissions(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(permissionView, response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).getPermissions(eq("objectId")); + } + + @Test + void testGetPublishedBundle_Success() { + // Given + String bundleId = "bundle-123"; + BundleInfoView publishedView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("published-bundle") + .build(); + + when(bundleApiService.getPublishedBundle(anyString(), any())) + .thenReturn(Mono.just(publishedView)); + + // When + Mono> result = bundleController.getPublishedBundle(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(publishedView, response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).getPublishedBundle(eq("objectId"), any()); + } + + @Test + void testGetPublishedMarketPlaceBundle_Success() { + // Given + String bundleId = "bundle-123"; + BundleInfoView marketplaceView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("marketplace-bundle") + .build(); + + when(bundleApiService.getPublishedBundle(anyString(), any())) + .thenReturn(Mono.just(marketplaceView)); + + // When + Mono> result = bundleController.getPublishedMarketPlaceBundle(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(marketplaceView, response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).getPublishedBundle(eq("objectId"), any()); + } + + @Test + void testGetAgencyProfileBundle_Success() { + // Given + String bundleId = "bundle-123"; + BundleInfoView agencyView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("agency-bundle") + .build(); + + when(bundleApiService.getPublishedBundle(anyString(), any())) + .thenReturn(Mono.just(agencyView)); + + // When + Mono> result = bundleController.getAgencyProfileBundle(bundleId); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(agencyView, response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).getPublishedBundle(eq("objectId"), any()); + } + + @Test + void testGetBundles_Success() { + // Given + BundleStatus bundleStatus = BundleStatus.NORMAL; + List bundles = List.of( + BundleInfoView.builder().bundleId("bundle-1").name("bundle1").build(), + BundleInfoView.builder().bundleId("bundle-2").name("bundle2").build() + ); + + when(userHomeApiService.getAllAuthorisedBundles4CurrentOrgMember(any(BundleStatus.class))) + .thenReturn(Flux.fromIterable(bundles)); + + // When + Mono>> result = bundleController.getBundles(bundleStatus); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(bundles, response.getData()); + }) + .verifyComplete(); + + verify(userHomeApiService).getAllAuthorisedBundles4CurrentOrgMember(bundleStatus); + } + + @Test + void testGetMarketplaceBundles_Success() { + // Given + List marketplaceBundles = List.of( + MarketplaceBundleInfoView.builder() + .bundleId("bundle-1") + .name("bundle1") + .title("title1") + .description("desc1") + .category("cat1") + .image("img1") + .orgId("org1") + .orgName("org1") + .creatorEmail("creator1@test.com") + .bundleGid("gid1") + .createAt(Instant.now().toEpochMilli()) + .createBy("user1") + .bundleStatus(BundleStatus.NORMAL) + .build(), + MarketplaceBundleInfoView.builder() + .bundleId("bundle-2") + .name("bundle2") + .title("title2") + .description("desc2") + .category("cat2") + .image("img2") + .orgId("org2") + .orgName("org2") + .creatorEmail("creator2@test.com") + .bundleGid("gid2") + .createAt(Instant.now().toEpochMilli()) + .createBy("user2") + .bundleStatus(BundleStatus.NORMAL) + .build() + ); + + when(userHomeApiService.getAllMarketplaceBundles()) + .thenReturn(Flux.fromIterable(marketplaceBundles)); + + // When + Mono>> result = bundleController.getMarketplaceBundles(); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(marketplaceBundles, response.getData()); + }) + .verifyComplete(); + + verify(userHomeApiService).getAllMarketplaceBundles(); + } + + @Test + void testGetAgencyProfileBundles_Success() { + // Given + List agencyBundles = List.of( + MarketplaceBundleInfoView.builder() + .bundleId("bundle-1") + .name("agency1") + .title("title1") + .description("desc1") + .category("cat1") + .image("img1") + .orgId("org1") + .orgName("org1") + .creatorEmail("creator1@test.com") + .bundleGid("gid1") + .createAt(Instant.now().toEpochMilli()) + .createBy("user1") + .bundleStatus(BundleStatus.NORMAL) + .build(), + MarketplaceBundleInfoView.builder() + .bundleId("bundle-2") + .name("agency2") + .title("title2") + .description("desc2") + .category("cat2") + .image("img2") + .orgId("org2") + .orgName("org2") + .creatorEmail("creator2@test.com") + .bundleGid("gid2") + .createAt(Instant.now().toEpochMilli()) + .createBy("user2") + .bundleStatus(BundleStatus.NORMAL) + .build() + ); + + when(userHomeApiService.getAllAgencyProfileBundles()) + .thenReturn(Flux.fromIterable(agencyBundles)); + + // When + Mono>> result = bundleController.getAgencyProfileBundles(); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertEquals(agencyBundles, response.getData()); + }) + .verifyComplete(); + + verify(userHomeApiService).getAllAgencyProfileBundles(); + } + + @Test + void testSetBundlePublicToAll_Success() { + // Given + String bundleId = "bundle-123"; + BundleEndpoints.BundlePublicToAllRequest request = new BundleEndpoints.BundlePublicToAllRequest(true); + + when(bundleApiService.setBundlePublicToAll(anyString(), anyBoolean())) + .thenReturn(Mono.just(true)); + + // When + Mono> result = bundleController.setBundlePublicToAll(bundleId, request); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).setBundlePublicToAll(eq("objectId"), eq(true)); + } + + @Test + void testSetBundlePublicToMarketplace_Success() { + // Given + String bundleId = "bundle-123"; + BundleEndpoints.BundlePublicToMarketplaceRequest request = new BundleEndpoints.BundlePublicToMarketplaceRequest(true); + + when(bundleApiService.setBundlePublicToMarketplace(anyString(), any(BundleEndpoints.BundlePublicToMarketplaceRequest.class))) + .thenReturn(Mono.just(true)); + + // When + Mono> result = bundleController.setBundlePublicToMarketplace(bundleId, request); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).setBundlePublicToMarketplace(eq("objectId"), eq(request)); + } + + @Test + void testSetBundleAsAgencyProfile_Success() { + // Given + String bundleId = "bundle-123"; + BundleEndpoints.BundleAsAgencyProfileRequest request = new BundleEndpoints.BundleAsAgencyProfileRequest(true); + + when(bundleApiService.setBundleAsAgencyProfile(anyString(), anyBoolean())) + .thenReturn(Mono.just(true)); + + // When + Mono> result = bundleController.setBundleAsAgencyProfile(bundleId, request); + + // Then + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService).setBundleAsAgencyProfile(eq("objectId"), eq(true)); + } + + @Test + void testGidServiceError_PropagatesException() { + // Given + String bundleId = "invalid-bundle-id"; + when(gidService.convertBundleIdToObjectId(anyString())) + .thenReturn(Mono.error(new BizException(BizError.INVALID_PARAMETER, "Invalid bundle ID"))); + + // When + Mono> result = bundleController.delete(bundleId); + + // Then + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + + verify(gidService).convertBundleIdToObjectId(eq(bundleId)); + verify(bundleApiService, never()).delete(anyString()); + } + + @Test + void testBusinessEventPublisherError_DoesNotAffectMainFlow() { + // Given + BundleEndpoints.CreateBundleRequest request = new BundleEndpoints.CreateBundleRequest( + "org01", "", "test-bundle", "Test Bundle", "Description", "category", "image", null + ); + + BundleInfoView expectedView = BundleInfoView.builder() + .bundleId("bundle-123") + .name("test-bundle") + .build(); + + when(bundleApiService.create(any(BundleEndpoints.CreateBundleRequest.class))) + .thenReturn(Mono.just(expectedView)); + when(businessEventPublisher.publishBundleCommonEvent(any(), any())) + .thenReturn(Mono.error(new RuntimeException("Event publishing failed"))); + + // When + Mono> result = bundleController.create(request); + + // Then + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + + verify(bundleApiService).create(eq(request)); + verify(businessEventPublisher).publishBundleCommonEvent(eq(expectedView), any()); + } +} \ No newline at end of file From 0e05cf626aa81041d8360628ba42824a1adb9a2e Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Mon, 21 Jul 2025 14:25:45 +0500 Subject: [PATCH 037/352] fixed imports in tag component --- .../src/comps/comps/tagsComp/tagsCompView.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx index f59898964c..ee6a4f28ef 100644 --- a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx @@ -1,16 +1,3 @@ -import { - BoolCodeControl, - ButtonEventHandlerControl, - InputLikeStyle, - NameConfig, - Section, - UICompBuilder, - hiddenPropertyView, - sectionNames, - showDataLoadingIndicatorsPropertyView, - styleControl, - withExposingConfigs -} from "@lowcoder-ee/index.sdk"; import styled from "styled-components"; import React, { useContext } from "react"; import { trans } from "i18n"; @@ -20,6 +7,15 @@ import { PresetStatusColorTypes } from "antd/es/_util/colors"; import { hashToNum } from "util/stringUtils"; import { TagsCompOptionsControl } from "comps/controls/optionsControl"; import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { styleControl } from "@lowcoder-ee/comps/controls/styleControl"; +import { ButtonEventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl"; +import { InputLikeStyle } from "@lowcoder-ee/comps/controls/styleControlConstants"; +import { BoolCodeControl } from "@lowcoder-ee/comps/controls/codeControl"; +import { UICompBuilder } from "@lowcoder-ee/comps/generators/uiCompBuilder"; +import { Section, sectionNames } from "lowcoder-design"; +import { NameConfig } from "@lowcoder-ee/comps/generators/withExposing"; +import { hiddenPropertyView, showDataLoadingIndicatorsPropertyView } from "@lowcoder-ee/comps/utils/propertyUtils"; +import { withExposingConfigs } from "@lowcoder-ee/comps/generators/withExposing"; const colors = PresetStatusColorTypes; @@ -145,7 +141,7 @@ const multiTags = (function () { .setPropertyViewFn((children: any) => { return ( <> -
+
{children.options.propertyView({})}
From 63043e6307b1aa880d8dfea89bfc737e73910e26 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Mon, 21 Jul 2025 14:37:25 +0500 Subject: [PATCH 038/352] small fix --- .../packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx index ee6a4f28ef..b8c4750763 100644 --- a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx @@ -129,7 +129,7 @@ const multiTags = (function () { $customStyle={tagStyle} icon={tagIcon} color={tagColor} - onClick={() => handleClickEvent()} + onClick={handleClickEvent} > {tag.label} From 7346eecb80b4796967a694bf74fc82eec2b24b6f Mon Sep 17 00:00:00 2001 From: FARAN Date: Mon, 21 Jul 2025 16:02:57 +0500 Subject: [PATCH 039/352] [Fix]: #1849 your apps menu item inactive --- client/packages/lowcoder/src/pages/ApplicationV2/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx index 14bf9df2e1..20ba7f5ab7 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/index.tsx @@ -176,6 +176,7 @@ export default function ApplicationHome() { routePath: ALL_APPLICATIONS_URL, routeComp: HomeView, icon: ({ selected, ...otherProps }) => selected ? : , + onSelected: (_, currentPath) => currentPath === ALL_APPLICATIONS_URL || currentPath.startsWith("/folder"), }, ], }, From 64b7f587e71db0e37a71e72bdbd04e51fcaa65b8 Mon Sep 17 00:00:00 2001 From: FARAN Date: Mon, 21 Jul 2025 16:19:33 +0500 Subject: [PATCH 040/352] [Fix]: #1849 remove common settings --- .../lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx b/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx index a9998fa5dd..e6d2a79a4e 100644 --- a/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx +++ b/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx @@ -110,9 +110,7 @@ export function AdvancedSetting() { } }, [canLeave]); - useEffect(() => { - dispatch(fetchCommonSettings({ orgId: currentUser.currentOrgId })); - }, [currentUser.currentOrgId, dispatch]); + const handleSave = (key: keyof typeof settings, onSuccess?: () => void) => { return (value?: any) => { From 52fe5485509571e1355355678a71ecab93dfc9fb Mon Sep 17 00:00:00 2001 From: FARAN Date: Mon, 21 Jul 2025 17:29:16 +0500 Subject: [PATCH 041/352] [Fix]: #1849 optimize api calls for advanced setting page --- .../pages/setting/advanced/AdvancedSetting.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx b/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx index e6d2a79a4e..5a85dd6e55 100644 --- a/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx +++ b/client/packages/lowcoder/src/pages/setting/advanced/AdvancedSetting.tsx @@ -96,9 +96,18 @@ export function AdvancedSetting() { }, [currentUser.currentOrgId]) useEffect(() => { - dispatch(fetchCommonSettings({ orgId: currentUser.currentOrgId })); - dispatch(fetchAllApplications({})); - }, [currentUser.currentOrgId, dispatch]); + // Only fetch common settings if not already loaded + if (Object.keys(commonSettings).length === 0) { + dispatch(fetchCommonSettings({ orgId: currentUser.currentOrgId })); + } + }, [currentUser.currentOrgId, dispatch, commonSettings]); + + // Lazy load applications only when dropdown is opened + const handleDropdownOpen = () => { + if (appList.length === 0) { + dispatch(fetchAllApplications({})); + } + }; useEffect(() => { setSettings(commonSettings); @@ -176,6 +185,9 @@ export function AdvancedSetting() { onChange={(value: string) => { setSettings((v) => ({ ...v, defaultHomePage: value })); }} + onDropdownVisibleChange={(open) => { + if (open) handleDropdownOpen(); + }} options={appListOptions} filterOption={(input, option) => (option?.label as string).includes(input)} /> From 510cb604fb1b9929fc68027f587a29656395da19 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Mon, 21 Jul 2025 18:02:46 +0500 Subject: [PATCH 042/352] text input glitch fix --- .../src/comps/comps/textInputComp/textInputConstants.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx index 2d1761f1df..0765b638b5 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx @@ -184,7 +184,7 @@ export const useTextInputProps = (props: RecordConstructorToView { - if (!changeRef.current) { + if (!touchRef.current) { setLocalInputValue(inputValue); } }, [inputValue]); From 93eb77a725636b3fbf5721bebaead485570db632 Mon Sep 17 00:00:00 2001 From: FARAN Date: Mon, 21 Jul 2025 19:18:38 +0500 Subject: [PATCH 043/352] [Fix]: #1466 border color for disabled button --- .../comps/buttonComp/buttonCompConstants.tsx | 1 + .../meetingComp/videobuttonCompConstants.tsx | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx index b878eabbdb..d838efbcd1 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx @@ -54,6 +54,7 @@ export function getButtonStyle(buttonStyle: ButtonStyleType, disabledStyle: Disa &.ant-btn-disabled { color: ${disabledStyle.disabledText}; background: ${disabledStyle.disabledBackground}; + border-color: ${disabledStyle.disabledBorder}; cursor: not-allowed; } } diff --git a/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx index 2ffa2e6cf3..8ba580f4ab 100644 --- a/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/meetingComp/videobuttonCompConstants.tsx @@ -48,6 +48,7 @@ export function getButtonStyle(buttonStyle: any, disabledStyle: any) { &.ant-btn-disabled { color: ${disabledStyle.disabledText}; background: ${disabledStyle.disabledBackground}; + border-color: ${disabledStyle.disabledBorder}; cursor: not-allowed; } } @@ -70,15 +71,15 @@ export const Button100 = styled(Button)<{ $buttonStyle?: any; $disabledStyle?: a `; export const ButtonCompWrapper = styled.div<{ disabled: boolean }>` - // The button component is disabled but can respond to drag & select events - ${(props) => - props.disabled && - ` - cursor: not-allowed; - button:disabled { - pointer-events: none; - } - `}; + ${(props) => + props.disabled + ? css` + cursor: not-allowed; + button:disabled { + pointer-events: none; + } + ` + : ''}; `; /** From 14fa759e8ca21c3999b8deff3011c42899293dda Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Mon, 21 Jul 2025 21:39:05 +0500 Subject: [PATCH 044/352] Updated styling of the tags component --- .../src/comps/comps/tagsComp/tagsCompView.tsx | 73 ++++++++++--------- .../src/comps/controls/optionsControl.tsx | 38 +++++++--- .../packages/lowcoder/src/i18n/locales/en.ts | 1 + 3 files changed, 70 insertions(+), 42 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx index b8c4750763..1f2a178c95 100644 --- a/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tagsComp/tagsCompView.tsx @@ -1,6 +1,5 @@ import styled from "styled-components"; import React, { useContext } from "react"; -import { trans } from "i18n"; import { Tag } from "antd"; import { EditorContext } from "comps/editorState"; import { PresetStatusColorTypes } from "antd/es/_util/colors"; @@ -23,7 +22,9 @@ const colors = PresetStatusColorTypes; function getTagColor(tagText : any, tagOptions: any[]) { const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); if (foundOption) { - if (foundOption.colorType === "preset") { + if (foundOption.colorType === "default") { + return undefined; + } else if (foundOption.colorType === "preset") { return foundOption.presetColor; } else if (foundOption.colorType === "custom") { return undefined; @@ -36,20 +37,32 @@ function getTagColor(tagText : any, tagOptions: any[]) { const getTagStyle = (tagText: any, tagOptions: any[], baseStyle: any = {}) => { const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + // If colorType is "default", use ONLY component styles + if (foundOption.colorType === "default") { + const style: any = { ...baseStyle }; + if (baseStyle.borderWidth && baseStyle.border && baseStyle.borderStyle) { + style.border = `${baseStyle.borderWidth} ${baseStyle.borderStyle} ${baseStyle.border}`; + } + return style; + } + const style: any = { ...baseStyle }; if (foundOption.colorType === "custom") { style.backgroundColor = foundOption.color; style.color = foundOption.textColor; - style.border = `1px solid ${foundOption.color}`; } - if (foundOption.border) { - style.borderColor = foundOption.border; - if (!foundOption.colorType || foundOption.colorType !== "custom") { - style.border = `1px solid ${foundOption.border}`; - } + let borderStyle = foundOption.borderStyle || "none"; + let borderWidth = foundOption.borderWidth || "0px"; + let borderColor = foundOption.border || "none"; + + if (borderStyle !== "none") { + style.border = `${borderWidth} ${borderStyle} ${borderColor}`; + } else { + style.border = "none"; } if (foundOption.radius) { @@ -64,33 +77,36 @@ const getTagStyle = (tagText: any, tagOptions: any[], baseStyle: any = {}) => { style.padding = foundOption.padding; } + if (foundOption.width) { + style.width = foundOption.width; + } + return style; } - return baseStyle; -}; -function getTagIcon(tagText: any, tagOptions: any[]) { - const foundOption = tagOptions.find(option => option.label === tagText); - return foundOption ? foundOption.icon : undefined; -} + const style: any = { ...baseStyle }; + if (baseStyle.borderWidth && baseStyle.border && baseStyle.borderStyle) { + style.border = `${baseStyle.borderWidth} ${baseStyle.borderStyle} ${baseStyle.border}`; + } + return style; +}; const multiTags = (function () { - const StyledTag = styled(Tag)<{ $style: any, $bordered: boolean, $customStyle: any }>` + const StyledTag = styled(Tag)<{ $style: any, $customStyle: any }>` display: flex; justify-content: center; align-items: center; - width: 100%; + min-width: fit-content; + width: ${(props) => props.$customStyle?.width || 'auto'}; + max-width: 100px; background: ${(props) => props.$customStyle?.backgroundColor || props.$style?.background}; color: ${(props) => props.$customStyle?.color || props.$style?.text}; border-radius: ${(props) => props.$customStyle?.borderRadius || props.$style?.borderRadius}; - border: ${(props) => { - if (props.$customStyle?.border) return props.$customStyle.border; - return props.$bordered ? `${props.$style?.borderStyle} ${props.$style?.borderWidth} ${props.$style?.border}` : 'none'; - }}; + border: ${(props) => props.$customStyle?.border || props.$style?.border || '1px solid #d9d9d9'}; padding: ${(props) => props.$customStyle?.padding || props.$style?.padding}; margin: ${(props) => props.$customStyle?.margin || props.$style?.margin}; - font-size: ${(props) => props.$style?.textSize}; + font-size: ${(props) => props.$style?.textSize || '8px'}; font-weight: ${(props) => props.$style?.fontWeight}; cursor: pointer; `; @@ -105,8 +121,6 @@ const multiTags = (function () { options: TagsCompOptionsControl, style: styleControl(InputLikeStyle, 'style'), onEvent: ButtonEventHandlerControl, - borderless: BoolCodeControl, - enableIndividualStyling: BoolCodeControl, }; return new UICompBuilder(childrenMap, (props) => { @@ -116,16 +130,14 @@ const multiTags = (function () { {props.options.map((tag, index) => { - // Use individual styling only if enableIndividualStyling is true - const tagColor = props.enableIndividualStyling ? getTagColor(tag.label, props.options) : undefined; - const tagIcon = props.enableIndividualStyling ? getTagIcon(tag.label, props.options) : tag.icon; - const tagStyle = props.enableIndividualStyling ? getTagStyle(tag.label, props.options, props.style) : {}; + const tagColor = getTagColor(tag.label, props.options); + const tagIcon = tag.icon; + const tagStyle = getTagStyle(tag.label, props.options, props.style); return ( - {children.enableIndividualStyling.propertyView({ - label: trans("style.individualStyling"), - tooltip: trans("style.individualStylingTooltip") - })} - {children.borderless.propertyView({ label: trans("style.borderless") })} {children.style.getPropertyView()}
)} diff --git a/client/packages/lowcoder/src/comps/controls/optionsControl.tsx b/client/packages/lowcoder/src/comps/controls/optionsControl.tsx index 1186057d9c..60f050a8ed 100644 --- a/client/packages/lowcoder/src/comps/controls/optionsControl.tsx +++ b/client/packages/lowcoder/src/comps/controls/optionsControl.tsx @@ -29,7 +29,6 @@ import { IconRadius, Option, WidthIcon, - ImageCompIcon, CloseEyeIcon, } from "lowcoder-design"; import styled from "styled-components"; @@ -39,8 +38,8 @@ import { JSONObject, JSONValue } from "util/jsonTypes"; import { ButtonEventHandlerControl } from "./eventHandlerControl"; import { ControlItemCompBuilder } from "comps/generators/controlCompBuilder"; import { ColorControl } from "./colorControl"; -import { StringStateControl } from "./codeStateControl"; import { reduceInContext } from "../utils/reduceContext"; +import { BorderOuterOutlined } from "@ant-design/icons"; // Tag preset color options const TAG_PRESET_COLORS = [ @@ -786,17 +785,26 @@ let TagsCompOptions = new MultiCompBuilder( { label: StringControl, icon: IconControl, - colorType: withDefault(dropdownControl([ + colorType: dropdownControl([ + { label: "Default", value: "default"}, { label: trans("style.preset"), value: "preset" }, { label: trans("style.custom"), value: "custom" }, - ] as const, "preset"), "preset"), - presetColor: withDefault(dropdownControl(TAG_PRESET_COLORS, "blue"), "blue"), + ], "default"), + presetColor: dropdownControl(TAG_PRESET_COLORS, "default"), color: withDefault(ColorControl, "#1890ff"), textColor: withDefault(ColorControl, "#ffffff"), border: withDefault(ColorControl, ""), + borderWidth: withDefault(RadiusControl, ""), + borderStyle: withDefault(dropdownControl([ + { label: "Solid", value: "solid" }, + { label: "Dashed", value: "dashed" }, + { label: "Dotted", value: "dotted" }, + { label: "None", value: "none" }, + ], "solid"), "solid"), radius: withDefault(RadiusControl, ""), margin: withDefault(StringControl, ""), padding: withDefault(StringControl, ""), + width: withDefault(StringControl, ""), }, (props) => props ).build(); @@ -809,8 +817,7 @@ TagsCompOptions = class extends TagsCompOptions implements OptionCompProperty { {this.children.label.propertyView({ label: trans("coloredTagOptionControl.tag") })} {this.children.icon.propertyView({ label: trans("coloredTagOptionControl.icon") })} {this.children.colorType.propertyView({ - label: trans("style.colorType"), - radioButton: true + label: trans("style.styleOptions") })} {colorType === "preset" && this.children.presetColor.propertyView({ label: trans("style.presetColor") @@ -821,9 +828,17 @@ TagsCompOptions = class extends TagsCompOptions implements OptionCompProperty { {this.children.textColor.propertyView({ label: trans("style.textColor") })} )} + {this.children.borderStyle.propertyView({ + label: trans('style.borderStyle'), + preInputNode: , + })} {this.children.border.propertyView({ label: trans('style.border') })} + {this.children.borderWidth.propertyView({ + label: trans('style.borderWidth'), + preInputNode: , + })} {this.children.radius.propertyView({ label: trans('style.borderRadius'), preInputNode: , @@ -839,6 +854,11 @@ TagsCompOptions = class extends TagsCompOptions implements OptionCompProperty { preInputNode: , placeholder: '3px', })} + {this.children.width.propertyView({ + label: trans('splitLayout.width'), + preInputNode: , + placeholder: '100px', + })} ); } @@ -846,8 +866,8 @@ TagsCompOptions = class extends TagsCompOptions implements OptionCompProperty { export const TagsCompOptionsControl = optionsControl(TagsCompOptions, { initOptions: [ - { label: "Option 1", colorType: "preset", presetColor: "blue" }, - { label: "Option 2", colorType: "preset", presetColor: "green" } + { label: "Option 1", colorType: "default"}, + { label: "Option 2", colorType: "default"} ], uniqField: "label", }); diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 045518e1ff..80f6672884 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -476,6 +476,7 @@ export const en = { // fourth part "style": { + "styleOptions": "Style Options", "boxShadowColor": 'Shadow Color', "boxShadow": 'Box Shadow', "opacity": 'Opacity', From 49370f942c11ab219c62d664e25b23e1cfc03627 Mon Sep 17 00:00:00 2001 From: FARAN Date: Mon, 21 Jul 2025 21:59:29 +0500 Subject: [PATCH 045/352] [Fix]: #1849 add useDebouncedValue hook, and fix chars length --- .../src/pages/ApplicationV2/FolderView.tsx | 16 ++++++++-------- .../src/pages/ApplicationV2/HomeView.tsx | 17 +++++++++-------- .../src/pages/ApplicationV2/TrashView.tsx | 18 +++++++++--------- .../src/pages/datasource/datasourceList.tsx | 17 +++++++++-------- .../src/pages/queryLibrary/LeftNav.tsx | 16 ++++++++-------- client/packages/lowcoder/src/util/hooks.ts | 19 +++++++++++++++++++ 6 files changed, 62 insertions(+), 41 deletions(-) diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx index 887a74cc1d..7016857116 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/FolderView.tsx @@ -1,7 +1,8 @@ import { useDispatch, useSelector } from "react-redux"; import { useParams } from "react-router-dom"; import { HomeBreadcrumbType, HomeLayout } from "./HomeLayout"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; +import { useDebouncedValue } from "util/hooks"; import {ApplicationCategoriesEnum, ApplicationMeta, FolderMeta} from "../../constants/applicationConstants"; import { buildFolderUrl } from "../../constants/routesURL"; import { folderElementsSelector, foldersSelector } from "../../redux/selectors/folderSelector"; @@ -100,13 +101,12 @@ export function FolderView() { }, [searchValues] ); - useEffect(()=> { - const timer = setTimeout(() => { - if (searchValue.length > 2 || searchValue === "") - setSearchValues(searchValue) - }, 500); - return () => clearTimeout(timer); - }, [searchValue]) + const debouncedSearchValue = useDebouncedValue(searchValue, 500); + + useEffect(() => { + if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") + setSearchValues(debouncedSearchValue); + }, [debouncedSearchValue]); return ( <> diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx index 3a435a6b87..a493555411 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeView.tsx @@ -3,7 +3,8 @@ import { HomeLayout } from "./HomeLayout"; import { getUser } from "../../redux/selectors/usersSelectors"; import { Helmet } from "react-helmet"; import { trans } from "i18n"; -import {useState, useEffect } from "react"; +import { useState, useEffect } from "react"; +import { useDebouncedValue } from "util/hooks"; import {fetchFolderElements} from "@lowcoder-ee/util/pagination/axios"; import {ApplicationCategoriesEnum, ApplicationMeta, FolderMeta} from "@lowcoder-ee/constants/applicationConstants"; import {ApplicationPaginationType} from "@lowcoder-ee/util/pagination/type"; @@ -53,13 +54,13 @@ export function HomeView() { }, [searchValues] ); - useEffect(()=> { - const timer = setTimeout(() => { - if (searchValue.length > 2 || searchValue === "") - setSearchValues(searchValue) - }, 500); - return () => clearTimeout(timer); - }, [searchValue]) + const debouncedSearchValue = useDebouncedValue(searchValue, 500); + + useEffect(() => { + if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { + setSearchValues(debouncedSearchValue); + } + }, [debouncedSearchValue]); const user = useSelector(getUser); diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx index 410a2632f0..3b6e843903 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/TrashView.tsx @@ -1,6 +1,7 @@ import { HomeLayout } from "./HomeLayout"; import { TRASH_URL } from "../../constants/routesURL"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; +import { useDebouncedValue } from "util/hooks"; import { trans } from "../../i18n"; import { Helmet } from "react-helmet"; import {fetchApplicationElements} from "@lowcoder-ee/util/pagination/axios"; @@ -46,14 +47,13 @@ export function TrashView() { }, [searchValues] ); - //debouncing - useEffect(()=> { - const timer = setTimeout(() => { - if (searchValue.length > 2 || searchValue === "") - setSearchValues(searchValue) - }, 500); - return () => clearTimeout(timer); - }, [searchValue]) + const debouncedSearchValue = useDebouncedValue(searchValue, 500); + + useEffect(() => { + if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { + setSearchValues(debouncedSearchValue); + } + }, [debouncedSearchValue]); return ( <> diff --git a/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx b/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx index 61eb621b23..cc83e64e63 100644 --- a/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx +++ b/client/packages/lowcoder/src/pages/datasource/datasourceList.tsx @@ -1,6 +1,7 @@ import styled from "styled-components"; import { EditPopover, PointIcon, Search, TacoButton } from "lowcoder-design"; -import {useEffect, useState} from "react"; +import { useState, useEffect } from "react"; +import { useDebouncedValue } from "util/hooks"; import { useDispatch, useSelector } from "react-redux"; import { getDataSourceTypesMap } from "../../redux/selectors/datasourceSelectors"; import { deleteDatasource } from "../../redux/reduxActions/datasourceActions"; @@ -124,13 +125,13 @@ export const DatasourceList = () => { const [pageSize, setPageSize] = useState(10); const [paginationLoading, setPaginationLoading] = useState(false); - useEffect(()=> { - const timer = setTimeout(() => { - if (searchValue.length > 2 || searchValue === "") - setSearchValues(searchValue) - }, 500); - return () => clearTimeout(timer); - }, [searchValue]) + const debouncedSearchValue = useDebouncedValue(searchValue, 500); + + useEffect(() => { + if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { + setSearchValues(debouncedSearchValue); + } + }, [debouncedSearchValue]); useEffect( () => { setPaginationLoading(true); diff --git a/client/packages/lowcoder/src/pages/queryLibrary/LeftNav.tsx b/client/packages/lowcoder/src/pages/queryLibrary/LeftNav.tsx index 84bdade673..6df81dd6b6 100644 --- a/client/packages/lowcoder/src/pages/queryLibrary/LeftNav.tsx +++ b/client/packages/lowcoder/src/pages/queryLibrary/LeftNav.tsx @@ -1,4 +1,5 @@ -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; +import { useDebouncedValue } from "util/hooks"; import styled, { css } from "styled-components"; import { BluePlusIcon, @@ -174,14 +175,13 @@ export const LeftNav = (props: { const [searchValue, setSearchValue] = useState(""); const datasourceTypes = useSelector(getDataSourceTypesMap); - useEffect(()=> { - const timer = setTimeout(() => { - if (searchValue.length > 2 || searchValue === "") - setSearchValues(searchValue) - }, 500); - return () => clearTimeout(timer); - }, [searchValue]) + const debouncedSearchValue = useDebouncedValue(searchValue, 500); + useEffect(() => { + if (debouncedSearchValue.trim().length > 0 || debouncedSearchValue === "") { + setSearchValues(debouncedSearchValue); + } + }, [debouncedSearchValue]); return ( diff --git a/client/packages/lowcoder/src/util/hooks.ts b/client/packages/lowcoder/src/util/hooks.ts index 9c9b7777cf..796ba7eed6 100644 --- a/client/packages/lowcoder/src/util/hooks.ts +++ b/client/packages/lowcoder/src/util/hooks.ts @@ -29,6 +29,7 @@ import { constantColors } from "components/colorSelect/colorUtils"; import { AppState } from "@lowcoder-ee/redux/reducers"; import { getOrgUserStats } from "@lowcoder-ee/redux/selectors/orgSelectors"; import { fetchGroupsAction } from "@lowcoder-ee/redux/reduxActions/orgActions"; +import debounce from "lodash/debounce"; export const ForceViewModeContext = React.createContext(false); @@ -282,3 +283,21 @@ export const useOrgUserCount = (orgId: string) => { return userCount; }; + +/** + * Returns a debounced version of the incoming value that only updates + */ +export function useDebouncedValue(value: T, delay = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + const updater = useMemo(() => debounce(setDebouncedValue, delay), [delay]); + + useEffect(() => { + updater(value); + return () => { + updater.cancel(); + }; + }, [value, updater]); + + return debouncedValue; +} From a6eb2a92bff2f8278aebdf64ed59098846fa0ac9 Mon Sep 17 00:00:00 2001 From: FARAN Date: Mon, 21 Jul 2025 23:08:42 +0500 Subject: [PATCH 046/352] [Fix]: #1849 key error --- client/packages/lowcoder/src/comps/controls/codeControl.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/controls/codeControl.tsx b/client/packages/lowcoder/src/comps/controls/codeControl.tsx index 2ae75d7a0d..7daa097d5e 100644 --- a/client/packages/lowcoder/src/comps/controls/codeControl.tsx +++ b/client/packages/lowcoder/src/comps/controls/codeControl.tsx @@ -189,6 +189,7 @@ export function codeControl< const cardContent = params.disableCard ? "" : getCardContent(this.unevaledValue, this.valueAndMsg, codeControlParams); + const { key, ...restParams } = params; return ( {(editorState) => ( @@ -197,7 +198,8 @@ export function codeControl< <> Date: Tue, 22 Jul 2025 22:38:23 +0500 Subject: [PATCH 047/352] fix:- Regex on password field --- .../comps/textInputComp/textInputConstants.test.tsx | 12 ++++++++++++ .../lowcoder/src/comps/controls/codeControl.tsx | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.test.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.test.tsx index 3466b486f5..e724b3e052 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.test.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.test.tsx @@ -79,4 +79,16 @@ test("textInputValidate", () => { validateStatus: "error", help: trans("validationDesc.maxLength", { length: 4, maxLength: 2 }), }); + + expect( + textInputValidate({ + value: { value: "" }, + required: false, + minLength: 0, + maxLength: 0, + validationType: "Regex", + regex: new RegExp("^.*$"), + customRule: "", + }) + ).toMatchObject({ validateStatus: "" }); }); diff --git a/client/packages/lowcoder/src/comps/controls/codeControl.tsx b/client/packages/lowcoder/src/comps/controls/codeControl.tsx index 7daa097d5e..36788b842d 100644 --- a/client/packages/lowcoder/src/comps/controls/codeControl.tsx +++ b/client/packages/lowcoder/src/comps/controls/codeControl.tsx @@ -281,7 +281,8 @@ function toRegExp(value: unknown): RegExp { return value as RegExp; } else if (valueType === "string") { const regexStr = trimStart(value as string, "^"); - return new RegExp("^" + (regexStr ?? ".*") + "$"); + const finalRegexStr = regexStr || ".*"; + return new RegExp("^" + finalRegexStr + "$"); } throw new TypeError( `must be a valid JavaScript regular expression without forward slashes around the pattern` From 2a91c02fadac22cbef3892c669b91201de33920c Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Wed, 23 Jul 2025 01:35:32 +0500 Subject: [PATCH 048/352] fix:- Table column alignment --- .../comps/comps/tableComp/tableCompView.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx index 139cc396d0..9702008dad 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx @@ -373,7 +373,7 @@ const TableTh = styled.th<{ width?: number }>` ${(props) => props.width && `width: ${props.width}px`}; `; -const TableTd = styled.td<{ +interface TableTdProps { $background: string; $style: TableColumnStyleType & { rowHeight?: string }; $defaultThemeDetail: ThemeDetail; @@ -381,7 +381,9 @@ const TableTd = styled.td<{ $isEditing: boolean; $tableSize?: string; $autoHeight?: boolean; -}>` + $customAlign?: 'left' | 'center' | 'right'; +} +const TableTd = styled.td` .ant-table-row-expand-icon, .ant-table-row-indent { display: ${(props) => (props.$isEditing ? "none" : "initial")}; @@ -394,6 +396,7 @@ const TableTd = styled.td<{ border-color: ${(props) => props.$style.border} !important; border-radius: ${(props) => props.$style.radius}; padding: 0 !important; + text-align: ${(props) => props.$customAlign || 'left'} !important; > div:not(.editing-border, .editing-wrapper), .editing-wrapper .ant-input, @@ -404,8 +407,13 @@ const TableTd = styled.td<{ font-weight: ${(props) => props.$style.textWeight}; font-family: ${(props) => props.$style.fontFamily}; overflow: hidden; + display: flex; + justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; + align-items: center; + text-align: ${(props) => props.$customAlign || 'left'}; + padding: 0 8px; + box-sizing: border-box; ${(props) => props.$tableSize === 'small' && ` - padding: 1px 8px; font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; font-style:${props.$style.fontStyle} !important; min-height: ${props.$style.rowHeight || '14px'}; @@ -416,7 +424,6 @@ const TableTd = styled.td<{ `}; `}; ${(props) => props.$tableSize === 'middle' && ` - padding: 8px 8px; font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; font-style:${props.$style.fontStyle} !important; min-height: ${props.$style.rowHeight || '24px'}; @@ -427,7 +434,6 @@ const TableTd = styled.td<{ `}; `}; ${(props) => props.$tableSize === 'large' && ` - padding: 16px 16px; font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; font-style:${props.$style.fontStyle} !important; min-height: ${props.$style.rowHeight || '48px'}; @@ -573,6 +579,7 @@ const TableCellView = React.memo((props: { tableSize?: string; autoHeight?: boolean; loading?: boolean; + customAlign?: 'left' | 'center' | 'right'; }) => { const { record, @@ -588,6 +595,7 @@ const TableCellView = React.memo((props: { tableSize, autoHeight, loading, + customAlign, ...restProps } = props; @@ -648,6 +656,7 @@ const TableCellView = React.memo((props: { $isEditing={editing} $tableSize={tableSize} $autoHeight={autoHeight} + $customAlign={customAlign} > {loading ? @@ -735,6 +744,7 @@ function ResizeableTableComp(props: CustomTableProps< autoHeight: rowAutoHeight, onClick: () => onCellClick(col.titleText, String(col.dataIndex)), loading: customLoading, + customAlign: col.align, }); }, [rowColorFn, rowHeightFn, columnsStyle, size, rowAutoHeight, onCellClick, customLoading]); From c284fd45e89fb99dd03f3fa0dbf4a45c0646a571 Mon Sep 17 00:00:00 2001 From: th37star Date: Tue, 22 Jul 2025 16:18:19 -0400 Subject: [PATCH 049/352] Created test case for the DatasourceEndpoints. --- .../DatasourceEndpointsIntegrationTest.java | 514 +++++++++++++++++ .../datasource/DatasourceEndpointsTest.java | 546 ++++++++++++++++++ 2 files changed, 1060 insertions(+) create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsIntegrationTest.java create mode 100644 server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsTest.java diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsIntegrationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsIntegrationTest.java new file mode 100644 index 0000000000..52199724d3 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsIntegrationTest.java @@ -0,0 +1,514 @@ +package org.lowcoder.api.datasource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.lowcoder.api.common.InitData; +import org.lowcoder.api.common.mockuser.WithMockUser; +import org.lowcoder.api.framework.view.PageResponseView; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.permission.view.CommonPermissionView; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceStatus; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.ActiveProfiles; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DatasourceEndpointsIntegrationTest { + + @Autowired + private DatasourceController datasourceController; + + @Autowired + private InitData initData; + + private static final String TEST_ORGANIZATION_ID = "org01"; + private static final String TEST_APPLICATION_ID = "app01"; + private static final String TEST_PERMISSION_ID = "permission01"; + + @BeforeEach + void setUp() { + try { + initData.init(); + } catch (RuntimeException e) { + // Handle duplicate key errors gracefully - this happens when test data already exists + if (e.getCause() instanceof DuplicateKeyException) { + // Data already exists, continue with test + System.out.println("Test data already exists, continuing with test..."); + } else { + // Re-throw other exceptions + throw e; + } + } + } + + @Test + @WithMockUser(id = "user01") + void testCreateDatasource_Integration_Success() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + + // Act + Mono> result = datasourceController.create(request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals("Test REST API Datasource", response.getData().getName()); + assertEquals("restapi", response.getData().getType()); + assertEquals(TEST_ORGANIZATION_ID, response.getData().getOrganizationId()); + assertNotNull(response.getData().getId()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetDatasourceById_Integration_Success() { + // Arrange - First create a datasource, then retrieve it + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Test Datasource for Get"); + + // Act - Create then get + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.getById(createdId); + }); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertEquals("Test Datasource for Get", response.getData().getName()); + assertEquals("restapi", response.getData().getType()); + assertEquals(TEST_ORGANIZATION_ID, response.getData().getOrganizationId()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testDeleteDatasource_Integration_Success() { + // Arrange - First create a datasource to delete + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Datasource to Delete"); + + // Act - Create then delete + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.delete(createdId); + }); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertTrue(response.getData()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetDatasourceStructure_Integration_Success() { + // Arrange - First create a datasource, then get its structure + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Datasource for Structure Test"); + + boolean ignoreCache = false; + + // Act - Create then get structure + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.getStructure(createdId, ignoreCache); + }); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + // Note: Structure might be null in test environment + // So we just verify the response structure + assertNotNull(response); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetDatasourceStructure_Integration_WithCache() { + // Arrange - First create a datasource, then get its structure with cache + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Datasource for Cache Structure Test"); + + boolean ignoreCache = true; + + // Act - Create then get structure + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.getStructure(createdId, ignoreCache); + }); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + // Note: Structure might be null in test environment + // So we just verify the response structure + assertNotNull(response); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListJsDatasourcePlugins_Integration_Success() { + // Arrange + String applicationId = TEST_APPLICATION_ID; + String name = null; + String type = null; + int pageNum = 1; + int pageSize = 10; + + // Act + Mono> result = datasourceController.listJsDatasourcePlugins( + applicationId, name, type, pageNum, pageSize); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getTotal()); + assertNotNull(response.getPageNum()); + assertNotNull(response.getPageSize()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListJsDatasourcePlugins_Integration_WithFilters() { + // Arrange + String applicationId = TEST_APPLICATION_ID; + String name = "restapi"; + String type = "restapi"; + int pageNum = 1; + int pageSize = 5; + + // Act + Mono> result = datasourceController.listJsDatasourcePlugins( + applicationId, name, type, pageNum, pageSize); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getTotal()); + assertNotNull(response.getPageNum()); + assertNotNull(response.getPageSize()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGetPluginDynamicConfig_Integration_EmptyList() { + // Arrange + List emptyList = List.of(); + + // Act + Mono>> result = datasourceController.getPluginDynamicConfig(emptyList); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertTrue(response.getData().isEmpty()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListOrgDataSources_Integration_Success() { + // Arrange + String orgId = TEST_ORGANIZATION_ID; + String name = null; + String type = null; + int pageNum = 1; + int pageSize = 10; + + // Act + Mono> result = datasourceController.listOrgDataSources( + orgId, name, type, pageNum, pageSize); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getTotal()); + assertNotNull(response.getPageNum()); + assertNotNull(response.getPageSize()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListOrgDataSources_Integration_WithFilters() { + // Arrange + String orgId = TEST_ORGANIZATION_ID; + String name = "restapi"; + String type = "restapi"; + int pageNum = 1; + int pageSize = 5; + + // Act + Mono> result = datasourceController.listOrgDataSources( + orgId, name, type, pageNum, pageSize); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getTotal()); + assertNotNull(response.getPageNum()); + assertNotNull(response.getPageSize()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListOrgDataSources_Integration_EmptyOrgId() { + // Arrange + String orgId = ""; + String name = null; + String type = null; + int pageNum = 1; + int pageSize = 10; + + // Act + Mono> result = datasourceController.listOrgDataSources( + orgId, name, type, pageNum, pageSize); + + // Assert - This method uses ofError() directly, so it throws BizException + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + @WithMockUser(id = "user01") + void testListAppDataSources_Integration_Success() { + // Arrange + String appId = TEST_APPLICATION_ID; + String name = null; + String type = null; + int pageNum = 1; + int pageSize = 10; + + // Act + Mono> result = datasourceController.listAppDataSources( + appId, name, type, pageNum, pageSize); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getTotal()); + assertNotNull(response.getPageNum()); + assertNotNull(response.getPageSize()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListAppDataSources_Integration_WithFilters() { + // Arrange + String appId = TEST_APPLICATION_ID; + String name = "restapi"; + String type = "restapi"; + int pageNum = 1; + int pageSize = 5; + + // Act + Mono> result = datasourceController.listAppDataSources( + appId, name, type, pageNum, pageSize); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getTotal()); + assertNotNull(response.getPageNum()); + assertNotNull(response.getPageSize()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testListAppDataSources_Integration_EmptyAppId() { + // Arrange + String appId = ""; + String name = null; + String type = null; + int pageNum = 1; + int pageSize = 10; + + // Act + Mono> result = datasourceController.listAppDataSources( + appId, name, type, pageNum, pageSize); + + // Assert - This method uses ofError() directly, so it throws BizException + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + @WithMockUser(id = "user01") + void testGetDatasourcePermissions_Integration_Success() { + // Arrange - First create a datasource, then get its permissions + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Datasource for Permissions Test"); + + // Act - Create then get permissions + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.getPermissions(createdId); + }); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + assertNotNull(response.getData().getUserPermissions()); + assertNotNull(response.getData().getGroupPermissions()); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(id = "user01") + void testGrantDatasourcePermission_Integration_InvalidRole() { + // Arrange - First create a datasource, then try to grant invalid permissions + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Datasource for Invalid Permission Test"); + + Set userIds = Set.of("user02"); + Set groupIds = Set.of(); + String invalidRole = "INVALID_ROLE"; + + DatasourceEndpoints.BatchAddPermissionRequest request = + new DatasourceEndpoints.BatchAddPermissionRequest(invalidRole, userIds, groupIds); + + // Act - Create then try to grant invalid permission + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.grantPermission(createdId, request); + }); + + // Assert - This method uses ofError() directly, so it throws BizException + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + @WithMockUser(id = "user01") + void testUpdateDatasourcePermission_Integration_InvalidRole() { + // Arrange + String permissionId = TEST_PERMISSION_ID; + String invalidRole = "INVALID_ROLE"; + + DatasourceEndpoints.UpdatePermissionRequest request = + new DatasourceEndpoints.UpdatePermissionRequest(invalidRole); + + // Act + Mono> result = datasourceController.updatePermission(permissionId, request); + + // Assert - This method uses ofError() directly, so it throws BizException + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + @WithMockUser(id = "user01") + void testGetDatasourceInfo_Integration_Success() { + // Arrange - First create a datasource, then get its info + UpsertDatasourceRequest createRequest = createTestUpsertDatasourceRequest(); + createRequest.setName("Datasource for Info Test"); + + // Act - Create then get info + Mono> result = datasourceController.create(createRequest) + .flatMap(createResponse -> { + String createdId = createResponse.getData().getId(); + return datasourceController.info(createdId); + }); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assertTrue(response.isSuccess()); + assertNotNull(response.getData()); + }) + .verifyComplete(); + } + + // Helper method to create a test UpsertDatasourceRequest + private UpsertDatasourceRequest createTestUpsertDatasourceRequest() { + UpsertDatasourceRequest request = new UpsertDatasourceRequest(); + request.setName("Test REST API Datasource"); + request.setType("restapi"); // Use REST_API instead of mysql + request.setOrganizationId(TEST_ORGANIZATION_ID); + request.setStatus(DatasourceStatus.NORMAL); + + // Create REST API datasource config + Map config = new HashMap<>(); + config.put("url", "https://api.example.com"); + config.put("method", "GET"); + config.put("headers", new HashMap()); + config.put("params", new HashMap()); + + request.setDatasourceConfig(config); + return request; + } +} diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsTest.java new file mode 100644 index 0000000000..c22e39ca00 --- /dev/null +++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/datasource/DatasourceEndpointsTest.java @@ -0,0 +1,546 @@ +package org.lowcoder.api.datasource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lowcoder.api.framework.view.PageResponseView; +import org.lowcoder.api.framework.view.ResponseView; +import org.lowcoder.api.permission.view.CommonPermissionView; +import org.lowcoder.api.util.BusinessEventPublisher; +import org.lowcoder.api.util.GidService; +import org.lowcoder.domain.datasource.model.Datasource; +import org.lowcoder.domain.datasource.model.DatasourceStatus; +import org.lowcoder.domain.datasource.service.DatasourceService; +import org.lowcoder.domain.datasource.service.DatasourceStructureService; +import org.lowcoder.domain.permission.model.ResourcePermission; +import org.lowcoder.domain.permission.model.ResourceRole; +import org.lowcoder.domain.permission.service.ResourcePermissionService; +import org.lowcoder.domain.plugin.client.dto.GetPluginDynamicConfigRequestDTO; +import org.lowcoder.sdk.exception.BizError; +import org.lowcoder.sdk.exception.BizException; +import org.lowcoder.sdk.models.DatasourceStructure; +import org.lowcoder.sdk.models.DatasourceTestResult; +import org.mockito.Mockito; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.core.publisher.Flux; +import java.util.ArrayList; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import org.lowcoder.api.datasource.DatasourceView; + +class DatasourceEndpointsTest { + + private DatasourceStructureService datasourceStructureService; + private DatasourceApiService datasourceApiService; + private UpsertDatasourceRequestMapper upsertDatasourceRequestMapper; + private BusinessEventPublisher businessEventPublisher; + private DatasourceService datasourceService; + private GidService gidService; + private ResourcePermissionService resourcePermissionService; + private DatasourceController controller; + + private static final String TEST_DATASOURCE_ID = "test-datasource-id"; + private static final String TEST_ORGANIZATION_ID = "test-org-id"; + private static final String TEST_PERMISSION_ID = "test-permission-id"; + + @BeforeEach + void setUp() { + // Create mocks manually + datasourceStructureService = Mockito.mock(DatasourceStructureService.class); + datasourceApiService = Mockito.mock(DatasourceApiService.class); + upsertDatasourceRequestMapper = Mockito.mock(UpsertDatasourceRequestMapper.class); + businessEventPublisher = Mockito.mock(BusinessEventPublisher.class); + datasourceService = Mockito.mock(DatasourceService.class); + gidService = Mockito.mock(GidService.class); + resourcePermissionService = Mockito.mock(ResourcePermissionService.class); + + // Setup common mocks + when(businessEventPublisher.publishDatasourceEvent(any(Datasource.class), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishDatasourceEvent(any(String.class), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishDatasourcePermissionEvent(any(), any(), any(), any(), any(), any(), any())).thenReturn(Mono.empty()); + when(businessEventPublisher.publishDatasourceResourcePermissionEvent(any(), any(), any())).thenReturn(Mono.empty()); + when(datasourceService.removePasswordTypeKeysFromJsDatasourcePluginConfig(any())).thenReturn(Mono.empty()); + when(gidService.convertDatasourceIdToObjectId(any())).thenAnswer(invocation -> { + String datasourceId = invocation.getArgument(0); + return datasourceId != null ? Mono.just(datasourceId) : Mono.empty(); + }); + when(gidService.convertApplicationIdToObjectId(any())).thenAnswer(invocation -> { + String applicationId = invocation.getArgument(0); + return applicationId != null ? Mono.just(applicationId) : Mono.empty(); + }); + when(gidService.convertOrganizationIdToObjectId(any())).thenAnswer(invocation -> { + String organizationId = invocation.getArgument(0); + return organizationId != null ? Mono.just(organizationId) : Mono.empty(); + }); + when(resourcePermissionService.getById(any())).thenAnswer(invocation -> { + String permissionId = invocation.getArgument(0); + ResourcePermission mockPermission = Mockito.mock(ResourcePermission.class); + return permissionId != null ? Mono.just(mockPermission) : Mono.empty(); + }); + + // Create controller with all required dependencies + controller = new DatasourceController( + datasourceStructureService, + datasourceApiService, + upsertDatasourceRequestMapper, + businessEventPublisher, + datasourceService, + gidService, + resourcePermissionService + ); + } + + @Test + void testCreateDatasource_Success() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + Datasource mockDatasource = createMockDatasource(); + + when(upsertDatasourceRequestMapper.resolve(request)).thenReturn(mockDatasource); + when(datasourceApiService.create(mockDatasource)).thenReturn(Mono.just(mockDatasource)); + + // Act + Mono> result = controller.create(request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockDatasource); + }) + .verifyComplete(); + } + + @Test + void testCreateDatasource_ServiceError() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + Datasource mockDatasource = createMockDatasource(); + + when(upsertDatasourceRequestMapper.resolve(request)).thenReturn(mockDatasource); + when(datasourceApiService.create(mockDatasource)).thenReturn(Mono.error(new BizException(BizError.INVALID_PARAMETER, "INVALID_PARAMETER"))); + + // Act + Mono> result = controller.create(request); + + // Assert + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + void testGetDatasourceById_Success() { + // Arrange + Datasource mockDatasource = createMockDatasource(); + when(datasourceApiService.findByIdWithPermission(TEST_DATASOURCE_ID)).thenReturn(Mono.just(mockDatasource)); + + // Act + Mono> result = controller.getById(TEST_DATASOURCE_ID); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockDatasource); + }) + .verifyComplete(); + } + + @Test + void testGetDatasourceById_NotFound() { + // Arrange + when(datasourceApiService.findByIdWithPermission(TEST_DATASOURCE_ID)).thenReturn(Mono.error(new BizException(BizError.DATASOURCE_NOT_FOUND, "DATASOURCE_NOT_FOUND"))); + + // Act + Mono> result = controller.getById(TEST_DATASOURCE_ID); + + // Assert + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + void testUpdateDatasource_Success() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + Datasource mockDatasource = createMockDatasource(); + Datasource originalDatasource = createMockDatasource(); + originalDatasource.setName("Original Name"); + + when(upsertDatasourceRequestMapper.resolve(request)).thenReturn(mockDatasource); + when(datasourceService.getById(TEST_DATASOURCE_ID)).thenReturn(Mono.just(originalDatasource)); + when(datasourceApiService.update(TEST_DATASOURCE_ID, mockDatasource)).thenReturn(Mono.just(mockDatasource)); + + // Act + Mono> result = controller.update(TEST_DATASOURCE_ID, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockDatasource); + }) + .verifyComplete(); + } + + @Test + void testUpdateDatasource_ServiceError() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + Datasource mockDatasource = createMockDatasource(); + + when(upsertDatasourceRequestMapper.resolve(request)).thenReturn(mockDatasource); + when(datasourceService.getById(TEST_DATASOURCE_ID)).thenReturn(Mono.just(mockDatasource)); + when(datasourceApiService.update(TEST_DATASOURCE_ID, mockDatasource)).thenReturn(Mono.error(new BizException(BizError.INVALID_PARAMETER, "INVALID_PARAMETER"))); + + // Act + Mono> result = controller.update(TEST_DATASOURCE_ID, request); + + // Assert + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + void testDeleteDatasource_Success() { + // Arrange + Datasource mockDatasource = createMockDatasource(); + when(datasourceService.getById(TEST_DATASOURCE_ID)).thenReturn(Mono.just(mockDatasource)); + when(datasourceApiService.delete(TEST_DATASOURCE_ID)).thenReturn(Mono.just(true)); + + // Act + Mono> result = controller.delete(TEST_DATASOURCE_ID); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(true); + }) + .verifyComplete(); + } + + @Test + void testDeleteDatasource_ServiceError() { + // Arrange + Datasource mockDatasource = createMockDatasource(); + when(datasourceService.getById(TEST_DATASOURCE_ID)).thenReturn(Mono.just(mockDatasource)); + when(datasourceApiService.delete(TEST_DATASOURCE_ID)).thenReturn(Mono.error(new BizException(BizError.DATASOURCE_NOT_FOUND, "DATASOURCE_NOT_FOUND"))); + + // Act + Mono> result = controller.delete(TEST_DATASOURCE_ID); + + // Assert + StepVerifier.create(result) + .expectError(BizException.class) + .verify(); + } + + @Test + void testTestDatasource_Success() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + Datasource mockDatasource = createMockDatasource(); + DatasourceTestResult testResult = DatasourceTestResult.testSuccess(); + + when(upsertDatasourceRequestMapper.resolve(request)).thenReturn(mockDatasource); + when(datasourceApiService.testDatasource(mockDatasource)).thenReturn(Mono.just(testResult)); + + // Act + Mono> result = controller.testDatasource(request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(true); + }) + .verifyComplete(); + } + + @Test + void testTestDatasource_Failure() { + // Arrange + UpsertDatasourceRequest request = createTestUpsertDatasourceRequest(); + Datasource mockDatasource = createMockDatasource(); + DatasourceTestResult testResult = DatasourceTestResult.testFail("Connection failed"); + + when(upsertDatasourceRequestMapper.resolve(request)).thenReturn(mockDatasource); + when(datasourceApiService.testDatasource(mockDatasource)).thenReturn(Mono.just(testResult)); + + // Act + Mono> result = controller.testDatasource(request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert !response.isSuccess(); + assert response.getCode() == 500; + }) + .verifyComplete(); + } + + @Test + void testGetStructure_Success() { + // Arrange + DatasourceStructure mockStructure = new DatasourceStructure(); + when(datasourceStructureService.getStructure(TEST_DATASOURCE_ID, false)).thenReturn(Mono.just(mockStructure)); + + // Act + Mono> result = controller.getStructure(TEST_DATASOURCE_ID, false); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockStructure); + }) + .verifyComplete(); + } + + @Test + void testGetStructure_WithIgnoreCache() { + // Arrange + DatasourceStructure mockStructure = new DatasourceStructure(); + when(datasourceStructureService.getStructure(TEST_DATASOURCE_ID, true)).thenReturn(Mono.just(mockStructure)); + + // Act + Mono> result = controller.getStructure(TEST_DATASOURCE_ID, true); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockStructure); + }) + .verifyComplete(); + } + + @Test + void testListJsDatasourcePlugins_Success() { + // Arrange + Flux mockDatasourceFlux = Flux.just(createMockDatasource()); + when(datasourceApiService.listJsDatasourcePlugins(anyString(), anyString(), anyString())) + .thenReturn(mockDatasourceFlux); + + // Act + Mono> result = controller.listJsDatasourcePlugins("appId", "name", "type", 1, 10); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + }) + .verifyComplete(); + } + + @Test + void testGetPluginDynamicConfig_Success() { + // Arrange + GetPluginDynamicConfigRequestDTO requestDTO = GetPluginDynamicConfigRequestDTO.builder() + .dataSourceId("test-id") + .pluginName("test-plugin") + .path("/test") + .dataSourceConfig(new HashMap<>()) + .build(); + List requestDTOs = List.of(requestDTO); + List mockConfigs = List.of(new HashMap<>()); + when(datasourceApiService.getPluginDynamicConfig(requestDTOs)).thenReturn(Mono.just(mockConfigs)); + + // Act + Mono>> result = controller.getPluginDynamicConfig(requestDTOs); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockConfigs); + }) + .verifyComplete(); + } + + @Test + void testListOrgDataSources_Success() { + // Arrange + Flux mockDatasourceViewFlux = Flux.just(Mockito.mock(DatasourceView.class)); + when(datasourceApiService.listOrgDataSources(anyString(), anyString(), anyString())) + .thenReturn(mockDatasourceViewFlux); + + // Act + Mono> result = controller.listOrgDataSources(TEST_ORGANIZATION_ID, "name", "type", 1, 10); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + }) + .verifyComplete(); + } + + @Test + void testListAppDataSources_Success() { + // Arrange + Flux mockDatasourceViewFlux = Flux.just(Mockito.mock(DatasourceView.class)); + when(datasourceApiService.listAppDataSources(anyString(), anyString(), anyString())) + .thenReturn(mockDatasourceViewFlux); + + // Act + Mono> result = controller.listAppDataSources("appId", "name", "type", 1, 10); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + }) + .verifyComplete(); + } + + @Test + void testGetPermissions_Success() { + // Arrange + CommonPermissionView mockPermissionView = CommonPermissionView.builder() + .orgName("Test Org") + .creatorId("user01") + .groupPermissions(new ArrayList<>()) + .userPermissions(new ArrayList<>()) + .build(); + when(datasourceApiService.getPermissions(TEST_DATASOURCE_ID)).thenReturn(Mono.just(mockPermissionView)); + + // Act + Mono> result = controller.getPermissions(TEST_DATASOURCE_ID); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockPermissionView); + }) + .verifyComplete(); + } + + @Test + void testGrantPermission_Success() { + // Arrange + DatasourceEndpoints.BatchAddPermissionRequest request = new DatasourceEndpoints.BatchAddPermissionRequest("owner", Set.of("user1"), Set.of("group1")); + when(datasourceApiService.grantPermission(eq(TEST_DATASOURCE_ID), any(), any(), any())).thenReturn(Mono.just(true)); + when(datasourceApiService.getPermissions(TEST_DATASOURCE_ID)).thenReturn(Mono.just(CommonPermissionView.builder() + .orgName("Test Org") + .creatorId("user01") + .groupPermissions(new ArrayList<>()) + .userPermissions(new ArrayList<>()) + .build())); + + // Act + Mono> result = controller.grantPermission(TEST_DATASOURCE_ID, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(true); + }) + .verifyComplete(); + } + + @Test + void testUpdatePermission_Success() { + // Arrange + DatasourceEndpoints.UpdatePermissionRequest request = new DatasourceEndpoints.UpdatePermissionRequest("viewer"); + ResourcePermission mockPermission = Mockito.mock(ResourcePermission.class); + when(resourcePermissionService.getById(TEST_PERMISSION_ID)).thenReturn(Mono.just(mockPermission)); + when(datasourceApiService.updatePermission(TEST_PERMISSION_ID, ResourceRole.VIEWER)).thenReturn(Mono.just(true)); + + // Act + Mono> result = controller.updatePermission(TEST_PERMISSION_ID, request); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(true); + }) + .verifyComplete(); + } + + @Test + void testDeletePermission_Success() { + // Arrange + when(datasourceApiService.deletePermission(TEST_PERMISSION_ID)).thenReturn(Mono.just(true)); + when(resourcePermissionService.getById(TEST_PERMISSION_ID)).thenReturn(Mono.just(Mockito.mock(ResourcePermission.class))); + + // Act + Mono> result = controller.deletePermission(TEST_PERMISSION_ID); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(true); + }) + .verifyComplete(); + } + + @Test + void testInfo_Success() { + // Arrange + Object mockInfo = new HashMap<>(); + when(datasourceApiService.info(TEST_DATASOURCE_ID)).thenReturn(mockInfo); + + // Act + Mono> result = controller.info(TEST_DATASOURCE_ID); + + // Assert + StepVerifier.create(result) + .assertNext(response -> { + assert response.isSuccess(); + assert response.getData().equals(mockInfo); + }) + .verifyComplete(); + } + + @Test + void testInfo_WithoutDatasourceId() { + // Arrange + Object mockInfo = new HashMap<>(); + when(datasourceApiService.info(null)).thenReturn(mockInfo); + when(gidService.convertDatasourceIdToObjectId(null)).thenReturn(Mono.empty()); + + // Act + Mono> result = controller.info(null); + + // Assert + StepVerifier.create(result) + .verifyComplete(); + } + + // Helper methods + private UpsertDatasourceRequest createTestUpsertDatasourceRequest() { + UpsertDatasourceRequest request = new UpsertDatasourceRequest(); + request.setId(TEST_DATASOURCE_ID); + request.setName("Test Datasource"); + request.setType("mysql"); + request.setOrganizationId(TEST_ORGANIZATION_ID); + request.setStatus(DatasourceStatus.NORMAL); + request.setDatasourceConfig(new HashMap<>()); + return request; + } + + private Datasource createMockDatasource() { + Datasource datasource = new Datasource(); + datasource.setId(TEST_DATASOURCE_ID); + datasource.setName("Test Datasource"); + datasource.setType("mysql"); + datasource.setOrganizationId(TEST_ORGANIZATION_ID); + // Note: Datasource doesn't have a setStatus method, it uses setDatasourceStatus + return datasource; + } +} \ No newline at end of file From 1b7d4b979d52799743f9ec99df2de0718bae6639 Mon Sep 17 00:00:00 2001 From: FalkWolsky Date: Thu, 24 Jul 2025 09:57:28 +0200 Subject: [PATCH 050/352] Updating Version Numbers to prepare the Release --- client/VERSION | 2 +- client/package.json | 2 +- client/packages/lowcoder-comps/package.json | 2 +- .../lowcoder-sdk-webpack-bundle/package.json | 2 +- client/packages/lowcoder-sdk/package.json | 2 +- client/packages/lowcoder/package.json | 2 +- deploy/helm/Chart.yaml | 2 +- .../src/main/resources/application.yaml | 2 +- server/api-service/pom.xml | 2 +- server/node-service/package.json | 2 +- server/node-service/yarn.lock | 24 +++++++++---------- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/client/VERSION b/client/VERSION index fbafd6b600..e2bdf6e45a 100644 --- a/client/VERSION +++ b/client/VERSION @@ -1 +1 @@ -2.7.2 \ No newline at end of file +2.7.3 \ No newline at end of file diff --git a/client/package.json b/client/package.json index 12f93a4aa7..28d9ac84eb 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-frontend", - "version": "2.7.2", + "version": "2.7.3", "type": "module", "private": true, "workspaces": [ diff --git a/client/packages/lowcoder-comps/package.json b/client/packages/lowcoder-comps/package.json index 613c8ee3db..c2a1a5b75b 100644 --- a/client/packages/lowcoder-comps/package.json +++ b/client/packages/lowcoder-comps/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-comps", - "version": "2.7.2", + "version": "2.7.3", "type": "module", "license": "MIT", "dependencies": { diff --git a/client/packages/lowcoder-sdk-webpack-bundle/package.json b/client/packages/lowcoder-sdk-webpack-bundle/package.json index 267fb8fb36..e0851a7e69 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/package.json +++ b/client/packages/lowcoder-sdk-webpack-bundle/package.json @@ -1,7 +1,7 @@ { "name": "lowcoder-sdk-webpack-bundle", "description": "", - "version": "2.7.2", + "version": "2.7.3", "main": "index.jsx", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/client/packages/lowcoder-sdk/package.json b/client/packages/lowcoder-sdk/package.json index 7cc5d5ea49..aeb3d8dd33 100644 --- a/client/packages/lowcoder-sdk/package.json +++ b/client/packages/lowcoder-sdk/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-sdk", - "version": "2.7.2", + "version": "2.7.3", "type": "module", "files": [ "src", diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 7137e23b6b..32e3a06a8d 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder", - "version": "2.7.2", + "version": "2.7.3", "private": true, "type": "module", "main": "src/index.sdk.ts", diff --git a/deploy/helm/Chart.yaml b/deploy/helm/Chart.yaml index 41fb54f3d8..c6165d3d0d 100644 --- a/deploy/helm/Chart.yaml +++ b/deploy/helm/Chart.yaml @@ -7,7 +7,7 @@ type: application version: 2.7.0 # Lowcoder version -appVersion: "2.7.2" +appVersion: "2.7.3" # Dependencies needed for Lowcoder deployment dependencies: diff --git a/server/api-service/lowcoder-server/src/main/resources/application.yaml b/server/api-service/lowcoder-server/src/main/resources/application.yaml index 2cb8c22f93..eaafdad855 100644 --- a/server/api-service/lowcoder-server/src/main/resources/application.yaml +++ b/server/api-service/lowcoder-server/src/main/resources/application.yaml @@ -64,7 +64,7 @@ common: domain: default-value: lowcoder.org cloud: false - version: 2.7.2 + version: 2.7.3 apiVersion: 1.2 block-hound-enable: false encrypt: diff --git a/server/api-service/pom.xml b/server/api-service/pom.xml index 972c236198..4f4aee7604 100644 --- a/server/api-service/pom.xml +++ b/server/api-service/pom.xml @@ -12,7 +12,7 @@ - 2.7.2 + 2.7.3 17 ${java.version} ${java.version} diff --git a/server/node-service/package.json b/server/node-service/package.json index db8736ac0b..12f424c207 100644 --- a/server/node-service/package.json +++ b/server/node-service/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-node-server", - "version": "2.7.2", + "version": "2.7.3", "private": true, "engines": { "node": "^14.18.0 || >=16.0.0" diff --git a/server/node-service/yarn.lock b/server/node-service/yarn.lock index 64cf824227..280fabf253 100644 --- a/server/node-service/yarn.lock +++ b/server/node-service/yarn.lock @@ -4902,9 +4902,9 @@ __metadata: linkType: hard "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": - version: 7.1.3 - resolution: "agent-base@npm:7.1.3" - checksum: 87bb7ee54f5ecf0ccbfcba0b07473885c43ecd76cb29a8db17d6137a19d9f9cd443a2a7c5fd8a3f24d58ad8145f9eb49116344a66b107e1aeab82cf2383f4753 + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 86a7f542af277cfbd77dd61e7df8422f90bac512953709003a1c530171a9d019d072e2400eab2b59f84b49ab9dd237be44315ca663ac73e82b3922d10ea5eafa languageName: node linkType: hard @@ -6530,14 +6530,14 @@ __metadata: linkType: hard "fdir@npm:^6.4.4": - version: 6.4.5 - resolution: "fdir@npm:6.4.5" + version: 6.4.6 + resolution: "fdir@npm:6.4.6" peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: picomatch: optional: true - checksum: 14efd2d6617a6f9fb314916ccff64e00bdb96216f26542ec9dfa532ed60a7ebb45463f7009aa215c14071566bf43caeb8ba268ccb52a11e6b51e4aaa8cb58d81 + checksum: fe9f3014901d023cf631831dcb9eae5447f4d7f69218001dd01ecf007eccc40f6c129a04411b5cc273a5f93c14e02e971e17270afc9022041c80be924091eb6f languageName: node linkType: hard @@ -9378,9 +9378,9 @@ __metadata: linkType: hard "picomatch@npm:^4.0.2": - version: 4.0.2 - resolution: "picomatch@npm:4.0.2" - checksum: a7a5188c954f82c6585720e9143297ccd0e35ad8072231608086ca950bee672d51b0ef676254af0788205e59bd4e4deb4e7708769226bed725bf13370a7d1464 + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 6817fb74eb745a71445debe1029768de55fd59a42b75606f478ee1d0dc1aa6e78b711d041a7c9d5550e042642029b7f373dc1a43b224c4b7f12d23436735dba0 languageName: node linkType: hard @@ -10122,12 +10122,12 @@ __metadata: linkType: hard "socks@npm:^2.8.3": - version: 2.8.4 - resolution: "socks@npm:2.8.4" + version: 2.8.6 + resolution: "socks@npm:2.8.6" dependencies: ip-address: ^9.0.5 smart-buffer: ^4.2.0 - checksum: cd1edc924475d5dfde534adf66038df7e62c7343e6b8c0113e52dc9bb6a0a10e25b2f136197f379d695f18e8f0f2b7f6e42977bf720ddbee912a851201c396ad + checksum: 3d2a696d42d94b05b2a7e797b9291483d6768b23300b015353f34f8046cce35f23fe59300a38a77a9f0dee4274dd6c333afbdef628cf48f3df171bfb86c2d21c languageName: node linkType: hard From 47b7d6bcfa7f848df4d0c982edfd91755bf3b567 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Thu, 24 Jul 2025 20:29:48 +0500 Subject: [PATCH 051/352] Styling update for username in user groups --- .../src/pages/setting/permission/styledComponents.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/pages/setting/permission/styledComponents.tsx b/client/packages/lowcoder/src/pages/setting/permission/styledComponents.tsx index 656e0eed55..b0921887a7 100644 --- a/client/packages/lowcoder/src/pages/setting/permission/styledComponents.tsx +++ b/client/packages/lowcoder/src/pages/setting/permission/styledComponents.tsx @@ -123,14 +123,20 @@ export const UserTableCellWrapper = styled.div` svg { margin-left: 8px; + flex-shrink: 0; } > span { - max-width: 120px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; word-break: keep-all; + flex-shrink: 1; + } + + /* Prevent profile image from shrinking */ + > div:first-child { + flex-shrink: 0; } `; From 145819a19a4032aefb235d1f62730f57f4eb86a1 Mon Sep 17 00:00:00 2001 From: Falk Wolsky Date: Thu, 24 Jul 2025 16:40:24 +0200 Subject: [PATCH 052/352] Update docker-images.yml --- .github/workflows/docker-images.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 439280b436..be06cf1a4b 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -72,10 +72,10 @@ jobs: fi; # Control which images to build - echo "BUILD_ALLINONE=${{ inputs.build_allinone || false }}" >> "${GITHUB_ENV}" - echo "BUILD_FRONTEND=${{ inputs.build_frontend || false }}" >> "${GITHUB_ENV}" - echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || false }}" >> "${GITHUB_ENV}" - echo "BUILD_APISERVICE=${{ inputs.build_apiservice || false }}" >> "${GITHUB_ENV}" + echo "BUILD_ALLINONE=${{ inputs.build_allinone || true }}" >> "${GITHUB_ENV}" + echo "BUILD_FRONTEND=${{ inputs.build_frontend || true }}" >> "${GITHUB_ENV}" + echo "BUILD_NODESERVICE=${{ inputs.build_nodeservice || true }}" >> "${GITHUB_ENV}" + echo "BUILD_APISERVICE=${{ inputs.build_apiservice || true }}" >> "${GITHUB_ENV}" # Image names ALLINONE_IMAGE_NAMES=lowcoderorg/lowcoder-ce:${IMAGE_TAG} From ee2c4f26773e4aeef7773f5161debe219443c5f6 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Thu, 24 Jul 2025 23:36:23 +0500 Subject: [PATCH 053/352] Updated editable styling for Text comp --- .../comps/tableComp/tableSummaryComp.tsx | 22 ++++++++++++++++++- .../lowcoder/src/comps/comps/textComp.tsx | 7 ++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableSummaryComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableSummaryComp.tsx index 56e4584c26..e026d5aef4 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableSummaryComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableSummaryComp.tsx @@ -7,6 +7,24 @@ import Table from "antd/es/table"; import { ReactNode, useMemo, memo, useCallback } from "react"; import Tooltip from "antd/es/tooltip"; +const CellContainer = styled.div<{ + $textAlign?: 'left' | 'center' | 'right'; +}>` + display: flex; + justify-content: ${(props) => { + switch (props.$textAlign) { + case 'left': + return 'flex-start'; + case 'center': + return 'center'; + case 'right': + return 'flex-end'; + default: + return 'flex-start'; + } + }}; +`; + const TableSummaryRow = styled(Table.Summary.Row)<{ $istoolbarPositionBelow: boolean; $background: string; @@ -170,7 +188,9 @@ const TableSummaryCellView = memo(function TableSummaryCellView(props: { $autoHeight={autoHeight} > -
{children}
+ + {children} +
); diff --git a/client/packages/lowcoder/src/comps/comps/textComp.tsx b/client/packages/lowcoder/src/comps/comps/textComp.tsx index dcc5ccdb2b..708a4f4aa3 100644 --- a/client/packages/lowcoder/src/comps/comps/textComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/textComp.tsx @@ -37,6 +37,9 @@ const getStyle = (style: TextStyleType) => { text-transform:${style.textTransform} !important; text-decoration:${style.textDecoration} !important; background: ${style.background}; + line-height: ${style.lineHeight}; + margin: ${style.margin} !important; + padding: ${style.padding}; .markdown-body a { color: ${style.links}; } @@ -68,7 +71,7 @@ const getStyle = (style: TextStyleType) => { h6 { color: ${style.text}; font-weight: ${style.textWeight} !important; - line-height:${style.lineHeight}; + line-height: ${style.lineHeight} !important; } img, pre { @@ -101,9 +104,9 @@ const TextContainer = React.memo(styled.div<{ margin: ${props.$styleConfig.margin}; padding: ${props.$styleConfig.padding}; `}; - ${(props) => props.$styleConfig && getStyle(props.$styleConfig)} display: flex; ${markdownCompCss}; + ${(props) => props.$styleConfig && getStyle(props.$styleConfig)} overflow-wrap: anywhere; .markdown-body { overflow-wrap: anywhere; From e0fa0b7914eb85ae5ff9e400c6da71b255ff79c8 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Fri, 25 Jul 2025 00:06:36 +0500 Subject: [PATCH 054/352] Consisten styles for button when disabled --- .../comps/buttonComp/buttonCompConstants.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx index d838efbcd1..a7c8e48dcd 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonCompConstants.tsx @@ -11,27 +11,28 @@ export function getButtonStyle(buttonStyle: ButtonStyleType, disabledStyle: Disa const hoverColor = buttonStyle.background && genHoverColor(buttonStyle.background); const activeColor = buttonStyle.background && genActiveColor(buttonStyle.background); return css` - & { + &&& { border-radius: ${buttonStyle.radius}; border-width:${buttonStyle.borderWidth}; margin: ${buttonStyle.margin}; padding: ${buttonStyle.padding}; rotate: ${buttonStyle.rotation}; + --antd-wave-shadow-color: ${buttonStyle.border}; + border-color: ${buttonStyle.border}; + color: ${buttonStyle.text}; + font-size: ${buttonStyle.textSize}; + font-weight: ${buttonStyle.textWeight}; + font-family: ${buttonStyle.fontFamily}; + font-style: ${buttonStyle.fontStyle}; + text-transform:${buttonStyle.textTransform}; + text-decoration:${buttonStyle.textDecoration}; + border-radius: ${buttonStyle.radius}; + margin: ${buttonStyle.margin}; + padding: ${buttonStyle.padding}; + &:not(:disabled) { - --antd-wave-shadow-color: ${buttonStyle.border}; - border-color: ${buttonStyle.border}; - color: ${buttonStyle.text}; - font-size: ${buttonStyle.textSize}; - font-weight: ${buttonStyle.textWeight}; - font-family: ${buttonStyle.fontFamily}; - font-style: ${buttonStyle.fontStyle}; - text-transform:${buttonStyle.textTransform}; - text-decoration:${buttonStyle.textDecoration}; background: ${buttonStyle.background}; - border-radius: ${buttonStyle.radius}; - margin: ${buttonStyle.margin}; - padding: ${buttonStyle.padding}; - + &:hover, &:focus { color: ${buttonStyle.text}; @@ -48,14 +49,13 @@ export function getButtonStyle(buttonStyle: ButtonStyleType, disabledStyle: Disa : buttonStyle.border} !important; } } - - /* Disabled state styling */ &:disabled, - &.ant-btn-disabled { - color: ${disabledStyle.disabledText}; - background: ${disabledStyle.disabledBackground}; - border-color: ${disabledStyle.disabledBorder}; - cursor: not-allowed; + &.ant-btn-disabled, + &[disabled] { + background: ${disabledStyle.disabledBackground} !important; + cursor: not-allowed !important; + color: ${disabledStyle.disabledText || buttonStyle.text} !important; + border-color: ${disabledStyle.disabledBorder || buttonStyle.border} !important; } } `; From 4d0d20348dfe094c61c2c8267397945003c40728 Mon Sep 17 00:00:00 2001 From: FARAN Date: Fri, 25 Jul 2025 18:16:50 +0500 Subject: [PATCH 055/352] [Fix]: #1900 workspace create / all orgs --- .../pages/setting/organization/orgList.tsx | 41 ++++++++++++------- .../lowcoder/src/redux/sagas/orgSagas.ts | 9 ++-- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx index 0e9c8a01c0..78b48b185f 100644 --- a/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx +++ b/client/packages/lowcoder/src/pages/setting/organization/orgList.tsx @@ -173,6 +173,9 @@ type DataItemInfo = { logoUrl: string; createdAt?: number; updatedAt?: number; + isCurrentOrg?: boolean; + isAdmin: boolean; + userRole: string; }; function OrganizationSetting() { @@ -198,21 +201,29 @@ function OrganizationSetting() { - // Filter to only show orgs where user has admin permissions - const adminOrgs = displayWorkspaces.filter((org: Org) => { + // Show all organizations with role information + const allOrgs = displayWorkspaces; + const adminOrgCount = displayWorkspaces.filter((org: Org) => { const role = user.orgRoleMap.get(org.id); return role === ADMIN_ROLE || role === SUPER_ADMIN_ROLE; - }); + }).length; - const dataSource = adminOrgs.map((org: Org) => ({ - id: org.id, - del: adminOrgs.length > 1, - orgName: org.name, - logoUrl: org.logoUrl || "", - createdAt: org.createdAt, - updatedAt: org.updatedAt, - isCurrentOrg: org.isCurrentOrg, - })); + const dataSource = allOrgs.map((org: Org) => { + const userRole = user.orgRoleMap.get(org.id); + const isAdmin = userRole === ADMIN_ROLE || userRole === SUPER_ADMIN_ROLE; + + return { + id: org.id, + del: isAdmin && adminOrgCount > 1, + orgName: org.name, + logoUrl: org.logoUrl || "", + createdAt: org.createdAt, + updatedAt: org.updatedAt, + isCurrentOrg: org.isCurrentOrg, + isAdmin, + userRole, + }; + }); @@ -321,13 +332,15 @@ function OrganizationSetting() { {trans("profile.switchWorkspace")} )} + {item.isAdmin && ( history.push(buildOrgId(item.id))} > - {trans("edit")} - + {trans("edit")} + + )} {item.del && ( { diff --git a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts index e4157abde7..9ac80186f2 100644 --- a/client/packages/lowcoder/src/redux/sagas/orgSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/orgSagas.ts @@ -266,9 +266,12 @@ export function* createOrgSaga(action: ReduxAction<{ orgName: string }>) { if (isValidResponse) { // update org list yield call(getUserSaga); - yield put({ - type: ReduxActionTypes.CREATE_ORG_SUCCESS, - }); + // Refetch workspaces to update the profile dropdown + yield put(fetchWorkspacesAction(1, 10)); + yield put({ + type: ReduxActionTypes.CREATE_ORG_SUCCESS, + }); + } } catch (error: any) { yield put({ From 48b26197de48198e5d9b7c5cfd0c94df3b46cb3e Mon Sep 17 00:00:00 2001 From: FARAN Date: Fri, 25 Jul 2025 22:06:32 +0500 Subject: [PATCH 056/352] remove min 3 chars search logic from groupUsers --- .../src/pages/setting/permission/addGroupUserDialog.tsx | 2 +- .../src/pages/setting/permission/groupUsersPermission.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/pages/setting/permission/addGroupUserDialog.tsx b/client/packages/lowcoder/src/pages/setting/permission/addGroupUserDialog.tsx index 46cad16b67..0aa12370c4 100644 --- a/client/packages/lowcoder/src/pages/setting/permission/addGroupUserDialog.tsx +++ b/client/packages/lowcoder/src/pages/setting/permission/addGroupUserDialog.tsx @@ -55,7 +55,7 @@ function AddGroupUserDialog(props: { ); useEffect(() => { - if (searchValue.length > 2 || searchValue === "") { + if (searchValue.length > 0 || searchValue === "") { debouncedFetchPotentialMembers(searchValue); } return () => { diff --git a/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx b/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx index c2d260fe49..b3aa8278ff 100644 --- a/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx +++ b/client/packages/lowcoder/src/pages/setting/permission/groupUsersPermission.tsx @@ -127,7 +127,7 @@ const GroupUsersPermission: React.FC = (props) => { ); useEffect(() => { - if (searchValue.length > 2 || searchValue === "" || roleFilter) { + if (searchValue.length > 0 || searchValue === "" || roleFilter) { debouncedFetchPotentialMembers(searchValue, roleFilter); } return () => { From f549801da7eedd03b948e29275d23f87407d7937 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 28 Jul 2025 14:08:33 +0500 Subject: [PATCH 057/352] [Fix]: #1826 refresh folder on press enter --- .../lowcoder/src/pages/ApplicationV2/useCreateFolder.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/useCreateFolder.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/useCreateFolder.tsx index 04c50f22c7..49f827dfbb 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/useCreateFolder.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/useCreateFolder.tsx @@ -50,7 +50,12 @@ export function useCreateFolder(setModify: any, modify: boolean) { onPressEnter={() => { form.validateFields().then(() => { dispatchCreateFolder( - () => modal?.destroy(), + () => { + modal?.destroy(); + setTimeout(() => { + setModify(!modify); + }, 200); + }, () => {} ); }); From fa20d2e59c93a25596fe8687d7bd082dcfb56383 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 28 Jul 2025 19:08:58 +0500 Subject: [PATCH 058/352] [Fix]: #1862 able to edit the usergroups without navigate --- .../src/pages/setting/permission/permissionList.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/pages/setting/permission/permissionList.tsx b/client/packages/lowcoder/src/pages/setting/permission/permissionList.tsx index c2a5f3778b..add0cdbca7 100644 --- a/client/packages/lowcoder/src/pages/setting/permission/permissionList.tsx +++ b/client/packages/lowcoder/src/pages/setting/permission/permissionList.tsx @@ -187,7 +187,14 @@ export default function PermissionSetting(props: PermissionSettingProps) { scroll={{ x: "100%" }} pagination={false} onRow={(record) => ({ - onClick: () => history.push(buildGroupId((record as DataItemInfo).key)), + onClick: (e) => { + // Don't navigate if this row is in rename mode + if ((record as DataItemInfo).key === needRenameId) { + e.stopPropagation(); + return; + } + history.push(buildGroupId((record as DataItemInfo).key)); + }, })} columns={[ { From 27b31d5c799a89d73007a7db00ca4f40938d72d1 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 28 Jul 2025 20:35:57 +0500 Subject: [PATCH 059/352] [Feat]: #1820 add search filter/sorting for columns gui query --- .../queries/sqlQuery/columnNameDropdown.tsx | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx b/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx index e33be20bde..d79c00a781 100644 --- a/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx +++ b/client/packages/lowcoder/src/comps/queries/sqlQuery/columnNameDropdown.tsx @@ -1,6 +1,6 @@ import { DispatchType } from "lowcoder-core"; import { ControlPlacement } from "../../controls/controlParams"; -import React, { useContext } from "react"; +import React, { useContext, useState, useEffect } from "react"; import { Dropdown, OptionsType } from "lowcoder-design"; import { isEmpty, values } from "lodash"; import { useSelector } from "react-redux"; @@ -8,6 +8,8 @@ import { getDataSourceStructures } from "../../../redux/selectors/datasourceSele import { changeValueAction } from "lowcoder-core"; import { QueryContext } from "../../../util/context/QueryContext"; +const COLUMN_SORT_KEY = "lowcoder_column_sort"; + export const ColumnNameDropdown = (props: { table: string; value: string; @@ -18,13 +20,27 @@ export const ColumnNameDropdown = (props: { }) => { const context = useContext(QueryContext); const datasourceId = context?.datasourceId ?? ""; - const columns: OptionsType = - values(useSelector(getDataSourceStructures)[datasourceId]) - ?.find((t) => t.name === props.table) - ?.columns.map((column) => ({ - label: column.name, - value: column.name, - })) ?? []; + + // Simple sort preference from localStorage + const [sortColumns, setSortColumns] = useState(() => { + return localStorage.getItem(COLUMN_SORT_KEY) === 'true'; + }); + + useEffect(() => { + localStorage.setItem(COLUMN_SORT_KEY, sortColumns.toString()); + }, [sortColumns]); + + const rawColumns = values(useSelector(getDataSourceStructures)[datasourceId]) + ?.find((t) => t.name === props.table) + ?.columns.map((column) => ({ + label: column.name, + value: column.name, + })) ?? []; + + const columns: OptionsType = sortColumns + ? [...rawColumns].sort((a, b) => a.label.localeCompare(b.label)) + : rawColumns; + return ( ( +
+ +
+ )} /> ); }; From f3d08e99f08a97402a931f5a178deac1249be3ad Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Mon, 28 Jul 2025 20:44:35 +0500 Subject: [PATCH 060/352] [Feat]: #1883 add tab-index on buttons/checkbox/select --- .../src/comps/comps/buttonComp/buttonComp.tsx | 12 ++++++++--- .../src/comps/comps/buttonComp/linkComp.tsx | 5 ++++- .../comps/buttonComp/toggleButtonComp.tsx | 5 ++++- .../comps/comps/meetingComp/controlButton.tsx | 7 +++++-- .../comps/selectInputComp/checkboxComp.tsx | 20 ++++++++++++++++++- .../selectInputComp/selectCompConstants.tsx | 5 ++++- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx index a6d6f88890..bd019ab66c 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/buttonComp.tsx @@ -1,4 +1,4 @@ -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import { ButtonEventHandlerControl } from "comps/controls/eventHandlerControl"; import { IconControl } from "comps/controls/iconControl"; @@ -137,7 +137,8 @@ const childrenMap = { disabledStyle: DisabledButtonStyleControl, animationStyle: styleControl(AnimationStyle, 'animationStyle'), viewRef: RefControl, - tooltip: StringControl + tooltip: StringControl, + tabIndex: NumberControl }; type ChildrenType = NewChildren>; @@ -162,8 +163,12 @@ const ButtonPropertyView = React.memo((props: { disabledPropertyView(props.children), hiddenPropertyView(props.children), loadingPropertyView(props.children), + props.children.tabIndex.propertyView({ label: trans("prop.tabIndex") }), ] - : props.children.form.getPropertyView()} + : [ + props.children.form.getPropertyView(), + props.children.tabIndex.propertyView({ label: trans("prop.tabIndex") }), + ]} )} @@ -222,6 +227,7 @@ const ButtonView = React.memo((props: ToViewReturn) => { (!isDefault(props.type) && getForm(editorState, props.form)?.disableSubmit()) } onClick={handleClick} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {props.prefixIcon && {props.prefixIcon}} { diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx index 43cb5959a3..b96fe77ea2 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/linkComp.tsx @@ -1,6 +1,6 @@ import { default as Button } from "antd/es/button"; import { ButtonCompWrapper, buttonRefMethods } from "comps/comps/buttonComp/buttonCompConstants"; -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { ButtonEventHandlerControl } from "comps/controls/eventHandlerControl"; import { styleControl } from "comps/controls/styleControl"; import { AnimationStyle, AnimationStyleType, LinkStyle, LinkStyleType } from "comps/controls/styleControlConstants"; @@ -91,6 +91,7 @@ const LinkTmpComp = (function () { prefixIcon: IconControl, suffixIcon: IconControl, viewRef: RefControl, + tabIndex: NumberControl, }; return new UICompBuilder(childrenMap, (props) => { // chrome86 bug: button children should not contain only empty span @@ -105,6 +106,7 @@ const LinkTmpComp = (function () { disabled={props.disabled} onClick={() => props.onEvent("click")} type={"link"} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {hasChildren && ( @@ -131,6 +133,7 @@ const LinkTmpComp = (function () { {hiddenPropertyView(children)} {loadingPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.prefixIcon.propertyView({ label: trans("button.prefixIcon") })} diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx index 654ec6659d..ce82c9ff51 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/toggleButtonComp.tsx @@ -1,4 +1,4 @@ -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { withDefault } from "comps/generators"; import { UICompBuilder } from "comps/generators/uiCompBuilder"; import { @@ -68,6 +68,7 @@ const ToggleTmpComp = (function () { showBorder: withDefault(BoolControl, true), viewRef: RefControl, tooltip: StringControl, + tabIndex: NumberControl, }; return new UICompBuilder(childrenMap, (props) => { const text = props.showText @@ -92,6 +93,7 @@ const ToggleTmpComp = (function () { props.onEvent("change"); props.value.onChange(!props.value.value); }} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {props.iconPosition === "right" && text} {{props.value.value ? props.trueIcon : props.falseIcon}} @@ -117,6 +119,7 @@ const ToggleTmpComp = (function () { {hiddenPropertyView(children)} {loadingPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.showText.propertyView({ label: trans("toggleButton.showText") })} diff --git a/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx b/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx index 543644b552..c5e7709c97 100644 --- a/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx +++ b/client/packages/lowcoder/src/comps/comps/meetingComp/controlButton.tsx @@ -1,4 +1,4 @@ -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { dropdownControl } from "comps/controls/dropdownControl"; import { ButtonEventHandlerControl } from "comps/controls/eventHandlerControl"; import { IconControl } from "comps/controls/iconControl"; @@ -204,7 +204,8 @@ const childrenMap = { style: ButtonStyleControl, viewRef: RefControl, restrictPaddingOnRotation:withDefault(StringControl, 'controlButton'), - tooltip: StringControl + tooltip: StringControl, + tabIndex: NumberControl }; let ButtonTmpComp = (function () { @@ -294,6 +295,7 @@ let ButtonTmpComp = (function () { ? handleClickEvent() : submitForm(editorState, props.form) } + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} > {props.sourceMode === 'standard' && props.prefixIcon && ( )} diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx index 9a7abb8bd1..6f3cfec54b 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/checkboxComp.tsx @@ -214,8 +214,20 @@ let CheckboxBasicComp = (function () { return new UICompBuilder(childrenMap, (props) => { const mountedRef = useRef(true); + const checkboxRef = useRef(null); const [validateState, handleChange] = useSelectInputValidate(props); + useEffect(() => { + if (!mountedRef.current) return; + if (checkboxRef.current && typeof props.tabIndex === 'number') { + const checkboxInputs = checkboxRef.current.querySelectorAll('input[type="checkbox"]'); + checkboxInputs.forEach((input, index) => { + // Set sequential tabindex for each checkbox + input.setAttribute('tabindex', (props.tabIndex + index).toString()); + }); + } + }, [props.tabIndex, props.options]); + useEffect(() => { return () => { mountedRef.current = false; @@ -251,7 +263,13 @@ let CheckboxBasicComp = (function () { layout={props.layout} options={filteredOptions()} onChange={handleValidateChange} - viewRef={props.viewRef} + viewRef={(el) => { + if (!mountedRef.current) return; + if (el) { + props.viewRef(el); + checkboxRef.current = el; + } + }} tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} /> ), diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx index a5d55ca935..a75b9f05ce 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/selectCompConstants.tsx @@ -6,7 +6,7 @@ import { } from "lowcoder-core"; import { BoolControl } from "../../controls/boolControl"; import { LabelControl } from "../../controls/labelControl"; -import { BoolCodeControl, StringControl } from "../../controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "../../controls/codeControl"; import { PaddingControl } from "../../controls/paddingControl"; import { MarginControl } from "../../controls/marginControl"; import { @@ -242,6 +242,7 @@ export const SelectChildrenMap = { margin: MarginControl, padding: PaddingControl, inputFieldStyle:styleControl(SelectStyle), + tabIndex: NumberControl, ...SelectInputValidationChildren, ...formDataChildren, }; @@ -269,6 +270,7 @@ export const SelectUIView = ( placeholder={props.placeholder} value={props.value} showSearch={props.showSearch} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} filterOption={(input, option) => { if (!option) return false; return String(option.label ?? option.value ?? "").toLowerCase().includes(input.toLowerCase()); @@ -348,6 +350,7 @@ export const SelectPropertyView = ( {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children as any)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
)} From 62e83b80b5d2c46b2b9b40b8bda27b07440d1634 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Tue, 29 Jul 2025 15:51:36 +0500 Subject: [PATCH 061/352] fix remote components not working properly in list/grid comps --- .../lowcoder/src/comps/comps/remoteComp/remoteComp.tsx | 2 +- .../lowcoder/src/comps/generators/withMultiContext.tsx | 2 ++ .../src/comps/generators/withSelectedMultiContext.tsx | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx b/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx index 8399535d7e..173c8c8613 100644 --- a/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/remoteComp/remoteComp.tsx @@ -45,7 +45,7 @@ function ViewLoading(props: { padding?: number }) { ); } -interface RemoteCompReadyAction { +export interface RemoteCompReadyAction { type: "RemoteCompReady"; comp: Comp; } diff --git a/client/packages/lowcoder/src/comps/generators/withMultiContext.tsx b/client/packages/lowcoder/src/comps/generators/withMultiContext.tsx index ec843eb91f..e934f009ab 100644 --- a/client/packages/lowcoder/src/comps/generators/withMultiContext.tsx +++ b/client/packages/lowcoder/src/comps/generators/withMultiContext.tsx @@ -26,6 +26,7 @@ import { setFieldsNoTypeCheck } from "util/objectUtils"; import { map } from "./map"; import { paramsEqual, withParamsWithDefault } from "./withParams"; import { LazyCompReadyAction } from "../comps/lazyLoadComp/lazyLoadComp"; +import { RemoteCompReadyAction } from "../comps/remoteComp/remoteComp"; export const COMP_KEY = "__comp__"; export const MAP_KEY = "__map__"; @@ -171,6 +172,7 @@ export function withMultiContext(VariantComp && ( !thisCompMap.hasOwnProperty(action.path[1]) || isCustomAction(action, "LazyCompReady") + || isCustomAction(action, "RemoteCompReady") ) ) { /** diff --git a/client/packages/lowcoder/src/comps/generators/withSelectedMultiContext.tsx b/client/packages/lowcoder/src/comps/generators/withSelectedMultiContext.tsx index 8ba85913b7..d34f0ad76f 100644 --- a/client/packages/lowcoder/src/comps/generators/withSelectedMultiContext.tsx +++ b/client/packages/lowcoder/src/comps/generators/withSelectedMultiContext.tsx @@ -14,6 +14,7 @@ import { COMP_KEY, MAP_KEY, withMultiContext } from "./withMultiContext"; import { paramsEqual } from "./withParams"; import { LazyCompReadyAction } from "../comps/lazyLoadComp/lazyLoadComp"; import { ModuleReadyAction } from "../comps/moduleComp/moduleComp"; +import { RemoteCompReadyAction } from "../comps/remoteComp/remoteComp"; const SELECTED_KEY = "SELECTED"; @@ -75,6 +76,7 @@ export function withSelectedMultiContext( } else if (( !action.editDSL && !isCustomAction(action, "LazyCompReady") + && !isCustomAction(action, "RemoteCompReady") && !isCustomAction(action, "moduleReady") ) || action.path[0] !== MAP_KEY || _.isNil(action.path[1]) ) { @@ -85,6 +87,7 @@ export function withSelectedMultiContext( } else if (( action.editDSL || isCustomAction(action, "LazyCompReady") + || isCustomAction(action, "RemoteCompReady") || isCustomAction(action, "moduleReady") ) && ( action.path[1] === SELECTED_KEY @@ -104,6 +107,7 @@ export function withSelectedMultiContext( && ( isCustomAction(action, "moduleReady") || isCustomAction(action, "LazyCompReady") + || isCustomAction(action, "RemoteCompReady") ) && action.path[0] === MAP_KEY ) { comp = super.reduce(action); From 8fdbf5a8afe69db2678d8ea6ca7fc2f791d6fb8c Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 29 Jul 2025 20:00:05 +0500 Subject: [PATCH 062/352] [Fix]: #1904 add permissions data sources --- .../DatasourcePermissionDialog.tsx | 4 ++ .../PermissionDialog/Permission.tsx | 66 +++++++++++-------- .../PermissionDialog/PermissionDialog.tsx | 6 +- .../lowcoder/src/util/pagination/axios.ts | 54 +++++++++++++++ 4 files changed, 101 insertions(+), 29 deletions(-) diff --git a/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx b/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx index 9bad678a13..41273467a7 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/DatasourcePermissionDialog.tsx @@ -13,6 +13,7 @@ import { DatasourceRole } from "../../api/datasourcePermissionApi"; import { getDataSourcePermissionInfo } from "../../redux/selectors/datasourceSelectors"; import { StyledLoading } from "./commonComponents"; import { PermissionRole } from "./Permission"; +import { getUser } from "../../redux/selectors/usersSelectors"; export const DatasourcePermissionDialog = (props: { datasourceId: string; @@ -22,6 +23,7 @@ export const DatasourcePermissionDialog = (props: { const { datasourceId } = props; const dispatch = useDispatch(); const permissionInfo = useSelector(getDataSourcePermissionInfo)[datasourceId]; + const user = useSelector(getUser); useEffect(() => { dispatch(fetchDatasourcePermissions({ datasourceId: datasourceId })); @@ -75,6 +77,8 @@ export const DatasourcePermissionDialog = (props: { { label: trans("share.datasourceOwner"), value: PermissionRole.Owner }, ]} permissionItems={permissions} + contextType="organization" + organizationId={user.currentOrgId} viewBodyRender={(list) => { if (!permissionInfo) { return ; diff --git a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx index adb9e9ffb3..f6fffc37d3 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/Permission.tsx @@ -27,7 +27,7 @@ import { EmptyContent } from "pages/common/styledComponent"; import { trans } from "i18n"; import { PermissionItem } from "./PermissionList"; import { currentApplication } from "@lowcoder-ee/redux/selectors/applicationSelector"; -import { fetchAvailableGroupsMembers } from "@lowcoder-ee/util/pagination/axios"; +import { fetchAvailableGroupsMembers, fetchAvailableOrgGroupsMembers } from "@lowcoder-ee/util/pagination/axios"; const AddAppUserContent = styled.div` display: flex; @@ -186,6 +186,13 @@ const AddRoleSelect = styled(StyledRoleSelect)<{ $isVisible: boolean }>` display: ${(props) => (props.$isVisible ? "unset" : "none")}; `; +type PermissionContextType = "application" | "organization"; + +type PermissionContextProps = { + contextType: PermissionContextType; + organizationId?: string; +}; + type AddAppOptionView = { type: ApplicationPermissionType; id: string; @@ -294,8 +301,10 @@ const PermissionSelector = (props: { user: User; filterItems: PermissionItem[]; supportRoles: { label: string; value: PermissionRole }[]; + contextType: PermissionContextType; + organizationId?: string; }) => { - const { selectedItems, setSelectRole, setSelectedItems, user } = props; + const { selectedItems, setSelectRole, setSelectedItems, user, contextType, organizationId } = props; const [roleSelectVisible, setRoleSelectVisible] = useState(false); const selectRef = useRef(null); const [optionViews, setOptionViews] = useState() @@ -305,42 +314,41 @@ const PermissionSelector = (props: { const debouncedUserSearch = useCallback( debounce((searchTerm: string) => { - if (!application) return; - setIsLoading(true); - fetchAvailableGroupsMembers(application.applicationId, searchTerm).then(res => { - if(res.success) { - setOptionViews(getPermissionOptionView(res.data, props.filterItems)) - } - setIsLoading(false); - }).catch(() => { + + if (contextType === "application" && application) { + fetchAvailableGroupsMembers(application.applicationId, searchTerm).then(res => { + if(res.success) { + setOptionViews(getPermissionOptionView(res.data, props.filterItems)) + } + setIsLoading(false); + }).catch(() => { + setIsLoading(false); + }); + } else if (contextType === "organization" && organizationId) { + fetchAvailableOrgGroupsMembers(organizationId, searchTerm).then(res => { + if(res.success) { + setOptionViews(getPermissionOptionView(res.data || [], props.filterItems)) + } + setIsLoading(false); + }).catch(() => { + setIsLoading(false); + }); + } else { setIsLoading(false); - }); + } }, 500), - [application, props.filterItems] + [application, props.filterItems, contextType, organizationId] ); useEffect(() => { debouncedUserSearch(searchValue); - return () => { debouncedUserSearch.cancel(); }; }, [searchValue, debouncedUserSearch]); - useEffect(() => { - if (!application) return; - - setIsLoading(true); - fetchAvailableGroupsMembers(application.applicationId, "").then(res => { - if(res.success) { - setOptionViews(getPermissionOptionView(res.data, props.filterItems)) - } - setIsLoading(false); - }).catch(() => { - setIsLoading(false); - }); - }, [application, props.filterItems]); + useEffect(() => { setRoleSelectVisible(selectedItems.length > 0); @@ -425,8 +433,8 @@ export const Permission = (props: { supportRoles: { label: string; value: PermissionRole }[]; onCancel: () => void; addPermission: (userIds: string[], groupIds: string[], role: string) => void; -}) => { - const { onCancel } = props; +} & PermissionContextProps) => { + const { onCancel, contextType = "application", organizationId } = props; const user = useSelector(getUser); const [selectRole, setSelectRole] = useState("viewer"); const [selectedItems, setSelectedItems] = useState([]); @@ -443,6 +451,8 @@ export const Permission = (props: { user={user} filterItems={props.filterItems} supportRoles={props.supportRoles} + contextType={contextType} + organizationId={organizationId} /> diff --git a/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx b/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx index b3645f7887..42fb2a070a 100644 --- a/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx +++ b/client/packages/lowcoder/src/components/PermissionDialog/PermissionDialog.tsx @@ -57,8 +57,10 @@ export const PermissionDialog = (props: { ) => void; updatePermission: (permissionId: string, role: string) => void; deletePermission: (permissionId: string) => void; + contextType?: "application" | "organization"; + organizationId?: string; }) => { - const { supportRoles, permissionItems, visible, onVisibleChange, addPermission, viewBodyRender } = + const { supportRoles, permissionItems, visible, onVisibleChange, addPermission, viewBodyRender, contextType, organizationId } = props; const [activeStepKey, setActiveStepKey] = useState("view"); @@ -117,6 +119,8 @@ export const PermissionDialog = (props: { addPermission={(userIds, groupIds, role) => addPermission(userIds, groupIds, role, props.back) } + contextType={contextType || "application"} + organizationId={organizationId} /> ), footerRender: (props) => null, diff --git a/client/packages/lowcoder/src/util/pagination/axios.ts b/client/packages/lowcoder/src/util/pagination/axios.ts index e59ccbfec1..13a81157ed 100644 --- a/client/packages/lowcoder/src/util/pagination/axios.ts +++ b/client/packages/lowcoder/src/util/pagination/axios.ts @@ -118,6 +118,60 @@ export const fetchAvailableGroupsMembers = async (applicationId: string, search: } } +export const fetchAvailableOrgGroupsMembers = async (orgId: string, search: string = "") => { + try { + + + // Fetch org members and groups in parallel + const [orgMembersResponse, groupsResponse] = await Promise.all([ + OrgApi.fetchOrgUsers(orgId), + OrgApi.fetchGroup() + ]); + + const orgMembers = orgMembersResponse.data.data.members || []; + const groups = groupsResponse.data.data || []; + + // Transform to the same format as application groups/members + const transformedData = [ + // Add groups + ...groups.map((group: any) => ({ + type: "Group", + data: { + groupId: group.groupId, + groupName: group.groupName, + } + })), + // Add users + ...orgMembers.map((member: any) => ({ + type: "User", + data: { + userId: member.userId, + name: member.name, + avatarUrl: member.avatarUrl, + } + })) + ]; + + // Filter by search term if provided + const filteredData = search ? + transformedData.filter((item: any) => { + const name = item.type === "Group" ? item.data.groupName : item.data.name; + return name.toLowerCase().includes(search.toLowerCase()); + }) : transformedData; + + return { + success: true, + data: filteredData + }; + } catch (error: any) { + console.error('Failed to fetch org data: ', error); + return { + success: false, + error: error + }; + } +} + export const fetchOrgUsrPagination = async (request: fetchOrgUserRequestType)=> { try { const response = await OrgApi.fetchOrgUsersPagination(request); From 4ca237d27a9cd3ae894eb89c2f2489729e42dfac Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 29 Jul 2025 22:12:38 +0500 Subject: [PATCH 063/352] [Feat]: #1903 add isEdit mode util --- client/packages/lowcoder/src/comps/hooks/utilsComp.ts | 11 +++++++++++ .../lowcoder/src/comps/utils/globalSettings.ts | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/client/packages/lowcoder/src/comps/hooks/utilsComp.ts b/client/packages/lowcoder/src/comps/hooks/utilsComp.ts index f91bae1948..2a8689efcb 100644 --- a/client/packages/lowcoder/src/comps/hooks/utilsComp.ts +++ b/client/packages/lowcoder/src/comps/hooks/utilsComp.ts @@ -10,6 +10,7 @@ import { logoutAction } from "redux/reduxActions/userActions"; import StoreRegistry from "@lowcoder-ee/redux/store/storeRegistry"; import UserApi from "@lowcoder-ee/api/userApi"; import { messageInstance } from "components/GlobalInstances"; +import { isEditMode } from "../utils/globalSettings"; const UtilsCompBase = simpleMultiComp({}); export let UtilsComp = withExposingConfigs(UtilsCompBase, []); @@ -30,6 +31,16 @@ interface DownloadFileOptions { } UtilsComp = withMethodExposing(UtilsComp, [ + { + method: { + name: "isEditMode", + description: trans("utilsComp.isEditMode"), + params: [], + }, + execute: (comp, params) => { + return isEditMode(); + }, + }, { method: { name: "openUrl", diff --git a/client/packages/lowcoder/src/comps/utils/globalSettings.ts b/client/packages/lowcoder/src/comps/utils/globalSettings.ts index 636f4bfc9a..173511f3f6 100644 --- a/client/packages/lowcoder/src/comps/utils/globalSettings.ts +++ b/client/packages/lowcoder/src/comps/utils/globalSettings.ts @@ -19,3 +19,9 @@ export function setGlobalSettings(patch: GlobalSettings) { export function getGlobalSettings() { return globalSettings; } + + +export function isEditMode(): boolean { + // Edit mode is when we're not in view mode + return globalSettings.isViewMode !== true; +} From f62bf22e7d459cf36e80aeda8bfe32b1187c01af Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Tue, 29 Jul 2025 23:55:43 +0500 Subject: [PATCH 064/352] fixed min-width not working in responsive layout --- .../src/comps/comps/responsiveLayout/responsiveLayout.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx b/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx index c9c8229ed4..b6649d8f96 100644 --- a/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx @@ -62,8 +62,7 @@ const RowWrapper = styled(Row)<{ padding: ${(props) => props.$style.padding}; rotate: ${(props) => props.$style.rotation}; overflow: ${(props) => (props.$showScrollbar ? 'auto' : 'hidden')}; - display: flex; - flex-wrap: wrap; // Ensure columns wrap properly when rowBreak = true + ::-webkit-scrollbar { display: ${(props) => (props.$showScrollbar ? 'block' : 'none')}; } From 9a52ed3804f8fd87d0b43804b8f506847dc574c4 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 30 Jul 2025 00:37:26 +0500 Subject: [PATCH 065/352] after updating input comp value inside form comp, it doesn't reflect form initial values --- .../src/comps/comps/textInputComp/textInputConstants.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx index 0765b638b5..ef31521c08 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx @@ -233,6 +233,10 @@ export const useTextInputProps = (props: RecordConstructorToView { + touchRef.current = false; + }; + // Cleanup refs on unmount useEffect(() => { return () => { @@ -252,6 +256,7 @@ export const useTextInputProps = (props: RecordConstructorToView Date: Wed, 30 Jul 2025 17:43:07 +0500 Subject: [PATCH 066/352] [Fix]: #1905 redux applications fix --- .../packages/lowcoder/src/redux/sagas/folderSagas.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/client/packages/lowcoder/src/redux/sagas/folderSagas.ts b/client/packages/lowcoder/src/redux/sagas/folderSagas.ts index 9db0a1eee6..da608bab61 100644 --- a/client/packages/lowcoder/src/redux/sagas/folderSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/folderSagas.ts @@ -118,17 +118,6 @@ export function* fetchFolderElementsSaga(action: ReduxAction m.folder), }); - - // filter out applications with NORMAL status - - const applications = response.data.data.filter((item): item is ApplicationMeta => - !item.folder && item.applicationStatus === "NORMAL" - ); - - yield put({ - type: ReduxActionTypes.FETCH_ALL_APPLICATIONS_SUCCESS, - payload: applications, - }); } yield put({ From 069c744216c34122d8ca861b14b04fa45b43d68b Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Wed, 30 Jul 2025 21:26:12 +0500 Subject: [PATCH 067/352] [Fix]: #1824 query as a last item in folder --- .../components/DraggableTree/DroppableMenuItem.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx b/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx index 7c9eac729f..29ebb14b09 100644 --- a/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx +++ b/client/packages/lowcoder/src/components/DraggableTree/DroppableMenuItem.tsx @@ -67,6 +67,9 @@ export default function DraggableMenuItem(props: IDraggableMenuItemProps) { const canDropBefore = checkDroppableFlag(item.canDropBefore, activeNode?.data); const canDropAfter = checkDroppableFlag(item.canDropAfter, activeNode?.data); + const lastChildNode = items[items.length - 1]; + const canDropAfterLastChild = checkDroppableFlag(lastChildNode?.canDropAfter, activeNode?.data); + const dropData: IDropData = { targetListSize: items.length, targetPath: dropInAsSub ? [...path, 0] : [...path.slice(0, -1), path[path.length - 1] + 1], @@ -136,6 +139,15 @@ export default function DraggableMenuItem(props: IDraggableMenuItemProps) { /> ))} + {activeNode && canDropAfterLastChild && ( +
+ +
+ )} )} From a6c5cf38168032bf12cbcad8034f25e2d3346992 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Thu, 31 Jul 2025 22:02:43 +0500 Subject: [PATCH 068/352] [Fix]: #1836 race condition barchart --- .../src/comps/barChartComp/barChartComp.tsx | 17 +++++++++++-- .../src/comps/barChartComp/barChartUtils.ts | 24 +++++++++++++++++++ .../chartConfigs/barChartConfig.tsx | 2 +- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx index df7fc06232..7b64f0f6c5 100644 --- a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartComp.tsx @@ -61,6 +61,8 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { const [chartSize, setChartSize] = useState(); const firstResize = useRef(true); const theme = useContext(ThemeContext); + const [chartKey, setChartKey] = useState(0); + const prevRaceMode = useRef(); const defaultChartTheme = { color: chartColorPalette, backgroundColor: "#fff", @@ -73,6 +75,16 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { log.error('theme chart error: ', error); } + // Detect race mode changes and force chart recreation + const currentRaceMode = comp.children.chartConfig?.children?.comp?.children?.race?.getView(); + useEffect(() => { + if (prevRaceMode.current !== undefined && prevRaceMode.current !== currentRaceMode) { + // Force chart recreation when race mode changes + setChartKey(prev => prev + 1); + } + prevRaceMode.current = currentRaceMode; + }, [currentRaceMode]); + const triggerClickEvent = async (dispatch: any, action: CompAction) => { await getPromiseAfterDispatch( dispatch, @@ -176,10 +188,11 @@ BarChartTmpComp = withViewFn(BarChartTmpComp, (comp) => { return (
(echartsCompRef.current = e)} style={{ height: "100%" }} - notMerge - lazyUpdate + notMerge={!currentRaceMode} + lazyUpdate={!currentRaceMode} opts={{ locale: getEchartsLocale() }} option={option} mode={mode} diff --git a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts index 72abe79f77..84b4ea05c6 100644 --- a/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts +++ b/client/packages/lowcoder-comps/src/comps/barChartComp/barChartUtils.ts @@ -201,6 +201,15 @@ export function getEchartsConfig( animationEasing: 'linear', animationEasingUpdate: 'linear', } + } else { + // Ensure proper animation settings when race is disabled + config = { + ...config, + animationDuration: 1000, + animationDurationUpdate: 1000, + animationEasing: 'cubicOut', + animationEasingUpdate: 'cubicOut', + } } if (props.data.length <= 0) { // no data @@ -333,6 +342,21 @@ export function getEchartsConfig( animationDurationUpdate: 300 }, } + } else { + // Reset axis animations when race is disabled + config = { + ...config, + xAxis: { + ...config.xAxis, + animationDuration: undefined, + animationDurationUpdate: undefined + }, + yAxis: { + ...config.yAxis, + animationDuration: undefined, + animationDurationUpdate: undefined + }, + } } } // console.log("Echarts transformedData and config", transformedData, config); diff --git a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx index dd7a369934..d1450007a6 100644 --- a/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx +++ b/client/packages/lowcoder-comps/src/comps/basicChartComp/chartConfigs/barChartConfig.tsx @@ -53,7 +53,7 @@ export const BarChartConfig = (function () { type: "bar", subtype: props.type, realtimeSort: props.race, - seriesLayoutBy: props.race?'column':undefined, + seriesLayoutBy: props.race?'column':'row', label: { show: props.showLabel, position: "top", From a3626356b74c6e9911f5c6065d1884a51369cb67 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Fri, 1 Aug 2025 15:02:41 +0500 Subject: [PATCH 069/352] fix responsiveLayout issues --- .../responsiveLayout/responsiveLayout.tsx | 143 ++++++++++-------- 1 file changed, 78 insertions(+), 65 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx b/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx index b6649d8f96..e4a7fd0b57 100644 --- a/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx +++ b/client/packages/lowcoder/src/comps/comps/responsiveLayout/responsiveLayout.tsx @@ -51,7 +51,6 @@ const RowWrapper = styled(Row)<{ $style: ResponsiveLayoutRowStyleType; $animationStyle: AnimationStyleType; $showScrollbar: boolean; - $columnCount: number; }>` ${(props) => props.$animationStyle} height: 100%; @@ -67,8 +66,6 @@ const RowWrapper = styled(Row)<{ display: ${(props) => (props.$showScrollbar ? 'block' : 'none')}; } ${props => getBackgroundStyle(props.$style)} - - --columns: ${(props) => props.$columnCount || 3}; `; const ColWrapper = styled(Col)<{ @@ -79,17 +76,31 @@ const ColWrapper = styled(Col)<{ }>` display: flex; flex-direction: column; - flex-grow: 1; - - // When rowBreak is true, columns are stretched evenly based on configured number - // When rowBreak is false, they stay at minWidth but break only if necessary - flex-basis: ${(props) => - props.$rowBreak - ? `calc(100% / var(--columns))` // Force exact column distribution - : `clamp(${props.$minWidth || "0px"}, calc(100% / var(--columns)), 100%)`}; // MinWidth respected + + /* When rowBreak is true: columns stretch evenly to fill available space */ + /* When rowBreak is false: columns take available space but respect minWidth */ + flex-grow: ${(props) => props.$rowBreak ? '1' : '1'}; + flex-shrink: ${(props) => { + if (props.$rowBreak) { + return '1'; // Can shrink when rowBreak is true + } else { + // When rowBreak is false, only allow shrinking if no minWidth is set + return props.$minWidth ? '0' : '1'; + } + }}; + flex-basis: ${(props) => { + if (props.$rowBreak) { + // When rowBreak is true, distribute columns evenly + return '0%'; + } else { + // When rowBreak is false, use minWidth if specified, otherwise auto + return props.$minWidth || 'auto'; + } + }}; - min-width: ${(props) => props.$minWidth}; // Ensure minWidth is respected - max-width: 100%; // Prevent more columns than allowed + /* Ensure minWidth is respected */ + min-width: ${(props) => props.$minWidth || 'auto'}; + max-width: 100%; > div { height: ${(props) => (props.$matchColumnsHeight ? "100%" : "auto")}; @@ -203,68 +214,70 @@ const ResponsiveLayout = (props: ResponsiveLayoutProps) => { const effectiveWidth = useComponentWidth ? componentWidth ?? safeScreenInfo.width : safeScreenInfo.width; const effectiveDeviceType = useComponentWidth ? getDeviceType(effectiveWidth || 1000) : safeScreenInfo.deviceType; - // Get columns per row based on device type - let configuredColumnsPerRow = effectiveDeviceType === "mobile" + // Get current columns per row based on device type + const currentColumnsPerRow = effectiveDeviceType === "mobile" ? columnPerRowSM : effectiveDeviceType === "tablet" ? columnPerRowMD : columnPerRowLG; - // Calculate max columns that fit based on minWidth - let maxColumnsThatFit = componentWidth - ? Math.floor(componentWidth / Math.max(...columns.map((col) => parseFloat(col.minWidth || "0")))) - : configuredColumnsPerRow; - - // Determine actual number of columns - let numberOfColumns = rowBreak ? configuredColumnsPerRow : Math.min(maxColumnsThatFit, totalColumns); + // Group columns into rows based on currentColumnsPerRow only when rowBreak is true + const columnRows = rowBreak ? (() => { + const rows = []; + for (let i = 0; i < columns.length; i += currentColumnsPerRow) { + rows.push(columns.slice(i, i + currentColumnsPerRow)); + } + return rows; + })() : [columns]; // When rowBreak is false, put all columns in a single row return (
- - {columns.map((column) => { - const id = String(column.id); - const childDispatch = wrapDispatch(wrapDispatch(dispatch, "containers"), id); - if (!containers[id] || column.hidden) return null; - const containerProps = containers[id].children; - - // Use the actual minWidth from column configuration instead of calculated width - const columnMinWidth = column.minWidth || `${100 / numberOfColumns}px`; - - return ( - - - - ); - })} - + {columnRows.map((row, rowIndex) => ( + + {row.map((column) => { + const id = String(column.id); + const childDispatch = wrapDispatch(wrapDispatch(dispatch, "containers"), id); + if (!containers[id] || column.hidden) return null; + const containerProps = containers[id].children; + + const columnMinWidth = column.minWidth; + + return ( + + + + ); + })} + + ))}
From e025b6461dbf85831488f3ffd6390133ceb542f2 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 1 Aug 2025 17:08:17 +0500 Subject: [PATCH 070/352] [Fix]: #1905 create app issue --- .../src/redux/reducers/uiReducers/applicationReducer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts index 6725028072..bf5369b869 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/applicationReducer.ts @@ -129,7 +129,10 @@ const usersReducer = createReducer(initialState, { action: ReduxAction ): ApplicationReduxState => ({ ...state, - applicationList: [action.payload.applicationInfoView, ...state.applicationList], + // Might be unnecessary to add the new application to the list + // TODO: Remove this after testing + // applicationList: [action.payload.applicationInfoView, ...state.applicationList], + applicationList:[], loadingStatus: { ...state.loadingStatus, isApplicationCreating: false, From 81d8e1fed6c7e519f5629fb390f73c687ee9e5ca Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Fri, 1 Aug 2025 17:22:25 +0500 Subject: [PATCH 071/352] fix form component fields not resetting --- .../lowcoder/src/comps/comps/formComp/formComp.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx index 02ca347ff0..69227d6940 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx @@ -392,9 +392,9 @@ let FormTmpComp = class extends FormBaseComp implements IForm { switch (action.type) { case CompActionTypes.UPDATE_NODES_V2: { const ret = super.reduce(action); - // When the initial value changes, update the form - if (action.value["initialData"] !== undefined) { - queueMicrotask(() => { + if (ret.children.initialData !== this.children.initialData) { + // FIXME: kill setTimeout ? + setTimeout(() => { this.dispatch( customAction( { @@ -404,7 +404,7 @@ let FormTmpComp = class extends FormBaseComp implements IForm { false ) ); - }); + }, 1000); } return ret; } From 6e9f52d6a04c8b742193b1cf70d4069e73de7726 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Fri, 1 Aug 2025 17:38:33 +0500 Subject: [PATCH 072/352] fix table column's padding --- .../comps/tableComp/column/columnTypeComps/columnTagsComp.tsx | 1 + .../lowcoder/src/comps/comps/tableComp/tableCompView.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx index 3bdbbed9dc..1e6a6e1a8a 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx @@ -142,6 +142,7 @@ export const Wrapper = styled.div` height: 100%; position: absolute; top: 0; + left: 0; background: transparent !important; padding: 8px; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx index 9702008dad..dc6c88b0d1 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx @@ -411,9 +411,9 @@ const TableTd = styled.td` justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; align-items: center; text-align: ${(props) => props.$customAlign || 'left'}; - padding: 0 8px; box-sizing: border-box; ${(props) => props.$tableSize === 'small' && ` + padding: 1px 8px; font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; font-style:${props.$style.fontStyle} !important; min-height: ${props.$style.rowHeight || '14px'}; @@ -424,6 +424,7 @@ const TableTd = styled.td` `}; `}; ${(props) => props.$tableSize === 'middle' && ` + padding: 8px 8px; font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; font-style:${props.$style.fontStyle} !important; min-height: ${props.$style.rowHeight || '24px'}; @@ -434,6 +435,7 @@ const TableTd = styled.td` `}; `}; ${(props) => props.$tableSize === 'large' && ` + padding: 16px 16px; font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; font-style:${props.$style.fontStyle} !important; min-height: ${props.$style.rowHeight || '48px'}; From 6660a8e5c997560e9a2414a9e3baba3871318d80 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Fri, 1 Aug 2025 19:17:17 +0500 Subject: [PATCH 073/352] fix module autoheight taking more height --- .../lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx b/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx index c5bb0cced9..d830a8e867 100644 --- a/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx +++ b/client/packages/lowcoder/src/comps/comps/gridLayoutComp/canvasView.tsx @@ -327,7 +327,7 @@ export const CanvasView = React.memo((props: ContainerBaseProps) => { bgColor={bgColor} radius="0px" emptyRows={defaultRowCount} - minHeight={defaultMinHeight} + minHeight={!isModule ? defaultMinHeight : undefined} extraHeight={defaultRowCount === DEFAULT_ROW_COUNT ? rootContainerExtraHeight : undefined } /> @@ -368,7 +368,7 @@ export const CanvasView = React.memo((props: ContainerBaseProps) => { bgColor={bgColor} positionParams={positionParams} emptyRows={defaultRowCount} - minHeight={defaultMinHeight} + minHeight={!isModule ? defaultMinHeight : undefined} extraHeight={defaultRowCount === DEFAULT_ROW_COUNT ? rootContainerExtraHeight : undefined} /> From 9ac3a8a82584a3a303656bf2c8cdfb8fc530472b Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 1 Aug 2025 19:21:14 +0500 Subject: [PATCH 074/352] [Fix]: #1883 add tab-index on more controls --- .../comps/comps/buttonComp/scannerComp.tsx | 5 +++- .../src/comps/comps/fileComp/fileComp.tsx | 24 ++++++++++++------ .../lowcoder/src/comps/comps/ratingComp.tsx | 25 ++++++++++++++++++- .../comps/selectInputComp/cascaderComp.tsx | 1 + .../selectInputComp/cascaderContants.tsx | 6 +++-- .../comps/comps/treeComp/treeSelectComp.tsx | 5 +++- 6 files changed, 53 insertions(+), 13 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx b/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx index a4ecd85a6c..8b061cb4c1 100644 --- a/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/buttonComp/scannerComp.tsx @@ -6,7 +6,7 @@ import { buttonRefMethods, ButtonStyleControl, } from "comps/comps/buttonComp/buttonCompConstants"; -import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { ScannerEventHandlerControl } from "comps/controls/eventHandlerControl"; import { withDefault } from "comps/generators"; import { UICompBuilder } from "comps/generators/uiCompBuilder"; @@ -128,6 +128,7 @@ const ScannerTmpComp = (function () { disabled: BoolCodeControl, style: ButtonStyleControl, viewRef: RefControl, + tabIndex: NumberControl, }; return new UICompBuilder(childrenMap, (props) => { const [showModal, setShowModal] = useState(false); @@ -199,6 +200,7 @@ const ScannerTmpComp = (function () { ref={props.viewRef} $buttonStyle={props.style} disabled={props.disabled} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onClick={() => { props.onEvent("click"); setShowModal(true); @@ -284,6 +286,7 @@ const ScannerTmpComp = (function () { {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.continuous.propertyView({ diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 8df84e1584..8580e2d5e1 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -3,6 +3,7 @@ import { default as AntdUpload } from "antd/es/upload"; import { default as Dropdown } from "antd/es/dropdown"; import { UploadFile, UploadProps, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; import { Buffer } from "buffer"; +import { v4 as uuidv4 } from "uuid"; import { darkenColor } from "components/colorSelect/colorUtils"; import { Section, sectionNames } from "components/Section"; import { IconControl } from "comps/controls/iconControl"; @@ -448,6 +449,7 @@ const Upload = ( text: string; dispatch: (action: CompAction) => void; forceCapture: boolean; + tabIndex?: number; }, ) => { const { dispatch, files, style } = props; @@ -564,13 +566,17 @@ const Upload = ( onChange={handleOnChange} > -
{children.fileType.propertyView({ diff --git a/client/packages/lowcoder/src/comps/comps/ratingComp.tsx b/client/packages/lowcoder/src/comps/comps/ratingComp.tsx index 42a80c8ee0..025453c256 100644 --- a/client/packages/lowcoder/src/comps/comps/ratingComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/ratingComp.tsx @@ -52,12 +52,31 @@ const RatingBasicComp = (function () { 'labelStyle', ), inputFieldStyle: migrateOldData(styleControl(RatingStyle, 'inputFieldStyle'), fixOldData), + tabIndex: NumberControl, ...formDataChildren, }; return new UICompBuilder(childrenMap, (props) => { const defaultValue = { ...props.defaultValue }.value; const value = { ...props.value }.value; const changeRef = useRef(false); + const mountedRef = useRef(true); + const rateRef = useRef(null); + + useEffect(() => { + if (!mountedRef.current) return; + if (rateRef.current && typeof props.tabIndex === 'number') { + const stars = rateRef.current.querySelectorAll('li'); + stars.forEach((star, index) => { + (star as HTMLElement).setAttribute('tabindex', (props.tabIndex + index).toString()); + }); + } + }, [props.tabIndex, props.max]); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); useEffect(() => { props.value.onChange(defaultValue); @@ -76,7 +95,8 @@ const RatingBasicComp = (function () { inputFieldStyle:props.inputFieldStyle, animationStyle:props.animationStyle, children: ( - + { @@ -86,7 +106,9 @@ const RatingBasicComp = (function () { allowHalf={props.allowHalf} disabled={props.disabled} $style={props.inputFieldStyle} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} /> +
), }); }) @@ -108,6 +130,7 @@ const RatingBasicComp = (function () { {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
{children.allowHalf.propertyView({ diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx index c71b85e2dd..b96f8eb3e8 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderComp.tsx @@ -56,6 +56,7 @@ let CascaderBasicComp = (function () { showSearch={props.showSearch} $style={props.inputFieldStyle} $childrenInputFieldStyle={props.childrenInputFieldStyle} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onFocus={() => props.onEvent("focus")} onBlur={() => props.onEvent("blur")} popupRender={(menus: React.ReactNode) => ( diff --git a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx index 330c94120d..d88289c880 100644 --- a/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx +++ b/client/packages/lowcoder/src/comps/comps/selectInputComp/cascaderContants.tsx @@ -1,7 +1,7 @@ import { SelectEventHandlerControl } from "../../controls/eventHandlerControl"; import { Section, sectionNames } from "lowcoder-design"; import { RecordConstructorToComp } from "lowcoder-core"; -import { BoolCodeControl, JSONObjectArrayControl, StringControl } from "comps/controls/codeControl"; +import { BoolCodeControl, JSONObjectArrayControl, StringControl, NumberControl } from "comps/controls/codeControl"; import { arrayStringExposingStateControl } from "comps/controls/codeStateControl"; import { BoolControl } from "comps/controls/boolControl"; import { LabelControl } from "comps/controls/labelControl"; @@ -43,7 +43,8 @@ export const CascaderChildren = { padding: PaddingControl, inputFieldStyle:styleControl(CascaderStyle , 'inputFieldStyle'), childrenInputFieldStyle:styleControl(ChildrenMultiSelectStyle), - animationStyle:styleControl(AnimationStyle ,'animationStyle') + animationStyle:styleControl(AnimationStyle ,'animationStyle'), + tabIndex: NumberControl }; export const CascaderPropertyView = ( @@ -62,6 +63,7 @@ export const CascaderPropertyView = ( {disabledPropertyView(children)} {hiddenPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children as any)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })}
)} diff --git a/client/packages/lowcoder/src/comps/comps/treeComp/treeSelectComp.tsx b/client/packages/lowcoder/src/comps/comps/treeComp/treeSelectComp.tsx index 1155345689..482316e0fe 100644 --- a/client/packages/lowcoder/src/comps/comps/treeComp/treeSelectComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/treeComp/treeSelectComp.tsx @@ -21,7 +21,7 @@ import { } from "./treeUtils"; import { baseSelectRefMethods, getStyle } from "../selectInputComp/selectCompConstants"; import { useSelectInputValidate, SelectInputValidationSection } from "../selectInputComp/selectInputConstants"; -import { StringControl } from "comps/controls/codeControl"; +import { StringControl, NumberControl } from "comps/controls/codeControl"; import { SelectEventHandlerControl } from "comps/controls/eventHandlerControl"; import { selectInputValidate } from "../selectInputComp/selectInputConstants"; import { BoolControl } from "comps/controls/boolControl"; @@ -70,6 +70,7 @@ const childrenMap = { labelStyle:styleControl(LabelStyle , 'labelStyle'), inputFieldStyle: styleControl(TreeSelectStyle, 'inputFieldStyle'), viewRef: RefControl, + tabIndex: NumberControl, }; function getCheckedStrategy(v: ValueFromOption) { @@ -123,6 +124,7 @@ const TreeCompView = ( treeLine={props.showLine ? { showLeafIcon: props.showLeafIcon } : false} // fix expand issue when searching treeExpandedKeys={inputValue ? undefined : expanded.value} + tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onTreeExpand={(keys) => { expanded.onChange(keys as (string | number)[]); }} @@ -172,6 +174,7 @@ let TreeBasicComp = (function () { {allowClearPropertyView(children)} {showSearchPropertyView(children)} {showDataLoadingIndicatorsPropertyView(children)} + {children.tabIndex.propertyView({ label: trans("prop.tabIndex") })} )} From d16e4751eeacb5c0aee847929c1a82db8c1fa46c Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Fri, 1 Aug 2025 21:50:00 +0500 Subject: [PATCH 075/352] [Fix]: #1928 folder edit/create issues --- .../src/pages/ApplicationV2/HomeResCard.tsx | 15 +++++--- .../pages/ApplicationV2/HomeResOptions.tsx | 38 +++++++++---------- .../pages/ApplicationV2/MoveToFolderModal.tsx | 15 ++++++-- 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx index db2758e737..627146ffcf 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResCard.tsx @@ -143,7 +143,6 @@ export const StyledTypographyText = styled(AntdTypographyText)` &:hover { color: #315efb; } - } `; const MONTH_MILLIS = 30 * 24 * 60 * 60 * 1000; @@ -244,11 +243,15 @@ export function HomeResCard(props: { res: HomeRes; onMove: (res: HomeRes) => voi const Icon = resInfo.icon; const handleModalOk = (values: any) => { - res.type === HomeResTypeEnum.Folder && - dispatch(updateFolder({ id: res.id, name: values.appName || res.name })) - dispatch( - updateAppMetaAction({ applicationId: res.id, name: values.appName || res.name, folderId: folderId }) - ); + if (res.type === HomeResTypeEnum.Folder) { + // Update folder + dispatch(updateFolder({ id: res.id, name: values.appName || res.name })); + } else { + // Update application + dispatch( + updateAppMetaAction({ applicationId: res.id, name: values.appName || res.name, folderId: folderId }) + ); + } setDialogVisible(false); setTimeout(() => { diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx index 38e4b68953..ce19949fbe 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/HomeResOptions.tsx @@ -12,8 +12,7 @@ import { AppTypeEnum } from "constants/applicationConstants"; import { CopyModal } from "pages/common/copyModal"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import ApplicationApi from "../../api/applicationApi"; -import { FolderApi } from "../../api/folderApi"; -import { ReduxActionTypes } from "constants/reduxActionConstants"; +import { deleteFolder } from "../../redux/reduxActions/folderActions"; const PopoverIcon = styled(PointIcon)` cursor: pointer; @@ -120,27 +119,24 @@ export const HomeResOptions = (props: { type: HomeResInfo[res.type].name.toLowerCase(), name: {res.name}, }), - onConfirm: async () => { - try { - await FolderApi.deleteFolder({ + onConfirm: () => { + dispatch(deleteFolder( + { folderId: res.id, parentFolderId: folderId || "" - }); - - // Update Redux state to remove deleted folder from dropdown - dispatch({ - type: ReduxActionTypes.DELETE_FOLDER_SUCCESS, - payload: { folderId: res.id, parentFolderId: folderId || "" } - }); - - messageInstance.success(trans("home.deleteSuccessMsg")); - setTimeout(() => { - setModify(!modify); - }, 200); - } catch (error) { - console.error("Failed to delete folder:", error); - messageInstance.error("Failed to delete folder"); - } + }, + () => { + // Success callback + messageInstance.success(trans("home.deleteSuccessMsg")); + setTimeout(() => { + setModify(!modify); + }, 200); + }, + () => { + // Error callback + messageInstance.error("Failed to delete folder"); + } + )); }, confirmBtnType: "delete", okText: trans("delete"), diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/MoveToFolderModal.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/MoveToFolderModal.tsx index 2e3d4888e1..c97f48bd0a 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/MoveToFolderModal.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/MoveToFolderModal.tsx @@ -1,6 +1,6 @@ import { HomeRes } from "./HomeLayout"; import { default as Form } from "antd/es/form"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useParams } from "react-router-dom"; import { @@ -11,10 +11,10 @@ import { FormSelectItem, TacoButton, } from "lowcoder-design"; -import { moveToFolder } from "../../redux/reduxActions/folderActions"; +import { moveToFolder, fetchFolderElements } from "../../redux/reduxActions/folderActions"; import styled from "styled-components"; import { trans } from "../../i18n"; -import { foldersSelector } from "../../redux/selectors/folderSelector"; +import { foldersSelector, isFetchingFolderElements } from "../../redux/selectors/folderSelector"; const MoveLabel = styled.div` font-size: 13px; @@ -47,11 +47,20 @@ export const MoveToFolderModal = (props: { source?: HomeRes; onClose: () => void const [loading, setLoading] = useState(false); const folders = useSelector(foldersSelector); + const isFetching = useSelector(isFetchingFolderElements); const dispatch = useDispatch(); const { folderId } = useParams<{ folderId: string }>(); + // Fetch folders when modal opens to populate Redux state (only if not already loaded or fetching) + useEffect(() => { + if (props.source && folders.length === 0 && !isFetching) { + // Dispatch the Redux action to fetch folders (empty folderId fetches all folders) + dispatch(fetchFolderElements({})); + } + }, [props.source, dispatch, folders.length, isFetching]); + return ( Date: Mon, 4 Aug 2025 17:13:07 +0500 Subject: [PATCH 076/352] fix js query trigger on page load when used exposed variables are referred e.g. utils, toast, input1 --- client/packages/lowcoder-core/lib/index.js | 9 +++++---- client/packages/lowcoder-core/src/eval/codeNode.tsx | 9 +++++---- client/packages/lowcoder/src/comps/queries/queryComp.tsx | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/client/packages/lowcoder-core/lib/index.js b/client/packages/lowcoder-core/lib/index.js index f972c7ba4b..719756c00b 100644 --- a/client/packages/lowcoder-core/lib/index.js +++ b/client/packages/lowcoder-core/lib/index.js @@ -1692,11 +1692,12 @@ class CodeNode extends AbstractNode { // if query is dependent on itself, mark as ready if (pathsArr?.[0] === options?.queryName) return; + // TODO: check if this is needed after removing lazy load // wait for lazy loaded comps to load before executing query on page load - if (value && !Object.keys(value).length && paths.size) { - isFetching = true; - ready = false; - } + // if (value && !Object.keys(value).length && paths.size) { + // isFetching = true; + // ready = false; + // } if (_.has(value, IS_FETCHING_FIELD)) { isFetching = isFetching || value.isFetching === true; } diff --git a/client/packages/lowcoder-core/src/eval/codeNode.tsx b/client/packages/lowcoder-core/src/eval/codeNode.tsx index 2b67e7bbfa..e2e69fab1b 100644 --- a/client/packages/lowcoder-core/src/eval/codeNode.tsx +++ b/client/packages/lowcoder-core/src/eval/codeNode.tsx @@ -176,11 +176,12 @@ export class CodeNode extends AbstractNode> { // if query is dependent on itself, mark as ready if (pathsArr?.[0] === options?.queryName) return; + // TODO: check if this is needed after removing lazy load // wait for lazy loaded comps to load before executing query on page load - if (value && !Object.keys(value).length && paths.size) { - isFetching = true; - ready = false; - } + // if (value && !Object.keys(value).length && paths.size) { + // isFetching = true; + // ready = false; + // } if (_.has(value, IS_FETCHING_FIELD)) { isFetching = isFetching || value.isFetching === true; } diff --git a/client/packages/lowcoder/src/comps/queries/queryComp.tsx b/client/packages/lowcoder/src/comps/queries/queryComp.tsx index e47694f963..c5b983cfee 100644 --- a/client/packages/lowcoder/src/comps/queries/queryComp.tsx +++ b/client/packages/lowcoder/src/comps/queries/queryComp.tsx @@ -335,7 +335,7 @@ function QueryView(props: QueryViewProps) { ) { setTimeout(() => { comp.dispatch(deferAction(executeQueryAction({}))); - }, 300); + }, 600); } if(getTriggerType(comp) === "onTimeout") { From 485cc3dee43cd8e7f3a4c615a6d5a4b16dc9bd0a Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Mon, 4 Aug 2025 20:23:11 +0500 Subject: [PATCH 077/352] fixed form comp's resetAfterSuccess doesn't work --- .../packages/lowcoder/src/comps/comps/formComp/formComp.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx index 69227d6940..8255813932 100644 --- a/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/formComp/formComp.tsx @@ -330,11 +330,6 @@ let FormTmpComp = class extends FormBaseComp implements IForm { // For the properties, first find in data, then initialData, subcomponent default value (resetValue), empty value (clearValue) const newData = { ...(initialData ?? this.children.initialData.getView()), ...data }; - // Only proceed if we have data to set - if (!Object.keys(newData).length) { - return Promise.resolve(); - } - return this.runMethodOfItems( { name: "setValue", From 16715857a634b22c096a08fcb1a6c12093169575 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Tue, 5 Aug 2025 15:25:49 +0500 Subject: [PATCH 078/352] reduce input change debounce time --- .../src/comps/comps/textInputComp/textInputConstants.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx index ef31521c08..0b6ca8f2e9 100644 --- a/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx +++ b/client/packages/lowcoder/src/comps/comps/textInputComp/textInputConstants.tsx @@ -221,7 +221,7 @@ export const useTextInputProps = (props: RecordConstructorToView) => { From 26640829f7d077855af3686010057b10b16a3cc2 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 5 Aug 2025 20:10:56 +0500 Subject: [PATCH 079/352] clone original Table Comp --- .../tableLiteComp/column/columnTypeComp.tsx | 170 +++ .../column/columnTypeCompBuilder.tsx | 212 +++ .../columnTypeComps/ColumnNumberComp.tsx | 224 ++++ .../columnTypeComps/columnAvatarsComp.tsx | 251 ++++ .../columnTypeComps/columnBooleanComp.tsx | 201 +++ .../column/columnTypeComps/columnDateComp.tsx | 283 ++++ .../columnTypeComps/columnDateTimeComp.tsx | 90 ++ .../columnTypeComps/columnDropdownComp.tsx | 200 +++ .../column/columnTypeComps/columnImgComp.tsx | 143 ++ .../column/columnTypeComps/columnLinkComp.tsx | 145 ++ .../columnTypeComps/columnLinksComp.tsx | 139 ++ .../columnTypeComps/columnMarkdownComp.tsx | 128 ++ .../columnTypeComps/columnProgressComp.tsx | 166 +++ .../columnTypeComps/columnRatingComp.tsx | 139 ++ .../columnTypeComps/columnSelectComp.tsx | 232 ++++ .../columnTypeComps/columnStatusComp.tsx | 192 +++ .../columnTypeComps/columnSwitchComp.tsx | 168 +++ .../column/columnTypeComps/columnTagsComp.tsx | 465 +++++++ .../column/columnTypeComps/columnTimeComp.tsx | 193 +++ .../column/columnTypeComps/simpleTextComp.tsx | 121 ++ .../column/simpleColumnTypeComps.tsx | 129 ++ .../tableLiteComp/column/tableColumnComp.tsx | 488 +++++++ .../column/tableColumnListComp.tsx | 202 +++ .../column/tableSummaryColumnComp.tsx | 205 +++ .../comps/tableLiteComp/expansionControl.tsx | 116 ++ .../src/comps/comps/tableLiteComp/index.tsx | 1 + .../comps/tableLiteComp/mockTableComp.tsx | 72 + .../comps/tableLiteComp/paginationControl.tsx | 90 ++ .../comps/tableLiteComp/selectionControl.tsx | 134 ++ .../comps/tableLiteComp/tableComp.test.tsx | 213 +++ .../comps/comps/tableLiteComp/tableComp.tsx | 1019 ++++++++++++++ .../comps/tableLiteComp/tableCompView.tsx | 1176 +++++++++++++++++ .../comps/tableLiteComp/tableContext.tsx | 12 + .../tableLiteComp/tableDynamicColumn.test.tsx | 241 ++++ .../comps/tableLiteComp/tablePropertyView.tsx | 650 +++++++++ .../comps/tableLiteComp/tableSummaryComp.tsx | 291 ++++ .../comps/tableLiteComp/tableToolbarComp.tsx | 947 +++++++++++++ .../comps/comps/tableLiteComp/tableTypes.tsx | 282 ++++ .../comps/comps/tableLiteComp/tableUtils.tsx | 492 +++++++ 39 files changed, 10622 insertions(+) create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableSummaryComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableToolbarComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableTypes.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableUtils.tsx diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx new file mode 100644 index 0000000000..ff3df44c4e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx @@ -0,0 +1,170 @@ +import { CellProps } from "components/table/EditableCell"; +import { DateTimeComp } from "comps/comps/tableComp/column/columnTypeComps/columnDateTimeComp"; +import { TimeComp } from "./columnTypeComps/columnTimeComp"; +import { ButtonComp } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { withType } from "comps/generators"; +import { trans } from "i18n"; +import { Dropdown } from "lowcoder-design/src/components/Dropdown"; +import { BooleanComp } from "./columnTypeComps/columnBooleanComp"; +import { SwitchComp } from "./columnTypeComps/columnSwitchComp"; +import { DateComp } from "./columnTypeComps/columnDateComp"; +import { ImageComp } from "./columnTypeComps/columnImgComp"; +import { LinkComp } from "./columnTypeComps/columnLinkComp"; +import { ColumnLinksComp } from "./columnTypeComps/columnLinksComp"; +import { ColumnMarkdownComp } from "./columnTypeComps/columnMarkdownComp"; +import { ProgressComp } from "./columnTypeComps/columnProgressComp"; +import { RatingComp } from "./columnTypeComps/columnRatingComp"; +import { BadgeStatusComp } from "./columnTypeComps/columnStatusComp"; +import { ColumnTagsComp } from "./columnTypeComps/columnTagsComp"; +import { ColumnSelectComp } from "./columnTypeComps/columnSelectComp"; +import { SimpleTextComp } from "./columnTypeComps/simpleTextComp"; +import { ColumnNumberComp } from "./columnTypeComps/ColumnNumberComp"; + +import { ColumnAvatarsComp } from "./columnTypeComps/columnAvatarsComp"; +import { ColumnDropdownComp } from "./columnTypeComps/columnDropdownComp"; + +const actionOptions = [ + { + label: trans("table.avatars"), + value: "avatars", + }, + { + label: trans("table.text"), + value: "text", + }, + { + label: trans("table.number"), + value: "number", + }, + { + label: trans("table.link"), + value: "link", + }, + { + label: trans("table.links"), + value: "links", + }, + { + label: trans("table.tag"), + value: "tag", + }, + { + label: trans("table.select"), + value: "select", + }, + { + label: trans("table.dropdown"), + value: "dropdown", + }, + { + label: trans("table.badgeStatus"), + value: "badgeStatus", + }, + { + label: trans("table.button"), + value: "button", + }, + { + label: trans("table.image"), + value: "image", + }, + { + label: trans("table.time"), + value: "time", + }, + + { + label: trans("table.date"), + value: "date", + }, + { + label: trans("table.dateTime"), + value: "dateTime", + }, + { + label: "Markdown", + value: "markdown", + }, + { + label: trans("table.boolean"), + value: "boolean", + }, + { + label: trans("table.switch"), + value: "switch", + }, + { + label: trans("table.rating"), + value: "rating", + }, + { + label: trans("table.progress"), + value: "progress", + }, +] as const; + +export const ColumnTypeCompMap = { + avatars: ColumnAvatarsComp, + text: SimpleTextComp, + number: ColumnNumberComp, + button: ButtonComp, + badgeStatus: BadgeStatusComp, + link: LinkComp, + tag: ColumnTagsComp, + select: ColumnSelectComp, + dropdown: ColumnDropdownComp, + links: ColumnLinksComp, + image: ImageComp, + markdown: ColumnMarkdownComp, + dateTime: DateTimeComp, + boolean: BooleanComp, + switch: SwitchComp, + rating: RatingComp, + progress: ProgressComp, + date: DateComp, + time: TimeComp, +}; + +type ColumnTypeMapType = typeof ColumnTypeCompMap; +export type ColumnTypeKeys = keyof ColumnTypeMapType; + +const TypedColumnTypeComp = withType(ColumnTypeCompMap, "text"); + +export class ColumnTypeComp extends TypedColumnTypeComp { + override getView() { + const childView = this.children.comp.getView(); + return { + view: (cellProps: CellProps) => { + return childView(cellProps); + }, + value: this.children.comp.getDisplayValue(), + }; + } + + private handleTypeChange: (value: ColumnTypeKeys) => void = (value) => { + // Keep the previous text value, some components do not have text, the default value is currentCell + let textRawData = "{{currentCell}}"; + if (this.children.comp.children.hasOwnProperty("text")) { + textRawData = (this.children.comp.children as any).text.toJsonValue(); + } + this.dispatchChangeValueAction({ + compType: value, + comp: { text: textRawData }, + } as any); + } + + override getPropertyView() { + return ( + <> + + {this.children.comp.getPropertyView()} + + ); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx new file mode 100644 index 0000000000..b401761d26 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx @@ -0,0 +1,212 @@ +import { + CellViewReturn, + EditableCell, + EditViewFn, + TABLE_EDITABLE_SWITCH_ON, +} from "components/table/EditableCell"; +import { stateComp } from "comps/generators"; +import { + MultiCompBuilder, + PropertyViewFnTypeForComp, + ToConstructor, + ViewFnTypeForComp, +} from "comps/generators/multi"; +import _ from "lodash"; +import { + CompConstructor, + ConstructorToNodeType, + fromRecord, + NodeToValue, + RecordConstructorToComp, + withFunction, +} from "lowcoder-core"; +import { ReactNode } from "react"; +import { JSONValue } from "util/jsonTypes"; + +export const __COLUMN_DISPLAY_VALUE_FN = "__COLUMN_DISPLAY_VALUE_FN"; + +type RecordConstructorToNodeValue = { + [K in keyof T]: NodeToValue>; +}; + +type ViewValueFnType>> = ( + nodeValue: RecordConstructorToNodeValue +) => JSONValue; + +type NewChildrenCtorMap = ChildrenCtorMap & { + changeValue: ReturnType>; +}; + +export type ColumnTypeViewFn = ViewFnTypeForComp< + ViewReturn, + RecordConstructorToComp> +>; + +export class ColumnTypeCompBuilder< + ChildrenCtorMap extends Record>, + T extends JSONValue = JSONValue +> { + private childrenMap: NewChildrenCtorMap; + private propertyViewFn?: PropertyViewFnTypeForComp< + RecordConstructorToComp> + >; + private stylePropertyViewFn?: PropertyViewFnTypeForComp< + RecordConstructorToComp> + >; + private editViewFn?: EditViewFn; + private cleanupFunctions: (() => void)[] = []; + + constructor( + childrenMap: ChildrenCtorMap, + private viewFn: ColumnTypeViewFn, + private displayValueFn: ViewValueFnType, + private baseValueFn?: ColumnTypeViewFn + ) { + this.childrenMap = { ...childrenMap, changeValue: stateComp(null) }; + } + + setEditViewFn(editViewFn: NonNullable) { + if (TABLE_EDITABLE_SWITCH_ON) { + this.editViewFn = editViewFn; + } + return this; + } + + setPropertyViewFn( + propertyViewFn: PropertyViewFnTypeForComp< + RecordConstructorToComp> + > + ) { + this.propertyViewFn = propertyViewFn; + return this; + } + + setStylePropertyViewFn( + stylePropertyViewFn: PropertyViewFnTypeForComp< + RecordConstructorToComp> + > + ) { + this.stylePropertyViewFn = stylePropertyViewFn; + return this; + } + + build() { + if (!this.propertyViewFn) { + throw new Error("need property view fn"); + } + + // Memoize the props processing + const memoizedViewFn = _.memoize( + (props: any, dispatch: any) => { + const baseValue = this.baseValueFn?.(props, dispatch); + const normalView = this.viewFn(props, dispatch); + return ( + + {...props} + normalView={normalView} + dispatch={dispatch} + baseValue={baseValue} + changeValue={props.changeValue as any} + editViewFn={this.editViewFn} + /> + ); + }, + (props) => { + let safeOptions = []; + let safeAvatars = []; + if(props.options) { + safeOptions = props.options.map((option: Record) => { + const {prefixIcon, suffixIcon, ...safeOption} = option; + return safeOption; + }) + } + if(props.avatars) { + safeAvatars = props.avatars.map((avatar: Record) => { + const {AvatarIcon, ...safeAvatar} = avatar; + return safeAvatar; + }) + } + const { + prefixIcon, + suffixIcon, + iconFalse, + iconTrue, + iconNull, + tagColors, + options, + avatars, + ...safeProps + } = props; + return safeProps; + } + ); + + const viewFn: ColumnTypeViewFn = + (props, dispatch): CellViewReturn => + (cellProps) => memoizedViewFn({ ...props, ...cellProps } as any, dispatch); + + const ColumnTypeCompTmp = new MultiCompBuilder( + this.childrenMap as ToConstructor< + RecordConstructorToComp> + >, + viewFn + ) + .setPropertyViewFn(this.propertyViewFn) + .build(); + + const displayValueFn = this.displayValueFn; + const editViewFn = this.editViewFn; + + return class extends ColumnTypeCompTmp { + // table cell data + private _displayValue: JSONValue = null; + private cleanupFunctions: (() => void)[] = []; + constructor(props: any) { + super(props); + this.cleanupFunctions.push(() => { + this._displayValue = null; + memoizedViewFn.cache.clear?.(); + }); + } + + override extraNode() { + return { + node: { + [__COLUMN_DISPLAY_VALUE_FN]: withFunction( + fromRecord(this.childrenNode()), + () => displayValueFn + ), + }, + updateNodeFields: (value: any) => { + const displayValueFunc = value[__COLUMN_DISPLAY_VALUE_FN]; + this._displayValue = displayValueFunc(value); + return { displayValue: this._displayValue }; + }, + }; + } + + /** + * Get the data actually displayed by the table cell + */ + getDisplayValue() { + return this._displayValue; + } + + static canBeEditable() { + return !_.isNil(editViewFn); + } + + componentWillUnmount() { + // Cleanup all registered cleanup functions + this.cleanupFunctions.forEach(cleanup => cleanup()); + this.cleanupFunctions = []; + } + }; + } + + // Cleanup method to be called when the builder is no longer needed + cleanup() { + this.cleanupFunctions.forEach(cleanup => cleanup()); + this.cleanupFunctions = []; + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx new file mode 100644 index 0000000000..619b42674f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx @@ -0,0 +1,224 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo, ReactNode } from "react"; +import { default as InputNumber } from "antd/es/input-number"; +import { NumberControl, RangeControl, StringControl } from "comps/controls/codeControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { withDefault } from "comps/generators"; +import styled from "styled-components"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const InputNumberWrapper = styled.div` + .ant-input-number { + width: 100%; + border-radius: 0; + background: transparent !important; + // padding: 0 !important; + box-shadow: none; + + input { + padding: 0; + border-radius: 0; + } + } +`; + +const NumberViewWrapper = styled.div` + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +`; + +const NumberEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: NumberControl, + step: withDefault(NumberControl, 1), + precision: RangeControl.closed(0, 20, 0), + float: BoolControl, + prefix: StringControl, + prefixIcon: IconControl, + suffixIcon: IconControl, + suffix: StringControl, + onEvent: eventHandlerControl(NumberEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type NumberViewProps = { + value: number; + prefix: string; + suffix: string; + prefixIcon: ReactNode; + suffixIcon: ReactNode; + float: boolean; + precision: number; + onEvent?: (eventName: string) => void; +}; + +type NumberEditProps = { + value: number; + onChange: (value: number) => void; + onChangeEnd: () => void; + step: number; + precision: number; + float: boolean; +}; + +const ColumnNumberView = React.memo((props: NumberViewProps) => { + const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent ?? (() => {})}) + + const formattedValue = useMemo(() => { + let result = !props.float ? Math.floor(props.value) : props.value; + if (props.float) { + result = Number(result.toFixed(props.precision + 1)); + } + return result; + }, [props.value, props.float, props.precision]); + + const handleClick = useCallback(() => { + handleClickEvent() + }, [props.onEvent]); + + return ( + + {hasIcon(props.prefixIcon) && ( + {props.prefixIcon} + )} + {props.prefix + formattedValue + props.suffix} + {hasIcon(props.suffixIcon) && ( + {props.suffixIcon} + )} + + ); +}); + +ColumnNumberView.displayName = 'ColumnNumberView'; + + +const ColumnNumberEdit = React.memo((props: NumberEditProps) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(0); + }; + }, []); + + const handleChange = useCallback((value: string | number | null) => { + if (!mountedRef.current) return; + const newValue = typeof value === 'number' ? value : 0; + const finalValue = !props.float ? Math.floor(newValue) : newValue; + props.onChange(finalValue); + setCurrentValue(finalValue); + }, [props.onChange, props.float]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + return ( + + + + ); +}); + +ColumnNumberEdit.displayName = 'NumberEdit'; + +export const ColumnNumberComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + const { value, onChange, onChangeEnd, otherProps } = props; + return ( + + ); + }) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.step.propertyView({ + label: trans("table.numberStep"), + tooltip: trans("table.numberStepTooltip"), + onFocus: (focused) => { + if (!focused) { + const value = children.step.getView(); + const isFloat = children.float.getView(); + const newValue = !isFloat ? Math.floor(value) : value; + children.step.dispatchChangeValueAction(String(newValue)); + } + } + })} + {children.float.getView() && ( + children.precision.propertyView({ + label: trans("table.precision"), + }) + )} + {children.prefix.propertyView({ + label: trans("table.prefix"), + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffix.propertyView({ + label: trans("table.suffix"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.float.propertyView({ + label: trans("table.float"), + onChange: (isFloat) => { + const value = children.step.getView(); + const newValue = !isFloat ? Math.floor(value) : value; + children.step.dispatchChangeValueAction(String(newValue)); + } + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx new file mode 100644 index 0000000000..f02ee19943 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx @@ -0,0 +1,251 @@ +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { StringControl } from "comps/controls/codeControl"; +import { MultiCompBuilder, stateComp, withDefault } from "comps/generators"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { LightActiveTextColor, PrimaryColor } from "constants/style"; +import { styleControl } from "comps/controls/styleControl"; +import { avatarGroupStyle, AvatarGroupStyleType } from "comps/controls/styleControlConstants"; +import { AlignCenter, AlignLeft, AlignRight } from "lowcoder-design"; +import { NumberControl } from "comps/controls/codeControl"; +import { Avatar, Tooltip } from "antd"; +import { clickEvent, eventHandlerControl, refreshEvent, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import React, { ReactElement, useCallback, useEffect, useRef } from "react"; +import { IconControl } from "comps/controls/iconControl"; +import { ColorControl } from "comps/controls/colorControl"; +import { optionsControl } from "comps/controls/optionsControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { JSONObject } from "util/jsonTypes"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const MacaroneList = [ + '#fde68a', + '#eecff3', + '#a7f3d0', + '#bfdbfe', + '#bfdbfe', + '#c7d2fe', + '#fecaca', + '#fcd6bb', +] + +const Container = styled.div<{ $style: AvatarGroupStyleType | undefined, alignment: string }>` + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: ${props => props.alignment}; + cursor: pointer; +`; + +const AvatarEventOptions = [clickEvent, refreshEvent] as const; + +const DropdownOption = new MultiCompBuilder( + { + src: StringControl, + AvatarIcon: IconControl, + label: StringControl, + color: ColorControl, + backgroundColor: ColorControl, + Tooltip: StringControl, + onEvent: eventHandlerControl(AvatarEventOptions), + }, + (props) => props +) +.setPropertyViewFn((children) => { + return ( + <> + {children.src.propertyView({ label: trans("avatarComp.src"), placeholder: "", tooltip: trans("avatarComp.avatarCompTooltip") })} + {children.label.propertyView({label: trans("avatarComp.title"), tooltip: trans("avatarComp.avatarCompTooltip"), + })} + {children.AvatarIcon.propertyView({ + label: trans("avatarComp.icon"), + IconType: "All", + tooltip: trans("avatarComp.avatarCompTooltip"), + })} + {children.color.propertyView({ label: trans("style.fill") })} + {children.backgroundColor.propertyView({ label: trans("style.background") })} + {children.Tooltip.propertyView({ label: trans("badge.tooltip") })} + {children.onEvent.propertyView()} + + ); +}) +.build(); + +const EventOptions = [clickEvent, refreshEvent, doubleClickEvent] as const; + +export const alignOptions = [ + { label: , value: "flex-start" }, + { label: , value: "center" }, + { label: , value: "flex-end" }, +] as const; + +// Memoized Avatar component +const MemoizedAvatar = React.memo(({ + item, + index, + style, + autoColor, + avatarSize, + onEvent, + onItemEvent +}: { + item: any; + index: number; + style: any; + autoColor: boolean; + avatarSize: number; + onEvent: (event: string) => void; + onItemEvent?: (event: string) => void; +}) => { + const mountedRef = useRef(true); + const handleClickEvent = useCompClickEventHandler({onEvent}) + + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(() => { + if (!mountedRef.current) return; + + // Trigger individual avatar event first + if (onItemEvent) { + onItemEvent("click"); + } + + // Then trigger main component event + handleClickEvent() + }, [onItemEvent, handleClickEvent]); + + return ( + + + {item.label} + + + ); +}); + +MemoizedAvatar.displayName = 'MemoizedAvatar'; + +// Memoized Avatar Group component +const MemoizedAvatarGroup = React.memo(({ + avatars, + maxCount, + avatarSize, + style, + autoColor, + onEvent +}: { + avatars: any[]; + maxCount: number; + avatarSize: number; + style: any; + autoColor: boolean; + onEvent: (event: string) => void; +}) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + return ( + + {avatars.map((item, index) => ( + + ))} + + ); +}); + +MemoizedAvatarGroup.displayName = 'MemoizedAvatarGroup'; + +export const ColumnAvatarsComp = (function () { + const childrenMap = { + style: styleControl(avatarGroupStyle), + maxCount: withDefault(NumberControl, 3), + avatarSize: withDefault(NumberControl, 40), + alignment: dropdownControl(alignOptions, "center"), + autoColor: BoolControl.DEFAULT_TRUE, + onEvent: eventHandlerControl(EventOptions), + currentAvatar: stateComp({}), + avatars: optionsControl(DropdownOption, { + initOptions: [ + { src: "https://api.dicebear.com/7.x/miniavs/svg?seed=1", label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + { AvatarIcon: "/icon:antd/startwotone" }, + { label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + { label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + ], + }) + }; + + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + return ( + + + + ); + }, + () => "" + ) + .setPropertyViewFn((children) => ( + <> + {children.avatars.propertyView({})} + {children.maxCount.propertyView({ + label: trans("avatarGroup.maxCount") + })} + {children.avatarSize.propertyView({ + label: trans("avatarGroup.avatarSize") + })} + {children.autoColor.propertyView({ + label: trans("avatarGroup.autoColor") + })} + {children.alignment.propertyView({ + label: trans("table.avatarGroupAlignment"), + radioButton: true, + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx new file mode 100644 index 0000000000..d1d530eb6c --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx @@ -0,0 +1,201 @@ +import React, { useCallback, useRef, useEffect, useMemo } from "react"; +import { BoolCodeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { default as Checkbox, CheckboxChangeEvent } from "antd/es/checkbox"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { getStyle } from "comps/comps/selectInputComp/checkboxComp"; +import styled from "styled-components"; +import { CheckboxStyle, CheckboxStyleType } from "comps/controls/styleControlConstants"; +import { useStyle } from "comps/controls/styleControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { TableCheckedIcon, TableUnCheckedIcon } from "lowcoder-design"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; + +const CheckboxStyled = styled(Checkbox)<{ $style: CheckboxStyleType }>` + ${(props) => props.$style && getStyle(props.$style)} +`; + +const Wrapper = styled.div` + background: transparent !important; + padding: 0 8px; +`; + +const IconWrapper = styled.span<{ $style: CheckboxStyleType; $ifChecked: boolean }>` + // pointer-events: none; + height: 22px; + display: inline-block; + svg { + width: 14px; + height: 22px; + g { + stroke: ${(props) => props.$ifChecked && props.$style.checkedBackground} !important; + } + } +`; + +const falseValuesOptions = [ + { + label: trans("table.empty"), + value: "", + }, + { + label: "-", + value: "-", + }, + { + label: , + value: "x", + }, +] as const; + +const childrenMap = { + text: BoolCodeControl, + falseValues: dropdownControl(falseValuesOptions, ""), + iconTrue: IconControl, + iconFalse: IconControl, + iconNull: IconControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type CheckBoxEditPropsType = { + value: boolean; + onChange: (value: boolean) => void; + onChangeEnd: () => void; +}; + +// Memoized checkbox edit component +const CheckBoxEdit = React.memo((props: CheckBoxEditPropsType) => { + const mountedRef = useRef(true); + const style = useStyle(CheckboxStyle); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!mountedRef.current) return; + if (e.key === "Enter") { + props.onChangeEnd(); + } + }, [props.onChangeEnd]); + + const handleChange = useCallback((e: CheckboxChangeEvent) => { + if (!mountedRef.current) return; + props.onChange(e.target.checked); + }, [props.onChange]); + + return ( + + + + ); +}); + +CheckBoxEdit.displayName = 'CheckBoxEdit'; + +// Memoized checkbox view component +const CheckBoxView = React.memo(({ + value, + iconTrue, + iconFalse, + iconNull, + falseValues +}: { + value: boolean; + iconTrue: React.ReactNode; + iconFalse: React.ReactNode; + iconNull: React.ReactNode; + falseValues: string; +}) => { + const style = useStyle(CheckboxStyle); + + const content = useMemo(() => { + if (value === true) { + return hasIcon(iconTrue) ? iconTrue : ; + } else if (value === false) { + return hasIcon(iconFalse) ? iconFalse : (falseValues === "x" ? : falseValues); + } else { + return hasIcon(iconNull) ? iconNull : "No Value"; + } + }, [value, iconTrue, iconFalse, iconNull, falseValues]); + + return ( + + {content} + + ); +}); + +CheckBoxView.displayName = 'CheckBoxView'; + +export const BooleanComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + + ); + }) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.falseValues.propertyView({ + label: trans("table.falseValues"), + radioButton: true, + })} + {children.iconTrue.propertyView({ + label: trans("table.iconTrue"), + })} + {children.iconFalse.propertyView({ + label: trans("table.iconFalse"), + })} + {children.iconNull.propertyView({ + label: trans("table.iconNull"), + })} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx new file mode 100644 index 0000000000..55168b1515 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx @@ -0,0 +1,283 @@ +import { default as DatePicker } from "antd/es/date-picker"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { withDefault } from "comps/generators"; +import { formatPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { isNumber } from "lodash"; +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { CalendarCompIconSmall, PrevIcon, SuperPrevIcon } from "lowcoder-design"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { DateParser, DATE_FORMAT } from "util/dateTimeUtils"; + +dayjs.extend(utc) + +const IconNext = styled(PrevIcon)` + transform: rotate(180deg); +`; +const IconSuperNext = styled(SuperPrevIcon)` + transform: rotate(180deg); +`; + +const DatePickerStyled = styled(DatePicker)<{ $open: boolean }>` + width: 100%; + height: 100%; + position: absolute; + top: 0; + padding: 0; + padding-left: 11px; + .ant-picker-input { + height: 100%; + } + input { + padding-right: 18px; + cursor: pointer; + } + &.ant-picker-focused .ant-picker-suffix svg g { + stroke: ${(props) => props.$open && "#315EFB"}; + } + .ant-picker-suffix { + height: calc(100% - 1px); + position: absolute; + right: 0; + top: 0.5px; + display: flex; + align-items: center; + padding: 0 3px; + } +`; + +const StylePanel = styled.div` + .ant-picker-header { + padding: 0 12px; + .ant-picker-header-super-prev-btn, + .ant-picker-header-prev-btn, + .ant-picker-header-next-btn, + .ant-picker-header-super-next-btn { + display: flex; + align-items: center; + justify-content: center; + svg { + max-width: 12px; + max-height: 12px; + } + &:hover svg g { + fill: #315efb; + } + } + } + .ant-picker-date-panel .ant-picker-body { + padding: 8px 16px; + } + .ant-picker-ranges { + padding: 10px 16px; + } + .ant-picker-now-btn { + color: #4965f2; + &:hover { + color: #315efb; + } + } + .ant-picker-cell { + color: #b8b9bf; + } + .ant-picker-cell-in-view { + color: rgba(0, 0, 0, 0.85); + } + .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner, + .ant-picker-ok .ant-btn-primary { + background: #4965f2; + border: none; + box-shadow: none; + &:hover { + background: #315efb; + border: none; + box-shadow: none; + } + } + .ant-picker-cell:hover:not(.ant-picker-cell-in-view) .ant-picker-cell-inner, + .ant-picker-cell:hover:not(.ant-picker-cell-selected):not(.ant-picker-cell-range-start):not(.ant-picker-cell-range-end):not(.ant-picker-cell-range-hover-start):not(.ant-picker-cell-range-hover-end) + .ant-picker-cell-inner { + background-color: #f2f7fc; + color: #4965f2; + } + .ant-picker-year-panel, + .ant-picker-month-panel { + & + div .ant-picker-now { + display: none; + } + } +`; + +const DatePickerPopup = styled.div` + border-radius: 8px; + box-shadow: 0 0 10px 0 rgba(0,0,0,0.10); + overflow: hidden; +`; + +const Wrapper = styled.div` + background: transparent !important; +`; + +export function formatDate(date: string, format: string) { + let mom = dayjs(date); + if (isNumber(Number(date)) && !isNaN(Number(date)) && date !== "") { + mom = dayjs(Number(date)); + } + if (!mom.isValid()) { + mom = dayjs.utc(date).local(); + } + + return mom.isValid() ? mom.format(format) : ""; +} + +const childrenMap = { + text: StringControl, + format: withDefault(StringControl, DATE_FORMAT), + inputFormat: withDefault(StringControl, DATE_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type DateEditProps = { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + showTime: boolean; + inputFormat: string; +}; + +// Memoized DateEdit component +export const DateEdit = React.memo((props: DateEditProps) => { + const pickerRef = useRef(); + const mountedRef = useRef(true); + const [panelOpen, setPanelOpen] = useState(true); + + // Initialize tempValue with proper validation + const [tempValue, setTempValue] = useState(() => { + const initialValue = dayjs(props.value, DateParser); + return initialValue.isValid() ? initialValue : dayjs(0, DateParser); + }); + + // Memoize event handlers + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!mountedRef.current) return; + if (e.key === "Enter" && !panelOpen) { + props.onChangeEnd(); + } + }, [panelOpen, props.onChangeEnd]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (!mountedRef.current) return; + e.stopPropagation(); + e.preventDefault(); + }, []); + + const handleOpenChange = useCallback((open: boolean) => { + if (!mountedRef.current) return; + setPanelOpen(open); + }, []); + + const handleChange = useCallback((value: dayjs.Dayjs | null, dateString: string | string[]) => { + if (!mountedRef.current) return; + props.onChange(dateString as string); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + // Update tempValue when props.value changes + useEffect(() => { + if (!mountedRef.current) return; + + const newValue = props.value ? dayjs(props.value, DateParser) : null; + if (newValue?.isValid()) { + setTempValue(newValue); + } + }, [props.value]); + + // Cleanup event listeners and state + useEffect(() => { + return () => { + mountedRef.current = false; + setTempValue(null); + if (pickerRef.current) { + pickerRef.current = null; + } + }; + }, []); + + return ( + + } + prevIcon={} + nextIcon={} + superNextIcon={} + superPrevIcon={} + format={props.inputFormat} + allowClear={true} + variant="borderless" + autoFocus + value={tempValue} + showTime={props.showTime} + showNow={true} + defaultOpen={true} + panelRender={(panelNode) => ( + + {panelNode} + + )} + onOpenChange={handleOpenChange} + onChange={(date: unknown, dateString: string | string[]) => handleChange(date as Dayjs | null, dateString)} + onBlur={handleBlur} + /> + + ); +}); + +DateEdit.displayName = 'DateEdit'; + +export const DateComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return formatDate(value, props.format); + }, + (nodeValue) => formatDate(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {formatPropertyView({ children, placeholder: DATE_FORMAT })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx new file mode 100644 index 0000000000..181fccba7b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx @@ -0,0 +1,90 @@ +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { withDefault } from "comps/generators"; +import { formatPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { DATE_TIME_FORMAT } from "util/dateTimeUtils"; +import { DateEdit, formatDate } from "./columnDateComp"; +import React, { useCallback, useEffect, useRef } from "react"; + +const childrenMap = { + text: StringControl, + format: withDefault(StringControl, DATE_TIME_FORMAT), + inputFormat: withDefault(StringControl, DATE_TIME_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +// Memoized DateTimeEdit component +const DateTimeEdit = React.memo((props: { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + inputFormat: string; +}) => { + const mountedRef = useRef(true); + + // Memoize event handlers + const handleChange = useCallback((value: string) => { + if (!mountedRef.current) return; + props.onChange(value); + }, [props.onChange]); + + const handleChangeEnd = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + return ( + + ); +}); + +DateTimeEdit.displayName = 'DateTimeEdit'; + +export const DateTimeComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return formatDate(value, props.format); + }, + (nodeValue) => formatDate(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {formatPropertyView({ children, placeholder: DATE_TIME_FORMAT })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx new file mode 100644 index 0000000000..b78601a5fa --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx @@ -0,0 +1,200 @@ +import React, { ReactNode, useCallback, useRef, useEffect, useMemo, ReactElement } from "react"; +import { DropdownOptionControl } from "comps/controls/optionsControl"; +import { StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import Menu from "antd/es/menu"; +import Dropdown from "antd/es/dropdown"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { IconControl } from "comps/controls/iconControl"; +import { withDefault } from "comps/generators"; +import { IconWrapper } from "util/bottomResUtils"; +import { ButtonTypeOptions } from "../simpleColumnTypeComps"; +import { useStyle } from "comps/controls/styleControl"; +import { ButtonStyle } from "comps/controls/styleControlConstants"; +import { Button100 } from "comps/comps/buttonComp/buttonCompConstants"; +import styled from "styled-components"; +import { ButtonType } from "antd/es/button"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const StyledButton = styled(Button100)` + display: flex; + align-items: center; + gap: 0; + min-width: 30px; + width: auto; +`; + +const StyledIconWrapper = styled(IconWrapper)` + margin: 0; +`; + +const DropdownEventOptions = [clickEvent] as const; + +const childrenMap = { + buttonType: dropdownControl(ButtonTypeOptions, "primary"), + label: withDefault(StringControl, 'Menu'), + prefixIcon: IconControl, + suffixIcon: IconControl, + options: DropdownOptionControl, + onEvent: eventHandlerControl(DropdownEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.label; + +// Memoized dropdown menu component +const DropdownMenu = React.memo(({ items, options, onEvent }: { items: any[]; options: any[]; onEvent: (eventName: string) => void }) => { + const mountedRef = useRef(true); + const handleClickEvent = useCompClickEventHandler({onEvent}) + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(({ key }: { key: string }) => { + if (!mountedRef.current) return; + const item = items.find((o) => o.key === key); + const itemIndex = options.findIndex(option => option.label === item?.label); + item && options[itemIndex]?.onEvent("click"); + // Also trigger the dropdown's main event handler + handleClickEvent(); + }, [items, options, handleClickEvent]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, []); + + return ( + + ); +}); + +DropdownMenu.displayName = 'DropdownMenu'; + +const DropdownView = React.memo((props: { + buttonType: ButtonType; + label: string; + prefixIcon: ReactNode; + suffixIcon: ReactNode; + options: any[]; + onEvent?: (eventName: string) => void; +}) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const hasOptionIcon = useMemo(() => + props.options.findIndex((option) => (option.prefixIcon as ReactElement)?.props.value) > -1, + [props.options] + ); + + const items = useMemo(() => + props.options + .filter((option) => !option.hidden) + .map((option, index) => ({ + title: option.label, + label: option.label, + key: option.label + " - " + index, + disabled: option.disabled, + icon: hasOptionIcon && {option.prefixIcon}, + index, + })), + [props.options, hasOptionIcon] + ); + + const hasPrefixIcon = useMemo(() => + (props.prefixIcon as ReactElement)?.props.value, + [props.prefixIcon] + ); + + const hasSuffixIcon = useMemo(() => + (props.suffixIcon as ReactElement)?.props.value, + [props.suffixIcon] + ); + + const buttonStyle = useStyle(ButtonStyle); + + const menu = useMemo(() => ( + {})} /> + ), [items, props.options, props.onEvent]); + + return ( + menu} + > + + {hasPrefixIcon && ( + + {props.prefixIcon} + + )} + {props.label || (hasPrefixIcon || hasSuffixIcon ? undefined : " ")} + {hasSuffixIcon && ( + + {props.suffixIcon} + + )} + + + ); +}); + +DropdownView.displayName = 'DropdownView'; + +export const ColumnDropdownComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props) => { + return ; + }, + (nodeValue) => nodeValue.label.value, + getBaseValue, + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.buttonType.propertyView({ + label: trans("table.type"), + radioButton: true, + })} + {children.label.propertyView({ + label: trans("text"), + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.options.propertyView({ + title: trans("optionsControl.optionList"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx new file mode 100644 index 0000000000..d3d2041016 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx @@ -0,0 +1,143 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Input } from "antd/es/input"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { StringControl, NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { withDefault } from "comps/generators"; +import { TacoImage } from "lowcoder-design"; +import styled from "styled-components"; +import { DEFAULT_IMG_URL } from "@lowcoder-ee/util/stringUtils"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; + +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +const childrenMap = { + src: withDefault(StringControl, "{{currentCell}}"), + size: withDefault(NumberControl, "50"), + onEvent: eventHandlerControl([clickEvent]), +}; + +const StyledTacoImage = styled(TacoImage)` + pointer-events: auto !important; + cursor: pointer !important; + + &:hover { + opacity: 0.8; + transition: opacity 0.2s ease; + } +`; + +// Memoized image component +const ImageView = React.memo(({ src, size, onEvent }: { src: string; size: number; onEvent?: (eventName: string) => void }) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(() => { + console.log("Image clicked!", { src, onEvent: !!onEvent }); // Debug log + if (mountedRef.current && onEvent) { + onEvent("click"); + } + }, [onEvent, src]); + + return ( + + ); +}); + +ImageView.displayName = 'ImageView'; + +// Memoized edit component +const ImageEdit = React.memo(({ value, onChange, onChangeEnd }: { value: string; onChange: (value: string) => void; onChangeEnd: () => void }) => { + const mountedRef = useRef(true); + const [currentValue, setCurrentValue] = useState(value); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((e: React.ChangeEvent) => { + if (mountedRef.current) { + const newValue = e.target.value; + setCurrentValue(newValue); + onChange(newValue); + } + }, [onChange]); + + const handleBlur = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + return ( + + ); +}); + +ImageEdit.displayName = 'ImageEdit'; + +const getBaseValue: ColumnTypeViewFn = (props) => props.src; + +export const ImageComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.src.value, + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .setPropertyViewFn((children) => { + return ( + <> + {children.src.propertyView({ + label: trans("table.imageSrc"), + tooltip: ColumnValueTooltip, + })} + {children.size.propertyView({ + label: trans("table.imageSize"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx new file mode 100644 index 0000000000..e93b3082a6 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx @@ -0,0 +1,145 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Input } from "antd/es/input"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { disabledPropertyView } from "comps/utils/propertyUtils"; +import styled, { css } from "styled-components"; +import { styleControl } from "comps/controls/styleControl"; +import { TableColumnLinkStyle } from "comps/controls/styleControlConstants"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { fixOldActionData } from "comps/comps/tableComp/column/simpleColumnTypeComps"; + +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +const LinkEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: StringControl, + onClick: eventHandlerControl(LinkEventOptions), + disabled: BoolCodeControl, + style: styleControl(TableColumnLinkStyle), +}; + +const disableCss = css` + &, + &:hover { + cursor: not-allowed; + color: rgba(0, 0, 0, 0.25) !important; + } +`; + +const StyledLink = styled.a<{ $disabled: boolean }>` + ${(props) => props.$disabled && disableCss}; +`; + +// Memoized link component +export const ColumnLink = React.memo(({ disabled, label, onClick }: { disabled: boolean; label: string; onClick: (eventName: string) => void }) => { + const handleClickEvent = useCompClickEventHandler({onEvent: onClick}) + const handleClick = useCallback(() => { + if (!disabled) { + handleClickEvent(); + } + }, [disabled, onClick]); + + return ( + + {label} + + ); +}); + +ColumnLink.displayName = 'ColumnLink'; + +// Memoized edit component +const LinkEdit = React.memo(({ value, onChange, onChangeEnd }: { value: string; onChange: (value: string) => void; onChangeEnd: () => void }) => { + const mountedRef = useRef(true); + const [currentValue, setCurrentValue] = useState(value); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((e: React.ChangeEvent) => { + if (mountedRef.current) { + const newValue = e.target.value; + setCurrentValue(newValue); + onChange(newValue); + } + }, [onChange]); + + const handleBlur = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + return ( + + ); +}); + +LinkEdit.displayName = 'LinkEdit'; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +const LinkCompTmp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + )) + .setStylePropertyViewFn((children) => ( + <> + {children.style.getPropertyView()} + + )) + .build(); +})(); + +export const LinkComp = migrateOldData(LinkCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx new file mode 100644 index 0000000000..5a7fae3d3e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Menu } from "antd/es/menu"; +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { manualOptionsControl } from "comps/controls/optionsControl"; +import { MultiCompBuilder } from "comps/generators"; +import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { ColumnLink } from "comps/comps/tableComp/column/columnTypeComps/columnLinkComp"; +import { LightActiveTextColor, PrimaryColor } from "constants/style"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { fixOldActionData } from "comps/comps/tableComp/column/simpleColumnTypeComps"; + +const MenuLinkWrapper = styled.div` + > a { + color: ${PrimaryColor} !important; + + &:hover { + color: ${LightActiveTextColor} !important; + } + } +`; + +const MenuWrapper = styled.div` + ul { + background: transparent !important; + border-bottom: 0; + + li { + padding: 0 10px 0 0 !important; + line-height: normal !important; + + &::after { + content: none !important; + } + } + } +`; + +const LinkEventOptions = [clickEvent, doubleClickEvent] as const; + +// Memoized menu item component +const MenuItem = React.memo(({ option, index }: { option: any; index: number }) => { + return ( + + + + ); +}); + +MenuItem.displayName = 'MenuItem'; + +const OptionItemTmp = new MultiCompBuilder( + { + label: StringControl, + onClick: eventHandlerControl(LinkEventOptions), + hidden: BoolCodeControl, + disabled: BoolCodeControl, + }, + (props) => { + return props; + } +) + .setPropertyViewFn((children) => { + return ( + <> + {children.label.propertyView({ label: trans("label") })} + {hiddenPropertyView(children)} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + ); + }) + .build(); + +const OptionItem = migrateOldData(OptionItemTmp, fixOldActionData); + +// Memoized menu component +const LinksMenu = React.memo(({ options }: { options: any[] }) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const menuItems = useMemo(() => + options + .filter((o) => !o.hidden) + .map((option, index) => ({ + key: index, + label: + })), + [options] + ); + + return ( + + + + ); +}); + +LinksMenu.displayName = 'LinksMenu'; + +const ColumnLinksCompTmp = (function () { + const childrenMap = { + options: manualOptionsControl(OptionItem, { + initOptions: [{ label: trans("table.option1") }], + }), + }; + return new ColumnTypeCompBuilder( + childrenMap, + (props) => { + return ; + }, + () => "" + ) + .setPropertyViewFn((children) => ( + <> + {children.options.propertyView({ + newOptionLabel: trans("table.option"), + title: trans("table.optionList"), + })} + + )) + .build(); +})(); + +export const ColumnLinksComp = migrateOldData(ColumnLinksCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx new file mode 100644 index 0000000000..17ad78efd3 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx @@ -0,0 +1,128 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Input } from "antd/es/input"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { markdownCompCss, TacoMarkDown } from "lowcoder-design"; +import styled from "styled-components"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; + +const Wrapper = styled.div` + ${markdownCompCss}; + max-height: 32px; + cursor: pointer; + + > .markdown-body { + margin: 0; + p { + line-height: 21px; + } + } +`; + +const MarkdownEventOptions = [clickEvent] as const; + +const childrenMap = { + text: StringControl, + onEvent: eventHandlerControl(MarkdownEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +// Memoized markdown view component +const MarkdownView = React.memo(({ value, onEvent }: { value: string; onEvent?: (eventName: string) => void }) => { + const handleClick = useCallback(() => { + if (onEvent) { + onEvent("click"); + } + }, [onEvent]); + + return ( + + {value} + + ); +}); + +MarkdownView.displayName = 'MarkdownView'; + +// Memoized edit component with proper cleanup +const MarkdownEdit = React.memo((props: { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; +}) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(''); + }; + }, []); + + const handleChange = useCallback((e: React.ChangeEvent) => { + if (!mountedRef.current) return; + const value = e.target.value; + props.onChange(value); + setCurrentValue(value); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + return ( + + ); +}); + +MarkdownEdit.displayName = 'MarkdownEdit'; + +export const ColumnMarkdownComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx new file mode 100644 index 0000000000..7e06f4c8ee --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx @@ -0,0 +1,166 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { default as InputNumber } from "antd/es/input-number"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { ProgressStyle } from "comps/controls/styleControlConstants"; +import { useStyle } from "comps/controls/styleControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { ProgressStyled as Progress } from "comps/comps/progressComp"; +import { TableMinusIcon, TablePlusIcon } from "lowcoder-design"; +import styled from "styled-components"; + +const ProgressStyled = styled(Progress)` + display: flex; + align-items: center; + .ant-progress-outer { + height: 22px; + display: flex; + align-items: center; + } + .ant-progress-text { + margin-left: 6px; + } +`; + +const InputNumberStyled = styled(InputNumber)` + background: transparent !important; + width: 100%; + height: 100%; + position: absolute; + top: 0; + .ant-input-number-input-wrap { + height: 100%; + display: flex; + align-items: center; + } + .ant-input-number-handler-wrap { + top: 1.5px; + right: 1.5px; + height: calc(100% - 3px); + border-radius: 0; + } + .ant-input-number-handler-up { + border-bottom: 1px solid #d7d9e0; + } + .ant-input-number-handler-up, + .ant-input-number-handler-down { + display: flex; + align-items: center; + justify-content: center; + > span { + width: 16px; + height: 16px; + margin-top: 0; + position: unset; + transform: none; + } + &:hover { + &:not(.ant-input-number-handler-up-disabled):not(.ant-input-number-handler-down-disabled) + path { + fill: #315efb; + } + } + } +`; + +const childrenMap = { + text: NumberControl, + showValue: BoolControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type ProgressEditProps = { + value: number; + onChange: (value: number) => void; + onChangeEnd: () => void; +}; + +const ProgressEdit = React.memo((props: ProgressEditProps) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(0); + }; + }, []); + + const handleChange = useCallback((value: string | number | null) => { + if (!mountedRef.current) return; + const newValue = typeof value === 'number' ? value : 0; + props.onChange(newValue); + setCurrentValue(newValue); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + return ( + , downIcon: }} + onChange={handleChange} + onBlur={handleBlur} + onPressEnter={handlePressEnter} + /> + ); +}); + +ProgressEdit.displayName = 'ProgressEdit'; + +export const ProgressComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + const Progress = () => { + const style = useStyle(ProgressStyle); + return ( + + ); + }; + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + + ); + }) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.showValue.propertyView({ + label: trans("table.showValue"), + })} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx new file mode 100644 index 0000000000..fc44cd9367 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import styled from "styled-components"; +import { default as Rate } from "antd/es/rate"; + +const RateStyled = styled(Rate)<{ isEdit?: boolean }>` + display: inline-flex; + align-items: center; + width: 100%; + overflow-x: auto; + overflow-x: overlay; + color: #ffd400; + display: block; + .ant-rate-star > div { + height: 18px; + width: 18px; + } + .ant-rate-star-half .ant-rate-star-first, + .ant-rate-star-full .ant-rate-star-second { + color: #ffd400; + position: absolute; + } + .ant-rate-star-first { + width: 100%; + } + .ant-rate-star-first, + .ant-rate-star-second { + display: inline-flex; + align-items: center; + color: #d7d9e0; + max-height: 20px; + bottom: 0; + } + svg { + height: 18px; + width: 18px; + } +`; + +const Wrapper = styled.div` + background: transparent !important; + padding: 0 8px; +`; + +const childrenMap = { + text: NumberControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type RatingEditProps = { + value: number; + onChange: (value: number) => void; + onChangeEnd: () => void; +}; + +const RatingEdit = React.memo((props: RatingEditProps) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(0); + }; + }, []); + + const handleChange = useCallback((value: number) => { + if (!mountedRef.current) return; + props.onChange(value); + setCurrentValue(value); + }, [props.onChange]); + + const handleBlur = useCallback((e: React.FocusEvent) => { + if (!mountedRef.current) return; + if (!e.currentTarget?.contains(e.relatedTarget)) { + props.onChangeEnd(); + } + }, [props.onChangeEnd]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!mountedRef.current) return; + if (e.key === "Enter") { + props.onChangeEnd(); + } + }, [props.onChangeEnd]); + + return ( + + + + ); +}); + +RatingEdit.displayName = 'RatingEdit'; + +export const RatingComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + + ); + }) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx new file mode 100644 index 0000000000..b54be87997 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx @@ -0,0 +1,232 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; + +import { SelectUIView } from "comps/comps/selectInputComp/selectCompConstants"; +import { StringControl, BoolCodeControl } from "comps/controls/codeControl"; +import { IconControl } from "comps/controls/iconControl"; +import { MultiCompBuilder } from "comps/generators"; +import { optionsControl } from "comps/controls/optionsControl"; +import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { styled } from "styled-components"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; + +const Wrapper = styled.div` + display: inline-flex; + align-items: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + background: transparent !important; + padding: 8px; + + > div { + width: 100%; + height: 100%; + } + + .ant-select { + height: 100%; + .ant-select-selector { + padding: 0 7px; + height: 100%; + overflow: hidden; + .ant-select-selection-item { + display: inline-flex; + align-items: center; + padding-right: 24px; + } + } + .ant-select-arrow { + height: calc(100% - 3px); + width: fit-content; + top: 1.5px; + margin-top: 0; + background-color: white; + right: 1.5px; + border-right: 1px solid #d7d9e0; + cursor: pointer; + pointer-events: auto; + svg { + min-width: 18px; + min-height: 18px; + } + &:hover svg path { + fill: #315efb; + } + } + .ant-select-selector .ant-select-selection-search { + left: 7px; + input { + height: 100%; + } + } + &.ant-select-open { + .ant-select-arrow { + border-right: none; + border-left: 1px solid #d7d9e0; + svg g path { + fill: #315efb; + } + } + .ant-select-selection-item { + opacity: 0.4; + } + } + } +`; + +const SelectOptionEventOptions = [clickEvent, doubleClickEvent] as const; + +// Create a new option type with event handlers for each option +const SelectOptionWithEvents = new MultiCompBuilder( + { + value: StringControl, + label: StringControl, + prefixIcon: IconControl, + disabled: BoolCodeControl, + hidden: BoolCodeControl, + onEvent: eventHandlerControl(SelectOptionEventOptions), + }, + (props) => props +) + .setPropertyViewFn((children) => ( + <> + {children.label.propertyView({ label: trans("label") })} + {children.value.propertyView({ label: trans("value") })} + {children.prefixIcon.propertyView({ label: trans("button.prefixIcon") })} + {disabledPropertyView(children)} + {hiddenPropertyView(children)} + {children.onEvent.propertyView()} + + )) + .build(); + +const SelectOptionWithEventsControl = optionsControl(SelectOptionWithEvents, { + initOptions: [ + { label: trans("optionsControl.optionI", { i: 1 }), value: "1" }, + { label: trans("optionsControl.optionI", { i: 2 }), value: "2" }, + ], + uniqField: "value", +}); + +const childrenMap = { + text: StringControl, + options: SelectOptionWithEventsControl, + onEvent: eventHandlerControl(SelectOptionEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type SelectEditProps = { + initialValue: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + options: any[]; + onMainEvent?: (eventName: string) => void; +}; + +const SelectEdit = React.memo((props: SelectEditProps) => { + const [currentValue, setCurrentValue] = useState(props.initialValue); + const mountedRef = useRef(true); + const defaultProps: any = {}; + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(''); + }; + }, []); + + const handleChange = useCallback((val: string) => { + if (!mountedRef.current) return; + props.onChange(val); + setCurrentValue(val); + + // Trigger the specific option's event handler + const selectedOption = props.options.find(option => option.value === val); + if (selectedOption?.onEvent) { + selectedOption.onEvent("click"); + } + + // Also trigger the main component's event handler + if (props.onMainEvent) { + props.onMainEvent("click"); + } + }, [props.onChange, props.options, props.onMainEvent]); + + const handleEvent = useCallback(async (eventName: string) => { + if (!mountedRef.current) return [] as unknown[]; + if (eventName === "blur") { + props.onChangeEnd(); + } + return [] as unknown[]; + }, [props.onChangeEnd]); + + const memoizedOptions = useMemo(() => props.options, [props.options]); + + return ( + + ); +}); + +SelectEdit.displayName = 'SelectEdit'; + +export const ColumnSelectComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + const option = props.options.find(x => x.value === value); + return ( + <> + {option?.prefixIcon} + {option?.label} + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue, + ) + .setEditViewFn((props) => { + return ( + + + + ) + }) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.options.propertyView({ + title: trans("optionsControl.optionList"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx new file mode 100644 index 0000000000..61a8fbc6f8 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx @@ -0,0 +1,192 @@ +import { default as Badge } from "antd/es/badge"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { trans } from "i18n"; +import { StringControl, stringUnionControl } from "comps/controls/codeControl"; +import { DropdownStyled, Wrapper } from "./columnTagsComp"; +import React, { ReactNode, useContext, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { StatusContext } from "components/table/EditableCell"; +import { CustomSelect, PackUpIcon, ScrollBar } from "lowcoder-design"; +import { PresetStatusColorType } from "antd/es/_util/colors"; + +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +export const BadgeStatusOptions = [ + "none", + "success", + "error", + "default", + "warning", + "processing", +] as const; + +export type StatusType = PresetStatusColorType | "none"; + +const childrenMap = { + text: StringControl, + status: stringUnionControl(BadgeStatusOptions, "none"), +}; + +const getBaseValue: ColumnTypeViewFn< + typeof childrenMap, + { value: string; status: StatusType }, + { value: string; status: StatusType } +> = (props) => ({ + value: props.text, + status: props.status, +}); + +type StatusEditPropsType = { + value: { value: string; status: StatusType }; + onChange: (value: { value: string; status: StatusType }) => void; + onChangeEnd: () => void; +}; + +const StatusEdit = React.memo((props: StatusEditPropsType) => { + const defaultStatus = useContext(StatusContext); + const [status, setStatus] = useState>(() => { + const result: Array<{ text: string; status: StatusType }> = []; + defaultStatus.forEach((item) => { + if (item.text.includes(",")) { + item.text.split(",").forEach((tag) => result.push({ text: tag, status: "none" })); + } + result.push({ text: item.text, status: item.status }); + }); + return result; + }); + const [open, setOpen] = useState(false); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setStatus([]); + setOpen(false); + }; + }, []); + + // Update status when defaultStatus changes + useEffect(() => { + if (!mountedRef.current) return; + + const result: Array<{ text: string; status: StatusType }> = []; + defaultStatus.forEach((item) => { + if (item.text.includes(",")) { + item.text.split(",").forEach((tag) => result.push({ text: tag, status: "none" })); + } + result.push({ text: item.text, status: item.status }); + }); + setStatus(result); + }, [defaultStatus]); + + const handleSearch = useCallback((value: string) => { + if (!mountedRef.current) return; + + if (defaultStatus.findIndex((item) => item.text.includes(value)) < 0) { + setStatus([...defaultStatus, { text: value, status: "none" }]); + } else { + setStatus(defaultStatus); + } + props.onChange({ + value, + status: status.find((item) => item.text === value)?.status || "none", + }); + }, [defaultStatus, status, props.onChange]); + + const handleChange = useCallback((value: string) => { + if (!mountedRef.current) return; + props.onChange({ + value, + status: status.find((item) => item.text === value)?.status || "none", + }); + setOpen(false); + }, [status, props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + setOpen(false); + }, [props.onChangeEnd]); + + const handleFocus = useCallback(() => { + if (!mountedRef.current) return; + setOpen(true); + }, []); + + const handleClick = useCallback(() => { + if (!mountedRef.current) return; + setOpen(!open); + }, [open]); + + const memoizedOptions = useMemo(() => + BadgeStatusOptions.map((value, index) => ( + + {value === "none" ? value : } + + )), + [] + ); + + return ( + + } + showSearch + onSearch={handleSearch} + onChange={handleChange} + popupRender={(originNode: ReactNode) => ( + + {originNode} + + )} + styles={{ popup: { root: { marginTop: "7px", padding: "8px 0 6px 0" }}}} + onBlur={handleBlur} + onFocus={handleFocus} + onClick={handleClick} + > + {memoizedOptions} + + + ); +}); + +StatusEdit.displayName = 'StatusEdit'; + +export const BadgeStatusComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const text = props.changeValue?.value ?? getBaseValue(props, dispatch).value; + const status = props.changeValue?.status ?? getBaseValue(props, dispatch).status; + return status === "none" ? text : ; + }, + (nodeValue) => [nodeValue.status.value, nodeValue.text.value].filter((t) => t).join(" "), + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.status.propertyView({ + label: trans("table.status"), + tooltip: trans("table.statusTooltip"), + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx new file mode 100644 index 0000000000..0cdeee48a4 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx @@ -0,0 +1,168 @@ +import { BoolCodeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { InputFieldStyle } from "comps/controls/styleControlConstants"; +import styled from "styled-components"; +import { default as Switch } from "antd/es/switch"; +import { styleControl } from "comps/controls/styleControl"; +import { RefControl } from "comps/controls/refControl"; +import { booleanExposingStateControl } from "comps/controls/codeStateControl"; +import { changeEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { disabledPropertyView } from "comps/utils/propertyUtils"; +import React, { useCallback, useRef, useEffect } from "react"; + +const EventOptions = [ + changeEvent, + { + label: trans("switchComp.open"), + value: "true", + description: trans("switchComp.openDesc"), + }, + { + label: trans("switchComp.close"), + value: "false", + description: trans("switchComp.closeDesc"), + }, +] as const; + +const Wrapper = styled.div` + background: transparent !important; + padding: 0 8px; +` + +const childrenMap = { + value: booleanExposingStateControl("value"), + switchState: BoolCodeControl, + onEvent: eventHandlerControl(EventOptions), + disabled: BoolCodeControl, + style: styleControl(InputFieldStyle), + // viewRef: RefControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.switchState; + +const SwitchView = React.memo(({ value, disabled, onEvent, valueControl }: { + value: boolean; + disabled: boolean; + // viewRef: (viewRef: HTMLButtonElement | null) => void; + onEvent: (event: string) => void; + valueControl: { onChange: (value: boolean) => void }; +}) => { + const mountedRef = useRef(true); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((checked: boolean) => { + if (!mountedRef.current) return; + valueControl.onChange(checked); + onEvent("change"); + onEvent(checked ? "true" : "false"); + }, [valueControl, onEvent]); + + return ( + + ); +}); + +SwitchView.displayName = 'SwitchView'; + +const SwitchEdit = React.memo(({ value, onChange, onChangeEnd }: { + value: boolean; + onChange: (value: boolean) => void; + onChangeEnd: () => void; +}) => { + const mountedRef = useRef(true); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((checked: boolean) => { + if (!mountedRef.current) return; + onChange(checked); + }, [onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + onChangeEnd(); + }, [onChangeEnd]); + + return ( + + + + ); +}); + +SwitchEdit.displayName = 'SwitchEdit'; + +export const SwitchComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.switchState.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + { + props.onChangeEnd() + }} + > + { + props.onChange(checked); + props.otherProps?.onEvent?.("change"); + props.otherProps?.onEvent?.(checked ? "true" : "false"); + }} + /> + + ); + }) + .setPropertyViewFn((children) => { + return ( + <> + {children.switchState.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.onEvent.propertyView()} + {disabledPropertyView(children)} + + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx new file mode 100644 index 0000000000..1e6a6e1a8a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx @@ -0,0 +1,465 @@ +import { default as Tag } from "antd/es/tag"; +import { PresetStatusColorTypes } from "antd/es/_util/colors"; +import { TagsContext } from "components/table/EditableCell"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { codeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import styled from "styled-components"; +import _ from "lodash"; +import React, { ReactNode, useContext, useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { toJson } from "really-relaxed-json"; +import { hashToNum } from "util/stringUtils"; +import { CustomSelect, PackUpIcon } from "lowcoder-design"; +import { ScrollBar } from "lowcoder-design"; +import { ColoredTagOptionControl } from "comps/controls/optionsControl"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; + +const colors = PresetStatusColorTypes; + +const isStringArray = (value: any) => { + return ( + _.isArray(value) && + value.every((v) => { + const type = typeof v; + return type === "string" || type === "number" || type === "boolean"; + }) + ); +}; + +// accept string, number, boolean and array input +const TagsControl = codeControl | string>( + (value) => { + if (isStringArray(value)) { + return value; + } + const valueType = typeof value; + if (valueType === "string") { + try { + const result = JSON.parse(toJson(value)); + if (isStringArray(result)) { + return result; + } + return value; + } catch (e) { + return value; + } + } else if (valueType === "number" || valueType === "boolean") { + return value; + } + throw new TypeError( + `Type "Array | string" is required, but find value: ${JSON.stringify(value)}` + ); + }, + { expectedType: "string | Array", codeType: "JSON" } +); + +function getTagColor(tagText : any, tagOptions: any[]) { + const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + if (foundOption.colorType === "preset") { + return foundOption.presetColor; + } else if (foundOption.colorType === "custom") { + return undefined; // For custom colors, we'll use style instead + } + // Backward compatibility - if no colorType specified, assume it's the old color field + return foundOption.color; + } + // Default fallback + const index = Math.abs(hashToNum(tagText)) % colors.length; + return colors[index]; +} + +function getTagStyle(tagText: any, tagOptions: any[]) { + const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + const style: any = {}; + + // Handle color styling + if (foundOption.colorType === "custom") { + style.backgroundColor = foundOption.color; + style.color = foundOption.textColor; + style.border = `1px solid ${foundOption.color}`; + } + + // Add border styling if specified + if (foundOption.border) { + style.borderColor = foundOption.border; + if (!foundOption.colorType || foundOption.colorType !== "custom") { + style.border = `1px solid ${foundOption.border}`; + } + } + + // Add border radius if specified + if (foundOption.radius) { + style.borderRadius = foundOption.radius; + } + + // Add margin if specified + if (foundOption.margin) { + style.margin = foundOption.margin; + } + + // Add padding if specified + if (foundOption.padding) { + style.padding = foundOption.padding; + } + + return style; + } + return {}; +} + +function getTagIcon(tagText: any, tagOptions: any[]) { + const foundOption = tagOptions.find(option => option.label === tagText); + return foundOption ? foundOption.icon : undefined; +} + +const childrenMap = { + text: TagsControl, + tagColors: ColoredTagOptionControl, + onEvent: eventHandlerControl([clickEvent]), +}; + +const getBaseValue: ColumnTypeViewFn = ( + props +) => props.text; + +type TagEditPropsType = { + value: string | string[]; + onChange: (value: string | string[]) => void; + onChangeEnd: () => void; + tagOptions: any[]; +}; + +export const Wrapper = styled.div` + display: inline-flex; + align-items: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background: transparent !important; + padding: 8px; + + > div { + width: 100%; + height: 100%; + } + + .ant-select { + height: 100%; + .ant-select-selector { + padding: 0 7px; + height: 100%; + overflow: hidden; + .ant-select-selection-item { + display: inline-flex; + align-items: center; + padding-right: 24px; + } + } + .ant-select-arrow { + height: calc(100% - 3px); + width: fit-content; + top: 1.5px; + margin-top: 0; + background-color: white; + right: 1.5px; + border-right: 1px solid #d7d9e0; + cursor: pointer; + pointer-events: auto; + svg { + min-width: 18px; + min-height: 18px; + } + &:hover svg path { + fill: #315efb; + } + } + .ant-select-selector .ant-select-selection-search { + left: 7px; + input { + height: 100%; + } + } + &.ant-select-open { + .ant-select-arrow { + border-right: none; + border-left: 1px solid #d7d9e0; + svg g path { + fill: #315efb; + } + } + .ant-select-selection-item { + opacity: 0.4; + } + } + } + .ant-tag { + margin-left: 5px; + } + .ant-tag svg { + margin-right: 4px; + } +`; + +export const DropdownStyled = styled.div` + .ant-select-item { + padding: 3px 8px; + margin: 0 0 2px 8px; + border-radius: 4px; + + &.ant-select-item-option-active { + background-color: #f2f7fc; + } + } + .ant-select-item-option-content { + display: flex; + align-items: center; + } + .ant-tag { + margin-right: 0; + } + .ant-tag svg { + margin-right: 4px; + } +`; + +export const TagStyled = styled(Tag)` + margin-right: 8px; + cursor: pointer; + svg { + margin-right: 4px; + } +`; + +const TagEdit = React.memo((props: TagEditPropsType) => { + const defaultTags = useContext(TagsContext); + const [tags, setTags] = useState(() => { + const result: string[] = []; + defaultTags.forEach((item) => { + if (item.split(",")[1]) { + item.split(",").forEach((tag) => result.push(tag)); + } + result.push(item); + }); + return result; + }); + const [open, setOpen] = useState(false); + const mountedRef = useRef(true); + + // Memoize tag options to prevent unnecessary re-renders + const memoizedTagOptions = useMemo(() => props.tagOptions || [], [props.tagOptions]); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setTags([]); + setOpen(false); + }; + }, []); + + // Update tags when defaultTags changes + useEffect(() => { + if (!mountedRef.current) return; + + const result: string[] = []; + defaultTags.forEach((item) => { + if (item.split(",")[1]) { + item.split(",").forEach((tag) => result.push(tag)); + } + result.push(item); + }); + setTags(result); + }, [defaultTags]); + + const handleSearch = useCallback((value: string) => { + if (!mountedRef.current) return; + + if (defaultTags.findIndex((item) => item.includes(value)) < 0) { + setTags([...defaultTags, value]); + } else { + setTags(defaultTags); + } + props.onChange(value); + }, [defaultTags, props.onChange]); + + const handleChange = useCallback((value: string | string[]) => { + if (!mountedRef.current) return; + props.onChange(value); + setOpen(false); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + setOpen(false); + }, [props.onChangeEnd]); + + const handleTagClick = useCallback((tagText: string, e: React.MouseEvent) => { + e.stopPropagation(); + const foundOption = memoizedTagOptions.find(option => option.label === tagText); + if (foundOption && foundOption.onEvent) { + foundOption.onEvent("click"); + } + }, [memoizedTagOptions]); + + return ( + + } + onSearch={handleSearch} + onChange={handleChange} + popupRender={(originNode: ReactNode) => ( + + {originNode} + + )} + styles={{ popup: { root: { marginTop: "7px", padding: "8px 0 6px 0" }}}} + onFocus={() => { + if (mountedRef.current) { + setOpen(true); + } + }} + onBlur={handleBlur} + onClick={() => { + if (mountedRef.current) { + setOpen(!open); + } + }} + > + {tags.map((value, index) => ( + + {value.split(",")[1] ? ( + value.split(",").map((item, i) => { + const tagColor = getTagColor(item, memoizedTagOptions); + const tagIcon = getTagIcon(item, memoizedTagOptions); + const tagStyle = getTagStyle(item, memoizedTagOptions); + + return ( + handleTagClick(item, e)} + > + {item} + + ); + }) + ) : ( + handleTagClick(value, e)} + > + {value} + + )} + + ))} + + + ); +}); + +TagEdit.displayName = 'TagEdit'; + +export const ColumnTagsComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const tagOptions = props.tagColors; + let value = props.changeValue ?? getBaseValue(props, dispatch); + value = typeof value === "string" && value.split(",")[1] ? value.split(",") : value; + const tags = _.isArray(value) ? value : (value.length ? [value] : []); + + const handleTagClick = (tagText: string) => { + const foundOption = tagOptions.find(option => option.label === tagText); + if (foundOption && foundOption.onEvent) { + foundOption.onEvent("click"); + } + // Also trigger the main component's event handler + if (props.onEvent) { + props.onEvent("click"); + } + }; + + const view = tags.map((tag, index) => { + // The actual eval value is of type number or boolean + const tagText = String(tag); + const tagColor = getTagColor(tagText, tagOptions); + const tagIcon = getTagIcon(tagText, tagOptions); + const tagStyle = getTagStyle(tagText, tagOptions); + + return ( +
+ handleTagClick(tagText)} + > + {tagText} + +
+ ); + }); + return view; + }, + (nodeValue) => { + const text = nodeValue.text.value; + return _.isArray(text) ? text.join(",") : text; + }, + getBaseValue + ) + .setEditViewFn((props) => { + const text = props.value; + const value = _.isArray(text) ? text.join(",") : text; + return ; + }) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.tagColors.propertyView({ + title: "Tag Options", + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx new file mode 100644 index 0000000000..f338f0f645 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx @@ -0,0 +1,193 @@ +import { default as TimePicker } from "antd/es/time-picker"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { withDefault } from "comps/generators"; +import { formatPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import dayjs from "dayjs"; +import React, { useEffect, useRef, useState, useCallback } from "react"; +import styled from "styled-components"; +import { TIME_FORMAT } from "util/dateTimeUtils"; +import { hasIcon } from "comps/utils"; +import { IconControl } from "comps/controls/iconControl"; + +const TimePickerStyled = styled(TimePicker)<{ $open: boolean }>` + width: 100%; + height: 100%; + position: absolute; + top: 0; + padding: 0; + padding-left: 11px; + .ant-picker-input { + height: 100%; + } + input { + padding-right: 18px; + cursor: pointer; + } + &.ant-picker-focused .ant-picker-suffix svg g { + stroke: ${(props) => props.$open && "#315EFB"}; + } + .ant-picker-suffix { + height: calc(100% - 1px); + position: absolute; + right: 0; + top: 0.5px; + display: flex; + align-items: center; + padding: 0 3px; + } +`; + +const Wrapper = styled.div` + background: transparent !important; +`; + +export function formatTime(time: string, format: string) { + const parsedTime = dayjs(time, TIME_FORMAT); + return parsedTime.isValid() ? parsedTime.format(format) : ""; +} + +const childrenMap = { + text: StringControl, + prefixIcon: IconControl, + suffixIcon: IconControl, + format: withDefault(StringControl, TIME_FORMAT), + inputFormat: withDefault(StringControl, TIME_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type TimeEditProps = { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + inputFormat: string; +}; + +export const TimeEdit = React.memo((props: TimeEditProps) => { + const pickerRef = useRef(); + const [panelOpen, setPanelOpen] = useState(true); + const mountedRef = useRef(true); + + // Initialize tempValue with proper validation + const [tempValue, setTempValue] = useState(() => { + const initialValue = dayjs(props.value, TIME_FORMAT); + return initialValue.isValid() ? initialValue : dayjs("00:00:00", TIME_FORMAT); + }); + + // Memoize event handlers + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" && !panelOpen) { + props.onChangeEnd(); + } + }, [panelOpen, props.onChangeEnd]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, []); + + const handleOpenChange = useCallback((open: boolean) => { + if (mountedRef.current) { + setPanelOpen(open); + } + }, []); + + const handleChange = useCallback((value: dayjs.Dayjs | null, dateString: string | string[]) => { + props.onChange(dateString as string); + }, [props.onChange]); + + // Update tempValue when props.value changes + useEffect(() => { + if (!mountedRef.current) return; + + const newValue = props.value ? dayjs(props.value, TIME_FORMAT) : null; + if (newValue?.isValid()) { + setTempValue(newValue); + } + }, [props.value]); + + // Cleanup event listeners and state + useEffect(() => { + return () => { + mountedRef.current = false; + setTempValue(null); + if (pickerRef.current) { + pickerRef.current = null; + } + }; + }, []); + + return ( + + + + ); +}); + +TimeEdit.displayName = 'TimeEdit'; + +export const TimeComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ( + <> + {hasIcon(props.prefixIcon) && ( + {props.prefixIcon} + )} + {value} + {hasIcon(props.suffixIcon) && ( + {props.suffixIcon} + )} + + ); + }, + (nodeValue) => formatTime(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setEditViewFn(({value, onChange, onChangeEnd, otherProps}) => ( + + )) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {formatPropertyView({ children, placeholder: TIME_FORMAT })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx new file mode 100644 index 0000000000..dcdffe3907 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx @@ -0,0 +1,121 @@ +import { default as Input } from "antd/es/input"; +import { StringOrNumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import React, { useCallback, useMemo } from "react"; +import { RecordConstructorToComp } from "lowcoder-core"; +import { clickEvent, doubleClickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import styled from "styled-components"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const TextEventOptions = [clickEvent, doubleClickEvent] as const; + +const TextWrapper = styled.div` + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +`; + +const childrenMap = { + text: StringOrNumberControl, + prefixIcon: IconControl, + suffixIcon: IconControl, + onEvent: eventHandlerControl(TextEventOptions), +}; + +// Memoize the base value function to prevent unnecessary string creation +const getBaseValue: ColumnTypeViewFn = (props) => + typeof props.text === 'string' ? props.text : String(props.text); + +// Memoized icon components to prevent unnecessary re-renders +const IconWrapper = React.memo(({ icon }: { icon: React.ReactNode }) => ( + {icon} +)); + +interface SimpleTextContentProps { + value: string | number; + prefixIcon?: React.ReactNode; + suffixIcon?: React.ReactNode; + onEvent?: (eventName: string) => void; +} + +interface SimpleTextEditViewProps { + value: string | number; + onChange: (value: string | number) => void; + onChangeEnd: () => void; +} + +const SimpleTextContent = React.memo(({ value, prefixIcon, suffixIcon, onEvent }: SimpleTextContentProps) => { + const handleClickEvent = useCompClickEventHandler({onEvent: onEvent ?? (() => {})}) + + const handleClick = useCallback(() => { + handleClickEvent() + }, [handleClickEvent]); + + return ( + + {hasIcon(prefixIcon) && } + {value} + {hasIcon(suffixIcon) && } + + ); +}); + +const SimpleTextEditView = React.memo(({ value, onChange, onChangeEnd }: SimpleTextEditViewProps) => { + const handleChange = useCallback((e: React.ChangeEvent) => { + onChange(e.target.value); + }, [onChange]); + + return ( + + ); +}); + +const SimpleTextPropertyView = React.memo(({ children }: { children: RecordConstructorToComp }) => { + return useMemo(() => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.onEvent.propertyView()} + + ), [children.text, children.prefixIcon, children.suffixIcon, children.onEvent]); +}); + +export const SimpleTextComp = new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => ) + .setPropertyViewFn((children) => ) + .build(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx new file mode 100644 index 0000000000..f9bedc7549 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx @@ -0,0 +1,129 @@ +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ActionSelectorControlInContext } from "comps/controls/actionSelector/actionSelectorControl"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { disabledPropertyView, loadingPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { useStyle } from "comps/controls/styleControl"; +import { ButtonStyle } from "comps/controls/styleControlConstants"; +import { Button100 } from "comps/comps/buttonComp/buttonCompConstants"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { CSSProperties } from "react"; +import { RecordConstructorToComp } from "lowcoder-core"; +import { ToViewReturn } from "@lowcoder-ee/comps/generators/multi"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { isArray } from "lodash"; + +export const fixOldActionData = (oldData: any) => { + if (!oldData) return oldData; + if (Boolean(oldData.onClick && !isArray(oldData.onClick))) { + return { + ...oldData, + onClick: [{ + name: "click", + handler: oldData.onClick, + }], + }; + } + return oldData; +} +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +export const ButtonTypeOptions = [ + { + label: trans("table.primaryButton"), + value: "primary", + }, + { + label: trans("table.defaultButton"), + value: "default", + }, + { + label: trans("table.text"), + value: "text", + }, +] as const; + +const ButtonEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: StringControl, + buttonType: dropdownControl(ButtonTypeOptions, "primary"), + onClick: eventHandlerControl(ButtonEventOptions), + loading: BoolCodeControl, + disabled: BoolCodeControl, + prefixIcon: IconControl, + suffixIcon: IconControl, +}; + +const ButtonStyled = React.memo(({ props }: { props: ToViewReturn>}) => { + const style = useStyle(ButtonStyle); + const hasText = !!props.text; + const hasPrefixIcon = hasIcon(props.prefixIcon); + const hasSuffixIcon = hasIcon(props.suffixIcon); + const iconOnly = !hasText && (hasPrefixIcon || hasSuffixIcon); + const handleClickEvent = useCompClickEventHandler({onEvent: props.onClick}) + + const handleClick = useCallback((e: React.MouseEvent) => { + handleClickEvent() + }, [handleClickEvent]); + + const buttonStyle = useMemo(() => ({ + margin: 0, + width: iconOnly ? 'auto' : undefined, + minWidth: iconOnly ? 'auto' : undefined, + padding: iconOnly ? '0 8px' : undefined + } as CSSProperties), [iconOnly]); + + return ( + + {/* prevent the button from disappearing */} + {hasText ? props.text : (iconOnly ? null : " ")} + {hasSuffixIcon && !props.loading && {props.suffixIcon}} + + ); +}); + +const ButtonCompTmp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props) => , + (nodeValue) => nodeValue.text.value + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.buttonType.propertyView({ + label: trans("table.type"), + radioButton: true, + })} + {loadingPropertyView(children)} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + )) + .build(); +})(); + +export const ButtonComp = migrateOldData(ButtonCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx new file mode 100644 index 0000000000..938983ac9e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx @@ -0,0 +1,488 @@ +import { BoolControl } from "comps/controls/boolControl"; +import { ColorOrBoolCodeControl, NumberControl, RadiusControl, StringControl } from "comps/controls/codeControl"; +import { dropdownControl, HorizontalAlignmentControl } from "comps/controls/dropdownControl"; +import { MultiCompBuilder, stateComp, valueComp, withContext, withDefault } from "comps/generators"; +import { withSelectedMultiContext } from "comps/generators/withSelectedMultiContext"; +import { genRandomKey } from "comps/utils/idGenerator"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + ConstructorToComp, + ConstructorToDataType, + ConstructorToNodeType, + ConstructorToView, + deferAction, + fromRecord, + multiChangeAction, + withFunction, + wrapChildAction, +} from "lowcoder-core"; +import { AlignClose, AlignLeft, AlignRight, IconRadius, BorderWidthIcon, TextSizeIcon, FontFamilyIcon, TextWeightIcon, ImageCompIcon, controlItem, Dropdown, OptionType } from "lowcoder-design"; +import { ColumnTypeComp, ColumnTypeCompMap } from "./columnTypeComp"; +import { ColorControl } from "comps/controls/colorControl"; +import { JSONValue } from "util/jsonTypes"; +import styled from "styled-components"; +import { TextOverflowControl } from "comps/controls/textOverflowControl"; +import { default as Divider } from "antd/es/divider"; +import { ColumnValueTooltip } from "./simpleColumnTypeComps"; +import { SummaryColumnComp } from "./tableSummaryColumnComp"; +import { list } from "@lowcoder-ee/comps/generators/list"; +import { EMPTY_ROW_KEY } from "../tableCompView"; +import React, { useCallback, useMemo } from "react"; + +export type Render = ReturnType["getOriginalComp"]>; +export const RenderComp = withSelectedMultiContext(ColumnTypeComp); + +const columnWidthOptions = [ + { + label: trans("table.auto"), + value: "auto", + }, + { + label: trans("table.fixed"), + value: "fixed", + }, +] as const; + +const columnFixOptions = [ + { + label: , + value: "left", + }, + { + label: , + value: "close", + }, + { + label: , + value: "right", + }, +] as const; + +const cellColorLabel = trans("table.cellColor"); +const CellColorTempComp = withContext( + new MultiCompBuilder({ color: ColorOrBoolCodeControl }, (props) => props.color) + .setPropertyViewFn((children) => + children.color.propertyView({ + label: cellColorLabel, + tooltip: trans("table.cellColorDesc"), + }) + ) + .build(), + ["currentCell", "currentRow"] as const +); + +// @ts-ignore +export class CellColorComp extends CellColorTempComp { + override getPropertyView() { + return controlItem({ filterText: cellColorLabel }, super.getPropertyView()); + } +} + +// fixme, should be infer from RowColorComp, but withContext type incorrect +export type CellColorViewType = (param: { + currentRow: any; + currentCell: JSONValue | undefined; //number | string; +}) => string; + +const cellTooltipLabel = trans("table.columnTooltip"); +const CellTooltipTempComp = withContext( + new MultiCompBuilder({ tooltip: StringControl }, (props) => props.tooltip) + .setPropertyViewFn((children) => + children.tooltip.propertyView({ + label: cellTooltipLabel, + tooltip: ColumnValueTooltip, + }) + ) + .build(), + ["currentCell", "currentRow", "currentIndex"] as const +); + +// @ts-ignore +export class CellTooltipComp extends CellTooltipTempComp { + override getPropertyView() { + return controlItem({ filterText: cellTooltipLabel }, super.getPropertyView()); + } +} + +// fixme, should be infer from RowColorComp, but withContext type incorrect +export type CellTooltipViewType = (param: { + currentRow: any; + currentCell: JSONValue | undefined; //number | string; +}) => string; + + +export const columnChildrenMap = { + // column title + title: StringControl, + titleTooltip: StringControl, + showTitle: withDefault(BoolControl, true), + cellTooltip: CellTooltipComp, + // a custom column or a data column + isCustom: valueComp(false), + // If it is a data column, it must be the name of the column and cannot be duplicated as a react key + dataIndex: valueComp(""), + hide: BoolControl, + sortable: BoolControl, + width: NumberControl, + autoWidth: dropdownControl(columnWidthOptions, "auto"), + render: RenderComp, + align: HorizontalAlignmentControl, + tempHide: stateComp(false), + fixed: dropdownControl(columnFixOptions, "close"), + editable: BoolControl, + background: withDefault(ColorControl, ""), + margin: withDefault(RadiusControl, ""), + text: withDefault(ColorControl, ""), + border: withDefault(ColorControl, ""), + borderWidth: withDefault(RadiusControl, ""), + radius: withDefault(RadiusControl, ""), + textSize: withDefault(RadiusControl, ""), + textWeight: withDefault(StringControl, "normal"), + fontFamily: withDefault(StringControl, "sans-serif"), + fontStyle: withDefault(StringControl, 'normal'), + cellColor: CellColorComp, + textOverflow: withDefault(TextOverflowControl, "wrap"), + linkColor: withDefault(ColorControl, "#3377ff"), + linkHoverColor: withDefault(ColorControl, ""), + linkActiveColor: withDefault(ColorControl, ""), + summaryColumns: withDefault(list(SummaryColumnComp), [ + {}, {}, {} + ]) +}; + +const StyledBorderRadiusIcon = styled(IconRadius)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledBorderIcon = styled(BorderWidthIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextSizeIcon = styled(TextSizeIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledFontFamilyIcon = styled(FontFamilyIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextWeightIcon = styled(TextWeightIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledBackgroundImageIcon = styled(ImageCompIcon)` width: 24px; margin: 0 0px 0 -12px;`; + +/** + * export for test. + * Put it here temporarily to avoid circular dependencies + */ +const ColumnInitComp = new MultiCompBuilder(columnChildrenMap, (props, dispatch) => { + const onWidthResize = (width: number) => { + dispatch( + multiChangeAction({ + width: changeValueAction(width, true), + autoWidth: changeValueAction("fixed", true), + }) + ); + }; + + return { + ...props, + onWidthResize, + }; +}) + .setPropertyViewFn(() => <>) + .build(); + +const ColumnPropertyView = React.memo(({ + comp, + viewMode, + summaryRowIndex +}: { + comp: ColumnComp; + viewMode: string; + summaryRowIndex: number; +}) => { + const selectedColumn = comp.children.render.getSelectedComp(); + const columnType = useMemo(() => + selectedColumn.getComp().children.compType.getView(), + [selectedColumn] + ); + + const initialColumns = useMemo(() => + selectedColumn.getParams()?.initialColumns as OptionType[] || [], + [selectedColumn] + ); + + const columnValue = useMemo(() => { + const column = selectedColumn.getComp().toJsonValue(); + if (column.comp?.hasOwnProperty('src')) { + return (column.comp as any).src; + } else if (column.comp?.hasOwnProperty('text')) { + const value = (column.comp as any).text; + const isDynamicValue = initialColumns.find((column) => column.value === value); + return !isDynamicValue ? '{{currentCell}}' : value; + } + return '{{currentCell}}'; + }, [selectedColumn, initialColumns]); + + const summaryColumns = comp.children.summaryColumns.getView(); + + return ( + <> + {viewMode === 'summary' && ( + summaryColumns[summaryRowIndex].propertyView('') + )} + {viewMode === 'normal' && ( + <> + {comp.children.title.propertyView({ + label: trans("table.columnTitle"), + placeholder: comp.children.dataIndex.getView(), + })} + {comp.children.titleTooltip.propertyView({ + label: trans("table.columnTitleTooltip"), + })} + {comp.children.cellTooltip.getPropertyView()} + { + // Keep the previous text value, some components do not have text, the default value is currentCell + const compType = columnType; + let compValue: Record = { text: value}; + if(columnType === 'image') { + compValue = { src: value }; + } + comp.children.render.dispatchChangeValueAction({ + compType, + comp: compValue, + } as any); + }} + /> + {/* FIXME: cast type currently, return type of withContext should be corrected later */} + {comp.children.render.getPropertyView()} + {comp.children.showTitle.propertyView({ + label: trans("table.showTitle"), + tooltip: trans("table.showTitleTooltip"), + })} + {ColumnTypeCompMap[columnType].canBeEditable() && + comp.children.editable.propertyView({ label: trans("table.editable") })} + {comp.children.sortable.propertyView({ + label: trans("table.sortable"), + })} + {comp.children.hide.propertyView({ + label: trans("prop.hide"), + })} + {comp.children.align.propertyView({ + label: trans("table.align"), + radioButton: true, + })} + {comp.children.fixed.propertyView({ + label: trans("table.fixedColumn"), + radioButton: true, + })} + {comp.children.autoWidth.propertyView({ + label: trans("table.autoWidth"), + radioButton: true, + })} + {comp.children.autoWidth.getView() === "fixed" && + comp.children.width.propertyView({ label: trans("prop.width") })} + + {(columnType === 'link' || columnType === 'links') && ( + <> + + {controlItem({}, ( +
+ {"Link Style"} +
+ ))} + {comp.children.linkColor.propertyView({ + label: trans('text') // trans('style.background'), + })} + {comp.children.linkHoverColor.propertyView({ + label: "Hover text", // trans('style.background'), + })} + {comp.children.linkActiveColor.propertyView({ + label: "Active text", // trans('style.background'), + })} + + )} + + {controlItem({}, ( +
+ {"Column Style"} +
+ ))} + {comp.children.background.propertyView({ + label: trans('style.background'), + })} + {columnType !== 'link' && comp.children.text.propertyView({ + label: trans('text'), + })} + {comp.children.border.propertyView({ + label: trans('style.border') + })} + {comp.children.borderWidth.propertyView({ + label: trans('style.borderWidth'), + preInputNode: , + placeholder: '1px', + })} + {comp.children.radius.propertyView({ + label: trans('style.borderRadius'), + preInputNode: , + placeholder: '3px', + })} + {columnType !== 'markdown' && comp.children.textSize.propertyView({ + label: trans('style.textSize'), + preInputNode: , + placeholder: '14px', + })} + {comp.children.textWeight.propertyView({ + label: trans('style.textWeight'), + preInputNode: , + placeholder: 'normal', + })} + {comp.children.fontFamily.propertyView({ + label: trans('style.fontFamily'), + preInputNode: , + placeholder: 'sans-serif', + })} + {comp.children.fontStyle.propertyView({ + label: trans('style.fontStyle'), + preInputNode: , + placeholder: 'normal' + })} + {comp.children.textOverflow.getPropertyView()} + {comp.children.cellColor.getPropertyView()} + + )} + + ); +}); + +ColumnPropertyView.displayName = 'ColumnPropertyView'; + +export class ColumnComp extends ColumnInitComp { + override reduce(action: CompAction) { + let comp = super.reduce(action); + if (action.type === CompActionTypes.UPDATE_NODES_V2) { + // Reset context data without cleanup since components are managed by React + comp = comp.setChild( + "cellColor", + comp.children.cellColor.reduce( + CellColorComp.changeContextDataAction({ + currentCell: undefined, + currentRow: {}, + }) + ) + ); + comp = comp.setChild( + "cellTooltip", + comp.children.cellTooltip.reduce( + CellTooltipComp.changeContextDataAction({ + currentCell: undefined, + currentRow: {}, + currentIndex: 0, + }) + ) + ); + } + if (action.type === CompActionTypes.CHANGE_VALUE) { + const title = comp.children.title.unevaledValue; + const dataIndex = comp.children.dataIndex.getView(); + if (!Boolean(title)) { + comp.children.title.dispatchChangeValueAction(dataIndex); + } + } + return comp; + } + + override getView() { + const superView = super.getView(); + const columnType = this.children.render.getSelectedComp().getComp().children.compType.getView(); + return { + ...superView, + columnType, + editable: ColumnTypeCompMap[columnType].canBeEditable() && superView.editable, + }; + } + + exposingNode() { + const dataIndexNode = this.children.dataIndex.exposingNode(); + + const renderNode = withFunction(this.children.render.node(), (render) => ({ + wrap: render.__comp__.wrap, + map: _.mapValues(render.__map__, (value) => value.comp), + })); + return fromRecord({ + dataIndex: dataIndexNode, + render: renderNode, + }); + } + + propertyView(key: string, viewMode: string, summaryRowIndex: number) { + return ; + } + + getChangeSet() { + const dataIndex = this.children.dataIndex.getView(); + const changeSet = _.mapValues(this.children.render.getMap(), (value) =>{ + return value.getComp().children.comp.children.changeValue.getView() + }); + return { [dataIndex]: changeSet }; + } + + dispatchClearChangeSet() { + this.children.render.dispatch( + deferAction( + RenderComp.forEachAction( + wrapChildAction( + "comp", + wrapChildAction("comp", changeChildAction("changeValue", null, false)) + ) + ) + ) + ); + // clear render comp cache when change set is cleared + this.children.render.dispatch(RenderComp.clearAction()); + } + + dispatchClearInsertSet() { + const renderMap = this.children.render.getMap(); + const insertMapKeys = Object.keys(renderMap).filter(key => key.startsWith(EMPTY_ROW_KEY)); + insertMapKeys.forEach(key => { + const render = renderMap[key]; + render.getComp().children.comp.children.changeValue.dispatchChangeValueAction(null); + }); + } + + static setSelectionAction(key: string) { + return wrapChildAction("render", RenderComp.setSelectionAction(key)); + } +} + +export type RawColumnType = ConstructorToView; +export type ColumNodeType = ConstructorToNodeType; +export type ColumnCompType = ConstructorToComp; + +/** + * Custom column initialization data + */ +export function newCustomColumn(): ConstructorToDataType { + return { + title: trans("table.customColumn"), + dataIndex: genRandomKey(), + isCustom: true, + }; +} + +/** + * Initialization data of primary column + */ +export function newPrimaryColumn( + key: string, + width: number, + title?: string, + isTag?: boolean +): ConstructorToDataType { + return { + title: title ?? key, + dataIndex: key, + isCustom: false, + autoWidth: "fixed", + width: width + "", + render: { compType: isTag ? "tag" : "text", comp: { text: "{{currentCell}}" } }, + }; +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx new file mode 100644 index 0000000000..7ad933cf92 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx @@ -0,0 +1,202 @@ +import { ColumnComp, newPrimaryColumn } from "comps/comps/tableComp/column/tableColumnComp"; +import { + calcColumnWidth, + COLUMN_CHILDREN_KEY, + supportChildrenTree, +} from "comps/comps/tableComp/tableUtils"; +import { list } from "comps/generators/list"; +import { getReduceContext } from "comps/utils/reduceContext"; +import _ from "lodash"; +import { + CompAction, + customAction, + fromRecord, + isMyCustomAction, + RecordNode, +} from "lowcoder-core"; +import { shallowEqual } from "react-redux"; +import { JSONObject, JSONValue } from "util/jsonTypes"; +import { lastValueIfEqual } from "util/objectUtils"; +import { EMPTY_ROW_KEY } from "../tableCompView"; + +/** + * column list + */ +const ColumnListTmpComp = list(ColumnComp); + +/** + * rowExample is used for code prompts + */ +type RowExampleType = JSONObject | undefined; +type ActionDataType = { + type: "dataChanged"; + rowExample: RowExampleType; + doGeneColumn: boolean; + dynamicColumn: boolean; + data: Array; +}; + +export function tableDataRowExample(data: Array) { + if (!data?.length) { + return undefined; + } + + if (typeof data[0] === "string") { + // do not parse arrays in string format + return undefined; + } + const rowExample: Record = {}; + // merge head 50 data keys + data.slice(0, 50).forEach((d) => { + Object.keys(d).forEach((key) => { + if (!rowExample.hasOwnProperty(key)) { + rowExample[key] = d[key]; + } + }); + }); + return rowExample; +} + +export class ColumnListComp extends ColumnListTmpComp { + override reduce(action: CompAction): this { + if (isMyCustomAction(action, "dataChanged")) { + const rowExample = action.value.rowExample; + const { readOnly } = getReduceContext(); + let comp = this; + if (action.value.doGeneColumn && (action.value.dynamicColumn || !readOnly)) { + const actions = this.geneColumnsAction(rowExample, action.value.data); + comp = this.reduce(this.multiAction(actions)); + } + return comp; + } + return super.reduce(action); + } + + getChangeSet(filterNewRowsChange?: boolean) { + const changeSet: Record> = {}; + const columns = this.getView(); + columns.forEach((column) => { + const columnChangeSet = column.getChangeSet(); + Object.keys(columnChangeSet).forEach((dataIndex) => { + Object.keys(columnChangeSet[dataIndex]).forEach((key) => { + const includeChange = filterNewRowsChange + ? key.startsWith(EMPTY_ROW_KEY) + : !key.startsWith(EMPTY_ROW_KEY); + if (!_.isNil(columnChangeSet[dataIndex][key]) && includeChange) { + if (!changeSet[key]) changeSet[key] = {}; + changeSet[key][dataIndex] = columnChangeSet[dataIndex][key]; + } + }); + }); + }); + return changeSet; + } + + dispatchClearChangeSet() { + const columns = this.getView(); + columns.forEach((column) => column.dispatchClearChangeSet()); + } + + dispatchClearInsertSet() { + const columns = this.getView(); + columns.forEach((column) => column.dispatchClearInsertSet()); + } + + /** + * If the table data changes, call this method to trigger the action + */ + dataChangedAction(param: { + rowExample: JSONObject; + doGeneColumn: boolean; + dynamicColumn: boolean; + data: Array; + }) { + return customAction( + { + type: "dataChanged", + ...param, + }, + true + ); + } + + /** + * According to the data, adjust the column + */ + private geneColumnsAction(rowExample: RowExampleType, data: Array) { + // If no data, return directly + if (rowExample === undefined || rowExample === null) { + return []; + } + const dataKeys = Object.keys(rowExample); + if (dataKeys.length === 0) { + return []; + } + const columnsView = this.getView(); + const actions: Array = []; + let deleteCnt = 0; + columnsView.forEach((column, index) => { + if (column.getView().isCustom) { + return; + } + const dataIndex = column.getView().dataIndex; + if (dataIndex === COLUMN_CHILDREN_KEY || !dataKeys.find((key) => dataIndex === key)) { + // to Delete + actions.push(this.deleteAction(index - deleteCnt)); + deleteCnt += 1; + } + }); + // The order should be the same as the data + dataKeys.forEach((key) => { + if (key === COLUMN_CHILDREN_KEY && supportChildrenTree(data)) { + return; + } + if (!columnsView.find((column) => column.getView().dataIndex === key)) { + // to Add + actions.push(this.pushAction(newPrimaryColumn(key, calcColumnWidth(key, data)))); + } + }); + if (actions.length === 0) { + return []; + } + return actions; + } + + withParamsNode() { + const columns = this.getView(); + const nodes = _(columns) + .map((col) => col.children.render.getOriginalComp().node()) + .toPairs() + .fromPairs() + .value(); + const result = lastValueIfEqual( + this, + "withParamsNode", + [fromRecord(nodes), nodes] as const, + (a, b) => shallowEqual(a[1], b[1]) + )[0]; + return result; + } + + getColumnsNode( + field: T + ): RecordNode>> { + const columns = this.getView(); + const nodes = _(columns) + .map((col) => col.children[field].node() as ReturnType) + .toPairs() + .fromPairs() + .value(); + const result = lastValueIfEqual( + this, + "col_nodes_" + field, + [fromRecord(nodes), nodes] as const, + (a, b) => shallowEqual(a[1], b[1]) + )[0]; + return result; + } + + setSelectionAction(key: string) { + return this.forEachAction(ColumnComp.setSelectionAction(key)); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx new file mode 100644 index 0000000000..6b0e2c4068 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx @@ -0,0 +1,205 @@ +import { RadiusControl, StringControl } from "comps/controls/codeControl"; +import { HorizontalAlignmentControl } from "comps/controls/dropdownControl"; +import { MultiCompBuilder, valueComp, withDefault } from "comps/generators"; +import { withSelectedMultiContext } from "comps/generators/withSelectedMultiContext"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeChildAction, + CompAction, + ConstructorToComp, + deferAction, + fromRecord, + withFunction, + wrapChildAction, +} from "lowcoder-core"; +import { IconRadius, TextSizeIcon, FontFamilyIcon, TextWeightIcon, controlItem } from "lowcoder-design"; +import { ColumnTypeComp } from "./columnTypeComp"; +import { ColorControl } from "comps/controls/colorControl"; +import styled from "styled-components"; +import { TextOverflowControl } from "comps/controls/textOverflowControl"; +import { default as Divider } from "antd/es/divider"; +export type Render = ReturnType["getOriginalComp"]>; +export const RenderComp = withSelectedMultiContext(ColumnTypeComp); + +export const columnChildrenMap = { + cellTooltip: StringControl, + // a custom column or a data column + isCustom: valueComp(false), + // If it is a data column, it must be the name of the column and cannot be duplicated as a react key + dataIndex: valueComp(""), + render: RenderComp, + align: HorizontalAlignmentControl, + background: withDefault(ColorControl, ""), + margin: withDefault(RadiusControl, ""), + text: withDefault(ColorControl, ""), + border: withDefault(ColorControl, ""), + radius: withDefault(RadiusControl, ""), + textSize: withDefault(RadiusControl, ""), + textWeight: withDefault(StringControl, "normal"), + fontFamily: withDefault(StringControl, "sans-serif"), + fontStyle: withDefault(StringControl, 'normal'), + cellColor: StringControl, + textOverflow: withDefault(TextOverflowControl, "wrap"), + linkColor: withDefault(ColorControl, "#3377ff"), + linkHoverColor: withDefault(ColorControl, ""), + linkActiveColor: withDefault(ColorControl, ""), +}; + +const StyledBorderRadiusIcon = styled(IconRadius)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextSizeIcon = styled(TextSizeIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledFontFamilyIcon = styled(FontFamilyIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextWeightIcon = styled(TextWeightIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; + +/** + * export for test. + * Put it here temporarily to avoid circular dependencies + */ +const ColumnInitComp = new MultiCompBuilder(columnChildrenMap, (props, dispatch) => { + return { + ...props, + }; +}) + .setPropertyViewFn(() => <>) + .build(); + +export class SummaryColumnComp extends ColumnInitComp { + override reduce(action: CompAction) { + const comp = super.reduce(action); + return comp; + } + + override getView() { + const superView = super.getView(); + const columnType = this.children.render.getSelectedComp().getComp().children.compType.getView(); + return { + ...superView, + columnType, + }; + } + + exposingNode() { + const dataIndexNode = this.children.dataIndex.exposingNode(); + + const renderNode = withFunction(this.children.render.node(), (render) => ({ + wrap: render.__comp__.wrap, + map: _.mapValues(render.__map__, (value) => value.comp), + })); + return fromRecord({ + dataIndex: dataIndexNode, + render: renderNode, + }); + } + + propertyView(key: string) { + const columnType = this.children.render.getSelectedComp().getComp().children.compType.getView(); + const column = this.children.render.getSelectedComp().getComp().toJsonValue(); + let columnValue = '{{currentCell}}'; + if (column.comp?.hasOwnProperty('src')) { + columnValue = (column.comp as any).src; + } else if (column.comp?.hasOwnProperty('text')) { + columnValue = (column.comp as any).text; + } + + return ( + <> + {this.children.cellTooltip.propertyView({ + label: trans("table.columnTooltip"), + })} + {this.children.render.getPropertyView()} + {this.children.align.propertyView({ + label: trans("table.align"), + radioButton: true, + })} + {(columnType === 'link' || columnType === 'links') && ( + <> + + {controlItem({}, ( +
+ {"Link Style"} +
+ ))} + {this.children.linkColor.propertyView({ + label: trans('text') + })} + {this.children.linkHoverColor.propertyView({ + label: "Hover text", + })} + {this.children.linkActiveColor.propertyView({ + label: "Active text", + })} + + )} + + {controlItem({}, ( +
+ {"Column Style"} +
+ ))} + {this.children.background.propertyView({ + label: trans('style.background'), + })} + {columnType !== 'link' && this.children.text.propertyView({ + label: trans('text'), + })} + {this.children.border.propertyView({ + label: trans('style.border') + })} + {this.children.radius.propertyView({ + label: trans('style.borderRadius'), + preInputNode: , + placeholder: '3px', + })} + {this.children.textSize.propertyView({ + label: trans('style.textSize'), + preInputNode: , + placeholder: '14px', + })} + {this.children.textWeight.propertyView({ + label: trans('style.textWeight'), + preInputNode: , + placeholder: 'normal', + })} + {this.children.fontFamily.propertyView({ + label: trans('style.fontFamily'), + preInputNode: , + placeholder: 'sans-serif', + })} + {this.children.fontStyle.propertyView({ + label: trans('style.fontStyle'), + preInputNode: , + placeholder: 'normal' + })} + {/* {this.children.textOverflow.getPropertyView()} */} + {this.children.cellColor.propertyView({ + label: trans("table.cellColor"), + })} + + ); + } + + getChangeSet() { + const dataIndex = this.children.dataIndex.getView(); + const changeSet = _.mapValues(this.children.render.getMap(), (value) => + value.getComp().children.comp.children.changeValue.getView() + ); + return { [dataIndex]: changeSet }; + } + + dispatchClearChangeSet() { + this.children.render.dispatch( + deferAction( + RenderComp.forEachAction( + wrapChildAction( + "comp", + wrapChildAction("comp", changeChildAction("changeValue", null, false)) + ) + ) + ) + ); + } + + static setSelectionAction(key: string) { + return wrapChildAction("render", RenderComp.setSelectionAction(key)); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx new file mode 100644 index 0000000000..98044cd318 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx @@ -0,0 +1,116 @@ +import { + ContainerBaseProps, + gridItemCompToGridItems, + InnerGrid, +} from "comps/comps/containerComp/containerView"; +import { BoolControl } from "comps/controls/boolControl"; +import { SlotControl } from "comps/controls/slotControl"; +import { withSelectedMultiContext } from "comps/generators"; +import { ControlItemCompBuilder } from "comps/generators/controlCompBuilder"; +import { BackgroundColorContext } from "comps/utils/backgroundColorContext"; +import { trans } from "i18n"; +import _ from "lodash"; +import { ConstructorToView, wrapChildAction } from "lowcoder-core"; +import { createContext, useContext, useCallback } from "react"; +import { tryToNumber } from "util/convertUtils"; +import { SimpleContainerComp } from "../containerBase/simpleContainerComp"; +import { OB_ROW_ORI_INDEX, RecordType } from "./tableUtils"; +import { NameGenerator } from "comps/utils"; +import { JSONValue } from "util/jsonTypes"; + +const ContextSlotControl = withSelectedMultiContext(SlotControl); +export const ExpandViewContext = createContext(false); + +const ContainerView = (props: ContainerBaseProps) => { + return ; +}; + +function ExpandView(props: { containerProps: ConstructorToView }) { + const { containerProps } = props; + const background = useContext(BackgroundColorContext); + return ( + + ); +} + +let ExpansionControlTmp = (function () { + const label = trans("table.expandable"); + return new ControlItemCompBuilder( + { + expandable: BoolControl, + slot: ContextSlotControl, + }, + () => ({ expandableConfig: {}, expandModalView: null }) + ) + .setControlItemData({ filterText: label }) + .setPropertyViewFn((children, dispatch) => { + return ( + <> + {children.expandable.propertyView({ label })} + {children.expandable.getView() && + children.slot + .getSelectedComp() + .getComp() + .propertyView({ buttonText: trans("table.configExpandedView") })} + + ); + }) + .build(); +})(); + +export class ExpansionControl extends ExpansionControlTmp { + getView() { + if (!this.children.expandable.getView()) { + return { expandableConfig: {}, expandModalView: null }; + } + const selectedContainer = this.children.slot.getSelectedComp(); + return { + expandableConfig: { + expandedRowRender: (record: RecordType, index: number) => { + const slotControl = this.children.slot.getView()( + { + currentRow: _.omit(record, OB_ROW_ORI_INDEX), + currentIndex: index, + currentOriginalIndex: tryToNumber(record[OB_ROW_ORI_INDEX]), + }, + String(record[OB_ROW_ORI_INDEX]) + ); + const containerProps = slotControl.children.container.getView(); + return ( + + + + ); + }, + }, + expandModalView: selectedContainer.getView(), + }; + } + + setSelectionAction(selection: string, params?: Record) { + return wrapChildAction("slot", ContextSlotControl.setSelectionAction(selection, params)); + } + + getPasteValue(nameGenerator: NameGenerator): JSONValue { + return { + ...this.toJsonValue(), + slot: this.children.slot.getSelectedComp().getComp().getPasteValue(nameGenerator), + }; + } + + reduce(action: any) { + const comp = super.reduce(action); + // console.info("ExpansionControl reduce. action: ", action, "\nthis: ", this, "\ncomp: ", comp); + return comp; + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx new file mode 100644 index 0000000000..3caa488f33 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx @@ -0,0 +1 @@ +export { TableLiteComp } from "./tableComp"; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx new file mode 100644 index 0000000000..65e1660f3f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx @@ -0,0 +1,72 @@ +import { withDefault } from "comps/generators"; +import { TableLiteComp } from "."; +import { newPrimaryColumn } from "comps/comps/tableComp/column/tableColumnComp"; +import { NameGenerator } from "../../utils"; +import { ConstructorToDataType } from "lowcoder-core"; +import { EditorState } from "../../editorState"; +import { isArrayLikeObject } from "lodash"; +import { i18nObjs } from "i18n"; +import { calcColumnWidth } from "comps/comps/tableComp/tableUtils"; +// for test only +const dataSource = [ + { + key: 0, + date: "2018-02-11", + amount: 120, + type: "income", + note: "transfer", + }, + { + key: 1, + date: "2018-03-11", + amount: 243, + type: "income", + note: "transfer", + }, + { + key: 2, + date: "2018-04-11", + amount: 98, + type: "income", + note: "transfer", + }, +]; +for (let i = 0; i < 53; i += 1) { + dataSource.push({ + key: 3 + i, + date: "2018-04-11", + amount: 98 + i, + type: "income" + (i % 3), + note: "transfer" + (i % 5), + }); +} + +const tableInitValue = { + toolbar: { + showDownload: true, + showFilter: true, + showRefresh: true, + }, +}; + +const tableData = { + ...tableInitValue, + data: JSON.stringify(i18nObjs.table.defaultData, null, " "), + columns: i18nObjs.table.columns.map((t: any) => + newPrimaryColumn(t.key, calcColumnWidth(t.key, i18nObjs.table.defaultData), t.title, t.isTag) + ), +}; +export const MockTableComp = withDefault(TableLiteComp, tableData); + +export function defaultTableData( + compName: string, + nameGenerator: NameGenerator, + editorState?: EditorState +): ConstructorToDataType { + const selectedQueryComp = editorState?.selectedQueryComp(); + const data = selectedQueryComp?.children.data.getView(); + const queryName = selectedQueryComp?.children.name.getView(); + return isArrayLikeObject(data) + ? { ...tableInitValue, data: `{{ ${queryName}.data }}` } + : tableData; +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx new file mode 100644 index 0000000000..eb5b4a0f8a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx @@ -0,0 +1,90 @@ +import { BoolControl } from "comps/controls/boolControl"; +import { ArrayNumberControl, NumberControl } from "comps/controls/codeControl"; +import { stateComp, valueComp, withDefault } from "comps/generators"; +import { ControlNodeCompBuilder } from "comps/generators/controlCompBuilder"; +import { migrateOldData } from "comps/generators/simpleGenerators"; +import { trans } from "i18n"; +import { changeChildAction, ConstructorToNodeType } from "lowcoder-core"; + +const DEFAULT_PAGE_SIZE = 5; + +export function getPageSize( + showSizeChanger: boolean, + pageSize: number, + pageSizeOptions: number[], + changeablePageSize: number +) { + if (showSizeChanger) { + return changeablePageSize || pageSizeOptions[0] || DEFAULT_PAGE_SIZE; + } else { + return pageSize || DEFAULT_PAGE_SIZE; + } +} + +export const PaginationTmpControl = (function () { + const childrenMap = { + showQuickJumper: BoolControl, + showSizeChanger: BoolControl, + hideOnSinglePage: BoolControl, + changeablePageSize: migrateOldData(valueComp(5), Number), + pageSize: NumberControl, + total: NumberControl, + pageNo: stateComp(1), + pageSizeOptions: withDefault(ArrayNumberControl, "[5, 10, 20, 50]"), + }; + return new ControlNodeCompBuilder(childrenMap, (props, dispatch) => { + return { + showQuickJumper: props.showQuickJumper, + showSizeChanger: props.showSizeChanger, + total: props.total, + hideOnSinglePage: props.hideOnSinglePage, + pageSize: getPageSize( + props.showSizeChanger, + props.pageSize, + props.pageSizeOptions, + props.changeablePageSize + ), + current: props.pageNo, + pageSizeOptions: props.pageSizeOptions, + onChange: (page: number, pageSize: number) => { + props.showSizeChanger && + pageSize !== props.changeablePageSize && + dispatch(changeChildAction("changeablePageSize", pageSize, true)); + page !== props.pageNo && dispatch(changeChildAction("pageNo", page, false)); + }, + }; + }) + .setPropertyViewFn((children) => [ + children.showQuickJumper.propertyView({ + label: trans("table.showQuickJumper"), + }), + children.hideOnSinglePage.propertyView({ + label: trans("table.hideOnSinglePage"), + }), + children.showSizeChanger.propertyView({ + label: trans("table.showSizeChanger"), + }), + children.showSizeChanger.getView() + ? children.pageSizeOptions.propertyView({ + label: trans("table.pageSizeOptions"), + }) + : children.pageSize.propertyView({ + label: trans("table.pageSize"), + placeholder: String(DEFAULT_PAGE_SIZE), + }), + children.total.propertyView({ + label: trans("table.total"), + tooltip: trans("table.totalTooltip"), + }), + ]) + .build(); +})(); + +export class PaginationControl extends PaginationTmpControl { + getOffset() { + const pagination = this.getView(); + return (pagination.current - 1) * pagination.pageSize; + } +} + +export type PaginationNodeType = ConstructorToNodeType; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx new file mode 100644 index 0000000000..037516d91b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx @@ -0,0 +1,134 @@ +import { TableRowSelection } from "antd/es/table/interface"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { stateComp } from "comps/generators"; +import { trans } from "i18n"; +import { changeChildAction, ConstructorToComp } from "lowcoder-core"; +import { TableOnEventView } from "./tableTypes"; +import { OB_ROW_ORI_INDEX, RecordType } from "comps/comps/tableComp/tableUtils"; +import { ControlNodeCompBuilder } from "comps/generators/controlCompBuilder"; + +// double-click detection constants +const DOUBLE_CLICK_THRESHOLD = 300; // ms +let lastClickTime = 0; +let clickTimer: ReturnType; + +const modeOptions = [ + { + label: trans("selectionControl.single"), + value: "single", + }, + { + label: trans("selectionControl.multiple"), + value: "multiple", + }, + { + label: trans("selectionControl.close"), + value: "close", + }, +] as const; + +/** + * Currently use index as key + */ +function getKey(record: RecordType) { + return record[OB_ROW_ORI_INDEX]; +} + +export function getSelectedRowKeys( + selection: ConstructorToComp +): Array { + const mode = selection.children.mode.getView(); + switch (mode) { + case "single": + return [selection.children.selectedRowKey.getView()]; + case "multiple": + return selection.children.selectedRowKeys.getView(); + default: + return []; + } +} + +export const SelectionControl = (function () { + const childrenMap = { + mode: dropdownControl(modeOptions, "single"), + selectedRowKey: stateComp("0"), + selectedRowKeys: stateComp>([]), + }; + return new ControlNodeCompBuilder(childrenMap, (props, dispatch) => { + const changeSelectedRowKey = (record: RecordType) => { + const key = getKey(record); + if (key !== props.selectedRowKey) { + dispatch(changeChildAction("selectedRowKey", key, false)); + } + }; + + return (onEvent: TableOnEventView) => { + const handleClick = (record: RecordType) => { + return () => { + const now = Date.now(); + clearTimeout(clickTimer); + if (now - lastClickTime < DOUBLE_CLICK_THRESHOLD) { + + changeSelectedRowKey(record); + onEvent("doubleClick"); + if (getKey(record) !== props.selectedRowKey) { + onEvent("rowSelectChange"); + } + } else { + clickTimer = setTimeout(() => { + changeSelectedRowKey(record); + onEvent("rowClick"); + if (getKey(record) !== props.selectedRowKey) { + onEvent("rowSelectChange"); + } + }, DOUBLE_CLICK_THRESHOLD); + } + lastClickTime = now; + }; + }; + + if (props.mode === "single" || props.mode === "close") { + return { + rowKey: getKey, + rowClassName: (record: RecordType, index: number, indent: number) => { + if (props.mode === "close") { + return ""; + } + return getKey(record) === props.selectedRowKey ? "ant-table-row-selected" : ""; + }, + onRow: (record: RecordType, index: number | undefined) => ({ + onClick: handleClick(record), + }), + }; + } + + const result: TableRowSelection = { + type: "checkbox", + selectedRowKeys: props.selectedRowKeys, + preserveSelectedRowKeys: true, + onChange: (selectedRowKeys) => { + dispatch(changeChildAction("selectedRowKeys", selectedRowKeys as string[], false)); + onEvent("rowSelectChange"); + }, + onSelect: (record: RecordType) => { + changeSelectedRowKey(record); + onEvent("rowClick"); + }, + }; + return { + rowKey: getKey, + rowSelection: result, + onRow: (record: RecordType) => ({ + onClick: handleClick(record), + }), + }; + }; + }) + .setPropertyViewFn((children) => + children.mode.propertyView({ + label: trans("selectionControl.mode"), + radioButton: true, + }) + ) + .build(); +})(); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx new file mode 100644 index 0000000000..96121e4f59 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx @@ -0,0 +1,213 @@ +import { ColumnComp } from "comps/comps/tableComp/column/tableColumnComp"; +import { evalAndReduce } from "comps/utils"; +import _ from "lodash"; +import { fromValue } from "lowcoder-core"; +import { MemoryRouter } from "react-router-dom"; +import { MockTableComp } from "./mockTableComp"; +import { TableLiteComp } from "./tableComp"; +import { OB_ROW_ORI_INDEX } from "./tableUtils"; + +test("test column", () => { + const columnData = { + title: "name", + // editable: true, // TODO: change to boolean + }; + let comp = new ColumnComp({ value: columnData }); + comp = evalAndReduce(comp); + const columnOutput = comp.getView(); + expect(columnOutput.title).toEqual(columnData.title); + // expect(columnOutput.editable).toEqual(columnData.editable); +}); + +test("test column render", () => { + const columnData = { + render: { + compType: "text" as const, + comp: { + text: "{{currentRow.id}}", + }, + }, + // editable: true, // TODO: change to boolean + }; + let comp = new ColumnComp({ value: columnData }); + comp = evalAndReduce(comp); + const columnOutput = comp.getView(); + expect( + ( + columnOutput + .render( + { + currentCell: null, + currentIndex: null, + currentRow: { id: "hello" }, + currentOriginalIndex: null, + }, + "0" + ) + .getView() + .view({}) as any + ).props.normalView + ).toEqual("hello"); + // FIXME: see what should be output if the input is wrong + // expect(columnOutput.render()).toEqual(""); + // expect(columnOutput.render(null, "def")).toEqual(""); +}); + +test("test table", async () => { + // jest.setTimeout(1000); + const tableData = { + data: JSON.stringify([{ a: 1 }]), + columns: [ + { + dataIndex: "a", + hide: true, + }, + { + title: "custom", + dataIndex: "", + isCustom: true, + }, + ], + }; + const exposingInfo: any = { + query1: fromValue({ data: [{ q: 1 }] }), + query2: fromValue({ data: [{ q2: 2 }] }), + }; + let comp = new TableLiteComp({ + dispatch: (action: any) => { + comp = evalAndReduce(comp.reduce(action), exposingInfo); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + let columns = comp.children.columns.getView(); + expect(columns.length).toEqual(2); + comp = evalAndReduce(comp.reduce(comp.changeChildAction("data", '[{"a":1, "c":2, "d":3}]'))); + await new Promise((r) => setTimeout(r, 20)); + columns = comp.children.columns.getView(); + expect(columns.length).toEqual(4); + expect(columns[0].getView().dataIndex).toEqual("a"); + expect(columns[0].getView().hide).toBe(true); + expect(columns[1].getView().title).toEqual("custom"); + expect(columns[2].getView().title).toEqual("c"); + expect(columns[3].getView().title).toEqual("d"); +}, 1000); + +// FIXME: add a single test for the click action of the table + +function DebugContainer(props: any) { + return ( + + {props.comp.getView()} + + ); +} + +test("test mock table render", () => { + let comp = new MockTableComp({}); + comp = evalAndReduce(comp); + // render(); + // screen.getByText(/Date/i); +}); + +test("test table data transform", () => { + function getAndExpectTableData(expectDisplayDataLen: number, comp: any) { + const exposingValues = comp.exposingValues; + const displayData = exposingValues["displayData"]; + const { data } = comp.getProps(); + const filteredData = comp.filterData; + // Transform, sort, filter the raw data. + expect(data.length).toEqual(3); + expect(displayData.length).toEqual(expectDisplayDataLen); + // Remove the custom column, displayData is the same as tranFormData, if title is not defined + expect(displayData.map((d: any) => _.omit(d, "custom"))).toEqual( + _.map(filteredData, (row) => _.omit(row, OB_ROW_ORI_INDEX)) + ); + return { transformedData: filteredData, data, displayData }; + } + + const tableData = { + data: JSON.stringify([ + { id: 1, name: "gg" }, + { id: 5, name: "gg2" }, + { id: 3, name: "jjj" }, + ]), + columns: [ + { + dataIndex: "id", + isCustom: false, + sortable: true, + render: { compType: "text" as const, comp: { text: "{{currentCell}}" } }, + }, + { + dataIndex: "name", + isCustom: false, + render: { compType: "text" as const, comp: { text: "{{currentCell}}" } }, + }, + { + title: "custom", + dataIndex: "ealekfg", + isCustom: true, + render: { + compType: "image" as const, + comp: { + src: "{{currentRow.id}}", + }, + }, + }, + ], + }; + let comp = new TableLiteComp({ + dispatch: (action: any) => { + comp = evalAndReduce(comp.reduce(action)); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + // id sort + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("sort", [ + { + column: "id", + desc: true, + }, + ]) + ) + ); + let { transformedData, data, displayData } = getAndExpectTableData(3, comp); + expect(transformedData.map((d: any) => d["id"])).toEqual([5, 3, 1]); + // search + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("toolbar", { + searchText: "gg", + }) + ) + ); + getAndExpectTableData(2, comp); + // filter + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("toolbar", { + showFilter: true, + filter: { + stackType: "and", + filters: [ + { + columnKey: "id", + filterValue: "4", + operator: "gt", + }, + { + columnKey: "id", + filterValue: "5", + operator: "lte", + }, + ], + }, + }) + ) + ); + getAndExpectTableData(1, comp); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx new file mode 100644 index 0000000000..3f3b324115 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx @@ -0,0 +1,1019 @@ +import { tableDataRowExample } from "comps/comps/tableComp/column/tableColumnListComp"; +import { getPageSize } from "comps/comps/tableComp/paginationControl"; +import { EMPTY_ROW_KEY, TableCompView } from "comps/comps/tableLiteComp/tableCompView"; +import { TableFilter } from "comps/comps/tableComp/tableToolbarComp"; +import { + columnHide, + ColumnsAggrData, + COLUMN_CHILDREN_KEY, + filterData, + genSelectionParams, + getColumnsAggr, + getOriDisplayData, + OB_ROW_ORI_INDEX, + RecordType, + sortData, + transformDispalyData, + tranToTableRecord, +} from "comps/comps/tableComp/tableUtils"; +import { isTriggerAction } from "comps/controls/actionSelector/actionSelectorControl"; +import { withPropertyViewFn, withViewFn } from "comps/generators"; +import { childrenToProps } from "comps/generators/multi"; +import { HidableView } from "comps/generators/uiCompBuilder"; +import { withDispatchHook } from "comps/generators/withDispatchHook"; +import { + CompDepsConfig, + depsConfig, + DepsConfig, + NameConfig, + withExposingConfigs, +} from "comps/generators/withExposing"; +import { withMethodExposing } from "comps/generators/withMethodExposing"; +import { MAP_KEY } from "comps/generators/withMultiContext"; +import { NameGenerator } from "comps/utils"; +import { trans } from "i18n"; +import _, { isArray } from "lodash"; +import { + changeChildAction, + CompAction, + CompActionTypes, + deferAction, + executeQueryAction, + fromRecord, + FunctionNode, + Node, + onlyEvalAction, + RecordNode, + RecordNodeToValue, + routeByNameAction, + ValueAndMsg, + withFunction, + wrapChildAction, +} from "lowcoder-core"; +import { saveDataAsFile } from "util/fileUtils"; +import { JSONObject, JSONValue } from "util/jsonTypes"; +import { lastValueIfEqual, shallowEqual } from "util/objectUtils"; +import { IContainer } from "../containerBase"; +import { getSelectedRowKeys } from "./selectionControl"; +import { compTablePropertyView } from "./tablePropertyView"; +import { RowColorComp, RowHeightComp, SortValue, TableChildrenView, TableInitComp } from "./tableTypes"; + +import { useContext, useState } from "react"; +import { EditorContext } from "comps/editorState"; + +export class TableImplComp extends TableInitComp implements IContainer { + private prevUnevaledValue?: string; + readonly filterData: RecordType[] = []; + readonly columnAggrData: ColumnsAggrData = {}; + + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } + + getTableAutoHeight() { + return this.children.autoHeight.getView(); + } + + private getSlotContainer() { + return this.children.expansion.children.slot.getSelectedComp().getComp().children.container; + } + + findContainer(key: string) { + return this.getSlotContainer().findContainer(key); + } + + getCompTree() { + return this.getSlotContainer().getCompTree(); + } + + getPasteValue(nameGenerator: NameGenerator) { + return { + ...this.toJsonValue(), + expansion: this.children.expansion.getPasteValue(nameGenerator), + }; + } + + realSimpleContainer(key?: string) { + return this.getSlotContainer().realSimpleContainer(key); + } + + downloadData(fileName: string) { + saveDataAsFile({ + data: (this as any).exposingValues["displayData"], + filename: fileName, + fileType: "csv", + delimiter: this.children.toolbar.children.columnSeparator.getView(), + }); + } + + refreshData(allQueryNames: Array, setLoading: (loading: boolean) => void) { + const deps: Array = this.children.data.exposingNode().dependNames(); + const depsQueryNames = deps.map((d) => d.split(".")[0]); + if (_.isEmpty(depsQueryNames)) { + // Independent query, using local data, giving a fake loading effect + setLoading(true); + setTimeout(() => setLoading(false), 200); + return; + } + const queryNameSet = new Set(allQueryNames); + depsQueryNames.forEach((name) => { + if (queryNameSet.has(name)) { + this.dispatch(deferAction(routeByNameAction(name, executeQueryAction({})))); + } + }); + } + + // only for test? + getProps() { + return childrenToProps(_.omit(this.children, "style")) as TableChildrenView; + } + + shouldGenerateColumn(comp: this, nextRowExample?: JSONObject) { + const columnKeys = comp.children.columns + .getView() + .map((col) => { + const colView = col.getView(); + if (colView.isCustom) { + return ""; + } else { + return colView.dataIndex; + } + }) + .filter((t) => !!t); + const nextUnevaledVal = comp.children.data.unevaledValue; + const prevUnevaledVal = this.prevUnevaledValue; + if (!nextRowExample) { + this.prevUnevaledValue = nextUnevaledVal; + return false; + } + let doGenColumn = false; + const nextRowKeys = Object.keys(nextRowExample); + const dynamicColumn = comp.children.dynamicColumn.getView(); + if (!prevUnevaledVal && columnKeys.length === 0) { + // the first time + doGenColumn = true; + } else if (prevUnevaledVal && nextUnevaledVal !== prevUnevaledVal) { + // modify later + doGenColumn = true; + } else if (dynamicColumn) { + doGenColumn = true; + } else if ( + columnKeys.length < nextRowKeys.length && + columnKeys.every((key) => nextRowKeys.includes(key)) + ) { + // new column is automatically generated + doGenColumn = true; + } + if (!doGenColumn) { + const toBeGenRow = comp.children.dataRowExample.getView(); + const columnKeyChanged = + columnKeys.length !== nextRowKeys.length || + !_.isEqual(_.sortBy(columnKeys), _.sortBy(nextRowKeys)); + // The data has changed, but can't judge the auto generation + if (columnKeyChanged && !_.isEqual(toBeGenRow, nextRowExample)) { + setTimeout(() => { + comp.children.dataRowExample.dispatchChangeValueAction(nextRowExample); + }); + } else if (!columnKeyChanged && toBeGenRow) { + setTimeout(() => { + comp.children.dataRowExample.dispatchChangeValueAction(null); + }); + } + } + this.prevUnevaledValue = nextUnevaledVal; + return doGenColumn; + } + + override reduce(action: CompAction): this { + let comp = super.reduce(action); + let dataChanged = false; + if (action.type === CompActionTypes.UPDATE_NODES_V2) { + const nextRowExample = tableDataRowExample(comp.children.data.getView()); + dataChanged = + comp.children.data !== this.children.data && + !_.isEqual(this.children.data.getView(), comp.children.data.getView()); + if (dataChanged) { + // update rowColor context + comp = comp.setChild( + "rowColor", + comp.children.rowColor.reduce( + RowColorComp.changeContextDataAction({ + currentRow: nextRowExample, + currentIndex: 0, + currentOriginalIndex: 0, + columnTitle: nextRowExample ? Object.keys(nextRowExample)[0] : undefined, + }) + ) + ); + comp = comp.setChild( + "rowHeight", + comp.children.rowHeight.reduce( + RowHeightComp.changeContextDataAction({ + currentRow: nextRowExample, + currentIndex: 0, + currentOriginalIndex: 0, + columnTitle: nextRowExample ? Object.keys(nextRowExample)[0] : undefined, + }) + ) + ); + } + + if (dataChanged) { + const doGene = comp.shouldGenerateColumn(comp, nextRowExample); + const actions: CompAction[] = []; + actions.push( + wrapChildAction( + "columns", + comp.children.columns.dataChangedAction({ + rowExample: nextRowExample || {}, + doGeneColumn: doGene, + dynamicColumn: comp.children.dynamicColumn.getView(), + data: comp.children.data.getView(), + }) + ) + ); + doGene && actions.push(comp.changeChildAction("dataRowExample", null)); + setTimeout(() => { + actions.forEach((action) => comp.dispatch(deferAction(action))); + }, 0); + } + } + + let needMoreEval = false; + + const thisSelection = getSelectedRowKeys(this.children.selection)[0] ?? "0"; + const newSelection = getSelectedRowKeys(comp.children.selection)[0] ?? "0"; + const selectionChanged = + this.children.selection !== comp.children.selection && thisSelection !== newSelection; + if ( + (action.type === CompActionTypes.CUSTOM && + comp.children.columns.getView().length !== this.children.columns.getView().length) || + selectionChanged + ) { + comp = comp.setChild( + "columns", + comp.children.columns.reduce(comp.children.columns.setSelectionAction(newSelection)) + ); + needMoreEval = true; + } + + let params = comp.children.expansion.children.slot.getCachedParams(newSelection); + if (selectionChanged || _.isNil(params) || dataChanged) { + params = + _.isNil(params) || dataChanged + ? genSelectionParams(comp.filterData, newSelection) + : undefined; + comp = comp.setChild( + "expansion", + comp.children.expansion.reduce( + comp.children.expansion.setSelectionAction(newSelection, params) + ) + ); + needMoreEval = true; + } + if (action.type === CompActionTypes.UPDATE_NODES_V2 && needMoreEval) { + setTimeout(() => comp.dispatch(onlyEvalAction())); + } + // console.info("exit tableComp reduce. action: ", action, "\nthis: ", this, "\ncomp: ", comp); + return comp; + } + + override extraNode() { + const extra = { + sortedData: this.sortDataNode(), + filterData: this.filterNode(), + oriDisplayData: this.oriDisplayDataNode(), + columnAggrData: this.columnAggrNode(), + }; + return { + node: extra, + updateNodeFields: (value: any) => ({ + filterData: value.filterData, + columnAggrData: value.columnAggrData, + }), + }; + } + + // handle sort: data -> sortedData + sortDataNode() { + const nodes: { + data: Node; + sort: Node; + dataIndexes: RecordNode>>; + sortables: RecordNode>>>; + withParams: RecordNode<_.Dictionary>, + } = { + data: this.children.data.exposingNode(), + sort: this.children.sort.node(), + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + sortables: this.children.columns.getColumnsNode("sortable"), + withParams: this.children.columns.withParamsNode(), + }; + const sortedDataNode = withFunction(fromRecord(nodes), (input) => { + const { data, sort, dataIndexes, sortables } = input; + const sortColumns = _(dataIndexes) + .mapValues((dataIndex, idx) => ({ sortable: !!sortables[idx] })) + .mapKeys((sortable, idx) => dataIndexes[idx]) + .value(); + const dataColumns = _(dataIndexes) + .mapValues((dataIndex, idx) => ({ + dataIndex, + render: input.withParams[idx] as any, + })) + .value(); + const updatedData: Array = data.map((row, index) => ({ + ...row, + [OB_ROW_ORI_INDEX]: index + "", + })); + const updatedDataMap: Record = {}; + updatedData.forEach((row) => { + updatedDataMap[row[OB_ROW_ORI_INDEX]] = row; + }) + const originalData = getOriDisplayData(updatedData, 1000, Object.values(dataColumns)) + const sortedData = sortData(originalData, sortColumns, sort); + + // console.info( "sortNode. data: ", data, " sort: ", sort, " columns: ", columns, " sortedData: ", sortedData); + const newData = sortedData.map(row => { + return { + ...row, + ...updatedDataMap[row[OB_ROW_ORI_INDEX]], + } + }); + return newData; + }); + return lastValueIfEqual(this, "sortedDataNode", [sortedDataNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + // handle hide/search/filter: sortedData->filteredData + filterNode() { + const nodes = { + data: this.sortDataNode(), + searchValue: this.children.searchText.node(), + filter: this.children.toolbar.children.filter.node(), + showFilter: this.children.toolbar.children.showFilter.node(), + }; + let context = this; + const filteredDataNode = withFunction(fromRecord(nodes), (input) => { + const { data, searchValue, filter, showFilter } = input; + const filteredData = filterData(data, searchValue.value, filter, showFilter.value); + // console.info("filterNode. data: ", data, " filter: ", filter, " filteredData: ", filteredData); + // if data is changed on search then trigger event + if(Boolean(searchValue.value) && data.length !== filteredData.length) { + const onEvent = context.children.onEvent.getView(); + setTimeout(() => { + onEvent("dataSearch"); + }); + } + return filteredData.map((row) => tranToTableRecord(row, row[OB_ROW_ORI_INDEX])); + }); + return lastValueIfEqual(this, "filteredDataNode", [filteredDataNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + oriDisplayDataNode() { + const nodes = { + data: this.filterNode(), + // --> pageSize + showSizeChanger: this.children.pagination.children.showSizeChanger.node(), + pageSize: this.children.pagination.children.pageSize.node(), + pageSizeOptions: this.children.pagination.children.pageSizeOptions.node(), + changablePageSize: this.children.pagination.children.changeablePageSize.node(), + // <-- pageSize + withParams: this.children.columns.withParamsNode(), + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + }; + const resNode = withFunction(fromRecord(nodes), (input) => { + const columns = _(input.dataIndexes) + .mapValues((dataIndex, idx) => ({ + dataIndex, + render: input.withParams[idx], + })) + .value(); + const pageSize = getPageSize( + input.showSizeChanger.value, + input.pageSize.value, + input.pageSizeOptions.value, + input.changablePageSize + ); + return getOriDisplayData(input.data, pageSize, Object.values(columns)); + }); + return lastValueIfEqual(this, "oriDisplayDataNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + displayDataIndexesNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + }; + const resNode = withFunction(fromRecord(nodes), (input) => { + return _(input.oriDisplayData) + .map((row, idx) => [row[OB_ROW_ORI_INDEX], idx] as [string, number]) + .fromPairs() + .value(); + }); + return lastValueIfEqual(this, "displayDataIndexesNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + private getUpsertSetResNode( + nodes: Record>>>, + filterNewRows?: boolean, + ) { + return withFunction(fromRecord(nodes), (input) => { + // merge input.dataIndexes and input.withParams into one structure + const dataIndexRenderDict = _(input.dataIndexes) + .mapValues((dataIndex, idx) => input.renders[idx]) + .mapKeys((render, idx) => input.dataIndexes[idx]) + .value(); + const record: Record> = {}; + _.forEach(dataIndexRenderDict, (render, dataIndex) => { + _.forEach(render[MAP_KEY], (value, key) => { + const changeValue = (value.comp as any).comp.changeValue; + const includeRecord = (filterNewRows && key.startsWith(EMPTY_ROW_KEY)) || (!filterNewRows && !key.startsWith(EMPTY_ROW_KEY)); + if (!_.isNil(changeValue) && includeRecord) { + if (!record[key]) record[key] = {}; + record[key][dataIndex] = changeValue; + } + }); + }); + return record; + }); + } + + changeSetNode() { + const nodes = { + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + renders: this.children.columns.getColumnsNode("render"), + }; + + const resNode = this.getUpsertSetResNode(nodes); + return lastValueIfEqual(this, "changeSetNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + insertSetNode() { + const nodes = { + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + renders: this.children.columns.getColumnsNode("render"), + }; + + const resNode = this.getUpsertSetResNode(nodes, true); + return lastValueIfEqual(this, "insertSetNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + private getToUpsertRowsResNodes( + nodes: Record> + ) { + return withFunction(fromRecord(nodes), (input) => { + const res = _(input.changeSet) + .map((changeValues, oriIndex) => { + const idx = input.indexes[oriIndex]; + const oriRow = _.omit(input.oriDisplayData[idx], OB_ROW_ORI_INDEX); + return { ...oriRow, ...changeValues }; + }) + .value(); + // console.info("toUpdateRowsNode. input: ", input, " res: ", res); + return res; + }); + } + + toUpdateRowsNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + indexes: this.displayDataIndexesNode(), + changeSet: this.changeSetNode(), + }; + + const resNode = this.getToUpsertRowsResNodes(nodes); + return lastValueIfEqual(this, "toUpdateRowsNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + toInsertRowsNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + indexes: this.displayDataIndexesNode(), + changeSet: this.insertSetNode(), + }; + + const resNode = this.getToUpsertRowsResNodes(nodes); + return lastValueIfEqual(this, "toInsertRowsNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + columnAggrNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + withParams: this.children.columns.withParamsNode(), + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + }; + const resNode = withFunction(fromRecord(nodes), (input) => { + const dataIndexWithParamsDict = _(input.dataIndexes) + .mapValues((dataIndex, idx) => input.withParams[idx]) + .mapKeys((withParams, idx) => input.dataIndexes[idx]) + .value(); + const res = getColumnsAggr(input.oriDisplayData, dataIndexWithParamsDict); + // console.info("columnAggrNode: ", res); + return res; + }); + return lastValueIfEqual(this, "columnAggrNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } +} + +let TableTmpComp = withViewFn(TableImplComp, (comp) => { + const [emptyRows, setEmptyRows] = useState([]); + return ( + + ); +}); + + +const withEditorModeStatus = (Component:any) => (props:any) => { + const editorModeStatus = useContext(EditorContext).editorModeStatus; + const {ref, ...otherProps} = props; + return ; +}; + +// Use this HOC when defining TableTmpComp +TableTmpComp = withPropertyViewFn(TableTmpComp, (comp) => withEditorModeStatus(compTablePropertyView)(comp)); + +// TableTmpComp = withPropertyViewFn(TableTmpComp, compTablePropertyView); + + + + + +/** + * Hijack children's execution events and ensure that selectedRow is modified first (you can also add a triggeredRow field). + */ +TableTmpComp = withDispatchHook(TableTmpComp, (dispatch) => (action) => { + if (!dispatch) { + return; + } + if (isTriggerAction(action)) { + const context = action.value.context; + if (context && !_.isNil(context["currentOriginalIndex"])) { + const key = context["currentOriginalIndex"] + ""; + dispatch(wrapChildAction("selection", changeChildAction("selectedRowKey", key, false))); + } + // action.context; + } + return dispatch(action); +}); + +function _indexKeyToRecord(data: JSONObject[], key: string) { + const keyPath = (key + "").split("-"); + let currentData = data; + let res = undefined; + for (let k of keyPath) { + const index = Number(k); + if (index >= 0 && Array.isArray(currentData) && index < currentData.length) { + res = currentData[index]; + currentData = res[COLUMN_CHILDREN_KEY] as JSONObject[]; + } + } + return res; +} + +function toDisplayIndex(displayData: JSONObject[], selectRowKey: string) { + const keyPath = selectRowKey.split("-"); + const originSelectKey = keyPath[0]; + if (!originSelectKey) { + return ""; + } + let displayIndex; + displayData.forEach((data, index) => { + if (data[OB_ROW_ORI_INDEX] === originSelectKey) { + displayIndex = index; + } + }); + if (displayIndex && keyPath.length > 1) { + return [displayIndex, ...keyPath.slice(1)].join("-"); + } + return displayIndex; +} + +TableTmpComp = withMethodExposing(TableTmpComp, [ + { + method: { + name: "setFilter", + description: "", + params: [{ name: "filter", type: "JSON" }], + }, + execute: (comp, values) => { + if (values[0]) { + const param = values[0] as TableFilter; + const currentVal = comp.children.toolbar.children.filter.getView(); + comp.children.toolbar.children.filter.dispatchChangeValueAction({ + ...currentVal, + ...param, + }); + } + }, + }, + { + method: { + name: "setPage", + description: "", + params: [{ name: "page", type: "number" }], + }, + execute: (comp, values) => { + const page = values[0] as number; + if (page && page > 0) { + comp.children.pagination.children.pageNo.dispatchChangeValueAction(page); + } + }, + }, + { + method: { + name: "setSort", + description: "", + params: [ + { name: "sortColumn", type: "string" }, + { name: "sortDesc", type: "boolean" }, + ], + }, + execute: (comp, values) => { + if (values[0]) { + comp.children.sort.dispatchChangeValueAction([ + { + column: values[0] as string, + desc: values[1] as boolean, + }, + ]); + } + }, + }, + { + method: { + name: "setMultiSort", + description: "", + params: [ + { name: "sortColumns", type: "arrayObject"}, + ], + }, + execute: (comp, values) => { + const sortColumns = values[0]; + if (!isArray(sortColumns)) { + return Promise.reject("setMultiSort function only accepts array of sort objects i.e. [{column: column_name, desc: boolean}]") + } + if (sortColumns && isArray(sortColumns)) { + comp.children.sort.dispatchChangeValueAction(sortColumns as SortValue[]); + } + }, + }, + { + method: { + name: "resetSelections", + description: "", + params: [], + }, + execute: (comp) => { + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction("0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction([]); + }, + }, + { + method: { + name: "selectAll", + description: "Select all rows in the current filtered view", + params: [], + }, + execute: (comp) => { + const displayData = comp.filterData ?? []; + const allKeys = displayData.map((row) => row[OB_ROW_ORI_INDEX] + ""); + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction(allKeys[0] || "0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction(allKeys); + }, + }, + { + method: { + name: "cancelChanges", + description: "", + params: [], + }, + execute: (comp, values) => { + comp.children.columns.dispatchClearChangeSet(); + }, + }, + { + method: { + name: "cancelInsertChanges", + description: "", + params: [], + }, + execute: (comp, values) => { + comp.children.columns.dispatchClearInsertSet(); + }, + }, + { + method: { + name: "setExpandedRows", + description: "", + params: [ + { name: "expandedRows", type: "arrayString"}, + ], + }, + execute: (comp, values) => { + const expandedRows = values[0]; + if (!isArray(expandedRows)) { + return Promise.reject("setExpandedRows function only accepts array of string i.e. ['1', '2', '3']") + } + if (expandedRows && isArray(expandedRows)) { + comp.children.currentExpandedRows.dispatchChangeValueAction(expandedRows as string[]); + } + }, + } +]); + +// exposing data +export const TableLiteComp = withExposingConfigs(TableTmpComp, [ + new DepsConfig( + "selectedRow", + (children) => { + return { + selectedRowKey: children.selection.children.selectedRowKey.node(), + data: children.data.exposingNode(), + }; + }, + (input) => { + if (!input.data) { + return undefined; + } + return _indexKeyToRecord(input.data, input.selectedRowKey); + }, + trans("table.selectedRowDesc") + ), + new DepsConfig( + "selectedRows", + (children) => { + return { + selectedRowKeys: children.selection.children.selectedRowKeys.node(), + data: children.data.exposingNode(), + }; + }, + (input) => { + if (!input.data) { + return undefined; + } + return input.selectedRowKeys.flatMap((key: string) => { + const result = _indexKeyToRecord(input.data, key); + return result === undefined ? [] : [result]; + }); + }, + trans("table.selectedRowsDesc") + ), + new CompDepsConfig( + "selectedIndex", + (comp) => { + return { + oriDisplayData: comp.oriDisplayDataNode(), + selectedRowKey: comp.children.selection.children.selectedRowKey.node(), + }; + }, + (input) => { + return toDisplayIndex(input.oriDisplayData, input.selectedRowKey); + }, + trans("table.selectedIndexDesc") + ), + new CompDepsConfig( + "selectedIndexes", + (comp) => { + return { + oriDisplayData: comp.oriDisplayDataNode(), + selectedRowKeys: comp.children.selection.children.selectedRowKeys.node(), + }; + }, + (input) => { + return input.selectedRowKeys.flatMap((key: string) => { + const result = toDisplayIndex(input.oriDisplayData, key); + return result === undefined ? [] : [result]; + }); + }, + trans("table.selectedIndexDesc") + ), + new CompDepsConfig( + "changeSet", + (comp) => ({ + changeSet: comp.changeSetNode(), + }), + (input) => input.changeSet, + trans("table.changeSetDesc") + ), + new CompDepsConfig( + "insertSet", + (comp) => ({ + insertSet: comp.insertSetNode(), + }), + (input) => input.insertSet, + trans("table.changeSetDesc") + ), + new CompDepsConfig( + "toUpdateRows", + (comp) => ({ + toUpdateRows: comp.toUpdateRowsNode(), + }), + (input) => { + return input.toUpdateRows; + }, + trans("table.toUpdateRowsDesc") + ), + new CompDepsConfig( + "toInsertRows", + (comp) => ({ + toInsertRows: comp.toInsertRowsNode(), + }), + (input) => { + return input.toInsertRows; + }, + trans("table.toUpdateRowsDesc") + ), + new DepsConfig( + "pageNo", + (children) => { + return { + pageNo: children.pagination.children.pageNo.exposingNode(), + }; + }, + (input) => input.pageNo, + trans("table.pageNoDesc") + ), + new DepsConfig( + "pageSize", + (children) => { + return { + showSizeChanger: children.pagination.children.showSizeChanger.node(), + changeablePageSize: children.pagination.children.changeablePageSize.node(), + pageSize: children.pagination.children.pageSize.node(), + pageSizeOptions: children.pagination.children.pageSizeOptions.node(), + }; + }, + (input) => { + return getPageSize( + input.showSizeChanger.value, + input.pageSize.value, + input.pageSizeOptions.value, + input.changeablePageSize + ); + }, + trans("table.pageSizeDesc") + ), + new DepsConfig( + "sortColumn", + (children) => { + return { + sort: children.sort.node(), + columns: children.columns.node()!, + }; + }, + (input) => { + const sortIndex = input.sort[0]?.column; + const column = Object.values(input.columns as any).find( + (c: any) => c.dataIndex === sortIndex + ) as any; + if (column?.isCustom && column?.title.value) { + return column.title.value; + } else { + return sortIndex; + } + }, + trans("table.sortColumnDesc") + ), + new DepsConfig( + "sortColumns", + (children) => { + return { + sort: children.sort.node(), + }; + }, + (input) => { + return input.sort; + }, + trans("table.sortColumnDesc") + ), + depsConfig({ + name: "sortDesc", + desc: trans("table.sortDesc"), + depKeys: ["sort"], + func: (input) => { + return input.sort[0]?.desc || false; + }, + }), + new DepsConfig( + "pageOffset", + (children) => { + return { + showSizeChanger: children.pagination.children.showSizeChanger.node(), + changeablePageSize: children.pagination.children.changeablePageSize.node(), + pageSize: children.pagination.children.pageSize.node(), + pageSizeOptions: children.pagination.children.pageSizeOptions.node(), + pageNo: children.pagination.children.pageNo.node(), + }; + }, + (input) => { + return ( + getPageSize( + input.showSizeChanger.value, + input.pageSize.value, + input.pageSizeOptions.value, + input.changeablePageSize + ) * + (input.pageNo - 1) + ); + }, + trans("table.pageOffsetDesc") + ), + new CompDepsConfig( + "displayData", + (comp) => { + return { + oriDisplayData: comp.oriDisplayDataNode(), + dataIndexes: comp.children.columns.getColumnsNode("dataIndex"), + titles: comp.children.columns.getColumnsNode("title"), + // --> hide + hides: comp.children.columns.getColumnsNode("hide"), + tempHides: comp.children.columns.getColumnsNode("tempHide"), + columnSetting: comp.children.toolbar.children.columnSetting.node(), + // <-- hide + }; + }, + (input) => { + const dataIndexTitleDict = _(input.dataIndexes) + .pickBy( + (_1, idx) => + !columnHide({ + hide: input.hides[idx].value, + tempHide: input.tempHides[idx], + enableColumnSetting: input.columnSetting.value, + }) + ) + .mapValues((_dataIndex, idx) => input.titles[idx]?.value) + .mapKeys((_title, idx) => input.dataIndexes[idx]) + .value(); + return transformDispalyData(input.oriDisplayData, dataIndexTitleDict); + }, + trans("table.displayDataDesc") + ), + new DepsConfig( + "filter", + (children) => { + return { + filter: children.toolbar.children.filter.node(), + }; + }, + (input) => { + return input.filter; + }, + trans("table.filterDesc") + ), + new DepsConfig( + "selectedCell", + (children) => { + return { + selectedCell: children.selectedCell.node(), + }; + }, + (input) => { + return input.selectedCell; + }, + trans("table.selectedCellDesc") + ), + depsConfig({ + name: "currentExpandedRow", + desc: trans("table.sortDesc"), + depKeys: ["currentExpandedRows"], + func: (input) => { + if (input.currentExpandedRows.length > 0) { + return input.currentExpandedRows[input.currentExpandedRows.length - 1]; + } + return ""; + }, + }), + depsConfig({ + name: "currentExpandedRows", + desc: trans("table.sortDesc"), + depKeys: ["currentExpandedRows"], + func: (input) => { + return input.currentExpandedRows; + }, + }), + new NameConfig("data", trans("table.dataDesc")), +]); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx new file mode 100644 index 0000000000..dc6c88b0d1 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx @@ -0,0 +1,1176 @@ +import { default as Table, TableProps, ColumnType } from "antd/es/table"; +import { TableCellContext, TableRowContext } from "comps/comps/tableComp/tableContext"; +import { TableToolbar } from "comps/comps/tableComp/tableToolbarComp"; +import { RowColorViewType, RowHeightViewType, TableEventOptionValues } from "comps/comps/tableComp/tableTypes"; +import { + COL_MIN_WIDTH, + COLUMN_CHILDREN_KEY, + ColumnsAggrData, + columnsToAntdFormat, + CustomColumnType, + OB_ROW_ORI_INDEX, + onTableChange, + RecordType, + supportChildrenTree, +} from "comps/comps/tableComp/tableUtils"; +import { + handleToHoverRow, + handleToSelectedRow, + TableColumnLinkStyleType, + TableColumnStyleType, + TableHeaderStyleType, + TableRowStyleType, + TableStyleType, + ThemeDetail, + TableToolbarStyleType, +} from "comps/controls/styleControlConstants"; +import { CompNameContext, EditorContext } from "comps/editorState"; +import { BackgroundColorContext } from "comps/utils/backgroundColorContext"; +import { PrimaryColor } from "constants/style"; +import { trans } from "i18n"; +import _, { isEqual } from "lodash"; +import { darkenColor, isDarkColor, isValidColor, ScrollBar } from "lowcoder-design"; +import React, { Children, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Resizable } from "react-resizable"; +import styled, { css } from "styled-components"; +import { useMergeCompStyles, useUserViewMode } from "util/hooks"; +import { TableImplComp } from "./tableComp"; +import { useResizeDetector } from "react-resize-detector"; +import { SlotConfigContext } from "comps/controls/slotControl"; +import { EmptyContent } from "pages/common/styledComponent"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { ReactRef, ResizeHandleAxis } from "layout/gridLayoutPropTypes"; +import { CellColorViewType } from "./column/tableColumnComp"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; +import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; +import { getVerticalMargin } from "@lowcoder-ee/util/cssUtil"; +import { TableSummary } from "./tableSummaryComp"; +import Skeleton from "antd/es/skeleton"; +import { SkeletonButtonProps } from "antd/es/skeleton/Button"; +import { ThemeContext } from "@lowcoder-ee/comps/utils/themeContext"; +import { useUpdateEffect } from "react-use"; + +export const EMPTY_ROW_KEY = 'empty_row'; + +function genLinerGradient(color: string) { + return isValidColor(color) ? `linear-gradient(${color}, ${color})` : color; +} + +const getStyle = ( + style: TableStyleType, + rowStyle: TableRowStyleType, + headerStyle: TableHeaderStyleType, + toolbarStyle: TableToolbarStyleType, +) => { + const background = genLinerGradient(style.background); + const selectedRowBackground = genLinerGradient(rowStyle.selectedRowBackground); + const hoverRowBackground = genLinerGradient(rowStyle.hoverRowBackground); + const alternateBackground = genLinerGradient(rowStyle.alternateBackground); + + return css` + .ant-table-body { + background: ${genLinerGradient(style.background)}; + } + .ant-table-tbody { + > tr:nth-of-type(2n + 1) { + background: ${genLinerGradient(rowStyle.background)}; + } + + > tr:nth-of-type(2n) { + background: ${alternateBackground}; + } + + // selected row + > tr:nth-of-type(2n + 1).ant-table-row-selected { + background: ${selectedRowBackground}, ${rowStyle.background} !important; + > td.ant-table-cell { + background: transparent !important; + } + + // > td.ant-table-cell-row-hover, + &:hover { + background: ${hoverRowBackground}, ${selectedRowBackground}, ${rowStyle.background} !important; + } + } + + > tr:nth-of-type(2n).ant-table-row-selected { + background: ${selectedRowBackground}, ${alternateBackground} !important; + > td.ant-table-cell { + background: transparent !important; + } + + // > td.ant-table-cell-row-hover, + &:hover { + background: ${hoverRowBackground}, ${selectedRowBackground}, ${alternateBackground} !important; + } + } + + // hover row + > tr:nth-of-type(2n + 1):hover { + background: ${hoverRowBackground}, ${rowStyle.background} !important; + > td.ant-table-cell-row-hover { + background: transparent; + } + } + > tr:nth-of-type(2n):hover { + background: ${hoverRowBackground}, ${alternateBackground} !important; + > td.ant-table-cell-row-hover { + background: transparent; + } + } + + > tr.ant-table-expanded-row { + background: ${background}; + } + } + `; +}; + +const TitleResizeHandle = styled.span` + position: absolute; + top: 0; + right: -5px; + width: 10px; + height: 100%; + cursor: col-resize; + z-index: 1; +`; + +const BackgroundWrapper = styled.div<{ + $style: TableStyleType; + $tableAutoHeight: boolean; + $showHorizontalScrollbar: boolean; + $showVerticalScrollbar: boolean; + $fixedToolbar: boolean; +}>` + display: flex; + flex-direction: column; + background: ${(props) => props.$style.background} !important; + border-radius: ${(props) => props.$style.radius} !important; + padding: ${(props) => props.$style.padding} !important; + margin: ${(props) => props.$style.margin} !important; + border-style: ${(props) => props.$style.borderStyle} !important; + border-width: ${(props) => `${props.$style.borderWidth} !important`}; + border-color: ${(props) => `${props.$style.border} !important`}; + height: calc(100% - ${(props) => props.$style.margin && getVerticalMargin(props.$style.margin.split(' '))}); + overflow: hidden; + + > div.table-scrollbar-wrapper { + overflow: auto; + ${(props) => props.$fixedToolbar && `height: auto`}; + + ${(props) => (props.$showHorizontalScrollbar || props.$showVerticalScrollbar) && ` + .simplebar-content-wrapper { + overflow: auto !important; + } + `} + + ${(props) => !props.$showHorizontalScrollbar && ` + div.simplebar-horizontal { + visibility: hidden !important; + } + `} + ${(props) => !props.$showVerticalScrollbar && ` + div.simplebar-vertical { + visibility: hidden !important; + } + `} + } +`; + +// TODO: find a way to limit the calc function for max-height only to first Margin value +const TableWrapper = styled.div<{ + $style: TableStyleType; + $headerStyle: TableHeaderStyleType; + $toolbarStyle: TableToolbarStyleType; + $rowStyle: TableRowStyleType; + $toolbarPosition: "above" | "below" | "close"; + $fixedHeader: boolean; + $fixedToolbar: boolean; + $visibleResizables: boolean; + $showHRowGridBorder?: boolean; +}>` + .ant-table-wrapper { + border-top: unset; + border-color: inherit; + } + + .ant-table-row-expand-icon { + color: ${PrimaryColor}; + } + + .ant-table .ant-table-cell-with-append .ant-table-row-expand-icon { + margin: 0; + top: 18px; + left: 4px; + } + + .ant-table.ant-table-small .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 10px; + } + + .ant-table.ant-table-middle .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 14px; + margin-right:5px; + } + + .ant-table { + background: ${(props) =>props.$style.background}; + .ant-table-container { + border-left: unset; + border-top: none !important; + border-inline-start: none !important; + + &::after { + box-shadow: none !important; + } + + .ant-table-content { + overflow: unset !important + } + + // A table expand row contains table + .ant-table-tbody .ant-table-wrapper:only-child .ant-table { + margin: 0; + } + + table { + border-top: unset; + + > .ant-table-thead { + ${(props) => + props.$fixedHeader && ` + position: sticky; + position: -webkit-sticky; + // top: ${props.$fixedToolbar ? '47px' : '0'}; + top: 0; + z-index: 2; + ` + } + > tr { + background: ${(props) => props.$headerStyle.headerBackground}; + } + > tr > th { + background: transparent; + border-color: ${(props) => props.$headerStyle.border}; + border-width: ${(props) => props.$headerStyle.borderWidth}; + color: ${(props) => props.$headerStyle.headerText}; + // border-inline-end: ${(props) => `${props.$headerStyle.borderWidth} solid ${props.$headerStyle.border}`} !important; + + /* Proper styling for fixed header cells */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: ${(props) => props.$headerStyle.headerBackground}; + } + + + + > div { + margin: ${(props) => props.$headerStyle.margin}; + + &, .ant-table-column-title > div { + font-size: ${(props) => props.$headerStyle.textSize}; + font-weight: ${(props) => props.$headerStyle.textWeight}; + font-family: ${(props) => props.$headerStyle.fontFamily}; + font-style: ${(props) => props.$headerStyle.fontStyle}; + color:${(props) => props.$headerStyle.text} + } + } + + &:last-child { + border-inline-end: none !important; + } + &.ant-table-column-has-sorters:hover { + background-color: ${(props) => darkenColor(props.$headerStyle.headerBackground, 0.05)}; + } + + > .ant-table-column-sorters > .ant-table-column-sorter { + color: ${(props) => props.$headerStyle.headerText === defaultTheme.textDark ? "#bfbfbf" : props.$headerStyle.headerText}; + } + + &::before { + background-color: ${(props) => props.$headerStyle.border}; + width: ${(props) => (props.$visibleResizables ? "1px" : "0px")} !important; + } + } + } + + > thead > tr > th, + > tbody > tr > td { + border-color: ${(props) => props.$headerStyle.border}; + ${(props) => !props.$showHRowGridBorder && `border-bottom: 0px;`} + } + + td { + padding: 0px 0px; + // ${(props) => props.$showHRowGridBorder ? 'border-bottom: 1px solid #D7D9E0 !important;': `border-bottom: 0px;`} + + /* Proper styling for Fixed columns in the table body */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: inherit; + background-color: ${(props) => props.$style.background}; + transition: background-color 0.3s; + } + + } + + /* Fix for selected and hovered rows */ + tr.ant-table-row-selected td.ant-table-cell-fix-left, + tr.ant-table-row-selected td.ant-table-cell-fix-right { + background-color: ${(props) => props.$rowStyle?.selectedRowBackground || '#e6f7ff'} !important; + } + + tr.ant-table-row:hover td.ant-table-cell-fix-left, + tr.ant-table-row:hover td.ant-table-cell-fix-right { + background-color: ${(props) => props.$rowStyle?.hoverRowBackground || '#f5f5f5'} !important; + } + + thead > tr:first-child { + th:last-child { + border-right: unset; + } + } + + tbody > tr > td:last-child { + border-right: unset !important; + } + + .ant-empty-img-simple-g { + fill: #fff; + } + + > thead > tr:first-child { + th:first-child { + border-top-left-radius: 0px; + } + + th:last-child { + border-top-right-radius: 0px; + } + } + } + + .ant-table-expanded-row-fixed:after { + border-right: unset !important; + } + } + } + + ${(props) => + props.$style && getStyle(props.$style, props.$rowStyle, props.$headerStyle, props.$toolbarStyle)} +`; + +const TableTh = styled.th<{ width?: number }>` + overflow: hidden; + + > div { + overflow: hidden; + white-space: pre; + text-overflow: ellipsis; + } + + ${(props) => props.width && `width: ${props.width}px`}; +`; + +interface TableTdProps { + $background: string; + $style: TableColumnStyleType & { rowHeight?: string }; + $defaultThemeDetail: ThemeDetail; + $linkStyle?: TableColumnLinkStyleType; + $isEditing: boolean; + $tableSize?: string; + $autoHeight?: boolean; + $customAlign?: 'left' | 'center' | 'right'; +} +const TableTd = styled.td` + .ant-table-row-expand-icon, + .ant-table-row-indent { + display: ${(props) => (props.$isEditing ? "none" : "initial")}; + } + &.ant-table-row-expand-icon-cell { + background: ${(props) => props.$background}; + border-color: ${(props) => props.$style.border}; + } + background: ${(props) => props.$background} !important; + border-color: ${(props) => props.$style.border} !important; + border-radius: ${(props) => props.$style.radius}; + padding: 0 !important; + text-align: ${(props) => props.$customAlign || 'left'} !important; + + > div:not(.editing-border, .editing-wrapper), + .editing-wrapper .ant-input, + .editing-wrapper .ant-input-number, + .editing-wrapper .ant-picker { + margin: ${(props) => props.$isEditing ? '0px' : props.$style.margin}; + color: ${(props) => props.$style.text}; + font-weight: ${(props) => props.$style.textWeight}; + font-family: ${(props) => props.$style.fontFamily}; + overflow: hidden; + display: flex; + justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; + align-items: center; + text-align: ${(props) => props.$customAlign || 'left'}; + box-sizing: border-box; + ${(props) => props.$tableSize === 'small' && ` + padding: 1px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '14px'}; + line-height: 20px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '28px'}; + `}; + `}; + ${(props) => props.$tableSize === 'middle' && ` + padding: 8px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '24px'}; + line-height: 24px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '48px'}; + `}; + `}; + ${(props) => props.$tableSize === 'large' && ` + padding: 16px 16px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '48px'}; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '96px'}; + `}; + `}; + + > .ant-badge > .ant-badge-status-text, + > div > .markdown-body { + color: ${(props) => props.$style.text}; + } + + > div > svg g { + stroke: ${(props) => props.$style.text}; + } + + // dark link|links color + > a, + > div a { + color: ${(props) => props.$linkStyle?.text}; + + &:hover { + color: ${(props) => props.$linkStyle?.hoverText}; + } + + &:active { + color: ${(props) => props.$linkStyle?.activeText}; + } + } + } +`; + +const TableTdLoading = styled(Skeleton.Button)` + width: 90% !important; + display: table !important; + + .ant-skeleton-button { + min-width: auto !important; + display: block !important; + ${(props) => props.$tableSize === 'small' && ` + height: 20px !important; + `} + ${(props) => props.$tableSize === 'middle' && ` + height: 24px !important; + `} + ${(props) => props.$tableSize === 'large' && ` + height: 28px !important; + `} + } +`; + +const ResizeableTitle = (props: any) => { + const { onResize, onResizeStop, width, viewModeResizable, ...restProps } = props; + const [childWidth, setChildWidth] = useState(0); + const resizeRef = useRef(null); + const isUserViewMode = useUserViewMode(); + + const updateChildWidth = useCallback(() => { + if (resizeRef.current) { + const width = resizeRef.current.getBoundingClientRect().width; + setChildWidth(width); + } + }, []); + + useEffect(() => { + updateChildWidth(); + const resizeObserver = new ResizeObserver(() => { + updateChildWidth(); + }); + + if (resizeRef.current) { + resizeObserver.observe(resizeRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateChildWidth]); + + const isNotDataColumn = _.isNil(restProps.title); + if ((isUserViewMode && !restProps.viewModeResizable) || isNotDataColumn) { + return ; + } + + return ( + 0 ? width : childWidth} + height={0} + onResize={(e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + e.stopPropagation(); + onResize(size.width); + }} + onResizeStart={(e: React.SyntheticEvent) => { + updateChildWidth(); + e.stopPropagation(); + e.preventDefault(); + }} + onResizeStop={onResizeStop} + draggableOpts={{ enableUserSelectHack: false }} + handle={(axis: ResizeHandleAxis, ref: ReactRef) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + /> + )} + > + + + ); +}; + +type CustomTableProps = Omit, "components" | "columns"> & { + columns: CustomColumnType[]; + viewModeResizable: boolean; + visibleResizables: boolean; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + columnsStyle: TableColumnStyleType; + size?: string; + rowAutoHeight?: boolean; + customLoading?: boolean; + onCellClick: (columnName: string, dataIndex: string) => void; +}; + +const TableCellView = React.memo((props: { + record: RecordType; + title: string; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + cellColorFn: CellColorViewType; + rowIndex: number; + children: any; + columnsStyle: TableColumnStyleType; + columnStyle: TableColumnStyleType; + linkStyle: TableColumnLinkStyleType; + tableSize?: string; + autoHeight?: boolean; + loading?: boolean; + customAlign?: 'left' | 'center' | 'right'; +}) => { + const { + record, + title, + rowIndex, + rowColorFn, + rowHeightFn, + cellColorFn, + children, + columnsStyle, + columnStyle, + linkStyle, + tableSize, + autoHeight, + loading, + customAlign, + ...restProps + } = props; + + const [editing, setEditing] = useState(false); + const rowContext = useContext(TableRowContext); + + // Memoize style calculations + const style = useMemo(() => { + if (!record) return null; + const rowColor = rowColorFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const rowHeight = rowHeightFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const cellColor = cellColorFn({ + currentCell: record[title], + currentRow: record, + }); + + return { + background: cellColor || rowColor || columnStyle.background || columnsStyle.background, + margin: columnStyle.margin || columnsStyle.margin, + text: columnStyle.text || columnsStyle.text, + border: columnStyle.border || columnsStyle.border, + radius: columnStyle.radius || columnsStyle.radius, + // borderWidth: columnStyle.borderWidth || columnsStyle.borderWidth, + textSize: columnStyle.textSize || columnsStyle.textSize, + textWeight: columnsStyle.textWeight || columnStyle.textWeight, + fontFamily: columnsStyle.fontFamily || columnStyle.fontFamily, + fontStyle: columnsStyle.fontStyle || columnStyle.fontStyle, + rowHeight: rowHeight, + }; + }, [record, rowIndex, title, rowColorFn, rowHeightFn, cellColorFn, columnStyle, columnsStyle]); + + let tdView; + if (!record) { + tdView = {children}; + } else { + let { background } = style!; + if (rowContext.hover) { + background = 'transparent'; + } + + tdView = ( + + {loading + ? + : children + } + + ); + } + + return ( + + {tdView} + + ); +}); + +const TableRowView = React.memo((props: any) => { + const [hover, setHover] = useState(false); + const [selected, setSelected] = useState(false); + + // Memoize event handlers + const handleMouseEnter = useCallback(() => setHover(true), []); + const handleMouseLeave = useCallback(() => setHover(false), []); + const handleFocus = useCallback(() => setSelected(true), []); + const handleBlur = useCallback(() => setSelected(false), []); + + return ( + + + + ); +}); + +/** + * A table with adjustable column width, width less than 0 means auto column width + */ +function ResizeableTableComp(props: CustomTableProps) { + const { + columns, + viewModeResizable, + visibleResizables, + rowColorFn, + rowHeightFn, + columnsStyle, + size, + rowAutoHeight, + customLoading, + onCellClick, + ...restProps + } = props; + const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); + + // Memoize resize handlers + const handleResize = useCallback((width: number, index: number) => { + setResizeData({ index, width }); + }, []); + + const handleResizeStop = useCallback((width: number, index: number, onWidthResize?: (width: number) => void) => { + setResizeData({ index: -1, width: -1 }); + if (onWidthResize) { + onWidthResize(width); + } + }, []); + + // Memoize cell handlers + const createCellHandler = useCallback((col: CustomColumnType) => { + return (record: RecordType, index: number) => ({ + record, + title: String(col.dataIndex), + rowColorFn, + rowHeightFn, + cellColorFn: col.cellColorFn, + rowIndex: index, + columnsStyle, + columnStyle: col.style, + linkStyle: col.linkStyle, + tableSize: size, + autoHeight: rowAutoHeight, + onClick: () => onCellClick(col.titleText, String(col.dataIndex)), + loading: customLoading, + customAlign: col.align, + }); + }, [rowColorFn, rowHeightFn, columnsStyle, size, rowAutoHeight, onCellClick, customLoading]); + + // Memoize header cell handlers + const createHeaderCellHandler = useCallback((col: CustomColumnType, index: number, resizeWidth: number) => { + return () => ({ + width: resizeWidth, + title: col.titleText, + viewModeResizable, + onResize: (width: React.SyntheticEvent) => { + if (width) { + handleResize(Number(width), index); + } + }, + onResizeStop: (e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + handleResizeStop(size.width, index, col.onWidthResize); + }, + }); + }, [viewModeResizable, handleResize, handleResizeStop]); + + // Memoize columns to prevent unnecessary re-renders + const memoizedColumns = useMemo(() => { + return columns.map((col, index) => { + const { width, style, linkStyle, cellColorFn, onWidthResize, ...restCol } = col; + const resizeWidth = (resizeData.index === index ? resizeData.width : col.width) ?? 0; + + const column: ColumnType = { + ...restCol, + width: typeof resizeWidth === "number" && resizeWidth > 0 ? resizeWidth : undefined, + minWidth: typeof resizeWidth === "number" && resizeWidth > 0 ? undefined : COL_MIN_WIDTH, + onCell: (record: RecordType, index?: number) => createCellHandler(col)(record, index ?? 0), + onHeaderCell: () => createHeaderCellHandler(col, index, Number(resizeWidth))(), + }; + return column; + }); + }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); + + return ( + + components={{ + header: { + cell: ResizeableTitle, + }, + body: { + cell: TableCellView, + row: TableRowView, + }, + }} + {...restProps} + pagination={false} + columns={memoizedColumns} + scroll={{ + x: COL_MIN_WIDTH * columns.length, + }} + /> + ); +} +ResizeableTableComp.whyDidYouRender = true; + +const ResizeableTable = React.memo(ResizeableTableComp) as typeof ResizeableTableComp; + + +const createNewEmptyRow = ( + rowIndex: number, + columnsAggrData: ColumnsAggrData, +) => { + const emptyRowData: RecordType = { + [OB_ROW_ORI_INDEX]: `${EMPTY_ROW_KEY}_${rowIndex}`, + }; + Object.keys(columnsAggrData).forEach(columnKey => { + emptyRowData[columnKey] = ''; + }); + return emptyRowData; +} + +export const TableCompView = React.memo((props: { + comp: InstanceType; + onRefresh: (allQueryNames: Array, setLoading: (loading: boolean) => void) => void; + onDownload: (fileName: string) => void; +}) => { + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [emptyRowsMap, setEmptyRowsMap] = useState>({}); + const editorState = useContext(EditorContext); + const currentTheme = useContext(ThemeContext)?.theme; + const showDataLoadingIndicators = currentTheme?.showDataLoadingIndicators; + const { width, ref } = useResizeDetector({ + refreshMode: "debounce", + refreshRate: 600, + handleHeight: false, + }); + const viewMode = useUserViewMode(); + const compName = useContext(CompNameContext); + const [loading, setLoading] = useState(false); + const { comp, onDownload, onRefresh } = props; + const compChildren = comp.children; + const style = compChildren.style.getView(); + const rowStyle = compChildren.rowStyle.getView(); + const headerStyle = compChildren.headerStyle.getView(); + const toolbarStyle = compChildren.toolbarStyle.getView(); + const hideToolbar = compChildren.hideToolbar.getView() + const rowAutoHeight = compChildren.rowAutoHeight.getView(); + const tableAutoHeight = comp.getTableAutoHeight(); + const showHorizontalScrollbar = compChildren.showHorizontalScrollbar.getView(); + const showVerticalScrollbar = compChildren.showVerticalScrollbar.getView(); + const visibleResizables = compChildren.visibleResizables.getView(); + const showHRowGridBorder = compChildren.showHRowGridBorder.getView(); + const columnsStyle = compChildren.columnsStyle.getView(); + const summaryRowStyle = compChildren.summaryRowStyle.getView(); + const changeSet = useMemo(() => compChildren.columns.getChangeSet(), [compChildren.columns]); + const insertSet = useMemo(() => compChildren.columns.getChangeSet(true), [compChildren.columns]); + const hasChange = useMemo(() => !_.isEmpty(changeSet) || !_.isEmpty(insertSet), [changeSet, insertSet]); + const columns = useMemo(() => compChildren.columns.getView(), [compChildren.columns]); + const columnViews = useMemo(() => columns.map((c) => c.getView()), [columns]); + const data = comp.filterData; + const sort = useMemo(() => compChildren.sort.getView(), [compChildren.sort]); + const toolbar = useMemo(() => compChildren.toolbar.getView(), [compChildren.toolbar]); + const showSummary = useMemo(() => compChildren.showSummary.getView(), [compChildren.showSummary]); + const summaryRows = useMemo(() => compChildren.summaryRows.getView(), [compChildren.summaryRows]); + const inlineAddNewRow = useMemo(() => compChildren.inlineAddNewRow.getView(), [compChildren.inlineAddNewRow]); + const pagination = useMemo(() => compChildren.pagination.getView(), [compChildren.pagination]); + const size = useMemo(() => compChildren.size.getView(), [compChildren.size]); + const editModeClicks = useMemo(() => compChildren.editModeClicks.getView(), [compChildren.editModeClicks]); + const onEvent = useMemo(() => compChildren.onEvent.getView(), [compChildren.onEvent]); + const currentExpandedRows = useMemo(() => compChildren.currentExpandedRows.getView(), [compChildren.currentExpandedRows]); + const dynamicColumn = compChildren.dynamicColumn.getView(); + const dynamicColumnConfig = useMemo( + () => compChildren.dynamicColumnConfig.getView(), + [compChildren.dynamicColumnConfig] + ); + const columnsAggrData = comp.columnAggrData; + const expansion = useMemo(() => compChildren.expansion.getView(), [compChildren.expansion]); + const antdColumns = useMemo( + () => + columnsToAntdFormat( + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + editModeClicks, + onEvent, + ), + [ + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + editModeClicks, + ] + ); + + const supportChildren = useMemo( + () => supportChildrenTree(compChildren.data.getView()), + [compChildren.data] + ); + + const updateEmptyRows = useCallback(() => { + if (!inlineAddNewRow) { + setEmptyRowsMap({}) + setTimeout(() => compChildren.columns.dispatchClearInsertSet()); + return; + } + + let emptyRows: Record = {...emptyRowsMap}; + const existingRowsKeys = Object.keys(emptyRows); + const existingRowsCount = existingRowsKeys.length; + const updatedRowsKeys = Object.keys(insertSet).filter( + key => key.startsWith(EMPTY_ROW_KEY) + ); + const updatedRowsCount = updatedRowsKeys.length; + const removedRowsKeys = existingRowsKeys.filter( + x => !updatedRowsKeys.includes(x) + ); + + if (removedRowsKeys.length === existingRowsCount) { + const newRowIndex = 0; + const newRowKey = `${EMPTY_ROW_KEY}_${newRowIndex}`; + setEmptyRowsMap({ + [newRowKey]: createNewEmptyRow(newRowIndex, columnsAggrData) + }); + const ele = document.querySelector(`[data-row-key=${newRowKey}]`); + if (ele) { + ele.style.display = ''; + } + return; + } + + removedRowsKeys.forEach(rowKey => { + if ( + rowKey === existingRowsKeys[existingRowsCount - 1] + || rowKey === existingRowsKeys[existingRowsCount - 2] + ) { + delete emptyRows[rowKey]; + } else { + const ele = document.querySelector(`[data-row-key=${rowKey}]`); + if (ele) { + ele.style.display = 'none'; + } + } + }) + const lastRowKey = updatedRowsCount ? updatedRowsKeys[updatedRowsCount - 1] : ''; + const lastRowIndex = lastRowKey ? parseInt(lastRowKey.replace(`${EMPTY_ROW_KEY}_`, '')) : -1; + + const newRowIndex = lastRowIndex + 1; + const newRowKey = `${EMPTY_ROW_KEY}_${newRowIndex}`; + emptyRows[newRowKey] = createNewEmptyRow(newRowIndex, columnsAggrData); + setEmptyRowsMap(emptyRows); + }, [ + inlineAddNewRow, + JSON.stringify(insertSet), + setEmptyRowsMap, + createNewEmptyRow, + ]); + + useEffect(() => { + updateEmptyRows(); + }, [updateEmptyRows]); + + useUpdateEffect(() => { + if (!isEqual(currentExpandedRows, expandedRowKeys)) { + compChildren.currentExpandedRows.dispatchChangeValueAction(expandedRowKeys); + } + }, [expandedRowKeys]); + + useUpdateEffect(() => { + if (!isEqual(currentExpandedRows, expandedRowKeys)) { + setExpandedRowKeys(currentExpandedRows); + } + }, [currentExpandedRows]); + + const pageDataInfo = useMemo(() => { + // Data pagination + let pagedData = data; + let current = pagination.current; + const total = pagination.total || data.length; + if (data.length > pagination.pageSize) { + // Local pagination + let offset = (current - 1) * pagination.pageSize; + if (offset >= total) { + current = 1; + offset = 0; + } + pagedData = pagedData.slice(offset, offset + pagination.pageSize); + } + + return { + total: total, + current: current, + data: pagedData, + }; + }, [pagination, data]); + + const childrenProps = childrenToProps(comp.children); + + useMergeCompStyles( + childrenProps as Record, + comp.dispatch + ); + + const handleChangeEvent = useCallback( + (eventName: TableEventOptionValues) => { + if (eventName === "saveChanges" && !compChildren.onEvent.isBind(eventName)) { + !viewMode && messageInstance.warning(trans("table.saveChangesNotBind")); + return; + } + compChildren.onEvent.getView()(eventName); + setTimeout(() => compChildren.columns.dispatchClearChangeSet()); + }, + [viewMode, compChildren.onEvent, compChildren.columns] + ); + + const toolbarView = !hideToolbar && ( + + onRefresh( + editorState.queryCompInfoList().map((info) => info.name), + setLoading + ) + } + onDownload={() => { + handleChangeEvent("download"); + onDownload(`${compName}-data`) + }} + hasChange={hasChange} + onSaveChanges={() => handleChangeEvent("saveChanges")} + onCancelChanges={() => { + handleChangeEvent("cancelChanges"); + if (inlineAddNewRow) { + setEmptyRowsMap({}); + } + }} + onEvent={onEvent} + /> + ); + + const summaryView = () => { + if (!showSummary) return undefined; + return ( + + ); + } + + if (antdColumns.length === 0) { + return ( +
+ {toolbar.position === "above" && !hideToolbar && toolbarView} + + {toolbar.position === "below" && !hideToolbar && toolbarView} +
+ ); + } + + const hideScrollbar = !showHorizontalScrollbar && !showVerticalScrollbar; + const showTableLoading = loading || + // fixme isLoading type + ((showDataLoadingIndicators) && + (compChildren.data as any).isLoading()) || + compChildren.loading.getView(); + + return ( + + + {toolbar.position === "above" && !hideToolbar && (toolbar.fixedToolbar || (tableAutoHeight && showHorizontalScrollbar)) && toolbarView} + + + + expandable={{ + ...expansion.expandableConfig, + childrenColumnName: supportChildren + ? COLUMN_CHILDREN_KEY + : "OB_CHILDREN_KEY_PLACEHOLDER", + fixed: "left", + onExpand: (expanded) => { + if (expanded) { + handleChangeEvent('rowExpand') + } else { + handleChangeEvent('rowShrink') + } + }, + onExpandedRowsChange: (expandedRowKeys) => { + setExpandedRowKeys(expandedRowKeys as unknown as string[]); + }, + expandedRowKeys: expandedRowKeys, + }} + // rowKey={OB_ROW_ORI_INDEX} + rowColorFn={compChildren.rowColor.getView() as any} + rowHeightFn={compChildren.rowHeight.getView() as any} + {...compChildren.selection.getView()(onEvent)} + bordered={compChildren.showRowGridBorder.getView()} + onChange={(pagination, filters, sorter, extra) => { + onTableChange(pagination, filters, sorter, extra, comp.dispatch, onEvent); + }} + showHeader={!compChildren.hideHeader.getView()} + columns={antdColumns} + columnsStyle={columnsStyle} + viewModeResizable={compChildren.viewModeResizable.getView()} + visibleResizables={compChildren.visibleResizables.getView()} + dataSource={pageDataInfo.data.concat(Object.values(emptyRowsMap))} + size={compChildren.size.getView()} + rowAutoHeight={rowAutoHeight} + tableLayout="fixed" + customLoading={showTableLoading} + onCellClick={(columnName: string, dataIndex: string) => { + comp.children.selectedCell.dispatchChangeValueAction({ + name: columnName, + dataIndex: dataIndex, + }); + }} + summary={summaryView} + /> + + {expansion.expandModalView} + + + + {toolbar.position === "below" && !hideToolbar && (toolbar.fixedToolbar || (tableAutoHeight && showHorizontalScrollbar)) && toolbarView} + + + + ); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx new file mode 100644 index 0000000000..47067a9e03 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import _ from "lodash"; + +export const TableRowContext = React.createContext<{ + hover: boolean; + selected: boolean; +}>({ hover: false, selected: false }); + +export const TableCellContext = React.createContext<{ + isEditing: boolean; + setIsEditing: (e: boolean) => void; +}>({ isEditing: false, setIsEditing: _.noop }); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx new file mode 100644 index 0000000000..d38cdb9877 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx @@ -0,0 +1,241 @@ +import { TableComp } from "comps/comps/tableComp/tableComp"; +import { columnsToAntdFormat } from "comps/comps/tableComp/tableUtils"; +import { evalAndReduce } from "comps/utils"; +import { reduceInContext } from "comps/utils/reduceContext"; +import _ from "lodash"; +import { changeChildAction, fromValue, SimpleNode } from "lowcoder-core"; +import { JSONObject } from "util/jsonTypes"; + +const expectColumn = ( + comp: InstanceType, + expectValues: Array<{ + dataIndex: string; + title: string; + hide?: boolean; + isCustom?: boolean; + }> +) => { + const columns = comp.children.columns.getView(); + const columnViews = columns.map((c) => c.getView()); + expect(expectValues.length).toEqual(columnViews.length); + expectValues.forEach((val) => { + const column = columnViews.find((c) => c.dataIndex === val.dataIndex); + if (!column) { + throw new Error(`expect column: ${JSON.stringify(val)}, but not found.`); + } + Object.keys(val).forEach((key) => { + const colVal = (column as any)[key]; + const expectVal = (val as any)[key]; + if (expectVal !== undefined) { + if (!_.isEqual(colVal, expectVal)) { + throw new Error(`ColumnKey:${key}, expect: "${expectVal}", but found: "${colVal}"`); + } + } + }); + }); + // with dynamic config + const dynamicColumnConfig = comp.children.dynamicColumnConfig.getView(); + if (dynamicColumnConfig?.length > 0) { + const onEvent = (eventName: any) => {}; + const antdColumns = columnsToAntdFormat( + columnViews, + comp.children.sort.getView(), + comp.children.toolbar.getView().columnSetting, + comp.children.size.getView(), + comp.children.dynamicColumn.getView(), + dynamicColumnConfig, + comp.columnAggrData, + comp.children.editModeClicks.getView(), + onEvent, + ); + expect(columnViews.length).toBeGreaterThanOrEqual(antdColumns.length); + antdColumns.forEach((column) => { + const dataIndex = (column as any).dataIndex; + const colView = columnViews.find((c) => c.dataIndex === dataIndex); + if (!colView) { + throw new Error(`Error, column should not be undefined, column: ${JSON.stringify(column)}`); + } + const configName = colView.isCustom ? colView.title : colView.dataIndex; + if (!dynamicColumnConfig.includes(configName)) { + throw new Error(`dynamic config test fail: unexpect column: ${configName}`); + } + }); + } +}; + +function getTableInitData() { + const exposingInfo: Record> = { + query1: fromValue({ data: [{ q1: 1 }] }), + query2: fromValue({ data: [{ q2: 2 }] }), + }; + return { + tableData: { + data: JSON.stringify([{ a: 1 }]), + columns: [ + { + dataIndex: "a", + title: "a", + hide: true, + }, + { + title: "custom", + dataIndex: "custom1", + isCustom: true, + }, + ], + }, + exposingInfo: exposingInfo, + initColumns: [ + { + dataIndex: "a", + hide: true, + title: "a", + }, + { + dataIndex: "custom1", + hide: false, + title: "custom", + isCustom: true, + }, + ], + }; +} + +async function sleep() { + await new Promise((r) => setTimeout(r, 20)); +} + +test("test table dynamic columns: Change unEvalValue", async () => { + // 0. Init check + const { initColumns, tableData, exposingInfo } = getTableInitData(); + let comp = new TableComp({ + dispatch: (action) => { + comp = evalAndReduce(comp.reduce(action), exposingInfo); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + expectColumn(comp, initColumns); + /** 1. Change unEvalValue data, change column whatever **/ + // 1.1 add column c & d + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", JSON.stringify([{ a: 1, c: 2, d: 3 }]))) + ); + await sleep(); + const columnsAfterAdd = [ + ...initColumns, + { + dataIndex: "c", + hide: false, + title: "c", + }, + { + dataIndex: "d", + title: "d", + }, + ]; + expectColumn(comp, columnsAfterAdd); + // 1.2 del column a + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", JSON.stringify([{ c: 2, d: 3 }]))) + ); + await sleep(); + expectColumn( + comp, + columnsAfterAdd.filter((c) => c.dataIndex !== "a") + ); +}, 1000); + +async function dynamicColumnsTest( + dynamicColumn: boolean, + isViewMode: boolean, + dynamicConfigs?: Array +) { + const { initColumns, tableData, exposingInfo } = getTableInitData(); + // init comp + let comp = new TableComp({ + dispatch: (action) => { + let tmpComp; + if (isViewMode) { + tmpComp = reduceInContext({ readOnly: isViewMode }, () => comp.reduce(action)); + } else { + tmpComp = comp.reduce(action); + } + comp = evalAndReduce(tmpComp, exposingInfo); + }, + value: { + ...tableData, + dynamicColumn: dynamicColumn, + ...(dynamicColumn && + dynamicConfigs && { dynamicColumnConfig: JSON.stringify(dynamicConfigs) }), + }, + }); + comp = evalAndReduce(comp); + + const updateTableComp = async () => { + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", "{{query1.data}}")), + exposingInfo + ); + await sleep(); + }; + // change data to query1 + const query1Columns = [ + { + dataIndex: "q1", + title: "q1", + }, + { + dataIndex: "custom1", + title: "custom", + isCustom: true, + }, + ]; + await updateTableComp(); + if (!dynamicColumn && isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, query1Columns); + } + // change query data, add column: a + const addData: Array = [{ q1: 1, a: 2 }]; + exposingInfo.query1 = fromValue({ data: addData }); + await updateTableComp(); + const columnsAfterAdd = [ + ...query1Columns, + { + dataIndex: "a", + title: "a", + }, + ]; + expect(comp.children.data.getView()).toEqual(addData); + if (!dynamicColumn && isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, columnsAfterAdd); + } + // change query data, del column: q1 + const delData = [{ a: 2 }]; + exposingInfo.query1 = fromValue({ data: delData }); + await updateTableComp(); + expect(comp.children.data.getView()).toEqual(delData); + if (dynamicColumn) { + expectColumn( + comp, + columnsAfterAdd.filter((c) => c.dataIndex !== "q1") + ); + } else if (isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, columnsAfterAdd); + } +} + +test("test table dynamic columns", async () => { + await dynamicColumnsTest(false, false); + await dynamicColumnsTest(false, true); + await dynamicColumnsTest(true, false); + await dynamicColumnsTest(true, true); + await dynamicColumnsTest(true, false, ["custom", "q1"]); + await dynamicColumnsTest(true, true, ["custom", "q1"]); +}, 2000); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx new file mode 100644 index 0000000000..70bb8ce05a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx @@ -0,0 +1,650 @@ +import { + ColumnCompType, + newCustomColumn, + RawColumnType, +} from "comps/comps/tableComp/column/tableColumnComp"; +import { hiddenPropertyView, loadingPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { changeValueAction, deferAction, MultiBaseComp, wrapChildAction } from "lowcoder-core"; +import { + BluePlusIcon, + CheckBox, + CloseEyeIcon, + controlItem, + CustomModal, + Dropdown, + labelCss, + LinkButton, + OpenEyeIcon, + Option, + OptionItem, + RedButton, + RefreshIcon, + Section, + sectionNames, + TextLabel, + ToolTipLabel, +} from "lowcoder-design"; +import { tableDataDivClassName } from "pages/tutorials/tutorialsConstant"; +import styled, { css } from "styled-components"; +import { getSelectedRowKeys } from "./selectionControl"; +import { TableChildrenType } from "./tableTypes"; +import React, { useMemo, useState, useCallback } from "react"; +import { GreyTextColor } from "constants/style"; +import { alignOptions } from "comps/controls/dropdownControl"; +import { ColumnTypeCompMap } from "comps/comps/tableComp/column/columnTypeComp"; +import Segmented from "antd/es/segmented"; +import { CheckboxChangeEvent } from "antd/es/checkbox"; + +const InsertDiv = styled.div` + display: flex; + justify-content: end; + width: 100%; + gap: 8px; + align-items: center; +`; +const Graylabel = styled.span` + ${labelCss}; + color: #8b8fa3; +`; + +const StyledRefreshIcon = styled(RefreshIcon)` + width: 16px; + height: 16px; + cursor: pointer; + + &:hover { + g g { + stroke: #4965f2; + } + } +`; + +const eyeIconCss = css` + height: 16px; + width: 16px; + display: inline-block; + + &:hover { + cursor: pointer; + } + + &:hover path { + fill: #315efb; + } +`; + +const CloseEye = styled(CloseEyeIcon)` + ${eyeIconCss} +`; +const OpenEye = styled(OpenEyeIcon)` + ${eyeIconCss} +`; + +const ColumnDropdown = styled(Dropdown)` + width: 100px; + + &, + > div { + height: 22px; + } + + .ant-segmented-item-label { + height: 18px; + min-height: 18px; + line-height: 18px; + padding: 0; + } +`; + +const ColumnBatchOptionWrapper = styled.div` + display: flex; + align-items: center; + color: ${GreyTextColor} + line-height: 16px; + font-size: 13px; +`; + +type ViewOptionType = "normal" | "summary"; + +const summaryRowOptions = [ + { + label: "Row 1", + value: 0, + }, + { + label: "Row 2", + value: 1, + }, + { + label: "Row 3", + value: 2, + }, +]; + +const columnViewOptions = [ + { + label: "Normal", + value: "normal", + }, + { + label: "Summary", + value: "summary", + }, +]; + +const columnFilterOptions = [ + { label: trans("table.allColumn"), value: "all" }, + { label: trans("table.visibleColumn"), value: "visible" }, +]; +type ColumnFilterOptionValueType = typeof columnFilterOptions[number]["value"]; + +const columnBatchOptions = [ + { + label: trans("prop.hide"), + value: "hide", + }, + { + label: trans("table.editable"), + value: "editable", + }, + { + label: trans("table.autoWidth"), + value: "autoWidth", + }, + { + label: trans("table.sortable"), + value: "sortable", + }, + { + label: trans("table.align"), + value: "align", + }, +] as const; + +type ColumnBatchOptionValueType = typeof columnBatchOptions[number]["value"]; + +const HideIcon = React.memo((props: { hide: boolean; setHide: (hide: boolean) => void }) => { + const { hide, setHide } = props; + const Eye = hide ? CloseEye : OpenEye; + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setHide(!hide); + }, [hide, setHide]); + + return ; +}); + +HideIcon.displayName = 'HideIcon'; + +function ColumnBatchCheckBox({ + childrenKey, + column, + convertValueFunc, +}: { + childrenKey: T; + column: ColumnCompType | Array; + convertValueFunc?: (checked: boolean) => RawColumnType[T]; +}) { + const isChecked = useCallback((column: ColumnCompType) => { + if (childrenKey === "autoWidth") { + return column.children.autoWidth.getView() === "auto"; + } else { + return column.children[childrenKey].getView(); + } + }, [childrenKey]); + + const convertValue = useCallback((checked: boolean) => + convertValueFunc ? convertValueFunc(checked) : checked, + [convertValueFunc] + ); + + const isBatch = Array.isArray(column); + const columns = isBatch ? column : [column]; + + const disabledStatus = useMemo(() => columns.map((c) => { + if (childrenKey !== "editable") { + return false; + } + const columnType = c.children.render + .getOriginalComp() + .children.comp.children.compType.getView(); + return !ColumnTypeCompMap[columnType].canBeEditable(); + }), [columns, childrenKey]); + + const { allChecked, allNotChecked } = useMemo(() => { + let allChecked = true; + let allNotChecked = true; + + columns.forEach((c, index) => { + if (disabledStatus[index]) { + if (!isBatch) { + allChecked = false; + } + return; + } + if (isChecked(c)) { + allNotChecked = false; + } else { + allChecked = false; + } + }); + + return { allChecked, allNotChecked }; + }, [columns, disabledStatus, isBatch, isChecked]); + + const onCheckChange = useCallback((checked: boolean) => { + columns.forEach( + (c, index) => + !disabledStatus[index] && + c.children[childrenKey].dispatch( + deferAction(changeValueAction(convertValue(checked) as any, true)) + ) + ); + }, [columns, disabledStatus, childrenKey, convertValue]); + + if (childrenKey === "hide") { + return ; + } + + return ( + { + onCheckChange(e.target.checked); + }} + /> + ); +} + +const ColumnBatchView: Record< + ColumnBatchOptionValueType, + (column: ColumnCompType | Array) => JSX.Element +> = { + hide: (column) => , + editable: (column) => , + sortable: (column) => , + autoWidth: (column) => ( + (checked ? "auto" : "fixed")} + /> + ), + align: (column) => { + const columns = Array.isArray(column) ? column : [column]; + const value = Array.isArray(column) ? undefined : column.children.align.getView(); + return ( + { + columns.forEach((c) => + c.children.align.dispatch(deferAction(changeValueAction(value, true))) + ); + }} + /> + ); + }, +}; + +function ColumnPropertyView>(props: { + comp: T; + columnLabel: string; +}) { + const { comp } = props; + const [viewMode, setViewMode] = useState('normal'); + const [summaryRow, setSummaryRow] = useState(0); + const [columnFilterType, setColumnFilterType] = useState("all"); + const [columnBatchType, setColumnBatchType] = useState("hide"); + + const selection = useMemo(() => getSelectedRowKeys(comp.children.selection)[0] ?? "0", [comp.children.selection]); + const columns = useMemo(() => comp.children.columns.getView(), [comp.children.columns]); + const rowExample = useMemo(() => comp.children.dataRowExample.getView(), [comp.children.dataRowExample]); + const dynamicColumn = useMemo(() => comp.children.dynamicColumn.getView(), [comp.children.dynamicColumn]); + const data = useMemo(() => comp.children.data.getView(), [comp.children.data]); + const columnOptionItems = useMemo( + () => columns.filter((c) => columnFilterType === "all" || !c.children.hide.getView()), + [columnFilterType, columns] + ); + const summaryRows = parseInt(comp.children.summaryRows.getView()); + + const handleViewModeChange = useCallback((value: string) => { + setViewMode(value as ViewOptionType); + }, []); + + const handleSummaryRowChange = useCallback((value: number) => { + setSummaryRow(value); + }, []); + + const handleColumnFilterChange = useCallback((value: ColumnFilterOptionValueType) => { + setColumnFilterType(value); + }, []); + + const handleColumnBatchTypeChange = useCallback((value: ColumnBatchOptionValueType) => { + setColumnBatchType(value); + }, []); + + const columnOptionToolbar = ( + +
+ + {" (" + columns.length + ")"} +
+ {rowExample && ( + + { + // console.log("comp", comp); + comp.dispatch( + wrapChildAction( + "columns", + comp.children.columns.dataChangedAction({ + rowExample, + doGeneColumn: true, + dynamicColumn: dynamicColumn, + data: data, + }) + ) + ); + // the function below is not working + // comp.dispatch(comp.changeChildAction("dataRowExample", null)); + }} + /> + + )} + } + text={trans("addItem")} + onClick={() => { + comp.children.columns.dispatch(comp.children.columns.pushAction(newCustomColumn())); + }} + /> +
+ ); + + return ( + <> +