feat: granular widgets for canvas (#24764)

## Description
Today if we have more than one collaborator on a page, merging is an
absolute nightmare. All of the widget config for a page is represented
in one single giant JSON blob in canvas.json this makes it very hard to
make isolated changes.

This PR is breaking down the git representation into smaller JSON files
that are aggregated by the running app.

Fixes https://github.com/appsmithorg/appsmith/issues/17033

#### Type of change
- New feature (non-breaking change which adds functionality)

## Testing
#### How Has This Been Tested?
- [ ] Manual
- [ ] Jest
- [ ] Cypress
- [ ] JUnit

#### Test Plan
#### Issues raised during DP testing
## Checklist:
#### Dev activity
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


#### QA activity:
- [ ] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#speedbreakers-)
have been covered
- [ ] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans#areas-of-interest-)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [ ] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [ ] Cypress test cases have been added and approved by SDET/manual QA
- [ ] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed

---------

Co-authored-by: brayn003 <rudra@appsmith.com>
Co-authored-by: Parthvi <80334441+Parthvi12@users.noreply.github.com>
This commit is contained in:
Anagh Hegde 2023-07-03 20:43:04 +05:30 committed by GitHub
parent f442682834
commit 578b82080f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 731 additions and 41 deletions

View File

@ -105,8 +105,9 @@ describe("Git Bugs", function () {
_.agHelper.GetNClick(_.locators._dialogCloseButton);
});
});
it("5. Bug 24206 : Open repository button is not functional in git sync modal", function () {
// skipping this test for now, will update test logic and create new PR for it
// TODO Parthvi
it.skip("5. Bug 24206 : Open repository button is not functional in git sync modal", function () {
_.gitSync.SwitchGitBranch("master");
_.entityExplorer.DragDropWidgetNVerify("modalwidget", 50, 50);
_.gitSync.CommitAndPush();

View File

@ -139,9 +139,9 @@ describe("Git import flow ", function () {
it("3. Verfiy imported app should have all the data binding visible in view and edit mode", () => {
// verify postgres data binded to table
cy.get(".tbody").first().should("contain.text", "Test user 7");
cy.get(".tbody").should("contain.text", "Test user 7");
//verify MySQL data binded to table
cy.get(".tbody").last().should("contain.text", "New Config");
cy.get(".tbody").should("contain.text", "New Config");
// verify api response binded to input widget
cy.xpath("//input[@value='this is a test']").should("be.visible");
// verify js object binded to input widget
@ -155,9 +155,9 @@ describe("Git import flow ", function () {
newBranch = branName;
cy.log("newBranch is " + newBranch);
});
cy.get(".tbody").first().should("contain.text", "Test user 7");
cy.get(".tbody").should("contain.text", "Test user 7");
// verify MySQL data binded to table
cy.get(".tbody").last().should("contain.text", "New Config");
cy.get(".tbody").should("contain.text", "New Config");
// verify api response binded to input widget
cy.xpath("//input[@value='this is a test']");
// verify js object binded to input widget

View File

@ -72,7 +72,7 @@ import DiscardFailedWarning from "../components/DiscardChangesError";
const Section = styled.div`
margin-top: 0;
margin-bottom: ${(props) => props.theme.spaces[11]}px;
margin-bottom: ${(props) => props.theme.spaces[7]}px;
`;
const Row = styled.div`
@ -337,7 +337,7 @@ function Deploy() {
{isFetchingGitStatus && (
<StatusLoader loaderMsg={createMessage(FETCH_GIT_STATUS)} />
)}
<Space size={11} />
{/* <Space size={11} /> */}
{pullRequired && !isConflicting && (
<>
<Callout

View File

@ -15,7 +15,7 @@ import {
} from "@appsmith/constants/messages";
import { getCurrentApplication } from "selectors/editorSelectors";
import { changeInfoSinceLastCommit } from "../utils";
import { Icon, Text } from "design-system";
import { Callout, Icon, Text } from "design-system";
const DummyChange = styled.div`
width: 50%;
@ -36,9 +36,13 @@ const Wrapper = styled.div`
gap: 6px;
`;
const CalloutContainer = styled.div`
margin-top: ${(props) => props.theme.spaces[7]}px;
`;
const Changes = styled.div`
margin-top: ${(props) => props.theme.spaces[7]}px;
margin-bottom: ${(props) => props.theme.spaces[11]}px;
margin-bottom: ${(props) => props.theme.spaces[7]}px;
`;
export enum Kind {
@ -216,6 +220,13 @@ export default function GitChangesList() {
return loading ? (
<DummyChange data-testid={"t--git-change-loading-dummy"} />
) : changes.length ? (
<Changes data-testid={"t--git-change-statuses"}>{changes}</Changes>
<Changes data-testid={"t--git-change-statuses"}>
{changes}
{status?.migrationMessage ? (
<CalloutContainer>
<Callout kind="info">{status.migrationMessage}</Callout>
</CalloutContainer>
) : null}
</Changes>
) : null;
}

View File

@ -552,6 +552,7 @@ export type GitStatusData = {
modifiedDatasources: number;
modifiedJSLibs: number;
discardDocUrl?: string;
migrationMessage?: string;
};
type GitErrorPayloadType = {

View File

@ -2,13 +2,26 @@ package com.appsmith.git.constants;
public class CommonConstants {
// This field will be useful when we migrate fields within JSON files (currently this will be useful for Git feature)
public static Integer fileFormatVersion = 4;
public static Integer fileFormatVersion = 5;
public static String FILE_FORMAT_VERSION = "fileFormatVersion";
public static final String CANVAS = "canvas";
public static final String APPLICATION = "application";
public static final String THEME = "theme";
public static final String METADATA = "metadata";
public static final String JSON_EXTENSION = ".json";
public static final String JS_EXTENSION = ".js";
public static final String TEXT_FILE_EXTENSION = ".txt";
public static final String WIDGETS = "widgets";
public static final String WIDGET_NAME = "widgetName";
public static final String WIDGET_TYPE = "type";
public static final String CHILDREN = "children";
public static final String CANVAS_WIDGET = "CANVAS_WIDGET";
public static final String MAIN_CONTAINER = "MainContainer";
public static final String DELIMITER_POINT = ".";
public static final String DELIMITER_PATH = "/";
public static final String EMPTY_STRING = "";
public static final String FILE_MIGRATION_MESSAGE = "Some of the changes above are due to an improved file structure designed to reduce merge conflicts. You can safely commit them to your repository.";
}

View File

@ -0,0 +1,180 @@
package com.appsmith.git.helpers;
import com.appsmith.git.constants.CommonConstants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
@Slf4j
public class DSLTransformerHelper {
public static Map<String, JSONObject> flatten(JSONObject jsonObject) {
Map<String, JSONObject> flattenedMap = new HashMap<>();
flattenObject(jsonObject, CommonConstants.EMPTY_STRING, flattenedMap);
return new TreeMap<>(flattenedMap);
}
private static void flattenObject(JSONObject jsonObject, String prefix, Map<String, JSONObject> flattenedMap) {
String widgetName = jsonObject.optString(CommonConstants.WIDGET_NAME);
if (widgetName.isEmpty()) {
return;
}
JSONArray children = jsonObject.optJSONArray(CommonConstants.CHILDREN);
if (children != null) {
// Check if the children object has type=CANVAS_WIDGET
removeChildrenIfNotCanvasWidget(jsonObject);
if (!isCanvasWidget(jsonObject)) {
flattenedMap.put(prefix + widgetName, jsonObject);
}
for (int i = 0; i < children.length(); i++) {
JSONObject childObject = children.getJSONObject(i);
String childPrefix = prefix + widgetName + CommonConstants.DELIMITER_POINT;
flattenObject(childObject, childPrefix, flattenedMap);
}
} else {
if (!isCanvasWidget(jsonObject)) {
flattenedMap.put(prefix + widgetName, jsonObject);
}
}
}
private static JSONObject removeChildrenIfNotCanvasWidget(JSONObject jsonObject) {
JSONArray children = jsonObject.optJSONArray(CommonConstants.CHILDREN);
if (children.length() == 1) {
JSONObject child = children.getJSONObject(0);
if (!CommonConstants.CANVAS_WIDGET.equals(child.optString(CommonConstants.WIDGET_TYPE))) {
jsonObject.remove(CommonConstants.CHILDREN);
} else {
JSONObject childCopy = new JSONObject(child.toString());
childCopy.remove(CommonConstants.CHILDREN);
JSONArray jsonArray = new JSONArray();
jsonArray.put(childCopy);
jsonObject.put(CommonConstants.CHILDREN, jsonArray);
}
} else {
jsonObject.remove(CommonConstants.CHILDREN);
}
return jsonObject;
}
public static boolean hasChildren(JSONObject jsonObject) {
JSONArray children = jsonObject.optJSONArray(CommonConstants.CHILDREN);
return children != null && children.length() > 0;
}
public static boolean isCanvasWidget(JSONObject jsonObject) {
return jsonObject.optString(CommonConstants.WIDGET_TYPE).startsWith(CommonConstants.CANVAS_WIDGET);
}
public static Map<String, List<String>> calculateParentDirectories(List<String> paths) {
Map<String, List<String>> parentDirectories = new HashMap<>();
paths = paths.stream().map(currentPath -> currentPath.replace(CommonConstants.JSON_EXTENSION, CommonConstants.EMPTY_STRING)).collect(Collectors.toList());
for (String path : paths) {
String[] directories = path.split(CommonConstants.DELIMITER_PATH);
int lastDirectoryIndex = directories.length - 1;
if (lastDirectoryIndex > 0 && directories[lastDirectoryIndex].equals(directories[lastDirectoryIndex - 1])) {
if (lastDirectoryIndex - 2 >= 0) {
String parentDirectory = directories[lastDirectoryIndex - 2];
List<String> pathsList = parentDirectories.getOrDefault(parentDirectory, new ArrayList<>());
pathsList.add(path);
parentDirectories.put(parentDirectory, pathsList);
}
} else {
String parentDirectory = directories[lastDirectoryIndex - 1];
List<String> pathsList = parentDirectories.getOrDefault(parentDirectory, new ArrayList<>());
pathsList.add(path);
parentDirectories.put(parentDirectory, pathsList);
}
}
return parentDirectories;
}
/*
* /Form1/Button1.json,
* /List1/List1.json,
* /List1/Container1/Text2.json,
* /List1/Container1/Image1.json,
* /Form1/Button2.json,
* /List1/Container1/Text1.json,
* /Form1/Text3.json,
* /Form1/Form1.json,
* /List1/Container1/Container1.json,
* /MainContainer.json
*/
public static JSONObject getNestedDSL(Map<String, JSONObject> jsonMap, Map<String, List<String>> pathMapping, JSONObject mainContainer) {
// start from the root
// Empty page with no widgets
if (!pathMapping.containsKey(CommonConstants.MAIN_CONTAINER)) {
return mainContainer;
}
for (String path : pathMapping.get(CommonConstants.MAIN_CONTAINER)) {
JSONObject child = getChildren(path, jsonMap, pathMapping);
JSONArray children = mainContainer.optJSONArray(CommonConstants.CHILDREN);
if (children == null) {
children = new JSONArray();
children.put(child);
mainContainer.put(CommonConstants.CHILDREN, children);
} else {
children.put(child);
}
}
return mainContainer;
}
public static JSONObject getChildren(String pathToWidget, Map<String, JSONObject> jsonMap, Map<String, List<String>> pathMapping) {
// Recursively get the children
List<String> children = pathMapping.get(getWidgetName(pathToWidget));
JSONObject parentObject = jsonMap.get(pathToWidget + CommonConstants.JSON_EXTENSION);
if (children != null) {
JSONArray childArray = new JSONArray();
for (String childWidget : children) {
childArray.put(getChildren(childWidget, jsonMap, pathMapping));
}
// Check if the parent object has type=CANVAS_WIDGET as children
// If yes, then add the children array to the CANVAS_WIDGET's children
appendChildren(parentObject, childArray);
}
return parentObject;
}
public static String getWidgetName(String path) {
String[] directories = path.split(CommonConstants.DELIMITER_PATH);
return directories[directories.length - 1];
}
public static JSONObject appendChildren(JSONObject parent, JSONArray childWidgets) {
JSONArray children = parent.optJSONArray(CommonConstants.CHILDREN);
if (children == null) {
parent.put(CommonConstants.CHILDREN, childWidgets);
} else {
// Is the children CANVAS_WIDGET
if (children.length() == 1) {
JSONObject childObject = children.getJSONObject(0);
if (CommonConstants.CANVAS_WIDGET.equals(childObject.optString(CommonConstants.WIDGET_TYPE))) {
childObject.put(CommonConstants.CHILDREN, childWidgets);
}
} else {
parent.put(CommonConstants.CHILDREN, childWidgets);
}
}
return parent;
}
}

View File

@ -12,24 +12,34 @@ import com.appsmith.git.configurations.GitServiceConfig;
import com.appsmith.git.constants.CommonConstants;
import com.appsmith.git.converters.GsonDoubleToLongConverter;
import com.appsmith.git.converters.GsonUnorderedToOrderedConverter;
import com.appsmith.util.WebClientUtils;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.stream.JsonReader;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import reactor.netty.resources.ConnectionProvider;
import java.io.BufferedWriter;
import java.io.File;
@ -42,10 +52,13 @@ import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@ -83,6 +96,8 @@ public class FileUtilsImpl implements FileInterface {
private final Scheduler scheduler = Schedulers.boundedElastic();
private static final String CANVAS_WIDGET = "(Canvas)[0-9]*.";
/**
Application will be stored in the following structure:
@ -227,11 +242,38 @@ public class FileUtilsImpl implements FileInterface {
Set<String> validPages = new HashSet<>();
for (Map.Entry<String, Object> pageResource : pageEntries) {
Map<String, String> validWidgetToParentMap = new HashMap<>();
final String pageName = pageResource.getKey();
Path pageSpecificDirectory = pageDirectory.resolve(pageName);
Boolean isResourceUpdated = updatedResources.get(PAGE_LIST).contains(pageName);
if(Boolean.TRUE.equals(isResourceUpdated)) {
saveResource(pageResource.getValue(), pageSpecificDirectory.resolve(CommonConstants.CANVAS + CommonConstants.JSON_EXTENSION), gson);
// Save page metadata
saveResource(pageResource.getValue(), pageSpecificDirectory.resolve(pageName + CommonConstants.JSON_EXTENSION), gson);
Map<String, JSONObject> result = DSLTransformerHelper.flatten(new JSONObject(applicationGitReference.getPageDsl().get(pageName)));
result.forEach((key, jsonObject) -> {
// get path with splitting the name via key
String widgetName = key.substring(key.lastIndexOf(CommonConstants.DELIMITER_POINT ) + 1 );
String childPath = key.replace(CommonConstants.MAIN_CONTAINER, CommonConstants.EMPTY_STRING)
.replace(CommonConstants.DELIMITER_POINT, CommonConstants.DELIMITER_PATH);
// Replace the canvas Widget as a child and add it to the same level as parent
childPath = childPath.replaceAll(CANVAS_WIDGET, CommonConstants.EMPTY_STRING);
if (!DSLTransformerHelper.hasChildren(jsonObject)) {
// Save the widget as a directory or Save the widget as a file
childPath = childPath.replace(widgetName, CommonConstants.EMPTY_STRING);
}
Path path = Paths.get(String.valueOf(pageSpecificDirectory.resolve(CommonConstants.WIDGETS)), childPath);
validWidgetToParentMap.put(widgetName, path.toFile().toString());
saveWidgets(
jsonObject,
widgetName,
path
);
});
// Remove deleted widgets from the file system
deleteWidgets(pageSpecificDirectory.resolve(CommonConstants.WIDGETS).toFile(), validWidgetToParentMap);
// Remove the canvas.json from the file system since the value is stored in the page.json
deleteFile(pageSpecificDirectory.resolve(CommonConstants.CANVAS + CommonConstants.JSON_EXTENSION));
}
validPages.add(pageName);
}
@ -371,6 +413,15 @@ public class FileUtilsImpl implements FileInterface {
return false;
}
private void saveWidgets(JSONObject sourceEntity, String resourceName, Path path) {
try {
Files.createDirectories(path);
writeStringToFile(sourceEntity.toString(4), path.resolve(resourceName + CommonConstants.JSON_EXTENSION));
} catch (IOException e) {
log.debug("Error while writings widgets data to file, {}", e.getMessage());
}
}
/**
* This method is used to write actionCollection specific resource to file system. We write the data in two steps
* 1. Actual js code
@ -453,8 +504,10 @@ public class FileUtilsImpl implements FileInterface {
// unwanted file : corresponding resource from DB has been deleted
if(resourceDirectory.toFile().exists()) {
try (Stream<Path> paths = Files.walk(resourceDirectory)) {
paths
.filter(path -> Files.isRegularFile(path) && !validResources.contains(path.getFileName().toString()))
paths.filter(pathLocal ->
Files.isRegularFile(pathLocal) &&
!validResources.contains(pathLocal.getFileName().toString())
)
.forEach(this::deleteFile);
} catch (IOException e) {
log.debug("Error while scanning directory: {}, with error {}", resourceDirectory, e);
@ -491,7 +544,7 @@ public class FileUtilsImpl implements FileInterface {
try {
FileUtils.deleteDirectory(directory.toFile());
} catch (IOException e){
log.debug("Unable to delete directory for path {} with message {}", directory, e.getMessage());
log.error("Unable to delete directory for path {} with message {}", directory, e.getMessage());
}
}
}
@ -507,11 +560,11 @@ public class FileUtilsImpl implements FileInterface {
}
catch(DirectoryNotEmptyException e)
{
log.debug("Unable to delete non-empty directory at {}", filePath);
log.error("Unable to delete non-empty directory at {}", filePath);
}
catch(IOException e)
{
log.debug("Unable to delete file, {}", e.getMessage());
log.error("Unable to delete file, {}", e.getMessage());
}
}
@ -650,7 +703,7 @@ public class FileUtilsImpl implements FileInterface {
* @return content of the file in the path
*/
private String readFileAsString(Path filePath) {
String data = "";
String data = CommonConstants.EMPTY_STRING;
try {
data = FileUtils.readFileToString(filePath.toFile(), "UTF-8");
} catch (IOException e) {
@ -672,7 +725,7 @@ public class FileUtilsImpl implements FileInterface {
for (File dirFile : Objects.requireNonNull(directory.listFiles())) {
String resourceName = dirFile.getName();
Path resourcePath = directoryPath.resolve(resourceName).resolve( resourceName + CommonConstants.JS_EXTENSION);
String body = "";
String body = CommonConstants.EMPTY_STRING;
if (resourcePath.toFile().exists()) {
body = readFileAsString(resourcePath);
}
@ -697,7 +750,7 @@ public class FileUtilsImpl implements FileInterface {
if (directory.isDirectory()) {
for (File dirFile : Objects.requireNonNull(directory.listFiles())) {
String resourceName = dirFile.getName();
String body = "";
String body = CommonConstants.EMPTY_STRING;
Path queryPath = directoryPath.resolve(resourceName).resolve( resourceName + CommonConstants.TEXT_FILE_EXTENSION);
if (queryPath.toFile().exists()) {
body = readFileAsString(queryPath);
@ -710,6 +763,10 @@ public class FileUtilsImpl implements FileInterface {
return resource;
}
private Object readPageMetadata(Path directoryPath, Gson gson) {
return readFile(directoryPath.resolve(directoryPath.toFile().getName() + CommonConstants.JSON_EXTENSION), gson);
}
private ApplicationGitReference fetchApplicationReference(Path baseRepoPath, Gson gson) {
ApplicationGitReference applicationGitReference = new ApplicationGitReference();
// Extract application metadata from the json
@ -727,13 +784,13 @@ public class FileUtilsImpl implements FileInterface {
switch (fileFormatVersion) {
case 1 :
// Extract actions
applicationGitReference.setActions(readFiles(baseRepoPath.resolve(ACTION_DIRECTORY), gson, ""));
applicationGitReference.setActions(readFiles(baseRepoPath.resolve(ACTION_DIRECTORY), gson, CommonConstants.EMPTY_STRING));
// Extract actionCollections
applicationGitReference.setActionCollections(readFiles(baseRepoPath.resolve(ACTION_COLLECTION_DIRECTORY), gson, ""));
applicationGitReference.setActionCollections(readFiles(baseRepoPath.resolve(ACTION_COLLECTION_DIRECTORY), gson, CommonConstants.EMPTY_STRING));
// Extract pages
applicationGitReference.setPages(readFiles(pageDirectory, gson, ""));
applicationGitReference.setPages(readFiles(pageDirectory, gson, CommonConstants.EMPTY_STRING));
// Extract datasources
applicationGitReference.setDatasources(readFiles(baseRepoPath.resolve(DATASOURCE_DIRECTORY), gson, ""));
applicationGitReference.setDatasources(readFiles(baseRepoPath.resolve(DATASOURCE_DIRECTORY), gson, CommonConstants.EMPTY_STRING));
break;
case 2:
@ -742,18 +799,23 @@ public class FileUtilsImpl implements FileInterface {
updateGitApplicationReference(baseRepoPath, gson, applicationGitReference, pageDirectory, fileFormatVersion);
break;
case 5:
updateGitApplicationReferenceV2(baseRepoPath, gson, applicationGitReference, pageDirectory, fileFormatVersion);
break;
default:
}
applicationGitReference.setMetadata(metadata);
Path jsLibDirectory = baseRepoPath.resolve(JS_LIB_DIRECTORY);
Map<String, Object> jsLibrariesMap = readFiles(jsLibDirectory, gson, "");
Map<String, Object> jsLibrariesMap = readFiles(jsLibDirectory, gson, CommonConstants.EMPTY_STRING);
applicationGitReference.setJsLibraries(jsLibrariesMap);
return applicationGitReference;
}
private void updateGitApplicationReference(Path baseRepoPath, Gson gson, ApplicationGitReference applicationGitReference, Path pageDirectory, int fileFormatVersion) {
@Deprecated
private void updateGitApplicationReference(Path baseRepoPath, Gson gson, ApplicationGitReference applicationGitReference, Path pageDirectory, int fileFormatVersion) {
// Extract pages and nested actions and actionCollections
File directory = pageDirectory.toFile();
Map<String, Object> pageMap = new HashMap<>();
@ -761,7 +823,6 @@ public class FileUtilsImpl implements FileInterface {
Map<String, String> actionBodyMap = new HashMap<>();
Map<String, Object> actionCollectionMap = new HashMap<>();
Map<String, String> actionCollectionBodyMap = new HashMap<>();
// TODO same approach should be followed for modules(app level actions, actionCollections, widgets etc)
if (directory.isDirectory()) {
// Loop through all the directories and nested directories inside the pages directory to extract
// pages, actions and actionCollections from the JSON files
@ -787,7 +848,7 @@ public class FileUtilsImpl implements FileInterface {
applicationGitReference.setActionCollectionBody(actionCollectionBodyMap);
applicationGitReference.setPages(pageMap);
// Extract datasources
applicationGitReference.setDatasources(readFiles(baseRepoPath.resolve(DATASOURCE_DIRECTORY), gson, ""));
applicationGitReference.setDatasources(readFiles(baseRepoPath.resolve(DATASOURCE_DIRECTORY), gson, CommonConstants.EMPTY_STRING));
}
private Integer getFileFormatVersion(Object metadata) {
@ -803,4 +864,121 @@ public class FileUtilsImpl implements FileInterface {
private boolean isFileFormatCompatible(int savedFileFormat) {
return savedFileFormat <= CommonConstants.fileFormatVersion;
}
private void updateGitApplicationReferenceV2(Path baseRepoPath, Gson gson, ApplicationGitReference applicationGitReference, Path pageDirectory, int fileFormatVersion) {
// Extract pages and nested actions and actionCollections
File directory = pageDirectory.toFile();
Map<String, Object> pageMap = new HashMap<>();
Map<String, String> pageDsl = new HashMap<>();
Map<String, Object> actionMap = new HashMap<>();
Map<String, String> actionBodyMap = new HashMap<>();
Map<String, Object> actionCollectionMap = new HashMap<>();
Map<String, String> actionCollectionBodyMap = new HashMap<>();
if (directory.isDirectory()) {
// Loop through all the directories and nested directories inside the pages directory to extract
// pages, actions and actionCollections from the JSON files
for (File page : Objects.requireNonNull(directory.listFiles())) {
pageMap.put(page.getName(), readPageMetadata(page.toPath(), gson));
JSONObject mainContainer = getMainContainer(pageMap.get(page.getName()), gson);
// Read widgets data recursively from the widgets directory
Map<String, JSONObject> widgetsData = readWidgetsData(page.toPath().resolve(CommonConstants.WIDGETS).toString());
// Construct the nested DSL from the widgets data
Map<String, List<String>> parentDirectories = DSLTransformerHelper.calculateParentDirectories(widgetsData.keySet().stream().toList());
JSONObject nestedDSL = DSLTransformerHelper.getNestedDSL(widgetsData, parentDirectories, mainContainer);
pageDsl.put(page.getName(), nestedDSL.toString());
actionMap.putAll(readAction(page.toPath().resolve(ACTION_DIRECTORY), gson, page.getName(), actionBodyMap));
actionCollectionMap.putAll(readActionCollection(page.toPath().resolve(ACTION_COLLECTION_DIRECTORY), gson, page.getName(), actionCollectionBodyMap));
}
}
applicationGitReference.setActions(actionMap);
applicationGitReference.setActionBody(actionBodyMap);
applicationGitReference.setActionCollections(actionCollectionMap);
applicationGitReference.setActionCollectionBody(actionCollectionBodyMap);
applicationGitReference.setPages(pageMap);
applicationGitReference.setPageDsl(pageDsl);
// Extract datasources
applicationGitReference.setDatasources(readFiles(baseRepoPath.resolve(DATASOURCE_DIRECTORY), gson, CommonConstants.EMPTY_STRING));
}
private Map<String, JSONObject> readWidgetsData(String directoryPath) {
Map<String, JSONObject> jsonMap = new HashMap<>();
File directory = new File(directoryPath);
if (!directory.isDirectory()) {
log.error("Error reading directory: {}", directoryPath);
return jsonMap;
}
try {
readFilesRecursively(directory, jsonMap, directoryPath);
} catch (IOException exception) {
log.error("Error reading directory: {}, error message {}", directoryPath, exception.getMessage());
}
return jsonMap;
}
private void readFilesRecursively(File directory, Map<String, JSONObject> jsonMap, String rootPath) throws IOException {
File[] files = directory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.isFile()) {
String filePath = file.getAbsolutePath();
String relativePath = filePath.replace(rootPath, CommonConstants.EMPTY_STRING);
relativePath = CommonConstants.DELIMITER_PATH + CommonConstants.MAIN_CONTAINER + relativePath.substring(relativePath.indexOf("//") + 1);
try {
String fileContent = new String(Files.readAllBytes(file.toPath()));
JSONObject jsonObject = new JSONObject(fileContent);
jsonMap.put(relativePath, jsonObject);
} catch (IOException exception) {
log.error("Error reading file: {}, error message {}", filePath, exception.getMessage());
}
} else if (file.isDirectory()) {
readFilesRecursively(file, jsonMap, rootPath);
}
}
}
private void deleteWidgets(File directory, Map<String, String> validWidgetToParentMap) {
File[] files = directory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.isDirectory()) {
deleteWidgets(file, validWidgetToParentMap);
}
String name = file.getName().replace(CommonConstants.JSON_EXTENSION, CommonConstants.EMPTY_STRING);
// If input widget was inside a container before, but the user moved it out of the container
// then we need to delete the widget from the container directory
// The check here is to validate if the parent is correct or not
if ( !validWidgetToParentMap.containsKey(name)) {
if (file.isDirectory()) {
deleteDirectory(file.toPath());
} else {
deleteFile(file.toPath());
}
} else if(!file.getParentFile().getPath().toString().equals(validWidgetToParentMap.get(name))
&& !file.getPath().toString().equals(validWidgetToParentMap.get(name))) {
if (file.isDirectory()) {
deleteDirectory(file.toPath());
} else {
deleteFile(file.toPath());
}
}
}
}
private JSONObject getMainContainer(Object pageJson, Gson gson) {
JSONObject pageJSON = new JSONObject(gson.toJson(pageJson));
JSONArray layouts = pageJSON.getJSONObject("unpublishedPage").getJSONArray("layouts");
return layouts.getJSONObject(0).getJSONObject("dsl");
}
}

View File

@ -2,6 +2,7 @@ package com.appsmith.git.service;
import com.appsmith.external.constants.AnalyticsEvents;
import com.appsmith.external.constants.ErrorReferenceDocUrl;
import com.appsmith.external.constants.GitConstants;
import com.appsmith.external.dtos.GitBranchDTO;
import com.appsmith.external.dtos.GitLogDTO;
import com.appsmith.external.dtos.GitStatusDTO;
@ -488,36 +489,51 @@ public class GitExecutorImpl implements GitExecutor {
Set<String> queriesModified = new HashSet<>();
Set<String> jsObjectsModified = new HashSet<>();
Set<String> pagesModified = new HashSet<>();
int modifiedPages = 0;
int modifiedQueries = 0;
int modifiedJSObjects = 0;
int modifiedDatasources = 0;
int modifiedJSLibs = 0;
for (String x : modifiedAssets) {
if (x.contains(CommonConstants.CANVAS)) {
modifiedPages++;
} else if (x.contains(GitDirectories.ACTION_DIRECTORY + "/")) {
String queryName = x.split(GitDirectories.ACTION_DIRECTORY + "/")[1];
int position = queryName.indexOf("/");
// begins with pages and filename and parent name should be same or contains widgets
if (x.contains(CommonConstants.WIDGETS)) {
if (!pagesModified.contains(getPageName(x))) {
pagesModified.add(getPageName(x));
modifiedPages++;
}
} else if (!x.contains(CommonConstants.WIDGETS)
&& x.startsWith(GitDirectories.PAGE_DIRECTORY)
&& !x.contains(GitDirectories.ACTION_DIRECTORY)
&& !x.contains(GitDirectories.ACTION_COLLECTION_DIRECTORY)) {
if (!pagesModified.contains(getPageName(x))) {
pagesModified.add(getPageName(x));
modifiedPages++;
}
} else if (x.contains(GitDirectories.ACTION_DIRECTORY + CommonConstants.DELIMITER_PATH)) {
String queryName = x.split(GitDirectories.ACTION_DIRECTORY + CommonConstants.DELIMITER_PATH)[1];
int position = queryName.indexOf(CommonConstants.DELIMITER_PATH);
if(position != -1) {
queryName = queryName.substring(0, position);
String pageName = x.split("/")[1];
String pageName = x.split(CommonConstants.DELIMITER_PATH)[1];
if (!queriesModified.contains(pageName + queryName)) {
queriesModified.add(pageName + queryName);
modifiedQueries++;
}
}
} else if (x.contains(GitDirectories.ACTION_COLLECTION_DIRECTORY + "/") && !x.endsWith(".json")) {
String queryName = x.substring(x.lastIndexOf("/") + 1);
String pageName = x.split("/")[1];
} else if (x.contains(GitDirectories.ACTION_COLLECTION_DIRECTORY + CommonConstants.DELIMITER_PATH) && !x.endsWith(CommonConstants.JSON_EXTENSION)) {
String queryName = x.substring(x.lastIndexOf(CommonConstants.DELIMITER_PATH) + 1);
String pageName = x.split(CommonConstants.DELIMITER_PATH)[1];
if (!jsObjectsModified.contains(pageName + queryName)) {
jsObjectsModified.add(pageName + queryName);
modifiedJSObjects++;
}
} else if (x.contains(GitDirectories.DATASOURCE_DIRECTORY + "/")) {
} else if (x.contains(GitDirectories.DATASOURCE_DIRECTORY + CommonConstants.DELIMITER_PATH)) {
modifiedDatasources++;
} else if (x.contains(GitDirectories.JS_LIB_DIRECTORY + "/")) {
} else if (x.contains(GitDirectories.JS_LIB_DIRECTORY + CommonConstants.DELIMITER_PATH)) {
modifiedJSLibs++;
} else if (x.equals(CommonConstants.METADATA + CommonConstants.JSON_EXTENSION)) {
response.setMigrationMessage(CommonConstants.FILE_MIGRATION_MESSAGE);
}
}
response.setModified(modifiedAssets);
@ -558,6 +574,11 @@ public class GitExecutorImpl implements GitExecutor {
.subscribeOn(scheduler);
}
private String getPageName(String path) {
String[] pathArray = path.split(CommonConstants.DELIMITER_PATH);
return pathArray[1];
}
@Override
public Mono<String> mergeBranch(Path repoSuffix, String sourceBranch, String destinationBranch) {
return Mono.fromCallable(() -> {

View File

@ -0,0 +1,255 @@
package com.appsmith.git.helpers;
import com.appsmith.git.constants.CommonConstants;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ExtendWith(SpringExtension.class)
public class DSLTransformHelperTest {
private Map<String, JSONObject> jsonMap;
private Map<String, List<String>> pathMapping;
@BeforeEach
public void setup() {
// Initialize the JSON map and path mapping for each test
jsonMap = new HashMap<>();
pathMapping = new HashMap<>();
}
@Test
public void testHasChildren_WithChildren() {
JSONObject jsonObject = new JSONObject();
JSONArray children = new JSONArray();
children.put(new JSONObject());
jsonObject.put(CommonConstants.CHILDREN, children);
boolean result = DSLTransformerHelper.hasChildren(jsonObject);
Assertions.assertTrue(result);
}
@Test
public void testHasChildren_WithoutChildren() {
JSONObject jsonObject = new JSONObject();
boolean result = DSLTransformerHelper.hasChildren(jsonObject);
Assertions.assertFalse(result);
}
@Test
public void testIsCanvasWidget_WithCanvasWidget() {
JSONObject widgetObject = new JSONObject();
widgetObject.put(CommonConstants.WIDGET_TYPE, "CANVAS_WIDGET_1");
boolean result = DSLTransformerHelper.isCanvasWidget(widgetObject);
Assertions.assertTrue(result);
}
@Test
public void testIsCanvasWidget_WithNonCanvasWidget() {
JSONObject widgetObject = new JSONObject();
widgetObject.put("widgetType", "BUTTON_WIDGET");
boolean result = DSLTransformerHelper.isCanvasWidget(widgetObject);
Assertions.assertFalse(result);
}
@Test
public void testIsCanvasWidget_WithMissingWidgetType() {
JSONObject widgetObject = new JSONObject();
boolean result = DSLTransformerHelper.isCanvasWidget(widgetObject);
Assertions.assertFalse(result);
}
/*@Test
public void testCalculateParentDirectories() {
// Test Case 1: Simple paths
List<String> paths1 = Arrays.asList(
"/root/dir1/file1",
"/root/dir1/file2",
"/root/dir2/file3",
"/root/dir3/file4"
);
Map<String, List<String>> result1 = DSLTransformerHelper.calculateParentDirectories(paths1);
Map<String, List<String>> expected1 = new HashMap<>();
expected1.put("root", Arrays.asList("/root/dir1/file1", "/root/dir1/file2", "/root/dir2/file3", "/root/dir3/file4"));
expected1.put("dir1", Arrays.asList("/root/dir1/file1", "/root/dir1/file2"));
expected1.put("dir2", Arrays.asList("/root/dir2/file3"));
expected1.put("dir3", Arrays.asList("/root/dir3/file4"));
Assertions.assertEquals(expected1, result1);
// Test Case 2: Paths with duplicate directories
List<String> paths2 = Arrays.asList(
"/root/dir1/file1",
"/root/dir1/file2",
"/root/dir2/file3",
"/root/dir1/file4"
);
Map<String, List<String>> result2 = DSLTransformerHelper.calculateParentDirectories(paths2);
Map<String, List<String>> expected2 = new HashMap<>();
expected2.put("root", Arrays.asList("/root/dir1/file1", "/root/dir1/file2", "/root/dir2/file3", "/root/dir1/file4"));
expected2.put("dir1", Arrays.asList("/root/dir1/file1", "/root/dir1/file2", "/root/dir1/file4"));
expected2.put("dir2", Arrays.asList("/root/dir2/file3"));
Assertions.assertEquals(expected2, result2);
// Test Case 3: Paths with empty list
List<String> paths3 = Collections.emptyList();
Map<String, List<String>> result3 = DSLTransformerHelper.calculateParentDirectories(paths3);
Map<String, List<String>> expected3 = Collections.emptyMap();
Assertions.assertEquals(expected3, result3);
// Test Case 4: Paths with single-level directories
List<String> paths4 = Arrays.asList(
"/dir1/file1",
"/dir2/file2",
"/dir3/file3"
);
Map<String, List<String>> result4 = DSLTransformerHelper.calculateParentDirectories(paths4);
Map<String, List<String>> expected4 = new HashMap<>();
expected4.put("dir1", Arrays.asList("/dir1/file1"));
expected4.put("dir2", Arrays.asList("/dir2/file2"));
expected4.put("dir3", Arrays.asList("/dir3/file3"));
Assertions.assertEquals(expected4, result4);
}*/
// Test case for nested JSON object construction --------------------------------------------------------------------
@Test
public void testGetNestedDSL_EmptyPageWithNoWidgets() {
JSONObject mainContainer = new JSONObject();
JSONObject result = DSLTransformerHelper.getNestedDSL(jsonMap, pathMapping, mainContainer);
Assertions.assertEquals(mainContainer, result);
}
@Test
public void testGetChildren_WithNoChildren() {
JSONObject widgetObject = new JSONObject();
widgetObject.put("type", "CANVAS_WIDGET");
widgetObject.put("id", "widget1");
jsonMap.put("widget1.json", widgetObject);
List<String> pathList = new ArrayList<>();
pathMapping.put("widget1", pathList);
JSONObject result = DSLTransformerHelper.getChildren("widget1", jsonMap, pathMapping);
Assertions.assertEquals(widgetObject, result);
}
@Test
public void testGetChildren_WithNestedChildren() {
JSONObject widgetObject = new JSONObject();
widgetObject.put(CommonConstants.WIDGET_TYPE, "CANVAS_WIDGET");
widgetObject.put("id", "widget1");
jsonMap.put("widget1.json", widgetObject);
JSONObject childObject = new JSONObject();
childObject.put(CommonConstants.WIDGET_TYPE, "BUTTON_WIDGET");
childObject.put("id", "widget2");
jsonMap.put("widget2.json", childObject);
List<String> pathList = new ArrayList<>();
pathList.add("widget2");
pathMapping.put("widget1", pathList);
JSONObject result = DSLTransformerHelper.getChildren("widget1", jsonMap, pathMapping);
Assertions.assertEquals(widgetObject, result);
JSONArray children = result.optJSONArray(CommonConstants.CHILDREN);
Assertions.assertNotNull(children);
Assertions.assertEquals(1, children.length());
JSONObject child = children.getJSONObject(0);
Assertions.assertEquals(childObject, child);
JSONArray childChildren = child.optJSONArray(CommonConstants.CHILDREN);
Assertions.assertNull(childChildren);
}
@Test
public void testGetWidgetName_WithSingleDirectory() {
String path = "widgets/parent/child";
String result = DSLTransformerHelper.getWidgetName(path);
Assertions.assertEquals("child", result);
}
@Test
public void testGetWidgetName_WithMultipleDirectories() {
String path = "widgets/parent/child/grandchild";
String result = DSLTransformerHelper.getWidgetName(path);
Assertions.assertEquals("grandchild", result);
}
@Test
public void testGetWidgetName_WithEmptyPath() {
String path = "";
String result = DSLTransformerHelper.getWidgetName(path);
Assertions.assertEquals("", result);
}
@Test
public void testGetWidgetName_WithRootDirectory() {
String path = "widgets";
String result = DSLTransformerHelper.getWidgetName(path);
Assertions.assertEquals("widgets", result);
}
@Test
public void testAppendChildren_WithNoExistingChildren() {
JSONObject parent = new JSONObject();
JSONArray childWidgets = new JSONArray()
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child1"))
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child2"));
JSONObject result = DSLTransformerHelper.appendChildren(parent, childWidgets);
JSONArray expectedChildren = new JSONArray()
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child1"))
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child2"));
Assertions.assertEquals(expectedChildren.toString(), result.optJSONArray(CommonConstants.CHILDREN).toString());
}
@Test
public void testAppendChildren_WithExistingMultipleChildren() {
JSONObject parent = new JSONObject();
JSONArray existingChildren = new JSONArray()
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "ExistingChild1"))
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "ExistingChild2"));
parent.put(CommonConstants.CHILDREN, existingChildren);
JSONArray childWidgets = new JSONArray()
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child1"))
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child2"));
JSONObject result = DSLTransformerHelper.appendChildren(parent, childWidgets);
JSONArray expectedChildren = new JSONArray()
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child1"))
.put(new JSONObject().put(CommonConstants.WIDGET_NAME, "Child2"));
Assertions.assertEquals(expectedChildren.toString(), result.optJSONArray(CommonConstants.CHILDREN).toString());
}
}

View File

@ -11,6 +11,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import reactor.core.publisher.Mono;

View File

@ -51,4 +51,7 @@ public class GitStatusDTO {
// Documentation url for discard and pull functionality
String discardDocUrl = Assets.GIT_DISCARD_DOC_URL;
// File Format migration
String migrationMessage = "";
}

View File

@ -22,6 +22,7 @@ public class ApplicationGitReference {
Map<String, Object> actionCollections;
Map<String, String> actionCollectionBody;
Map<String, Object> pages;
Map<String, String> pageDsl;
Map<String, Object> datasources;
Map<String, Object> jsLibraries;

View File

@ -26,6 +26,9 @@ import com.appsmith.server.services.SessionUserService;
import com.google.gson.Gson;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import net.minidev.json.parser.ParseException;
import org.apache.commons.collections.PredicateUtils;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.api.errors.GitAPIException;
@ -156,6 +159,7 @@ public class GitFileUtils {
// Insert only active pages which will then be committed to repo as individual file
Map<String, Object> resourceMap = new HashMap<>();
Map<String, String> resourceMapBody = new HashMap<>();
Map<String, String> dslBody = new HashMap<>();
applicationJson
.getPageList()
.stream()
@ -168,12 +172,20 @@ public class GitFileUtils {
? newPage.getUnpublishedPage().getName()
: newPage.getPublishedPage().getName();
removeUnwantedFieldsFromPage(newPage);
JSONObject dsl = newPage.getUnpublishedPage().getLayouts().get(0).getDsl();
// Get MainContainer widget data, remove the children and club with Canvas.json file
JSONObject mainContainer = new JSONObject(dsl);
mainContainer.remove("children");
newPage.getUnpublishedPage().getLayouts().get(0).setDsl(mainContainer);
// pageName will be used for naming the json file
dslBody.put(pageName, dsl.toString());
resourceMap.put(pageName, newPage);
});
applicationReference.setPages(new HashMap<>(resourceMap));
applicationReference.setPageDsl(new HashMap<>(dslBody));
resourceMap.clear();
resourceMapBody.clear();
// Insert active actions and also assign the keys which later will be used for saving the resource in actual filepath
// For actions, we are referring to validNames to maintain unique file names as just name
@ -403,6 +415,19 @@ public class GitFileUtils {
List<NewPage> pages = getApplicationResource(applicationReference.getPages(), NewPage.class);
// Remove null values
org.apache.commons.collections.CollectionUtils.filter(pages, PredicateUtils.notNullPredicate());
// Set the DSL to page object before saving
Map<String, String> pageDsl = applicationReference.getPageDsl();
pages.forEach(page -> {
JSONParser jsonParser = new JSONParser();
try {
if (pageDsl != null && pageDsl.get(page.getUnpublishedPage().getName()) != null) {
page.getUnpublishedPage().getLayouts().get(0).setDsl( (JSONObject) jsonParser.parse(pageDsl.get(page.getUnpublishedPage().getName())) );
}
} catch (ParseException e) {
log.error("Error parsing the page dsl for page: {}", page.getUnpublishedPage().getName(), e);
throw new AppsmithException(AppsmithError.JSON_PROCESSING_ERROR, page.getUnpublishedPage().getName());
}
});
pages.forEach(newPage -> {
// As we are publishing the app and then committing to git we expect the published and unpublished PageDTO
// will be same, so we create a deep copy for the published version for page from the unpublishedPageDTO
@ -426,7 +451,7 @@ public class GitFileUtils {
// For REMOTE plugin like Twilio the user actions are stored in key value pairs and hence they need to be
// deserialized separately unlike the body which is stored as string in the db.
if (newAction.getPluginType().toString().equals("REMOTE")) {
Map<String, Object> formData = new Gson().fromJson(actionBody.get(keyName), Map.class);
Map<String, Object> formData = gson.fromJson(actionBody.get(keyName), Map.class);
newAction.getUnpublishedAction().getActionConfiguration().setFormData(formData);
} else {
newAction.getUnpublishedAction().getActionConfiguration().setBody(actionBody.get(keyName));