diff --git a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/BindDataButton.tsx b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/BindDataButton.tsx
index 2bcf1f3a89e2..7039945b9e6c 100644
--- a/app/client/src/PluginActionEditor/components/PluginActionResponse/components/BindDataButton.tsx
+++ b/app/client/src/PluginActionEditor/components/PluginActionResponse/components/BindDataButton.tsx
@@ -17,6 +17,7 @@ import {
import { useDispatch, useSelector } from "react-redux";
import {
getCurrentApplicationId,
+ getCurrentBasePageId,
getPageList,
getPagePermissions,
} from "selectors/editorSelectors";
@@ -247,11 +248,11 @@ function BindDataButton(props: BindDataButtonProps) {
const pagePermissions = useSelector(getPagePermissions);
const params = useParams<{
- basePageId: string;
baseApiId?: string;
baseQueryId?: string;
moduleInstanceId?: string;
}>();
+ const currentBasePageId = useSelector(getCurrentBasePageId);
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
@@ -346,7 +347,7 @@ function BindDataButton(props: BindDataButtonProps) {
params.baseQueryId ||
params.moduleInstanceId) as string,
applicationId: applicationId as string,
- basePageId: params.basePageId,
+ basePageId: currentBasePageId,
}),
);
diff --git a/app/client/src/actions/initActions.ts b/app/client/src/actions/initActions.ts
index ba5917a95a54..1a5463a86e9a 100644
--- a/app/client/src/actions/initActions.ts
+++ b/app/client/src/actions/initActions.ts
@@ -14,6 +14,8 @@ export interface InitEditorActionPayload {
branch?: string;
mode: APP_MODE;
shouldInitialiseUserDetails?: boolean;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
}
export const initEditorAction = (
@@ -26,9 +28,11 @@ export const initEditorAction = (
export interface InitAppViewerPayload {
branch: string;
baseApplicationId?: string;
- basePageId: string;
+ basePageId?: string;
mode: APP_MODE;
shouldInitialiseUserDetails?: boolean;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
}
export const initAppViewerAction = ({
@@ -37,6 +41,8 @@ export const initAppViewerAction = ({
branch,
mode,
shouldInitialiseUserDetails,
+ staticApplicationSlug,
+ staticPageSlug,
}: InitAppViewerPayload) => ({
type: ReduxActionTypes.INITIALIZE_PAGE_VIEWER,
payload: {
@@ -45,6 +51,8 @@ export const initAppViewerAction = ({
basePageId,
mode,
shouldInitialiseUserDetails,
+ staticApplicationSlug,
+ staticPageSlug,
},
});
diff --git a/app/client/src/actions/pageActions.tsx b/app/client/src/actions/pageActions.tsx
index f776940728c0..6212b4e3dfc5 100644
--- a/app/client/src/actions/pageActions.tsx
+++ b/app/client/src/actions/pageActions.tsx
@@ -705,3 +705,23 @@ export const navigateToAnotherPage = (
type: ReduxActionTypes.NAVIGATE_TO_ANOTHER_PAGE,
payload,
});
+
+export const persistPageSlug = (pageId: string, slug: string) => {
+ return {
+ type: ReduxActionTypes.PERSIST_PAGE_SLUG,
+ payload: {
+ pageId,
+ slug,
+ },
+ };
+};
+
+export const validatePageSlug = (pageId: string, slug: string) => {
+ return {
+ type: ReduxActionTypes.VALIDATE_PAGE_SLUG,
+ payload: {
+ pageId,
+ slug,
+ },
+ };
+};
diff --git a/app/client/src/api/PageApi.tsx b/app/client/src/api/PageApi.tsx
index f93b157f6364..0ca32ab24712 100644
--- a/app/client/src/api/PageApi.tsx
+++ b/app/client/src/api/PageApi.tsx
@@ -89,6 +89,7 @@ export interface UpdatePageRequest {
name?: string;
isHidden?: boolean;
customSlug?: string;
+ uniqueSlug?: string;
}
export interface UpdatePageResponse {
@@ -97,6 +98,7 @@ export interface UpdatePageResponse {
name: string;
slug: string;
customSlug?: string;
+ uniqueSlug?: string;
applicationId: string;
layouts: Array;
isHidden: boolean;
@@ -300,6 +302,20 @@ class PageApi extends Api {
): Promise> {
return Api.get(PageApi.url, params);
}
+
+ static async persistPageSlug(request: {
+ branchedPageId: string;
+ uniquePageSlug: string;
+ }): Promise> {
+ return Api.patch(`${PageApi.url}/static-url`, request);
+ }
+
+ static async validatePageSlug(
+ pageId: string,
+ uniqueSlug: string,
+ ): Promise> {
+ return Api.get(`${PageApi.url}/${pageId}/static-url/verify/${uniqueSlug}`);
+ }
}
export default PageApi;
diff --git a/app/client/src/api/services/ConsolidatedPageLoadApi/types.ts b/app/client/src/api/services/ConsolidatedPageLoadApi/types.ts
index 5a8deb86d891..553bbe6690b3 100644
--- a/app/client/src/api/services/ConsolidatedPageLoadApi/types.ts
+++ b/app/client/src/api/services/ConsolidatedPageLoadApi/types.ts
@@ -2,4 +2,6 @@ export interface ConsolidatedApiParams {
applicationId?: string;
defaultPageId?: string;
branchName?: string;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
}
diff --git a/app/client/src/ce/AppRouter.tsx b/app/client/src/ce/AppRouter.tsx
index f843421610d7..43e9799a44aa 100644
--- a/app/client/src/ce/AppRouter.tsx
+++ b/app/client/src/ce/AppRouter.tsx
@@ -14,6 +14,7 @@ import {
BUILDER_PATCH_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
CUSTOM_WIDGETS_DEPRECATED_EDITOR_ID_PATH,
CUSTOM_WIDGETS_EDITOR_ID_PATH,
CUSTOM_WIDGETS_EDITOR_ID_PATH_CUSTOM,
@@ -27,6 +28,7 @@ import {
VIEWER_PATCH_PATH,
VIEWER_PATH,
VIEWER_PATH_DEPRECATED,
+ VIEWER_PATH_STATIC,
WORKSPACE_URL,
} from "constants/routes";
import WorkspaceLoader from "pages/workspace/loader";
@@ -135,6 +137,9 @@ export function Routes() {
{/*
* End Note: When making changes to the order of the paths above
*/}
+ {/* Static URL routes that accept any page slug - must be after more specific routes */}
+
+
@@ -175,7 +180,6 @@ export default function AppRouter() {
return (
-
{safeCrash && safeCrashCode ? (
<>
@@ -183,6 +187,7 @@ export default function AppRouter() {
>
) : (
<>
+
diff --git a/app/client/src/ce/IDE/constants/routes.ts b/app/client/src/ce/IDE/constants/routes.ts
index 2f96deb54409..5b87583c0701 100644
--- a/app/client/src/ce/IDE/constants/routes.ts
+++ b/app/client/src/ce/IDE/constants/routes.ts
@@ -5,6 +5,7 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
DATA_SOURCES_EDITOR_ID_PATH,
ENTITY_PATH,
INTEGRATION_EDITOR_PATH,
@@ -40,6 +41,11 @@ export const EntityPaths: string[] = [
];
export const IDEBasePaths: Readonly> = {
[IDE_TYPE.None]: [],
- [IDE_TYPE.App]: [BUILDER_PATH, BUILDER_PATH_DEPRECATED, BUILDER_CUSTOM_PATH],
+ [IDE_TYPE.App]: [
+ BUILDER_PATH,
+ BUILDER_PATH_DEPRECATED,
+ BUILDER_CUSTOM_PATH,
+ BUILDER_PATH_STATIC,
+ ],
[IDE_TYPE.UIPackage]: [],
};
diff --git a/app/client/src/ce/actions/applicationActions.ts b/app/client/src/ce/actions/applicationActions.ts
index ed0d21c799f8..444909dc4436 100644
--- a/app/client/src/ce/actions/applicationActions.ts
+++ b/app/client/src/ce/actions/applicationActions.ts
@@ -79,6 +79,34 @@ export const updateApplication = (
};
};
+export const persistAppSlug = (slug: string, onSuccess?: () => void) => {
+ return {
+ type: ReduxActionTypes.PERSIST_APP_SLUG,
+ payload: {
+ slug,
+ onSuccess,
+ },
+ };
+};
+
+export const validateAppSlug = (slug: string) => {
+ return {
+ type: ReduxActionTypes.VALIDATE_APP_SLUG,
+ payload: {
+ slug,
+ },
+ };
+};
+
+export const fetchAppSlugSuggestion = (applicationId: string) => {
+ return {
+ type: ReduxActionTypes.FETCH_APP_SLUG_SUGGESTION,
+ payload: {
+ applicationId,
+ },
+ };
+};
+
export const updateCurrentApplicationIcon = (icon: IconNames) => {
return {
type: ReduxActionTypes.CURRENT_APPLICATION_ICON_UPDATE,
@@ -290,3 +318,28 @@ export const setIsAppSidebarPinned = (payload: boolean) => ({
export const fetchAllPackages = () => {
return {};
};
+
+export const enableStaticUrl = (slug: string, onSuccess?: () => void) => {
+ return {
+ type: ReduxActionTypes.ENABLE_STATIC_URL,
+ payload: {
+ slug,
+ onSuccess,
+ },
+ };
+};
+
+export const disableStaticUrl = (onSuccess?: () => void) => {
+ return {
+ type: ReduxActionTypes.DISABLE_STATIC_URL,
+ payload: {
+ onSuccess,
+ },
+ };
+};
+
+export const resetAppSlugValidation = () => {
+ return {
+ type: ReduxActionTypes.RESET_APP_SLUG_VALIDATION,
+ };
+};
diff --git a/app/client/src/ce/api/ApplicationApi.tsx b/app/client/src/ce/api/ApplicationApi.tsx
index 22555b8dd460..56c1f2f5a53b 100644
--- a/app/client/src/ce/api/ApplicationApi.tsx
+++ b/app/client/src/ce/api/ApplicationApi.tsx
@@ -35,6 +35,7 @@ export interface ApplicationPagePayload {
slug: string;
isHidden?: boolean;
customSlug?: string;
+ uniqueSlug?: string;
userPermissions?: string[];
}
@@ -517,6 +518,52 @@ export class ApplicationApi extends Api {
> {
return Api.post(`${ApplicationApi.baseURL}/import/partial/block`, request);
}
+
+ static async persistAppSlug(
+ applicationId: string,
+ request: {
+ branchedApplicationId: string;
+ uniqueApplicationSlug: string;
+ },
+ ): Promise> {
+ return Api.patch(
+ `${ApplicationApi.baseURL}/${applicationId}/static-url`,
+ request,
+ );
+ }
+
+ static async validateAppSlug(
+ applicationId: string,
+ uniqueSlug: string,
+ ): Promise> {
+ return Api.get(
+ `${ApplicationApi.baseURL}/${applicationId}/static-url/${uniqueSlug}`,
+ );
+ }
+
+ static async enableStaticUrl(
+ applicationId: string,
+ request: { uniqueApplicationSlug: string },
+ ): Promise> {
+ return Api.post(
+ `${ApplicationApi.baseURL}/${applicationId}/static-url`,
+ request,
+ );
+ }
+
+ static async disableStaticUrl(
+ applicationId: string,
+ ): Promise> {
+ return Api.delete(`${ApplicationApi.baseURL}/${applicationId}/static-url`);
+ }
+
+ static async fetchAppSlugSuggestion(
+ applicationId: string,
+ ): Promise> {
+ return Api.get(
+ `${ApplicationApi.baseURL}/${applicationId}/static-url/suggest-app-slug`,
+ );
+ }
}
export default ApplicationApi;
diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx
index 36f44caa2a15..707f7f1f117e 100644
--- a/app/client/src/ce/constants/ReduxActionConstants.tsx
+++ b/app/client/src/ce/constants/ReduxActionConstants.tsx
@@ -636,9 +636,28 @@ const PageActionErrorTypes = {
};
const ApplicationActionTypes = {
+ ENABLE_STATIC_URL: "ENABLE_STATIC_URL",
+ ENABLE_STATIC_URL_SUCCESS: "ENABLE_STATIC_URL_SUCCESS",
+ DISABLE_STATIC_URL: "DISABLE_STATIC_URL",
+ DISABLE_STATIC_URL_SUCCESS: "DISABLE_STATIC_URL_SUCCESS",
UPDATE_APPLICATION: "UPDATE_APPLICATION",
UPDATE_APP_LAYOUT: "UPDATE_APP_LAYOUT",
UPDATE_APPLICATION_SUCCESS: "UPDATE_APPLICATION_SUCCESS",
+ PERSIST_APP_SLUG: "PERSIST_APP_SLUG",
+ PERSIST_APP_SLUG_SUCCESS: "PERSIST_APP_SLUG_SUCCESS",
+ PERSIST_APP_SLUG_ERROR: "PERSIST_APP_SLUG_ERROR",
+ RESET_APP_SLUG_VALIDATION: "RESET_APP_SLUG_VALIDATION",
+ VALIDATE_APP_SLUG: "VALIDATE_APP_SLUG",
+ VALIDATE_APP_SLUG_SUCCESS: "VALIDATE_APP_SLUG_SUCCESS",
+ VALIDATE_APP_SLUG_ERROR: "VALIDATE_APP_SLUG_ERROR",
+ PERSIST_PAGE_SLUG: "PERSIST_PAGE_SLUG",
+ PERSIST_PAGE_SLUG_SUCCESS: "PERSIST_PAGE_SLUG_SUCCESS",
+ PERSIST_PAGE_SLUG_ERROR: "PERSIST_PAGE_SLUG_ERROR",
+ VALIDATE_PAGE_SLUG: "VALIDATE_PAGE_SLUG",
+ VALIDATE_PAGE_SLUG_SUCCESS: "VALIDATE_PAGE_SLUG_SUCCESS",
+ VALIDATE_PAGE_SLUG_ERROR: "VALIDATE_PAGE_SLUG_ERROR",
+ FETCH_APP_SLUG_SUGGESTION: "FETCH_APP_SLUG_SUGGESTION",
+ FETCH_APP_SLUG_SUGGESTION_SUCCESS: "FETCH_APP_SLUG_SUGGESTION_SUCCESS",
FETCH_APPLICATION_INIT: "FETCH_APPLICATION_INIT",
FETCH_APPLICATION_SUCCESS: "FETCH_APPLICATION_SUCCESS",
CREATE_APPLICATION_INIT: "CREATE_APPLICATION_INIT",
@@ -670,6 +689,9 @@ const ApplicationActionErrorTypes = {
DELETE_APPLICATION_ERROR: "DELETE_APPLICATION_ERROR",
SET_DEFAULT_APPLICATION_PAGE_ERROR: "SET_DEFAULT_APPLICATION_PAGE_ERROR",
FORK_APPLICATION_ERROR: "FORK_APPLICATION_ERROR",
+ FETCH_APP_SLUG_SUGGESTION_ERROR: "FETCH_APP_SLUG_SUGGESTION_ERROR",
+ ENABLE_STATIC_URL_ERROR: "ENABLE_STATIC_URL_ERROR",
+ DISABLE_STATIC_URL_ERROR: "DISABLE_STATIC_URL_ERROR",
};
const IDEDebuggerActionTypes = {
diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts
index 95e3220a909d..d8360fa96496 100644
--- a/app/client/src/ce/constants/messages.ts
+++ b/app/client/src/ce/constants/messages.ts
@@ -1916,6 +1916,41 @@ export const GENERAL_SETTINGS_NAME_EMPTY_MESSAGE = () =>
export const GENERAL_SETTINGS_NAME_SPECIAL_CHARACTER_ERROR = () =>
"Only alphanumeric or '-()' are allowed";
export const GENERAL_SETTINGS_APP_ICON_LABEL = () => "App icon";
+export const GENERAL_SETTINGS_APP_URL_LABEL = () => "App slug";
+export const GENERAL_SETTINGS_APP_URL_PLACEHOLDER = () => "app-url";
+export const GENERAL_SETTINGS_APP_URL_PLACEHOLDER_FETCHING = () =>
+ "Generating app slug";
+export const GENERAL_SETTINGS_APP_URL_INVALID_MESSAGE = () =>
+ "Slug can only contain lowercase letters, numbers, and hyphens.";
+export const GENERAL_SETTINGS_APP_URL_WARNING_MESSAGE = () =>
+ "Changing this application slug will affect both edit and deployed versions of the app.";
+export const GENERAL_SETTINGS_APP_URL_CHECKING_MESSAGE = () =>
+ "Checking availability...";
+export const GENERAL_SETTINGS_APP_URL_AVAILABLE_MESSAGE = () => "Available";
+export const GENERAL_SETTINGS_APP_URL_UNAVAILABLE_MESSAGE = () =>
+ "There is already an app with this slug.";
+export const GENERAL_SETTINGS_APP_URL_EMPTY_VALUE_MESSAGE = () =>
+ "Enter a value";
+export const ERROR_IN_DISABLING_STATIC_URL = () =>
+ "Error in disabling static URL. Please try again.";
+export const STATIC_URL_DISABLED_SUCCESS = () =>
+ "Static URL disabled. The app has reverted to default Appsmith URLs.";
+export const STATIC_URL_CHANGE_SUCCESS = () =>
+ "App slug updated. All pages now use the new base URL.";
+export const ERROR_IN_FETCHING_APP_SLUG_SUGGESTION = () =>
+ "Error in fetching app slug suggestion. Please try again.";
+export const ERROR_IN_ENABLING_STATIC_URL = () =>
+ "Error in enabling static URL. Please try again.";
+
+export const PAGE_SETTINGS_PAGE_SLUG_CHECKING_MESSAGE = () =>
+ "Checking availability...";
+export const PAGE_SETTINGS_PAGE_SLUG_AVAILABLE_MESSAGE = () => "Available";
+export const PAGE_SETTINGS_PAGE_SLUG_UNAVAILABLE_MESSAGE = () =>
+ "There is already a page with this slug.";
+export const PAGE_SETTINGS_PAGE_SLUG_DEPLOY_MESSAGE = () =>
+ "Deploy app to apply this slug";
+export const PAGE_SETTINGS_PAGE_NAME_CONFLICTING_SLUG_MESSAGE = () =>
+ "This page name conflicts with an existing page slug.";
export const THEME_SETTINGS_SECTION_HEADER = () => "Theme";
export const THEME_SETTINGS_SECTION_CONTENT_HEADER = () => "Theme settings";
diff --git a/app/client/src/ce/constants/routes/appRoutes.ts b/app/client/src/ce/constants/routes/appRoutes.ts
index 0000fc74f139..ef5deddeaa02 100644
--- a/app/client/src/ce/constants/routes/appRoutes.ts
+++ b/app/client/src/ce/constants/routes/appRoutes.ts
@@ -22,6 +22,11 @@ export const BUILDER_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:applicationSlug/:page
export const BUILDER_CUSTOM_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:customSlug(.*\-):basePageId${ID_EXTRACTION_REGEX}/edit`;
export const VIEWER_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:applicationSlug/:pageSlug(.*\-):basePageId${ID_EXTRACTION_REGEX}`;
export const VIEWER_CUSTOM_PATH = `${BUILDER_VIEWER_PATH_PREFIX}:customSlug(.*\-):basePageId${ID_EXTRACTION_REGEX}`;
+
+// Static URL routes that accept any page slug (must be added after more specific routes)
+export const BUILDER_PATH_STATIC = `${BUILDER_VIEWER_PATH_PREFIX}:staticApplicationSlug/:staticPageSlug/edit`;
+export const VIEWER_PATH_STATIC = `${BUILDER_VIEWER_PATH_PREFIX}:staticApplicationSlug/:staticPageSlug`;
+
export const getViewerPath = (
applicationSlug: string,
pageSlug: string,
@@ -111,25 +116,35 @@ export const matchBuilderPath = (
match(BUILDER_PATH + WIDGETS_EDITOR_ID_PATH, options)(pathName) ||
match(BUILDER_CUSTOM_PATH + WIDGETS_EDITOR_ID_PATH, options)(pathName) ||
match(BUILDER_PATH_DEPRECATED + WIDGETS_EDITOR_ID_PATH, options)(pathName) ||
- match(BUILDER_PATH + WIDGETS_EDITOR_ID_PATH + ADD_PATH, options)(pathName);
+ match(BUILDER_PATH + WIDGETS_EDITOR_ID_PATH + ADD_PATH, options)(pathName) ||
+ match(BUILDER_PATH_STATIC, options)(pathName) ||
+ match(BUILDER_PATH_STATIC + WIDGETS_EDITOR_ID_PATH, options)(pathName) ||
+ match(
+ BUILDER_PATH_STATIC + WIDGETS_EDITOR_ID_PATH + ADD_PATH,
+ options,
+ )(pathName);
export const matchJSObjectPath = match(JS_COLLECTION_ID_PATH);
export const matchViewerPath = (pathName: string) =>
match(VIEWER_PATH)(pathName) ||
match(VIEWER_PATH_DEPRECATED)(pathName) ||
- match(VIEWER_CUSTOM_PATH)(pathName);
+ match(VIEWER_CUSTOM_PATH)(pathName) ||
+ match(VIEWER_PATH_STATIC)(pathName);
export const matchViewerForkPath = (pathName: string) =>
match(`${VIEWER_PATH}${VIEWER_FORK_PATH}`)(pathName) ||
match(`${VIEWER_CUSTOM_PATH}${VIEWER_FORK_PATH}`)(pathName) ||
- match(`${VIEWER_PATH_DEPRECATED}${VIEWER_FORK_PATH}`)(pathName);
+ match(`${VIEWER_PATH_DEPRECATED}${VIEWER_FORK_PATH}`)(pathName) ||
+ match(`${VIEWER_PATH_STATIC}${VIEWER_FORK_PATH}`)(pathName);
export const matchAppLibrariesPath = (pathName: string) =>
match(`${BUILDER_PATH}${APP_LIBRARIES_EDITOR_PATH}`)(pathName) ||
- match(`${BUILDER_CUSTOM_PATH}${APP_LIBRARIES_EDITOR_PATH}`)(pathName);
+ match(`${BUILDER_CUSTOM_PATH}${APP_LIBRARIES_EDITOR_PATH}`)(pathName) ||
+ match(`${BUILDER_PATH_STATIC}${APP_LIBRARIES_EDITOR_PATH}`)(pathName);
export const matchAppPackagesPath = (pathName: string) =>
match(`${BUILDER_PATH}${APP_PACKAGES_EDITOR_PATH}`)(pathName) ||
- match(`${BUILDER_CUSTOM_PATH}${APP_PACKAGES_EDITOR_PATH}`)(pathName);
+ match(`${BUILDER_CUSTOM_PATH}${APP_PACKAGES_EDITOR_PATH}`)(pathName) ||
+ match(`${BUILDER_PATH_STATIC}${APP_PACKAGES_EDITOR_PATH}`)(pathName);
export const addBranchParam = (branch: string) => {
const url = new URL(window.location.href);
@@ -140,13 +155,17 @@ export const addBranchParam = (branch: string) => {
};
export interface BuilderRouteParams {
- basePageId: string;
- baseApplicationId: string;
+ basePageId?: string;
+ baseApplicationId?: string;
+ staticPageSlug?: string;
+ staticApplicationSlug?: string;
}
export interface AppViewerRouteParams {
- basePageId: string;
+ basePageId?: string;
baseApplicationId?: string;
+ staticPageSlug?: string;
+ staticApplicationSlug?: string;
}
export interface APIEditorRouteParams {
diff --git a/app/client/src/ce/entities/FeatureFlag.ts b/app/client/src/ce/entities/FeatureFlag.ts
index d8efbcae67d0..b7085d0c0ea2 100644
--- a/app/client/src/ce/entities/FeatureFlag.ts
+++ b/app/client/src/ce/entities/FeatureFlag.ts
@@ -64,6 +64,7 @@ export const FEATURE_FLAG = {
"release_jsobjects_onpageunloadactions_enabled",
configure_block_event_tracking_for_anonymous_users:
"configure_block_event_tracking_for_anonymous_users",
+ release_static_url_enabled: "release_static_url_enabled",
} as const;
export type FeatureFlag = keyof typeof FEATURE_FLAG;
@@ -116,6 +117,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = {
license_ai_agent_instance_enabled: false,
release_jsobjects_onpageunloadactions_enabled: false,
configure_block_event_tracking_for_anonymous_users: false,
+ release_static_url_enabled: false,
};
export const AB_TESTING_EVENT_KEYS = {
diff --git a/app/client/src/ce/entities/IDE/utils/getEditableTabPermissions.ts b/app/client/src/ce/entities/IDE/utils/getEditableTabPermissions.ts
index 1103a3dca627..8a52c22d03ac 100644
--- a/app/client/src/ce/entities/IDE/utils/getEditableTabPermissions.ts
+++ b/app/client/src/ce/entities/IDE/utils/getEditableTabPermissions.ts
@@ -2,6 +2,7 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
} from "ee/constants/routes/appRoutes";
import type { EntityItem } from "ee/IDE/Interfaces/EntityItem";
@@ -11,6 +12,7 @@ export const EDITOR_PATHS = [
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
];
export interface EditableTabPermissions {
diff --git a/app/client/src/ce/entities/URLRedirect/URLAssembly.ts b/app/client/src/ce/entities/URLRedirect/URLAssembly.ts
index e006897d61ef..786fb98c80d5 100644
--- a/app/client/src/ce/entities/URLRedirect/URLAssembly.ts
+++ b/app/client/src/ce/entities/URLRedirect/URLAssembly.ts
@@ -3,6 +3,8 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
+ VIEWER_PATH_STATIC,
PLACEHOLDER_APP_SLUG,
PLACEHOLDER_PAGE_SLUG,
VIEWER_CUSTOM_PATH,
@@ -33,6 +35,7 @@ export enum URL_TYPE {
DEFAULT,
SLUG,
CUSTOM_SLUG,
+ STATIC,
}
export const baseURLRegistry = {
@@ -48,18 +51,24 @@ export const baseURLRegistry = {
[APP_MODE.EDIT]: BUILDER_CUSTOM_PATH,
[APP_MODE.PUBLISHED]: VIEWER_CUSTOM_PATH,
},
+ [URL_TYPE.STATIC]: {
+ [APP_MODE.EDIT]: BUILDER_PATH_STATIC,
+ [APP_MODE.PUBLISHED]: VIEWER_PATH_STATIC,
+ },
};
export interface ApplicationURLParams {
baseApplicationId?: string;
applicationSlug?: string;
applicationVersion?: ApplicationVersion;
+ staticApplicationSlug?: string;
}
export interface PageURLParams {
basePageId: string;
pageSlug: string;
customSlug?: string;
+ staticPageSlug?: string;
}
export function getQueryStringfromObject(
@@ -145,38 +154,55 @@ export class URLBuilder {
return URLBuilder._instance;
}
- private getURLType(
- applicationVersion: ApplicationURLParams["applicationVersion"],
- customSlug?: string,
- ) {
+ private getURLType(options: {
+ applicationVersion?: ApplicationURLParams["applicationVersion"];
+ customSlug?: string;
+ hasStaticSlug?: boolean;
+ }) {
if (
- typeof applicationVersion !== "undefined" &&
- applicationVersion < ApplicationVersion.SLUG_URL
+ typeof options.applicationVersion !== "undefined" &&
+ options.applicationVersion < ApplicationVersion.SLUG_URL
)
return URL_TYPE.DEFAULT;
- if (customSlug) return URL_TYPE.CUSTOM_SLUG;
+ if (options.hasStaticSlug) return URL_TYPE.STATIC;
+
+ if (options.customSlug) return URL_TYPE.CUSTOM_SLUG;
return URL_TYPE.SLUG;
}
- private getFormattedParams(basePageId: string) {
+ private getFormattedParams(basePageId: string, mode: APP_MODE) {
+ const staticApplicationSlug = this.appParams.staticApplicationSlug;
+ const currentPageParams = this.pageParams[basePageId] || {};
+
+ // Static slugs are only computed for published/viewer mode
+ // If staticApplicationSlug is present and mode is PUBLISHED, use staticPageSlug (or fallback to pageSlug)
+ // Otherwise, use the regular pageSlug for non-static URLs
+ const staticPageSlug =
+ mode === APP_MODE.PUBLISHED && staticApplicationSlug
+ ? currentPageParams.staticPageSlug ||
+ currentPageParams.pageSlug ||
+ PLACEHOLDER_PAGE_SLUG
+ : currentPageParams.pageSlug || PLACEHOLDER_PAGE_SLUG;
+
const currentAppParams = {
applicationSlug: this.appParams.applicationSlug || PLACEHOLDER_APP_SLUG,
baseApplicationId: this.appParams.baseApplicationId,
+ staticApplicationSlug: staticApplicationSlug || PLACEHOLDER_APP_SLUG,
};
- let currentPageParams = this.pageParams[basePageId] || {};
- currentPageParams = {
+ const formattedPageParams = {
...currentPageParams,
pageSlug: `${currentPageParams.pageSlug || PLACEHOLDER_PAGE_SLUG}-`,
customSlug: currentPageParams.customSlug
? `${currentPageParams.customSlug}-`
: "",
+ staticPageSlug,
basePageId,
};
- return { ...currentAppParams, ...currentPageParams };
+ return { ...currentAppParams, ...formattedPageParams };
}
setCurrentBasePageId(basePageId?: string | null) {
@@ -194,6 +220,8 @@ export class URLBuilder {
appParams.applicationSlug || this.appParams.applicationSlug;
this.appParams.applicationVersion =
appParams.applicationVersion || this.appParams.applicationVersion;
+ this.appParams.staticApplicationSlug =
+ appParams.staticApplicationSlug ?? this.appParams.staticApplicationSlug;
}
if (pageParams) {
@@ -225,15 +253,22 @@ export class URLBuilder {
}
generateBasePathForApp(basePageId: string, mode: APP_MODE) {
- const { applicationVersion } = this.appParams;
+ const { applicationVersion, staticApplicationSlug } = this.appParams;
const customSlug = this.pageParams[basePageId]?.customSlug || "";
-
- const urlType = this.getURLType(applicationVersion, customSlug);
+ // Static URLs are only used in published/viewer mode
+ const hasStaticSlug =
+ mode === APP_MODE.PUBLISHED && !!staticApplicationSlug;
+
+ const urlType = this.getURLType({
+ applicationVersion,
+ customSlug,
+ hasStaticSlug,
+ });
const urlPattern = baseURLRegistry[urlType][mode];
- const formattedParams = this.getFormattedParams(basePageId);
+ const formattedParams = this.getFormattedParams(basePageId, mode);
const basePath = generatePath(urlPattern, formattedParams);
@@ -257,17 +292,36 @@ export class URLBuilder {
getPagePathPreview(basePageId: string, pageName: string) {
const { applicationVersion } = this.appParams;
- const urlType = this.getURLType(applicationVersion);
+ const urlType = this.getURLType({ applicationVersion });
const urlPattern = baseURLRegistry[urlType][APP_MODE.PUBLISHED];
- const formattedParams = this.getFormattedParams(basePageId);
+ const formattedParams = this.getFormattedParams(
+ basePageId,
+ APP_MODE.PUBLISHED,
+ );
formattedParams.pageSlug = `${pageName}-`;
return generatePath(urlPattern, formattedParams).toLowerCase();
}
+ getStaticUrlPathPreviewWithSlugs(
+ applicationUniqueSlug: string,
+ pageSlug: string,
+ ) {
+ const urlPattern = baseURLRegistry[URL_TYPE.STATIC][APP_MODE.PUBLISHED];
+
+ const formattedParams = {
+ staticApplicationSlug: applicationUniqueSlug,
+ staticPageSlug: pageSlug,
+ baseApplicationId: this.appParams.baseApplicationId,
+ basePageId: PLACEHOLDER_PAGE_SLUG,
+ };
+
+ return generatePath(urlPattern, formattedParams).toLowerCase();
+ }
+
resolveEntityIdForApp(builderParams: URLBuilderParams) {
const basePageId =
builderParams.basePageId ||
diff --git a/app/client/src/ce/middlewares/RouteParamsMiddleware.ts b/app/client/src/ce/middlewares/RouteParamsMiddleware.ts
index 4b83ce97bc61..887ddf3eb573 100644
--- a/app/client/src/ce/middlewares/RouteParamsMiddleware.ts
+++ b/app/client/src/ce/middlewares/RouteParamsMiddleware.ts
@@ -27,11 +27,13 @@ export const handler = (action: ReduxAction) => {
baseApplicationId: application.baseId,
applicationSlug: application.slug,
applicationVersion: application.applicationVersion,
+ staticApplicationSlug: application?.staticUrlSettings?.uniqueSlug || "",
};
pageParams = pages.map((page) => ({
pageSlug: page.slug,
basePageId: page.baseId,
customSlug: page.customSlug,
+ staticPageSlug: page?.uniqueSlug || "",
}));
break;
}
@@ -44,11 +46,13 @@ export const handler = (action: ReduxAction) => {
baseApplicationId: application.baseId,
applicationSlug: application.slug,
applicationVersion: application.applicationVersion,
+ staticApplicationSlug: application?.staticUrlSettings?.uniqueSlug || "",
};
pageParams = pages.map((page) => ({
pageSlug: page.slug,
basePageId: page.baseId,
customSlug: page.customSlug,
+ staticPageSlug: page?.uniqueSlug || "",
}));
break;
}
@@ -59,6 +63,7 @@ export const handler = (action: ReduxAction) => {
baseApplicationId: application.baseId,
applicationSlug: application.slug,
applicationVersion: application.applicationVersion,
+ staticApplicationSlug: application?.staticUrlSettings?.uniqueSlug || "",
};
break;
}
@@ -69,6 +74,7 @@ export const handler = (action: ReduxAction) => {
pageSlug: page.slug,
basePageId: page.basePageId,
customSlug: page.customSlug,
+ staticPageSlug: page?.uniqueSlug || "",
}));
break;
}
@@ -80,6 +86,7 @@ export const handler = (action: ReduxAction) => {
pageSlug: page.slug,
basePageId: page.baseId,
customSlug: page.customSlug,
+ staticPageSlug: page?.uniqueSlug || "",
},
];
break;
@@ -92,6 +99,7 @@ export const handler = (action: ReduxAction) => {
pageSlug: page.slug,
basePageId: page.basePageId,
customSlug: page.customSlug,
+ staticPageSlug: page?.uniqueSlug || "",
},
];
break;
@@ -104,6 +112,7 @@ export const handler = (action: ReduxAction) => {
pageSlug: page.slug,
basePageId: page.baseId,
customSlug: page.customSlug,
+ staticPageSlug: page?.uniqueSlug || "",
},
]);
break;
@@ -115,15 +124,17 @@ export const handler = (action: ReduxAction) => {
baseApplicationId: application.baseid,
applicationSlug: application.slug,
applicationVersion: application.applicationVersion,
+ staticApplicationSlug: application?.staticUrlSettings?.uniqueSlug || "",
};
break;
case ReduxActionTypes.CLONE_PAGE_SUCCESS:
- const { basePageId, pageSlug } = action.payload;
+ const { basePageId, pageSlug, uniqueSlug } = action.payload;
pageParams = [
{
basePageId,
pageSlug,
+ staticPageSlug: uniqueSlug || "",
},
];
break;
diff --git a/app/client/src/ce/pages/AppIDE/layout/routers/MainPane/constants.ts b/app/client/src/ce/pages/AppIDE/layout/routers/MainPane/constants.ts
index 7be3b3ccde39..601fec425709 100644
--- a/app/client/src/ce/pages/AppIDE/layout/routers/MainPane/constants.ts
+++ b/app/client/src/ce/pages/AppIDE/layout/routers/MainPane/constants.ts
@@ -11,6 +11,7 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
DATA_SOURCES_EDITOR_ID_PATH,
DATA_SOURCES_EDITOR_LIST_PATH,
INTEGRATION_EDITOR_PATH,
@@ -47,6 +48,7 @@ export const MainPaneRoutes = (
BUILDER_PATH_DEPRECATED,
BUILDER_PATH,
BUILDER_CUSTOM_PATH,
+ BUILDER_PATH_STATIC,
`${BUILDER_PATH_DEPRECATED}${ADD_PATH}`,
`${BUILDER_PATH}${ADD_PATH}`,
`${BUILDER_CUSTOM_PATH}${ADD_PATH}`,
diff --git a/app/client/src/ce/pages/Editor/Explorer/helpers.tsx b/app/client/src/ce/pages/Editor/Explorer/helpers.tsx
index 2963d74d66bc..fd60ed6d86f1 100644
--- a/app/client/src/ce/pages/Editor/Explorer/helpers.tsx
+++ b/app/client/src/ce/pages/Editor/Explorer/helpers.tsx
@@ -105,13 +105,33 @@ export function getAppViewerPageIdFromPath(path: string): string | null {
export const matchEditorPath = (
path: string,
-): Match<{ baseApplicationId: string; basePageId: string }> => {
+): Match<{
+ baseApplicationId: string;
+ basePageId: string;
+ applicationSlug?: string;
+ pageSlug?: string;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
+}> => {
return matchBuilderPath(path, { end: false });
};
export const isEditorPath = (path: string) => {
return !!matchEditorPath(path);
};
+export const matchViewerPathTyped = (
+ path: string,
+): Match<{
+ baseApplicationId?: string;
+ basePageId?: string;
+ applicationSlug?: string;
+ pageSlug?: string;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
+}> => {
+ return matchViewerPath(path);
+};
+
export const isViewerPath = (path: string) => {
return !!matchViewerPath(path);
};
diff --git a/app/client/src/ce/pages/common/AppHeader.tsx b/app/client/src/ce/pages/common/AppHeader.tsx
index c85696568649..2db70bdb6ae7 100644
--- a/app/client/src/ce/pages/common/AppHeader.tsx
+++ b/app/client/src/ce/pages/common/AppHeader.tsx
@@ -12,6 +12,8 @@ import {
ADMIN_SETTINGS_CATEGORY_PATH,
VIEWER_CUSTOM_PATH,
BUILDER_CUSTOM_PATH,
+ BUILDER_PATH_STATIC,
+ VIEWER_PATH_STATIC,
BASE_URL,
CUSTOM_WIDGETS_EDITOR_ID_PATH,
CUSTOM_WIDGETS_EDITOR_ID_PATH_CUSTOM,
@@ -49,6 +51,8 @@ export const Routes = () => {
+
+
);
diff --git a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx
index 50c563673f74..ee34d42f0725 100644
--- a/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx
+++ b/app/client/src/ce/reducers/uiReducers/applicationsReducer.tsx
@@ -45,6 +45,12 @@ export const initialState: ApplicationsReduxState = {
isErrorSavingNavigationSetting: false,
isUploadingNavigationLogo: false,
isDeletingNavigationLogo: false,
+ isPersistingAppSlug: false,
+ isErrorPersistingAppSlug: false,
+ isValidatingAppSlug: false,
+ isApplicationSlugValid: true,
+ isFetchingAppSlugSuggestion: false,
+ appSlugSuggestion: "",
loadingStates: {
isFetchingAllRoles: false,
isFetchingAllUsers: false,
@@ -744,6 +750,137 @@ export const handlers = {
isSavingNavigationSetting: false,
};
},
+ [ReduxActionTypes.RESET_APP_SLUG_VALIDATION]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isValidatingAppSlug: false,
+ isApplicationSlugValid: true,
+ };
+ },
+ [ReduxActionTypes.VALIDATE_APP_SLUG]: (state: ApplicationsReduxState) => {
+ return {
+ ...state,
+ isValidatingAppSlug: true,
+ isApplicationSlugValid: true, // Reset to valid while validating
+ };
+ },
+ [ReduxActionTypes.VALIDATE_APP_SLUG_SUCCESS]: (
+ state: ApplicationsReduxState,
+ action: ReduxAction<{ slug: string; isValid: boolean }>,
+ ) => {
+ return {
+ ...state,
+ isValidatingAppSlug: false,
+ isApplicationSlugValid: action.payload.isValid,
+ };
+ },
+ [ReduxActionTypes.VALIDATE_APP_SLUG_ERROR]: (
+ state: ApplicationsReduxState,
+ action: ReduxAction<{ slug: string; isValid: boolean }>,
+ ) => {
+ return {
+ ...state,
+ isValidatingAppSlug: false,
+ isApplicationSlugValid: action.payload.isValid,
+ };
+ },
+ [ReduxActionTypes.FETCH_APP_SLUG_SUGGESTION]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isFetchingAppSlugSuggestion: true,
+ appSlugSuggestion: "",
+ };
+ },
+ [ReduxActionTypes.FETCH_APP_SLUG_SUGGESTION_SUCCESS]: (
+ state: ApplicationsReduxState,
+ action: ReduxAction<{ uniqueApplicationSlug: string }>,
+ ) => {
+ return {
+ ...state,
+ isFetchingAppSlugSuggestion: false,
+ appSlugSuggestion: action.payload.uniqueApplicationSlug,
+ };
+ },
+ [ReduxActionErrorTypes.FETCH_APP_SLUG_SUGGESTION_ERROR]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isFetchingAppSlugSuggestion: false,
+ appSlugSuggestion: "",
+ };
+ },
+ [ReduxActionTypes.ENABLE_STATIC_URL]: (state: ApplicationsReduxState) => {
+ return {
+ ...state,
+ isPersistingAppSlug: true,
+ };
+ },
+ [ReduxActionTypes.ENABLE_STATIC_URL_SUCCESS]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isPersistingAppSlug: false,
+ appSlugSuggestion: "",
+ };
+ },
+ [ReduxActionErrorTypes.ENABLE_STATIC_URL_ERROR]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isPersistingAppSlug: false,
+ };
+ },
+ [ReduxActionTypes.DISABLE_STATIC_URL]: (state: ApplicationsReduxState) => {
+ return {
+ ...state,
+ isPersistingAppSlug: true,
+ };
+ },
+ [ReduxActionTypes.DISABLE_STATIC_URL_SUCCESS]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isPersistingAppSlug: false,
+ };
+ },
+ [ReduxActionErrorTypes.DISABLE_STATIC_URL_ERROR]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isPersistingAppSlug: false,
+ };
+ },
+ [ReduxActionTypes.PERSIST_APP_SLUG]: (state: ApplicationsReduxState) => {
+ return {
+ ...state,
+ isPersistingAppSlug: true,
+ };
+ },
+ [ReduxActionTypes.PERSIST_APP_SLUG_SUCCESS]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isPersistingAppSlug: false,
+ };
+ },
+ [ReduxActionTypes.PERSIST_APP_SLUG_ERROR]: (
+ state: ApplicationsReduxState,
+ ) => {
+ return {
+ ...state,
+ isPersistingAppSlug: false,
+ };
+ },
};
const applicationsReducer = createReducer(initialState, handlers);
@@ -775,6 +912,12 @@ export interface ApplicationsReduxState {
isErrorSavingNavigationSetting: boolean;
isUploadingNavigationLogo: boolean;
isDeletingNavigationLogo: boolean;
+ isPersistingAppSlug: boolean;
+ isErrorPersistingAppSlug: boolean;
+ isValidatingAppSlug: boolean;
+ isApplicationSlugValid: boolean;
+ isFetchingAppSlugSuggestion: boolean;
+ appSlugSuggestion: string;
loadingStates: {
isFetchingAllRoles: boolean;
isFetchingAllUsers: boolean;
diff --git a/app/client/src/ce/sagas/ApplicationSagas.tsx b/app/client/src/ce/sagas/ApplicationSagas.tsx
index baf2e30f5a06..439ff0c28494 100644
--- a/app/client/src/ce/sagas/ApplicationSagas.tsx
+++ b/app/client/src/ce/sagas/ApplicationSagas.tsx
@@ -60,6 +60,7 @@ import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import {
createMessage,
ERROR_IMPORTING_APPLICATION_TO_WORKSPACE,
+ ERROR_IN_DISABLING_STATIC_URL,
IMPORT_APP_SUCCESSFUL,
} from "ee/constants/messages";
import { APP_MODE } from "entities/App";
@@ -185,10 +186,6 @@ export function* publishApplicationSaga(
const currentBasePageId: string = yield select(getCurrentBasePageId);
const currentPageId: string = yield select(getCurrentPageId);
- const appicationViewPageUrl = viewerURL({
- basePageId: currentBasePageId,
- });
-
yield put(
fetchApplication({
applicationId,
@@ -197,6 +194,17 @@ export function* publishApplicationSaga(
}),
);
+ // Wait for the fetch application success or error to ensure the application is fetched before getting the view page url
+ // This is to get the latest static url for the application
+ yield take([
+ ReduxActionTypes.FETCH_APPLICATION_SUCCESS,
+ ReduxActionErrorTypes.FETCH_APPLICATION_ERROR,
+ ]);
+
+ const appicationViewPageUrl = viewerURL({
+ basePageId: currentBasePageId,
+ });
+
// If the tab is opened focus and reload else open in new tab
if (!windowReference || windowReference.closed) {
windowReference = window.open(appicationViewPageUrl, "_blank");
@@ -319,6 +327,7 @@ export function* fetchAppAndPagesSaga(
isHidden: !!page.isHidden,
slug: page.slug,
customSlug: page.customSlug,
+ uniqueSlug: page.uniqueSlug,
userPermissions: page.userPermissions
? page.userPermissions
: pagePermissionsMap[page.id],
@@ -1172,3 +1181,244 @@ export function* publishAnvilApplicationSaga(
});
}
}
+
+export function* persistAppSlugSaga(
+ action: ReduxAction<{ slug: string; onSuccess?: () => void }>,
+) {
+ try {
+ const currentApplication: ApplicationPayload | undefined = yield select(
+ getCurrentApplication,
+ );
+
+ if (!currentApplication) {
+ throw new Error("No current application found");
+ }
+
+ const applicationId = currentApplication.id;
+ const { onSuccess, slug } = action.payload;
+
+ const request = {
+ branchedApplicationId: applicationId,
+ uniqueApplicationSlug: slug,
+ };
+
+ const response: ApiResponse = yield call(
+ ApplicationApi.persistAppSlug,
+ applicationId,
+ request,
+ );
+
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ // Fetch the application again to get updated data
+ yield call(fetchAppAndPagesSaga, {
+ type: ReduxActionTypes.FETCH_APPLICATION_INIT,
+ payload: { applicationId, mode: APP_MODE.EDIT },
+ });
+
+ yield put({
+ type: ReduxActionTypes.PERSIST_APP_SLUG_SUCCESS,
+ payload: {
+ slug,
+ },
+ });
+
+ // Call success callback if provided
+ if (onSuccess) {
+ onSuccess();
+ }
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionTypes.PERSIST_APP_SLUG_ERROR,
+ payload: {
+ error,
+ slug: action.payload.slug,
+ },
+ });
+ }
+}
+
+interface ValidateAppSlugResponse {
+ uniqueAppSlug: string;
+ isUniqueSlugAvailable: boolean;
+}
+
+export function* validateAppSlugSaga(action: ReduxAction<{ slug: string }>) {
+ try {
+ const currentApplication: ApplicationPayload | undefined = yield select(
+ getCurrentApplication,
+ );
+
+ if (!currentApplication) {
+ throw new Error("No current application found");
+ }
+
+ const applicationId = currentApplication.id;
+ const { slug } = action.payload;
+
+ const response: ApiResponse = yield call(
+ ApplicationApi.validateAppSlug,
+ applicationId,
+ slug,
+ );
+
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ const { isUniqueSlugAvailable } = response.data;
+
+ if (isUniqueSlugAvailable) {
+ yield put({
+ type: ReduxActionTypes.VALIDATE_APP_SLUG_SUCCESS,
+ payload: {
+ slug,
+ isValid: true,
+ },
+ });
+ } else {
+ yield put({
+ type: ReduxActionTypes.VALIDATE_APP_SLUG_ERROR,
+ payload: {
+ slug,
+ isValid: false,
+ },
+ });
+ }
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionTypes.VALIDATE_APP_SLUG_ERROR,
+ payload: {
+ error,
+ slug: action.payload.slug,
+ isValid: false,
+ },
+ });
+ }
+}
+
+export function* enableStaticUrlSaga(
+ action: ReduxAction<{ slug: string; onSuccess?: () => void }>,
+) {
+ try {
+ const currentApplication: ApplicationPayload | undefined = yield select(
+ getCurrentApplication,
+ );
+
+ if (!currentApplication) {
+ throw new Error("No current application found");
+ }
+
+ const applicationId = currentApplication.id;
+ const { onSuccess, slug } = action.payload;
+ const response: ApiResponse = yield call(
+ ApplicationApi.enableStaticUrl,
+ applicationId,
+ { uniqueApplicationSlug: slug },
+ );
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ yield call(fetchAppAndPagesSaga, {
+ type: ReduxActionTypes.FETCH_APPLICATION_INIT,
+ payload: { applicationId, mode: APP_MODE.EDIT },
+ });
+ yield put({
+ type: ReduxActionTypes.ENABLE_STATIC_URL_SUCCESS,
+ });
+
+ // Call success callback if provided
+ if (onSuccess) {
+ onSuccess();
+ }
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionErrorTypes.ENABLE_STATIC_URL_ERROR,
+ payload: {
+ error,
+ },
+ });
+ }
+}
+
+export function* disableStaticUrlSaga(
+ action: ReduxAction<{ onSuccess?: () => void }>,
+) {
+ try {
+ const currentApplication: ApplicationPayload | undefined = yield select(
+ getCurrentApplication,
+ );
+
+ if (!currentApplication) {
+ throw new Error("No current application found");
+ }
+
+ const applicationId = currentApplication.id;
+ const { onSuccess } = action.payload || {};
+
+ const response: ApiResponse = yield call(
+ ApplicationApi.disableStaticUrl,
+ applicationId,
+ );
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ yield call(fetchAppAndPagesSaga, {
+ type: ReduxActionTypes.FETCH_APPLICATION_INIT,
+ payload: { applicationId, mode: APP_MODE.EDIT },
+ });
+ yield put({
+ type: ReduxActionTypes.DISABLE_STATIC_URL_SUCCESS,
+ });
+
+ // Call success callback if provided
+ if (onSuccess) {
+ onSuccess();
+ }
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionErrorTypes.DISABLE_STATIC_URL_ERROR,
+ payload: {
+ show: true,
+ message: createMessage(ERROR_IN_DISABLING_STATIC_URL),
+ },
+ });
+ }
+}
+
+export function* fetchAppSlugSuggestionSaga(
+ action: ReduxAction<{
+ applicationId: string;
+ }>,
+) {
+ try {
+ const { applicationId } = action.payload;
+
+ const response: ApiResponse = yield call(
+ ApplicationApi.fetchAppSlugSuggestion,
+ applicationId,
+ );
+
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ const uniqueApplicationSlug = response.data;
+
+ yield put({
+ type: ReduxActionTypes.FETCH_APP_SLUG_SUGGESTION_SUCCESS,
+ payload: { uniqueApplicationSlug },
+ });
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionErrorTypes.FETCH_APP_SLUG_SUGGESTION_ERROR,
+ payload: {
+ error,
+ },
+ });
+ }
+}
diff --git a/app/client/src/ce/sagas/PageSagas.tsx b/app/client/src/ce/sagas/PageSagas.tsx
index 53902eb7f691..cf3180f088bb 100644
--- a/app/client/src/ce/sagas/PageSagas.tsx
+++ b/app/client/src/ce/sagas/PageSagas.tsx
@@ -1561,3 +1561,89 @@ export function* setupPublishedPageSaga(
});
}
}
+
+export function* persistPageSlugSaga(
+ action: ReduxAction<{ pageId: string; slug: string }>,
+) {
+ try {
+ const request = {
+ branchedPageId: action.payload.pageId,
+ uniquePageSlug: action.payload.slug,
+ };
+
+ const response: ApiResponse = yield call(PageApi.persistPageSlug, request);
+
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ yield put({
+ type: ReduxActionTypes.PERSIST_PAGE_SLUG_SUCCESS,
+ payload: {
+ pageId: action.payload.pageId,
+ slug: action.payload.slug,
+ },
+ });
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionTypes.PERSIST_PAGE_SLUG_ERROR,
+ payload: {
+ error,
+ pageId: action.payload.pageId,
+ slug: action.payload.slug,
+ },
+ });
+ }
+}
+
+interface ValidatePageSlugResponse {
+ uniquePageSlug: string;
+ isUniqueSlugAvailable: boolean;
+}
+
+export function* validatePageSlugSaga(
+ action: ReduxAction<{ pageId: string; slug: string }>,
+) {
+ try {
+ const { pageId, slug } = action.payload;
+
+ const response: ApiResponse = yield call(
+ PageApi.validatePageSlug,
+ pageId,
+ slug,
+ );
+
+ const isValidResponse: boolean = yield validateResponse(response);
+
+ if (isValidResponse) {
+ const { isUniqueSlugAvailable } = response.data;
+
+ if (isUniqueSlugAvailable) {
+ yield put({
+ type: ReduxActionTypes.VALIDATE_PAGE_SLUG_SUCCESS,
+ payload: {
+ slug,
+ isValid: true,
+ },
+ });
+ } else {
+ yield put({
+ type: ReduxActionTypes.VALIDATE_PAGE_SLUG_ERROR,
+ payload: {
+ slug,
+ isValid: false,
+ },
+ });
+ }
+ }
+ } catch (error) {
+ yield put({
+ type: ReduxActionTypes.VALIDATE_PAGE_SLUG_ERROR,
+ payload: {
+ error,
+ slug: action.payload.slug,
+ isValid: false,
+ },
+ });
+ }
+}
diff --git a/app/client/src/ce/selectors/applicationSelectors.tsx b/app/client/src/ce/selectors/applicationSelectors.tsx
index 0d52b7fc5522..508889357111 100644
--- a/app/client/src/ce/selectors/applicationSelectors.tsx
+++ b/app/client/src/ce/selectors/applicationSelectors.tsx
@@ -57,6 +57,12 @@ export const getIsSavingAppName = (state: DefaultRootState) =>
state.ui.applications.isSavingAppName;
export const getIsErroredSavingAppName = (state: DefaultRootState) =>
state.ui.applications.isErrorSavingAppName;
+export const getIsPersistingAppSlug = (state: DefaultRootState) =>
+ state.ui.applications.isPersistingAppSlug;
+export const getIsValidatingAppSlug = (state: DefaultRootState) =>
+ state.ui.applications.isValidatingAppSlug;
+export const getIsApplicationSlugValid = (state: DefaultRootState) =>
+ state.ui.applications.isApplicationSlugValid;
export const getApplicationList = createSelector(
getApplications,
@@ -202,3 +208,9 @@ export const getAppThemeSettings = (state: DefaultRootState) => {
state.ui.applications.currentApplication?.applicationDetail?.themeSetting,
);
};
+
+export const getIsFetchingAppSlugSuggestion = (state: DefaultRootState) =>
+ state.ui.applications.isFetchingAppSlugSuggestion;
+
+export const getAppSlugSuggestion = (state: DefaultRootState) =>
+ state.ui.applications.appSlugSuggestion;
diff --git a/app/client/src/components/editorComponents/GlobalSearch/index.tsx b/app/client/src/components/editorComponents/GlobalSearch/index.tsx
index a0815664e272..9f63a7eb4336 100644
--- a/app/client/src/components/editorComponents/GlobalSearch/index.tsx
+++ b/app/client/src/components/editorComponents/GlobalSearch/index.tsx
@@ -7,7 +7,6 @@ import React, {
} from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import styled, { ThemeProvider } from "styled-components";
-import { useParams } from "react-router";
import history, { NavigationMethod } from "utils/history";
import type { DefaultRootState } from "react-redux";
import SearchModal from "./SearchModal";
@@ -39,13 +38,13 @@ import {
SEARCH_ITEM_TYPES,
} from "./utils";
import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers";
-import type { ExplorerURLParams } from "ee/pages/Editor/Explorer/helpers";
import { getLastSelectedWidget } from "selectors/ui";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import useRecentEntities from "./useRecentEntities";
import { keyBy, noop } from "lodash";
import {
getCurrentPageId,
+ getCurrentBasePageId,
getPagePermissions,
} from "selectors/editorSelectors";
import { getQueryParams } from "utils/URLUtils";
@@ -189,9 +188,9 @@ function GlobalSearch() {
},
[dispatch],
);
- const params = useParams();
const pageIdToBasePageIdMap = useSelector(getPageIdToBasePageIdMap);
const basePageIdToPageIdMap = useSelector(getBasePageIdToPageIdMap);
+ const currentBasePageId = useSelector(getCurrentBasePageId);
const toggleShow = () => {
if (modalOpen) {
@@ -232,9 +231,9 @@ function GlobalSearch() {
const datasourcesList = useMemo(() => {
return reducerDatasources.map((datasource) => ({
...datasource,
- pageId: basePageIdToPageIdMap[params?.basePageId],
+ pageId: basePageIdToPageIdMap[currentBasePageId],
}));
- }, [basePageIdToPageIdMap, params?.basePageId, reducerDatasources]);
+ }, [basePageIdToPageIdMap, currentBasePageId, reducerDatasources]);
const filteredDatasources = useMemo(() => {
if (!query)
diff --git a/app/client/src/ee/sagas/ApplicationSagas.tsx b/app/client/src/ee/sagas/ApplicationSagas.tsx
index fd1e960df29d..6dfe53d804e1 100644
--- a/app/client/src/ee/sagas/ApplicationSagas.tsx
+++ b/app/client/src/ee/sagas/ApplicationSagas.tsx
@@ -18,9 +18,14 @@ import {
deleteNavigationLogoSaga,
fetchAllApplicationsOfWorkspaceSaga,
publishAnvilApplicationSaga,
+ persistAppSlugSaga,
+ validateAppSlugSaga,
+ fetchAppSlugSuggestionSaga,
+ enableStaticUrlSaga,
+ disableStaticUrlSaga,
} from "ce/sagas/ApplicationSagas";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
-import { all, takeLatest } from "redux-saga/effects";
+import { all, debounce, takeLatest } from "redux-saga/effects";
export default function* applicationSagas() {
yield all([
@@ -72,5 +77,13 @@ export default function* applicationSagas() {
ReduxActionTypes.PUBLISH_ANVIL_APPLICATION_INIT,
publishAnvilApplicationSaga,
),
+ takeLatest(ReduxActionTypes.PERSIST_APP_SLUG, persistAppSlugSaga),
+ debounce(500, ReduxActionTypes.VALIDATE_APP_SLUG, validateAppSlugSaga),
+ takeLatest(
+ ReduxActionTypes.FETCH_APP_SLUG_SUGGESTION,
+ fetchAppSlugSuggestionSaga,
+ ),
+ takeLatest(ReduxActionTypes.ENABLE_STATIC_URL, enableStaticUrlSaga),
+ takeLatest(ReduxActionTypes.DISABLE_STATIC_URL, disableStaticUrlSaga),
]);
}
diff --git a/app/client/src/ee/sagas/PageSagas.tsx b/app/client/src/ee/sagas/PageSagas.tsx
index 1661b83810a2..c49b4793fa26 100644
--- a/app/client/src/ee/sagas/PageSagas.tsx
+++ b/app/client/src/ee/sagas/PageSagas.tsx
@@ -22,6 +22,8 @@ import {
setupPageSaga,
setupPublishedPageSaga,
fetchPublishedPageResourcesSaga,
+ validatePageSlugSaga,
+ persistPageSlugSaga,
} from "ce/sagas/PageSagas";
import {
all,
@@ -77,5 +79,7 @@ export default function* pageSagas() {
ReduxActionTypes.FETCH_PUBLISHED_PAGE_RESOURCES_INIT,
fetchPublishedPageResourcesSaga,
),
+ takeLatest(ReduxActionTypes.PERSIST_PAGE_SLUG, persistPageSlugSaga),
+ debounce(500, ReduxActionTypes.VALIDATE_PAGE_SLUG, validatePageSlugSaga),
]);
}
diff --git a/app/client/src/entities/Application/types.ts b/app/client/src/entities/Application/types.ts
index b8e4e6542322..d88e1029e026 100644
--- a/app/client/src/entities/Application/types.ts
+++ b/app/client/src/entities/Application/types.ts
@@ -46,4 +46,8 @@ export interface ApplicationPayload {
publishedAppToCommunityTemplate?: boolean;
forkedFromTemplateTitle?: string;
connectedWorkflowId?: string;
+ staticUrlSettings?: {
+ enabled: boolean;
+ uniqueSlug: string;
+ };
}
diff --git a/app/client/src/entities/Engine/index.ts b/app/client/src/entities/Engine/index.ts
index 8fab09e8c1a6..d25e0f1175e5 100644
--- a/app/client/src/entities/Engine/index.ts
+++ b/app/client/src/entities/Engine/index.ts
@@ -28,6 +28,8 @@ export interface AppEnginePayload {
branch?: string;
mode: APP_MODE;
shouldInitialiseUserDetails?: boolean;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
}
export interface IAppEngine {
@@ -87,9 +89,20 @@ export default abstract class AppEngine {
rootSpan: Span,
) {
const loadAppDataSpan = startNestedSpan("AppEngine.loadAppData", rootSpan);
- const { applicationId, basePageId, branch } = payload;
+ const {
+ applicationId,
+ basePageId,
+ branch,
+ staticApplicationSlug,
+ staticPageSlug,
+ } = payload;
const { pages } = allResponses;
- const page = pages.data?.pages?.find((page) => page.baseId === basePageId);
+
+ // For static URLs, look up page by staticPageSlug using uniqueSlug
+ const isStaticPageUrl = staticApplicationSlug && staticPageSlug;
+ const page = isStaticPageUrl
+ ? pages.data?.pages?.find((page) => page.uniqueSlug === staticPageSlug)
+ : pages.data?.pages?.find((page) => page.baseId === basePageId);
const apiCalls: boolean = yield failFastApiCalls(
[
fetchApplication({
diff --git a/app/client/src/entities/Page/types.ts b/app/client/src/entities/Page/types.ts
index 762ed54811c0..8950fc95b871 100644
--- a/app/client/src/entities/Page/types.ts
+++ b/app/client/src/entities/Page/types.ts
@@ -8,5 +8,6 @@ export interface Page {
isHidden?: boolean;
slug: string;
customSlug?: string;
+ uniqueSlug?: string;
userPermissions?: string[];
}
diff --git a/app/client/src/entities/URLRedirect/SlugURLRedirect.ts b/app/client/src/entities/URLRedirect/SlugURLRedirect.ts
index d87ca2df738d..4c3567b92117 100644
--- a/app/client/src/entities/URLRedirect/SlugURLRedirect.ts
+++ b/app/client/src/entities/URLRedirect/SlugURLRedirect.ts
@@ -26,12 +26,22 @@ export class SlugURLRedirect extends URLRedirect {
isURLDeprecated(pathname) || !basePageIdInUrl;
if (!isCurrentURLDeprecated) {
+ // Only use static slugs for viewer mode, not for edit mode
+ const staticApplicationSlug =
+ this._mode === APP_MODE.PUBLISHED
+ ? currentApplication?.staticUrlSettings?.uniqueSlug || ""
+ : "";
+ const staticPageSlug =
+ this._mode === APP_MODE.PUBLISHED ? currentPage?.uniqueSlug || "" : "";
+
newURL =
getUpdatedRoute(pathname, {
applicationSlug,
pageSlug,
basePageId,
customSlug,
+ staticApplicationSlug,
+ staticPageSlug,
}) +
search +
hash;
diff --git a/app/client/src/pages/AppIDE/AppIDE.tsx b/app/client/src/pages/AppIDE/AppIDE.tsx
index fe7b429b39be..072b49255059 100644
--- a/app/client/src/pages/AppIDE/AppIDE.tsx
+++ b/app/client/src/pages/AppIDE/AppIDE.tsx
@@ -68,9 +68,20 @@ class Editor extends Component {
prevPageId: string | null = null;
componentDidMount() {
- const { basePageId } = this.props.match.params || {};
+ const { basePageId, staticPageSlug } = this.props.match.params || {};
- urlBuilder.setCurrentBasePageId(basePageId);
+ // If basePageId is not available but staticPageSlug is, try to find the basePageId from the slug
+ let resolvedBasePageId = basePageId;
+
+ if (!resolvedBasePageId && staticPageSlug) {
+ const matchingPage = this.props.pages.find(
+ (page) => page.uniqueSlug === staticPageSlug,
+ );
+
+ resolvedBasePageId = matchingPage?.basePageId;
+ }
+
+ urlBuilder.setCurrentBasePageId(resolvedBasePageId);
editorInitializer().then(() => {
this.props.widgetConfigBuildSuccess();
@@ -88,6 +99,8 @@ class Editor extends Component {
nextProps.currentApplicationName !== this.props.currentApplicationName ||
nextProps.match?.params?.basePageId !==
this.props.match?.params?.basePageId ||
+ nextProps.match?.params?.staticPageSlug !==
+ this.props.match?.params?.staticPageSlug ||
nextProps.currentApplicationId !== this.props.currentApplicationId ||
nextProps.isEditorInitialized !== this.props.isEditorInitialized ||
nextProps.isPublishing !== this.props.isPublishing ||
@@ -100,15 +113,38 @@ class Editor extends Component {
}
componentDidUpdate(prevProps: Props) {
- const { baseApplicationId, basePageId } = this.props.match.params || {};
- const { basePageId: prevBasePageId } = prevProps.match.params || {};
+ const { baseApplicationId, basePageId, staticPageSlug } =
+ this.props.match.params || {};
+ const { basePageId: prevBasePageId, staticPageSlug: prevStaticPageSlug } =
+ prevProps.match.params || {};
+
+ // Resolve basePageId from staticPageSlug if needed
+ let resolvedBasePageId = basePageId;
+
+ if (!resolvedBasePageId && staticPageSlug) {
+ const matchingPage = this.props.pages.find(
+ (page) => page.uniqueSlug === staticPageSlug,
+ );
+
+ resolvedBasePageId = matchingPage?.basePageId;
+ }
+
+ let prevResolvedBasePageId = prevBasePageId;
+
+ if (!prevResolvedBasePageId && prevStaticPageSlug) {
+ const matchingPage = prevProps.pages.find(
+ (page) => page.uniqueSlug === prevStaticPageSlug,
+ );
+
+ prevResolvedBasePageId = matchingPage?.basePageId;
+ }
const pageId = this.props.pages.find(
- (page) => page.basePageId === basePageId,
+ (page) => page.basePageId === resolvedBasePageId,
)?.pageId;
const prevPageId = prevProps.pages.find(
- (page) => page.basePageId === prevBasePageId,
+ (page) => page.basePageId === prevResolvedBasePageId,
)?.pageId;
// caching value for prevPageId as it is required in future lifecycles
@@ -117,7 +153,7 @@ class Editor extends Component {
}
const isPageIdUpdated = pageId !== this.prevPageId;
- const isBasePageIdUpdated = basePageId !== prevBasePageId;
+ const isBasePageIdUpdated = resolvedBasePageId !== prevResolvedBasePageId;
const isPageUpdated = isPageIdUpdated || isBasePageIdUpdated;
const isBranchUpdated = getIsBranchUpdated(
@@ -135,12 +171,14 @@ class Editor extends Component {
);
// to prevent re-init during connect
- if (prevBranch && isBranchUpdated && basePageId) {
+ if (prevBranch && isBranchUpdated && resolvedBasePageId) {
this.props.initEditor({
baseApplicationId,
- basePageId,
+ basePageId: resolvedBasePageId,
branch,
mode: APP_MODE.EDIT,
+ staticApplicationSlug: this.props.match.params.staticApplicationSlug,
+ staticPageSlug: this.props.match.params.staticPageSlug,
});
} else {
/**
@@ -149,9 +187,27 @@ class Editor extends Component {
* when redirected to the default page
*/
if (pageId && this.prevPageId && isPageUpdated) {
- this.props.updateCurrentPage(pageId);
- this.props.setupPage(pageId);
- urlBuilder.setCurrentBasePageId(basePageId);
+ // For static URLs, we need to call initEditor to trigger consolidated API
+ // with static slug parameters, not just updateCurrentPage and setupPage
+ if (
+ this.props.match.params.staticApplicationSlug &&
+ this.props.match.params.staticPageSlug
+ ) {
+ this.props.initEditor({
+ baseApplicationId,
+ basePageId: resolvedBasePageId,
+ branch,
+ mode: APP_MODE.EDIT,
+ staticApplicationSlug:
+ this.props.match.params.staticApplicationSlug,
+ staticPageSlug: this.props.match.params.staticPageSlug,
+ });
+ } else {
+ // For regular URLs, use the existing updateCurrentPage and setupPage
+ this.props.updateCurrentPage(pageId);
+ this.props.setupPage(pageId);
+ urlBuilder.setCurrentBasePageId(resolvedBasePageId);
+ }
}
}
}
diff --git a/app/client/src/pages/AppIDE/AppIDELoader.tsx b/app/client/src/pages/AppIDE/AppIDELoader.tsx
index 75a16a9f7030..936c40a7b800 100644
--- a/app/client/src/pages/AppIDE/AppIDELoader.tsx
+++ b/app/client/src/pages/AppIDE/AppIDELoader.tsx
@@ -14,7 +14,11 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
type Props = {
initEditor: (payload: InitEditorActionPayload) => void;
clearCache: () => void;
-} & RouteComponentProps<{ basePageId: string }>;
+} & RouteComponentProps<{
+ basePageId?: string;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
+}>;
class AppIDELoader extends React.PureComponent<
Props,
@@ -36,13 +40,15 @@ class AppIDELoader extends React.PureComponent<
} = this.props;
const branch = getSearchQuery(search, GIT_BRANCH_QUERY_KEY);
- const { basePageId } = params;
+ const { basePageId, staticApplicationSlug, staticPageSlug } = params;
- if (basePageId) {
+ if (basePageId || (staticApplicationSlug && staticPageSlug)) {
initEditor({
basePageId,
branch,
mode: APP_MODE.EDIT,
+ staticApplicationSlug,
+ staticPageSlug,
});
}
}
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/AppSettings.tsx b/app/client/src/pages/AppIDE/components/AppSettings/AppSettings.tsx
index 87444f799a47..c02a479c1508 100644
--- a/app/client/src/pages/AppIDE/components/AppSettings/AppSettings.tsx
+++ b/app/client/src/pages/AppIDE/components/AppSettings/AppSettings.tsx
@@ -35,15 +35,7 @@ import { Divider } from "@appsmith/ads";
import { ImportAppSettings } from "./components/ImportAppSettings";
import { getIsAnvilLayout } from "layoutSystems/anvil/integrations/selectors";
import { getIsAiAgentApp } from "ee/selectors/aiAgentSelectors";
-
-export enum AppSettingsTabs {
- General,
- Embed,
- Theme,
- Navigation,
- Page,
- Import,
-}
+import { AppSettingsTabs } from "./types";
export interface SelectedTab {
type: AppSettingsTabs;
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/components/GeneralSettings.tsx b/app/client/src/pages/AppIDE/components/AppSettings/components/GeneralSettings.tsx
index 27a7d35dec84..5da75deee8be 100644
--- a/app/client/src/pages/AppIDE/components/AppSettings/components/GeneralSettings.tsx
+++ b/app/client/src/pages/AppIDE/components/AppSettings/components/GeneralSettings.tsx
@@ -1,25 +1,60 @@
-import { updateApplication } from "ee/actions/applicationActions";
+import {
+ updateApplication,
+ persistAppSlug,
+ validateAppSlug,
+ fetchAppSlugSuggestion,
+ enableStaticUrl,
+ disableStaticUrl,
+ resetAppSlugValidation,
+} from "ee/actions/applicationActions";
import type { UpdateApplicationPayload } from "ee/api/ApplicationApi";
import {
GENERAL_SETTINGS_APP_ICON_LABEL,
GENERAL_SETTINGS_APP_NAME_LABEL,
GENERAL_SETTINGS_NAME_EMPTY_MESSAGE,
+ GENERAL_SETTINGS_APP_URL_LABEL,
+ GENERAL_SETTINGS_APP_URL_INVALID_MESSAGE,
+ GENERAL_SETTINGS_APP_URL_CHECKING_MESSAGE,
+ GENERAL_SETTINGS_APP_URL_AVAILABLE_MESSAGE,
+ GENERAL_SETTINGS_APP_URL_UNAVAILABLE_MESSAGE,
+ GENERAL_SETTINGS_APP_URL_EMPTY_VALUE_MESSAGE,
+ GENERAL_SETTINGS_APP_URL_PLACEHOLDER_FETCHING,
+ GENERAL_SETTINGS_APP_URL_PLACEHOLDER,
+ createMessage,
+ STATIC_URL_CHANGE_SUCCESS,
+ STATIC_URL_DISABLED_SUCCESS,
} from "ee/constants/messages";
import classNames from "classnames";
import type { AppIconName } from "@appsmith/ads-old";
-import { Input, Text } from "@appsmith/ads";
+import { Input, Switch, Text, Icon, Flex, Button, toast } from "@appsmith/ads";
import { IconSelector } from "@appsmith/ads-old";
-import { debounce } from "lodash";
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useMemo, useState } from "react";
import { useEffect } from "react";
+import StaticURLConfirmationModal from "./StaticURLConfirmationModal";
+import { debounce } from "lodash";
import { useDispatch, useSelector } from "react-redux";
+
+const APPLICATION_SLUG_REGEX = /^[a-z0-9-]+$/;
+
import {
getCurrentApplication,
getIsSavingAppName,
+ getIsPersistingAppSlug,
+ getIsValidatingAppSlug,
+ getIsApplicationSlugValid,
+ getIsFetchingAppSlugSuggestion,
+ getAppSlugSuggestion,
} from "ee/selectors/applicationSelectors";
-import { getCurrentApplicationId } from "selectors/editorSelectors";
+import {
+ getCurrentApplicationId,
+ getCurrentBasePageId,
+ getPageList,
+} from "selectors/editorSelectors";
import styled from "styled-components";
import TextLoaderIcon from "./TextLoaderIcon";
+import UrlPreview from "./UrlPreview";
+import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
+import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
const IconSelectorWrapper = styled.div`
position: relative;
@@ -52,17 +87,112 @@ function GeneralSettings() {
const dispatch = useDispatch();
const applicationId = useSelector(getCurrentApplicationId);
const application = useSelector(getCurrentApplication);
+ const pages = useSelector(getPageList);
+ const currentBasePageId = useSelector(getCurrentBasePageId);
const isSavingAppName = useSelector(getIsSavingAppName);
+ const isApplicationSlugValid = useSelector(getIsApplicationSlugValid);
+ const isValidatingAppSlug = useSelector(getIsValidatingAppSlug);
+ const isFetchingAppSlugSuggestion = useSelector(
+ getIsFetchingAppSlugSuggestion,
+ );
+ const appSlugSuggestion = useSelector(getAppSlugSuggestion);
+ const isAppSlugSaving = useSelector(getIsPersistingAppSlug);
const [applicationName, setApplicationName] = useState(application?.name);
const [isAppNameValid, setIsAppNameValid] = useState(true);
const [applicationIcon, setApplicationIcon] = useState(
application?.icon as AppIconName,
);
+ const [applicationSlug, setApplicationSlug] = useState(
+ application?.staticUrlSettings?.uniqueSlug || "",
+ );
+ const [isClientSideSlugValid, setIsClientSideSlugValid] = useState(true);
+ const [isStaticUrlToggleEnabled, setIsStaticUrlToggleEnabled] =
+ useState(!!applicationSlug);
+ const [
+ isStaticUrlConfirmationModalOpen,
+ setIsStaticUrlConfirmationModalOpen,
+ ] = useState(false);
+ const [modalType, setModalType] = useState<"change" | "disable">("change");
+ const isStaticUrlFeatureEnabled = useFeatureFlag(
+ FEATURE_FLAG.release_static_url_enabled,
+ );
+
+ useEffect(
+ function updateApplicationName() {
+ !isSavingAppName && setApplicationName(application?.name);
+ },
+ [application, application?.name, isSavingAppName],
+ );
+
+ useEffect(
+ function updateApplicationSlug() {
+ setApplicationSlug(application?.staticUrlSettings?.uniqueSlug || "");
+ },
+ [application?.staticUrlSettings?.uniqueSlug],
+ );
+
+ useEffect(
+ function updateApplicationSlugSuggestion() {
+ if (appSlugSuggestion) {
+ setApplicationSlug(appSlugSuggestion || "");
+ }
+ },
+ [appSlugSuggestion],
+ );
+
+ const openStaticUrlConfirmationModal = useCallback(() => {
+ setModalType("change");
+ setIsStaticUrlConfirmationModalOpen(true);
+ }, []);
+
+ const closeStaticUrlConfirmationModal = useCallback(() => {
+ setIsStaticUrlConfirmationModalOpen(false);
- useEffect(() => {
- !isSavingAppName && setApplicationName(application?.name);
- }, [application, application?.name, isSavingAppName]);
+ // Reset toggle to original state if disabling
+ if (modalType === "disable") {
+ setIsStaticUrlToggleEnabled(true);
+ }
+ }, [modalType]);
+
+ const confirmStaticUrlChange = useCallback(() => {
+ const onSuccess = () => {
+ setIsStaticUrlConfirmationModalOpen(false);
+ toast.show(createMessage(STATIC_URL_CHANGE_SUCCESS), {
+ kind: "success",
+ });
+ };
+
+ if (
+ applicationSlug &&
+ applicationSlug !== application?.staticUrlSettings?.uniqueSlug
+ ) {
+ if (!application?.staticUrlSettings?.enabled) {
+ dispatch(enableStaticUrl(applicationSlug, onSuccess));
+ } else {
+ dispatch(persistAppSlug(applicationSlug, onSuccess));
+ }
+ } else {
+ // If no change needed, just close the modal
+ onSuccess();
+ }
+ }, [
+ applicationSlug,
+ application?.staticUrlSettings?.uniqueSlug,
+ dispatch,
+ application?.staticUrlSettings?.enabled,
+ ]);
+
+ const cancelSlugChange = useCallback(() => {
+ setApplicationSlug(application?.staticUrlSettings?.uniqueSlug || "");
+ setIsClientSideSlugValid(true);
+ dispatch(resetAppSlugValidation());
+
+ // Reset toggle to false if uniqueSlug is empty or not available
+ if (!application?.staticUrlSettings?.uniqueSlug) {
+ setIsStaticUrlToggleEnabled(false);
+ }
+ }, [application?.staticUrlSettings?.uniqueSlug, dispatch]);
const updateAppSettings = useCallback(
debounce((icon?: AppIconName) => {
@@ -79,7 +209,7 @@ function GeneralSettings() {
(isAppNameUpdated || icon) &&
dispatch(updateApplication(applicationId, payload));
}, 50),
- [applicationName, application, applicationId],
+ [applicationName, application, applicationId, isAppNameValid, dispatch],
);
const onChange = (value: string) => {
@@ -94,6 +224,176 @@ function GeneralSettings() {
setApplicationName(value);
};
+ const onSlugChange = useCallback(
+ (value: string) => {
+ // Convert to lowercase and replace spaces with hyphens
+ const normalizedValue = value.toLowerCase().replace(/\s+/g, "-");
+
+ if (normalizedValue && normalizedValue.trim().length > 0) {
+ // Basic validation: only lowercase letters, numbers, and hyphens
+ const isValid = APPLICATION_SLUG_REGEX.test(normalizedValue);
+
+ setIsClientSideSlugValid(isValid);
+
+ if (isValid) {
+ // Dispatch validation action instead of persisting
+ dispatch(validateAppSlug(normalizedValue));
+ }
+ } else {
+ setIsClientSideSlugValid(true);
+ }
+
+ setApplicationSlug(normalizedValue);
+ },
+ [dispatch],
+ );
+
+ const shouldShowUrl = applicationSlug && applicationSlug.trim().length > 0;
+ const appUrl = `${window.location.origin}/app/${applicationSlug}`;
+
+ // Get current page details for constructing URLs
+ const currentAppPage = useMemo(
+ () => pages?.find((page) => page.basePageId === currentBasePageId),
+ [pages, currentBasePageId],
+ );
+
+ // Compute modal slugs based on the scenario
+ const modalOldSlug = useMemo(() => {
+ const pageSlug = currentAppPage?.uniqueSlug || currentAppPage?.slug;
+
+ if (modalType === "disable") {
+ // Disabling: show current static URL with current page
+ return `${application?.staticUrlSettings?.uniqueSlug || ""}/${pageSlug}`;
+ } else {
+ const initialPageSlug =
+ currentAppPage?.customSlug || currentAppPage?.slug;
+
+ // Enabling for first time or changing: show legacy format if not enabled yet
+ if (!application?.staticUrlSettings?.enabled) {
+ return `${application?.slug || ""}/${initialPageSlug}-${currentBasePageId}`;
+ }
+
+ // Changing existing static URL: show current static URL with current page
+ return `${application?.staticUrlSettings?.uniqueSlug || ""}/${pageSlug}`;
+ }
+ }, [
+ modalType,
+ application?.staticUrlSettings?.uniqueSlug,
+ application?.slug,
+ application?.staticUrlSettings?.enabled,
+ currentAppPage,
+ ]);
+
+ const modalNewSlug = useMemo(() => {
+ if (modalType === "disable") {
+ const pageSlug = currentAppPage?.customSlug || currentAppPage?.slug;
+
+ // Disabling: show legacy format with current page ID
+ return `${application?.slug || ""}/${pageSlug}-${currentBasePageId}`;
+ } else {
+ const pageSlug = currentAppPage?.uniqueSlug || currentAppPage?.slug;
+
+ // Enabling or changing: show new static URL with current page
+ return `${applicationSlug || ""}/${pageSlug}`;
+ }
+ }, [
+ modalType,
+ applicationSlug,
+ application?.slug,
+ currentAppPage,
+ currentBasePageId,
+ ]);
+
+ const AppUrlContent = () => (
+ <>
+ {window.location.origin}/app/
+
+ {applicationSlug}
+
+ >
+ );
+
+ const handleStaticUrlToggle = useCallback(
+ (isEnabled: boolean) => {
+ if (!isEnabled && isStaticUrlToggleEnabled) {
+ if (application?.staticUrlSettings?.enabled) {
+ // Show confirmation modal when disabling
+ setModalType("disable");
+ setIsStaticUrlConfirmationModalOpen(true);
+ } else {
+ // If the user just toggled it on and immediately turned it off, disable it immediately
+ // since the slug is not persisted yet.
+ setIsStaticUrlToggleEnabled(false);
+ }
+ } else if (isEnabled) {
+ // Enable immediately
+ setIsStaticUrlToggleEnabled(true);
+ dispatch(fetchAppSlugSuggestion(applicationId));
+ }
+ },
+ [
+ dispatch,
+ applicationId,
+ isStaticUrlToggleEnabled,
+ application?.staticUrlSettings?.enabled,
+ ],
+ );
+
+ const handleUrlCopy = useCallback(async () => {
+ await navigator.clipboard.writeText(appUrl);
+ }, [appUrl]);
+
+ const confirmDisableStaticUrl = useCallback(() => {
+ const onSuccess = () => {
+ setIsStaticUrlToggleEnabled(false);
+ setIsStaticUrlConfirmationModalOpen(false);
+ toast.show(createMessage(STATIC_URL_DISABLED_SUCCESS), {
+ kind: "success",
+ });
+ };
+
+ dispatch(disableStaticUrl(onSuccess));
+ }, [dispatch]);
+
+ const applicationSlugErrorMessage = useMemo(() => {
+ if (isFetchingAppSlugSuggestion) return undefined;
+
+ if (!applicationSlug || applicationSlug.trim().length === 0) {
+ return createMessage(GENERAL_SETTINGS_APP_URL_EMPTY_VALUE_MESSAGE);
+ }
+
+ if (!isClientSideSlugValid) {
+ return createMessage(GENERAL_SETTINGS_APP_URL_INVALID_MESSAGE);
+ }
+
+ return undefined;
+ }, [
+ isFetchingAppSlugSuggestion,
+ applicationSlug,
+ isClientSideSlugValid,
+ isApplicationSlugValid,
+ ]);
+
+ const isApplicationSlugInputValid = useMemo(() => {
+ if (isFetchingAppSlugSuggestion) return true;
+
+ return (
+ !!applicationSlug &&
+ applicationSlug.trim().length > 0 &&
+ isClientSideSlugValid &&
+ isApplicationSlugValid
+ );
+ }, [
+ isFetchingAppSlugSuggestion,
+ applicationSlug,
+ isClientSideSlugValid,
+ isApplicationSlugValid,
+ ]);
+
+ const hasSlugChanged = useMemo(() => {
+ return applicationSlug !== application?.staticUrlSettings?.uniqueSlug;
+ }, [applicationSlug, application?.staticUrlSettings?.uniqueSlug]);
+
return (
<>
- {GENERAL_SETTINGS_APP_ICON_LABEL()}
+
+ {createMessage(GENERAL_SETTINGS_APP_ICON_LABEL)}
+
+
+ {isStaticUrlFeatureEnabled && (
+
+
+ Static URL
+
+
+ )}
+
+ {isStaticUrlFeatureEnabled && isStaticUrlToggleEnabled && (
+
+ {isAppSlugSaving &&
}
+
+ {!isFetchingAppSlugSuggestion &&
+ isClientSideSlugValid &&
+ applicationSlug &&
+ applicationSlug.trim().length > 0 &&
+ applicationSlug !== application?.staticUrlSettings?.uniqueSlug && (
+
+ {isValidatingAppSlug ? (
+ <>
+
+
+ {createMessage(GENERAL_SETTINGS_APP_URL_CHECKING_MESSAGE)}
+
+ >
+ ) : isApplicationSlugValid ? (
+ <>
+
+
+ {createMessage(
+ GENERAL_SETTINGS_APP_URL_AVAILABLE_MESSAGE,
+ )}
+
+ >
+ ) : (
+ <>
+
+
+ {createMessage(
+ GENERAL_SETTINGS_APP_URL_UNAVAILABLE_MESSAGE,
+ )}
+
+ >
+ )}
+
+ )}
+ {!isFetchingAppSlugSuggestion &&
+ isApplicationSlugInputValid &&
+ shouldShowUrl && (
+
+ )}
+
+
+
+
+
+ )}
+
+
>
);
}
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/components/PageSettings.tsx b/app/client/src/pages/AppIDE/components/AppSettings/components/PageSettings.tsx
index 8d035e3e9e55..d8765ec25aad 100644
--- a/app/client/src/pages/AppIDE/components/AppSettings/components/PageSettings.tsx
+++ b/app/client/src/pages/AppIDE/components/AppSettings/components/PageSettings.tsx
@@ -1,6 +1,11 @@
import { ApplicationVersion } from "ee/actions/applicationActions";
import type { UpdatePageActionPayload } from "actions/pageActions";
-import { setPageAsDefault, updatePageAction } from "actions/pageActions";
+import {
+ setPageAsDefault,
+ updatePageAction,
+ persistPageSlug,
+ validatePageSlug,
+} from "actions/pageActions";
import {
PAGE_SETTINGS_SHOW_PAGE_NAV,
PAGE_SETTINGS_PAGE_NAME_LABEL,
@@ -14,22 +19,34 @@ import {
PAGE_SETTINGS_SHOW_PAGE_NAV_TOOLTIP,
PAGE_SETTINGS_SET_AS_HOMEPAGE_TOOLTIP_NON_HOME_PAGE,
PAGE_SETTINGS_ACTION_NAME_CONFLICT_ERROR,
+ PAGE_SETTINGS_PAGE_SLUG_CHECKING_MESSAGE,
+ PAGE_SETTINGS_PAGE_SLUG_AVAILABLE_MESSAGE,
+ PAGE_SETTINGS_PAGE_SLUG_UNAVAILABLE_MESSAGE,
+ PAGE_SETTINGS_PAGE_SLUG_DEPLOY_MESSAGE,
+ PAGE_SETTINGS_PAGE_NAME_CONFLICTING_SLUG_MESSAGE,
+ createMessage,
} from "ee/constants/messages";
import type { Page } from "entities/Page";
import classNames from "classnames";
-import { Input, Switch } from "@appsmith/ads";
+import { Input, Switch, Text, Icon } from "@appsmith/ads";
import ManualUpgrades from "components/BottomBar/ManualUpgrades";
import PropertyHelpLabel from "pages/Editor/PropertyPane/PropertyHelpLabel";
-import React, { useCallback, useEffect, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import {
getCurrentApplicationId,
selectApplicationVersion,
+ getIsPersistingPageSlug,
+ getIsValidatingPageSlug,
+ getIsPageSlugValid,
+ getPageList,
+ getIsStaticUrlEnabled,
} from "selectors/editorSelectors";
+import { getCurrentApplication } from "ee/selectors/applicationSelectors";
import { getUpdatingEntity } from "selectors/explorerSelector";
import { getPageLoadingState } from "selectors/pageListSelectors";
-import styled from "styled-components";
import TextLoaderIcon from "./TextLoaderIcon";
+import UrlPreview from "./UrlPreview";
import { filterAccentedAndSpecialCharacters, getUrlPreview } from "../utils";
import type { DefaultRootState } from "react-redux";
import { getUsedActionNames } from "selectors/actionSelectors";
@@ -38,18 +55,10 @@ import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getHasManagePagePermission } from "ee/utils/BusinessFeatures/permissionPageHelpers";
-const UrlPreviewWrapper = styled.div`
- height: 52px;
- color: var(--ads-v2-color-fg);
- border-radius: var(--ads-v2-border-radius);
- background-color: var(--ads-v2-color-bg-subtle);
- line-height: 1.17;
-`;
-
-const UrlPreviewScroll = styled.div`
- height: 48px;
- overflow-y: auto;
-`;
+// Patterns for pageSlug and customSlug from routes: (.*\-) followed by ID
+const PAGE_SLUG_WITH_MONGO_ID = /^.*\-[0-9a-f]{24}$/;
+const PAGE_SLUG_WITH_UUID =
+ /^.*\-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function PageSettings(props: { page: Page }) {
const dispatch = useDispatch();
@@ -57,6 +66,7 @@ function PageSettings(props: { page: Page }) {
const applicationId = useSelector(getCurrentApplicationId);
const applicationVersion = useSelector(selectApplicationVersion);
const isPageLoading = useSelector(getPageLoadingState(page.pageId));
+ const currentApplication = useSelector(getCurrentApplication);
const updatingEntity = useSelector(getUpdatingEntity);
const isUpdatingEntity = updatingEntity === page.pageId;
@@ -65,6 +75,12 @@ function PageSettings(props: { page: Page }) {
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
+ const isStaticUrlFeatureFlagEnabled = useFeatureFlag(
+ FEATURE_FLAG.release_static_url_enabled,
+ );
+ const isStaticUrlEnabled =
+ useSelector(getIsStaticUrlEnabled) && isStaticUrlFeatureFlagEnabled;
+
const [canManagePages, setCanManagePages] = useState(
getHasManagePagePermission(isFeatureEnabled, page?.userPermissions || []),
);
@@ -76,6 +92,18 @@ function PageSettings(props: { page: Page }) {
const [customSlug, setCustomSlug] = useState(page.customSlug);
const [isCustomSlugSaving, setIsCustomSlugSaving] = useState(false);
+ const [staticPageSlug, setStaticPageSlug] = useState(page.uniqueSlug || "");
+ const [staticPageSlugError, setStaticPageSlugError] = useState(
+ null,
+ );
+ const isStaticPageSlugSaving = useSelector((state) =>
+ getIsPersistingPageSlug(state, page.pageId),
+ );
+ const isValidatingPageSlug = useSelector(getIsValidatingPageSlug);
+ const isPageSlugValid = useSelector(getIsPageSlugValid);
+ // TODO Will need to use the right selector pageWithMigratedDsl
+ const pageList = useSelector(getPageList);
+
const [isShown, setIsShown] = useState(!!!page.isHidden);
const [isShownSaving, setIsShownSaving] = useState(false);
@@ -86,29 +114,140 @@ function PageSettings(props: { page: Page }) {
page.pageId,
pageName,
page.pageName,
+ currentApplication?.staticUrlSettings?.uniqueSlug,
+ customSlug,
+ page.customSlug,
+ isStaticUrlEnabled,
+ staticPageSlug,
+ page.uniqueSlug || page.slug,
+ ])(
+ page.pageId,
+ pageName,
+ page.pageName,
+ currentApplication?.staticUrlSettings?.uniqueSlug || "",
customSlug,
page.customSlug,
- ])(page.pageId, pageName, page.pageName, customSlug, page.customSlug);
+ isStaticUrlEnabled,
+ staticPageSlug,
+ page.uniqueSlug || page.slug,
+ );
const conflictingNames = useSelector(
(state: DefaultRootState) => getUsedActionNames(state, ""),
shallowEqual,
);
+ // Determine which validation message to show for page slug
+ const pageSlugValidationMessage = useMemo(() => {
+ // Only show messages if there's a slug and no error
+ if (
+ !staticPageSlug ||
+ staticPageSlug.trim().length === 0 ||
+ staticPageSlugError
+ ) {
+ return null;
+ }
+
+ // Get the deployed page slug from currentApplication.pages
+ const deployedPageSlug = currentApplication?.pages?.find(
+ (p) => p.id === page.pageId,
+ )?.uniqueSlug;
+
+ // Check if saved but not deployed
+ const isSavedButNotDeployed =
+ deployedPageSlug !== page.uniqueSlug &&
+ staticPageSlug === page.uniqueSlug;
+
+ // Check if edited and different from saved
+ const isEditedAndDifferentFromSaved = staticPageSlug !== page.uniqueSlug;
+
+ // If saved but not deployed, show deploy message
+ if (isSavedButNotDeployed) {
+ return {
+ icon: "" as const,
+ color: "var(--ads-v2-color-fg)",
+ message: createMessage(PAGE_SETTINGS_PAGE_SLUG_DEPLOY_MESSAGE),
+ };
+ }
+
+ // If edited and different from saved, show availability check
+ if (isEditedAndDifferentFromSaved) {
+ if (isValidatingPageSlug) {
+ return {
+ icon: "loader-line" as const,
+ color: "var(--ads-v2-color-fg-muted)",
+ message: createMessage(PAGE_SETTINGS_PAGE_SLUG_CHECKING_MESSAGE),
+ };
+ }
+
+ if (isPageSlugValid) {
+ return {
+ icon: "check-line" as const,
+ color: "var(--ads-v2-color-fg-success)",
+ message: createMessage(PAGE_SETTINGS_PAGE_SLUG_AVAILABLE_MESSAGE),
+ };
+ }
+
+ return {
+ icon: "close-line" as const,
+ color: "var(--ads-v2-color-fg-error)",
+ message: createMessage(PAGE_SETTINGS_PAGE_SLUG_UNAVAILABLE_MESSAGE),
+ };
+ }
+
+ return null;
+ }, [
+ staticPageSlug,
+ staticPageSlugError,
+ currentApplication?.pages,
+ page.pageId,
+ page.uniqueSlug,
+ isValidatingPageSlug,
+ isPageSlugValid,
+ ]);
+
const hasActionNameConflict = useCallback(
(name: string) => !isNameValid(name, conflictingNames),
[conflictingNames],
);
+ const validateStaticPageSlug = useCallback((value: string): string | null => {
+ if (!value || value.trim().length === 0) {
+ return null; // Allow empty values
+ }
+
+ // Check if the value matches pageSlug with MongoDB Object ID pattern
+ if (PAGE_SLUG_WITH_MONGO_ID.test(value)) {
+ return "This slug is invalid. It matches a reserved pattern used by the system.";
+ }
+
+ // Check if the value matches pageSlug with UUID pattern
+ if (PAGE_SLUG_WITH_UUID.test(value)) {
+ return "This slug is invalid. It matches a reserved pattern used by the system.";
+ }
+
+ // Any other patterns are valid
+ return null;
+ }, []);
+
useEffect(() => {
setPageName(page.pageName);
setCustomSlug(page.customSlug || "");
+ setStaticPageSlug(page.uniqueSlug || "");
+ setStaticPageSlugError(null); // Clear any validation errors
setIsShown(!!!page.isHidden);
setIsDefault(!!page.isDefault);
setCanManagePages(
getHasManagePagePermission(isFeatureEnabled, page?.userPermissions || []),
);
- }, [page, page.pageName, page.customSlug, page.isHidden, page.isDefault]);
+ }, [
+ page,
+ page.pageName,
+ page.customSlug,
+ page.uniqueSlug,
+ page.isHidden,
+ page.isDefault,
+ ]);
useEffect(() => {
if (!isPageLoading) {
@@ -153,6 +292,26 @@ function PageSettings(props: { page: Page }) {
dispatch(updatePageAction(payload));
}, [page.pageId, page.customSlug, customSlug]);
+ const saveStaticPageSlug = useCallback(() => {
+ if (!canManagePages || page.uniqueSlug === staticPageSlug) return;
+
+ // Don't save if there's a validation error
+ if (staticPageSlugError) return;
+
+ // Don't save if the page slug is not valid
+ if (!isPageSlugValid) return;
+
+ dispatch(persistPageSlug(page.pageId, staticPageSlug || ""));
+ }, [
+ page.pageId,
+ page.uniqueSlug,
+ staticPageSlug,
+ canManagePages,
+ dispatch,
+ staticPageSlugError,
+ isPageSlugValid,
+ ]);
+
const saveIsShown = useCallback(
(isShown: boolean) => {
if (!canManagePages) return;
@@ -168,13 +327,51 @@ function PageSettings(props: { page: Page }) {
[page.pageId, isShown],
);
+ const checkPageNameSlugConflict = useCallback(
+ (pageName: string): boolean => {
+ const filteredValue = filterAccentedAndSpecialCharacters(pageName);
+
+ // Check against existing pages for slug conflicts
+ return pageList.some((existingPage) => {
+ // Skip the current page
+ if (existingPage.pageId === page.pageId) return false;
+
+ // If the existing page has a uniqueSlug, check against it
+ if (
+ existingPage.uniqueSlug &&
+ existingPage.uniqueSlug.trim().length > 0
+ ) {
+ return existingPage.uniqueSlug === filteredValue;
+ }
+
+ // If uniqueSlug is empty or not present, check against slug
+ if (
+ !existingPage.uniqueSlug ||
+ existingPage.uniqueSlug.trim().length === 0
+ ) {
+ return existingPage.slug === filteredValue;
+ }
+
+ return false;
+ });
+ },
+ [pageList, page.pageId],
+ );
+
const onPageNameChange = (value: string) => {
let errorMessage = null;
if (!value || value.trim().length === 0) {
- errorMessage = PAGE_SETTINGS_NAME_EMPTY_MESSAGE();
+ errorMessage = createMessage(PAGE_SETTINGS_NAME_EMPTY_MESSAGE);
} else if (value !== page.pageName && hasActionNameConflict(value)) {
- errorMessage = PAGE_SETTINGS_ACTION_NAME_CONFLICT_ERROR(value);
+ errorMessage = createMessage(
+ PAGE_SETTINGS_ACTION_NAME_CONFLICT_ERROR,
+ value,
+ );
+ } else if (value !== page.pageName && checkPageNameSlugConflict(value)) {
+ errorMessage = createMessage(
+ PAGE_SETTINGS_PAGE_NAME_CONFLICTING_SLUG_MESSAGE,
+ );
}
setPageNameError(errorMessage);
@@ -187,6 +384,23 @@ function PageSettings(props: { page: Page }) {
: setCustomSlug(value);
};
+ const onStaticPageSlugChange = (value: string) => {
+ const normalizedValue =
+ value.length > 0 ? filterAccentedAndSpecialCharacters(value) : value;
+
+ // Validate the normalized value
+ const errorMessage = validateStaticPageSlug(normalizedValue);
+
+ setStaticPageSlugError(errorMessage);
+
+ // If no validation error, call the API to check availability
+ if (!errorMessage && normalizedValue && normalizedValue.trim().length > 0) {
+ dispatch(validatePageSlug(page.pageId, normalizedValue));
+ }
+
+ setStaticPageSlug(normalizedValue);
+ };
+
return (
<>
onPageNameChange(value)}
onKeyPress={(ev: React.KeyboardEvent) => {
@@ -216,82 +430,123 @@ function PageSettings(props: { page: Page }) {
/>
- {appNeedsUpdate && (
+ {!isStaticUrlEnabled && appNeedsUpdate && (
+ )}
+ {!isStaticUrlEnabled && (
+
+ {isCustomSlugSaving && }
+ onPageSlugChange(value)}
+ onKeyPress={(ev: React.KeyboardEvent) => {
+ if (ev.key === "Enter") {
+ saveCustomSlug();
+ }
+ }}
+ placeholder="Page URL"
+ size="md"
+ type="text"
+ value={customSlug}
+ />
)}
-
- {isCustomSlugSaving && }
- onPageSlugChange(value)}
- onKeyPress={(ev: React.KeyboardEvent) => {
- if (ev.key === "Enter") {
- saveCustomSlug();
- }
- }}
- placeholder="Page URL"
- size="md"
- type="text"
- value={customSlug}
- />
-
- {!appNeedsUpdate && (
-
- {
- navigator.clipboard.writeText(
- location.protocol +
- "//" +
- window.location.hostname +
- pathPreview.relativePath,
- );
+ {isStaticUrlEnabled && !appNeedsUpdate && (
+
+ {isStaticPageSlugSaving &&
}
+
onStaticPageSlugChange(value)}
+ onKeyPress={(ev: React.KeyboardEvent) => {
+ if (ev.key === "Enter") {
+ saveStaticPageSlug();
+ }
}}
- style={{ lineHeight: "1.17" }}
- >
- {location.protocol}
- {"//"}
- {window.location.hostname}
- {Array.isArray(pathPreview.splitRelativePath) && (
- <>
- {pathPreview.splitRelativePath[0]}
-
- {pathPreview.splitRelativePath[1]}
-
- {pathPreview.splitRelativePath[2]}
- {pathPreview.splitRelativePath[3]}
- >
- )}
- {!Array.isArray(pathPreview.splitRelativePath) &&
- pathPreview.splitRelativePath}
-
-
+ placeholder="Page slug"
+ size="md"
+ type="text"
+ value={staticPageSlug}
+ />
+ {pageSlugValidationMessage && (
+
+
+
+ {pageSlugValidationMessage.message}
+
+
+ )}
+
+ )}
+
+ {!appNeedsUpdate && (
+ {
+ navigator.clipboard.writeText(
+ location.protocol +
+ "//" +
+ window.location.hostname +
+ pathPreview.relativePath,
+ );
+ }}
+ >
+ {location.protocol}
+ {"//"}
+ {window.location.hostname}
+ {Array.isArray(pathPreview.splitRelativePath) && (
+ <>
+ {pathPreview.splitRelativePath[0]}
+
+ {pathPreview.splitRelativePath[1]}
+
+ {pathPreview.splitRelativePath[2]}
+ {pathPreview.splitRelativePath[3]}
+ >
+ )}
+ {!Array.isArray(pathPreview.splitRelativePath) &&
+ pathPreview.splitRelativePath}
+
)}
@@ -306,10 +561,10 @@ function PageSettings(props: { page: Page }) {
}}
>
@@ -329,13 +584,15 @@ function PageSettings(props: { page: Page }) {
}}
>
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/components/StaticURLConfirmationModal.tsx b/app/client/src/pages/AppIDE/components/AppSettings/components/StaticURLConfirmationModal.tsx
new file mode 100644
index 000000000000..050349fa21e0
--- /dev/null
+++ b/app/client/src/pages/AppIDE/components/AppSettings/components/StaticURLConfirmationModal.tsx
@@ -0,0 +1,154 @@
+import React from "react";
+import {
+ Button,
+ Icon,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ Text,
+} from "@appsmith/ads";
+import styled from "styled-components";
+import UrlPreview from "./UrlPreview";
+
+const StyledModalContent = styled(ModalContent)`
+ &&& {
+ width: 600px;
+ max-height: calc(100vh - 100px);
+ }
+`;
+
+const WarningContainer = styled.div`
+ display: flex;
+ gap: 12px;
+ padding: 12px;
+ background-color: var(--ads-v2-color-bg-warning);
+ border-radius: var(--ads-v2-border-radius);
+ margin-bottom: 16px;
+ align-items: flex-start;
+`;
+
+const StyledWarningIcon = styled(Icon)`
+ color: var(--ads-v2-color-fg-warning);
+ flex-shrink: 0;
+`;
+
+const UrlSection = styled.div`
+ margin-bottom: 16px;
+`;
+
+const UrlLabel = styled(Text)`
+ margin-bottom: 8px;
+ display: block;
+`;
+
+const UrlHighlight = styled.span`
+ font-weight: 600;
+ color: var(--ads-v2-color-fg-emphasis);
+`;
+
+interface StaticURLConfirmationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ baseUrl: string;
+ oldSlug?: string;
+ newSlug?: string;
+ isSaving: boolean;
+ isDisabling?: boolean;
+}
+
+function StaticURLConfirmationModal({
+ baseUrl,
+ isDisabling = false,
+ isOpen,
+ isSaving,
+ newSlug,
+ oldSlug,
+ onClose,
+ onConfirm,
+}: StaticURLConfirmationModalProps) {
+ // Helper to render slug with app slug in bold
+ const renderSlugWithFormatting = (slug?: string) => {
+ if (!slug) return null;
+
+ // Check if slug contains a slash (legacy format: app-slug/page-slug-id)
+ const slashIndex = slug.indexOf("/");
+
+ if (slashIndex > 0) {
+ // Legacy format: bold the app slug, normal for page slug
+ const appSlug = slug.substring(0, slashIndex);
+ const pageSlug = slug.substring(slashIndex);
+
+ return (
+ <>
+ {appSlug}
+ {pageSlug}
+ >
+ );
+ } else {
+ // Static URL format: just the app slug in bold
+ return {slug};
+ }
+ };
+
+ return (
+
+
+
+ {isDisabling ? "Disable App Static URL" : "Change App Slug"}
+
+
+
+
+
+ {isDisabling
+ ? "Disabling Static URL will revert this app to its default Appsmith URLs. These URLs are automatically generated from the app its pages names and identifiers."
+ : "Changing the app slug affects every page and deployed version of this app. The change applies right away and may break existing links."}
+
+
+
+
+ From
+
+ {baseUrl}
+ {renderSlugWithFormatting(oldSlug)}
+
+
+
+
+ To
+
+ {baseUrl}
+ {renderSlugWithFormatting(newSlug)}
+
+
+
+ Are you sure you want to continue?
+
+
+
+
+
+
+
+ );
+}
+
+export default StaticURLConfirmationModal;
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/components/UrlPreview.tsx b/app/client/src/pages/AppIDE/components/AppSettings/components/UrlPreview.tsx
new file mode 100644
index 000000000000..a50ff378071a
--- /dev/null
+++ b/app/client/src/pages/AppIDE/components/AppSettings/components/UrlPreview.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import styled from "styled-components";
+import { Flex } from "@appsmith/ads";
+
+const UrlPreviewWrapper = styled(Flex)`
+ color: var(--ads-v2-color-fg);
+ border-radius: var(--ads-v2-border-radius);
+ background-color: var(--ads-v2-color-bg-subtle);
+ padding: 8px 12px;
+ min-height: 36px;
+ align-items: center;
+`;
+
+const UrlPreviewScroll = styled.div`
+ overflow-y: auto;
+ word-break: break-all;
+ line-height: 1.17;
+ font-size: 12px;
+`;
+
+interface UrlPreviewProps {
+ children: React.ReactNode;
+ className?: string;
+ onCopy?: () => void;
+}
+
+function UrlPreview({ children, className, onCopy }: UrlPreviewProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export default UrlPreview;
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/types.ts b/app/client/src/pages/AppIDE/components/AppSettings/types.ts
index 20edc86c441e..ddb4de5f699f 100644
--- a/app/client/src/pages/AppIDE/components/AppSettings/types.ts
+++ b/app/client/src/pages/AppIDE/components/AppSettings/types.ts
@@ -9,3 +9,12 @@ export interface LogoConfigurationSwitches {
logo: boolean;
applicationTitle: boolean;
}
+
+export enum AppSettingsTabs {
+ General,
+ Embed,
+ Theme,
+ Navigation,
+ Page,
+ Import,
+}
diff --git a/app/client/src/pages/AppIDE/components/AppSettings/utils.ts b/app/client/src/pages/AppIDE/components/AppSettings/utils.ts
index da6758c380b6..748608e098e3 100644
--- a/app/client/src/pages/AppIDE/components/AppSettings/utils.ts
+++ b/app/client/src/pages/AppIDE/components/AppSettings/utils.ts
@@ -1,13 +1,39 @@
import { APP_MODE } from "entities/App";
import urlBuilder from "ee/entities/URLRedirect/URLAssembly";
import { splitPathPreview } from "utils/helpers";
+import { matchPath } from "react-router";
+import { VIEWER_PATH_STATIC } from "constants/routes";
+
+// Custom splitPathPreview function specifically for static URL paths
+const splitPathPreviewForStaticUrl = (url: string): string | string[] => {
+ const staticUrlMatch = matchPath<{
+ staticApplicationSlug: string;
+ staticPageSlug: string;
+ }>(url, VIEWER_PATH_STATIC);
+
+ if (staticUrlMatch?.isExact) {
+ const { staticApplicationSlug, staticPageSlug } = staticUrlMatch.params;
+
+ // Split at the actual page slug position, not just the text
+ const appPart = `/app/${staticApplicationSlug}/`;
+ const pagePart = staticPageSlug;
+
+ return [appPart, pagePart];
+ }
+
+ return url;
+};
export const getUrlPreview = (
pageId: string,
newPageName: string,
currentPageName: string,
+ applicationUniqueSlug: string,
newCustomSlug?: string,
currentCustomSlug?: string,
+ isStaticUrlEnabled?: boolean,
+ newStaticPageSlug?: string,
+ currentPageSlug?: string,
) => {
let relativePath: string;
@@ -17,6 +43,27 @@ export const getUrlPreview = (
(newCustomSlug = filterAccentedAndSpecialCharacters(newCustomSlug));
currentCustomSlug &&
(currentCustomSlug = filterAccentedAndSpecialCharacters(currentCustomSlug));
+ newStaticPageSlug &&
+ (newStaticPageSlug = filterAccentedAndSpecialCharacters(newStaticPageSlug));
+ currentPageSlug &&
+ (currentPageSlug = filterAccentedAndSpecialCharacters(currentPageSlug));
+
+ // when static URL is enabled
+ if (isStaticUrlEnabled) {
+ // Determine the page slug to use: newStaticPageSlug if user has typed something, otherwise currentPageSlug
+ const pageSlugToUse = newStaticPageSlug || currentPageSlug;
+
+ // Generate static URL preview using application uniqueSlug and page slug
+ relativePath = urlBuilder.getStaticUrlPathPreviewWithSlugs(
+ applicationUniqueSlug,
+ pageSlugToUse || newPageName,
+ );
+
+ return {
+ relativePath,
+ splitRelativePath: splitPathPreviewForStaticUrl(relativePath),
+ };
+ }
// when page name is changed
// and when custom slug doesn't exist
diff --git a/app/client/src/pages/AppIDE/layouts/components/Explorer.tsx b/app/client/src/pages/AppIDE/layouts/components/Explorer.tsx
index 98fd20865f22..18e8ebbd22bb 100644
--- a/app/client/src/pages/AppIDE/layouts/components/Explorer.tsx
+++ b/app/client/src/pages/AppIDE/layouts/components/Explorer.tsx
@@ -13,6 +13,7 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
} from "ee/constants/routes/appRoutes";
import SegmentSwitcher from "./SegmentSwitcher/SegmentSwitcher";
import { useSelector } from "react-redux";
@@ -32,6 +33,7 @@ const EditorPaneExplorer = () => {
BUILDER_PATH,
BUILDER_CUSTOM_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
...widgetSegmentRoutes.map((route) => `${path}${route}`),
],
[path],
diff --git a/app/client/src/pages/AppIDE/layouts/routers/RightPane.tsx b/app/client/src/pages/AppIDE/layouts/routers/RightPane.tsx
index 740440968f71..22f33571ea20 100644
--- a/app/client/src/pages/AppIDE/layouts/routers/RightPane.tsx
+++ b/app/client/src/pages/AppIDE/layouts/routers/RightPane.tsx
@@ -5,6 +5,7 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
WIDGETS_EDITOR_BASE_PATH,
WIDGETS_EDITOR_ID_PATH,
} from "constants/routes";
@@ -22,6 +23,7 @@ const RightPane = () => {
BUILDER_PATH_DEPRECATED,
BUILDER_PATH,
BUILDER_CUSTOM_PATH,
+ BUILDER_PATH_STATIC,
`${path}${ADD_PATH}`,
`${path}${WIDGETS_EDITOR_BASE_PATH}`,
`${path}${WIDGETS_EDITOR_ID_PATH}`,
diff --git a/app/client/src/pages/AppIDE/layouts/routers/UISegmentLeftPane/UISegmentLeftPane.tsx b/app/client/src/pages/AppIDE/layouts/routers/UISegmentLeftPane/UISegmentLeftPane.tsx
index 5b117e701998..a2f5d558bded 100644
--- a/app/client/src/pages/AppIDE/layouts/routers/UISegmentLeftPane/UISegmentLeftPane.tsx
+++ b/app/client/src/pages/AppIDE/layouts/routers/UISegmentLeftPane/UISegmentLeftPane.tsx
@@ -6,6 +6,7 @@ import {
BUILDER_CUSTOM_PATH,
BUILDER_PATH,
BUILDER_PATH_DEPRECATED,
+ BUILDER_PATH_STATIC,
WIDGETS_EDITOR_BASE_PATH,
WIDGETS_EDITOR_ID_PATH,
} from "constants/routes";
@@ -50,6 +51,7 @@ const UISegment = () => {
BUILDER_PATH_DEPRECATED,
BUILDER_PATH,
BUILDER_CUSTOM_PATH,
+ BUILDER_PATH_STATIC,
`${path}${WIDGETS_EDITOR_ID_PATH}${ADD_PATH}`,
]}
>
diff --git a/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx b/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx
index be263b91b37a..055ca9579571 100644
--- a/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx
+++ b/app/client/src/pages/AppViewer/AppViewerPageContainer.tsx
@@ -1,15 +1,17 @@
import React, { useMemo } from "react";
-import type { RouteComponentProps } from "react-router-dom";
import { Link, withRouter } from "react-router-dom";
import { useSelector } from "react-redux";
import { getIsFetchingPage } from "selectors/appViewSelectors";
import styled from "styled-components";
-import type { AppViewerRouteParams } from "constants/routes";
import { theme } from "constants/DefaultTheme";
import { Icon, NonIdealState, Spinner } from "@blueprintjs/core";
import Centered from "components/designSystems/appsmith/CenteredWrapper";
import AppPage from "./AppPage";
-import { getCanvasWidth, getCurrentPageName } from "selectors/editorSelectors";
+import {
+ getCanvasWidth,
+ getCurrentPageName,
+ getCurrentBasePageId,
+} from "selectors/editorSelectors";
import RequestConfirmationModal from "pages/Editor/RequestConfirmationModal";
import { getCurrentApplication } from "ee/selectors/applicationSelectors";
import { isPermitted, PERMISSION_TYPE } from "ee/utils/permissionHelpers";
@@ -26,15 +28,13 @@ const Section = styled.section`
overflow-y: auto;
`;
-type AppViewerPageContainerProps = RouteComponentProps;
-
-function AppViewerPageContainer(props: AppViewerPageContainerProps) {
+function AppViewerPageContainer() {
const currentPageName = useSelector(getCurrentPageName);
+ const currentBasePageId = useSelector(getCurrentBasePageId);
const widgetsStructure = useSelector(getCanvasWidgetsStructure, equal);
const canvasWidth = useSelector(getCanvasWidth);
const isFetchingPage = useSelector(getIsFetchingPage);
const currentApplication = useSelector(getCurrentApplication);
- const { match } = props;
// get appsmith editr link
const appsmithEditorLink = useMemo(() => {
@@ -50,7 +50,7 @@ function AppViewerPageContainer(props: AppViewerPageContainerProps) {
Please add widgets to this page in the
Appsmith Editor
@@ -58,7 +58,7 @@ function AppViewerPageContainer(props: AppViewerPageContainerProps) {
);
}
- }, [currentApplication?.userPermissions]);
+ }, [currentApplication?.userPermissions, currentBasePageId]);
const pageNotFound = (
@@ -91,7 +91,7 @@ function AppViewerPageContainer(props: AppViewerPageContainerProps) {
({
...jest.requireActual("react-router-dom"),
useLocation: jest.fn(),
+ useParams: jest.fn(),
}));
jest.mock("../MenuItem.styled", () => ({
@@ -79,6 +80,7 @@ const mockPage: Page = {
isDefault: true,
isHidden: false,
slug: "test-page-1",
+ uniqueSlug: "test-page-1-unique",
};
const mockQuery = "param=value";
@@ -92,6 +94,11 @@ describe("MenuItem Component", () => {
initialState: Partial = {},
currentPathname = "/app/page1_id/section",
appMode?: APP_MODE,
+ useParamsMock?: {
+ staticPageSlug?: string;
+ staticApplicationSlug?: string;
+ basePageId?: string;
+ },
) => {
const testState = getTestState(initialState, appMode);
@@ -138,6 +145,14 @@ describe("MenuItem Component", () => {
pathname: currentPathname,
});
+ (require("react-router-dom").useParams as jest.Mock).mockReturnValue(
+ useParamsMock || {
+ staticPageSlug: undefined,
+ staticApplicationSlug: undefined,
+ basePageId: "base_page1_id",
+ },
+ );
+
// Mock the useNavigateToAnotherPage hook
const useNavigateToAnotherPageMock =
require("../../hooks/useNavigateToAnotherPage").default;
@@ -266,6 +281,36 @@ describe("MenuItem Component", () => {
NAVIGATION_SETTINGS.COLOR_STYLE.LIGHT, // Default
);
});
+
+ it("is marked active for static URL when staticPageSlug matches uniqueSlug", () => {
+ renderComponent(
+ undefined,
+ undefined,
+ "/app/test-app/test-page-1-unique",
+ undefined,
+ {
+ staticPageSlug: "test-page-1-unique",
+ staticApplicationSlug: "test-app",
+ basePageId: "base_page1_id",
+ },
+ );
+ expect(screen.getByTestId("styled-menu-item")).toHaveClass("is-active");
+ });
+
+ it("is not marked active for static URL when staticPageSlug does not match uniqueSlug", () => {
+ renderComponent(
+ undefined,
+ undefined,
+ "/app/test-app/different-page-slug",
+ undefined,
+ {
+ staticPageSlug: "different-page-slug",
+ staticApplicationSlug: "test-app",
+ basePageId: "base_page1_id",
+ },
+ );
+ expect(screen.getByTestId("styled-menu-item")).not.toHaveClass("is-active");
+ });
});
const mockSelectedTheme: AppTheme = {
diff --git a/app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx b/app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx
index 25b2bfc827f9..6a582adcfa64 100644
--- a/app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx
+++ b/app/client/src/pages/AppViewer/Navigation/components/MenuItem/index.tsx
@@ -1,8 +1,8 @@
import { NAVIGATION_SETTINGS } from "constants/AppConstants";
import { get } from "lodash";
-import React, { useMemo } from "react";
+import React, { useCallback, useMemo } from "react";
import { useSelector } from "react-redux";
-import { useLocation } from "react-router-dom";
+import { useLocation, useParams } from "react-router-dom";
import { getSelectedAppTheme } from "selectors/appThemingSelectors";
import { NavigationMethod } from "utils/history";
import useNavigateToAnotherPage from "../../hooks/useNavigateToAnotherPage";
@@ -12,6 +12,11 @@ import type { MenuItemProps } from "./types";
const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => {
const location = useLocation();
+ const params = useParams<{
+ staticPageSlug?: string;
+ staticApplicationSlug?: string;
+ basePageId?: string;
+ }>();
const navigateToAnotherPage = useNavigateToAnotherPage({
basePageId: page.basePageId,
@@ -32,14 +37,30 @@ const MenuItem = ({ navigationSetting, page, query }: MenuItemProps) => {
"inherit",
);
- const isActive = useMemo(
- () => location.pathname.indexOf(page.pageId) > -1,
- [location, page.pageId],
- );
+ const isActive = useMemo(() => {
+ // Check if we're on a static URL route (both staticApplicationSlug and staticPageSlug must be present)
+ const isStaticUrl = !!(
+ params.staticApplicationSlug && params.staticPageSlug
+ );
+
+ if (isStaticUrl) {
+ // For static URLs, check if the staticPageSlug matches the page's uniqueSlug
+ return !!(page.uniqueSlug && page.uniqueSlug === params.staticPageSlug);
+ } else {
+ // For regular URLs, fall back to the older logic using indexOf
+ return location.pathname.indexOf(page.pageId) > -1;
+ }
+ }, [
+ params.staticApplicationSlug,
+ params.staticPageSlug,
+ page.uniqueSlug,
+ page.pageId,
+ location.pathname,
+ ]);
- const handleClick = () => {
+ const handleClick = useCallback(() => {
navigateToAnotherPage();
- };
+ }, [navigateToAnotherPage]);
return (
{
navigateToAnotherPage();
};
- const isActive = useMemo(
- () => location.pathname.indexOf(page.pageId) > -1,
- [location, page.pageId],
- );
+ const isActive = useMemo(() => {
+ // Check if current pathname matches either pageId or uniqueSlug (for static URLs)
+ return (
+ location.pathname.indexOf(page.pageId) > -1 ||
+ (page.uniqueSlug && location.pathname.indexOf(page.uniqueSlug) > -1)
+ );
+ }, [location, page.pageId, page.uniqueSlug]);
return (
{
"inherit",
);
const basePageId = useSelector(getCurrentBasePageId);
+
const editorURL = useHref(builderURL, { basePageId });
return (
diff --git a/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx b/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx
index 63b76d0e0fcc..e242febc9416 100644
--- a/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx
+++ b/app/client/src/pages/AppViewer/PrimaryCTA.test.tsx
@@ -32,6 +32,23 @@ jest.mock("react-redux", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const initialState: any = {
entities: {
+ app: {
+ mode: "PUBLISHED",
+ user: { username: "", email: "", id: "" },
+ URL: {
+ queryParams: {},
+ protocol: "",
+ host: "",
+ hostname: "",
+ port: "",
+ pathname: "",
+ hash: "",
+ fullPath: "",
+ },
+ store: {},
+ geolocation: { canBeRequested: false },
+ workflows: {},
+ },
pageList: {
applicationId: 1,
currentPageId: "0123456789abcdef00000000",
diff --git a/app/client/src/pages/AppViewer/index.tsx b/app/client/src/pages/AppViewer/index.tsx
index df515323ef2a..c0adb9f4d711 100644
--- a/app/client/src/pages/AppViewer/index.tsx
+++ b/app/client/src/pages/AppViewer/index.tsx
@@ -16,6 +16,7 @@ import {
import EditorContextProvider from "components/editorComponents/EditorContextProvider";
import AppViewerPageContainer from "./AppViewerPageContainer";
import {
+ getBasePageIdFromStaticSlug,
getCurrentPageDescription,
getIsAutoLayout,
getPageList,
@@ -100,9 +101,24 @@ function WDSThemeProviderWithTheme({ children }: { children: ReactNode }) {
function AppViewer(props: Props) {
const dispatch = useDispatch();
const { pathname, search } = props.location;
- const { baseApplicationId, basePageId } = props.match.params;
+ const {
+ baseApplicationId,
+ basePageId,
+ staticApplicationSlug,
+ staticPageSlug,
+ } = props.match.params;
const isInitialized = useSelector(getIsInitialized);
const pages = useSelector(getPageList);
+ const resolvedBasePageIdFromSlug = useSelector((state) =>
+ staticPageSlug
+ ? getBasePageIdFromStaticSlug(state, staticPageSlug)
+ : undefined,
+ );
+
+ // Resolve basePageId from staticPageSlug if needed
+ const resolvedBasePageId =
+ !basePageId && staticPageSlug ? resolvedBasePageIdFromSlug : basePageId;
+
const selectedTheme = useSelector(getSelectedAppTheme);
const lightTheme = useSelector((state: DefaultRootState) =>
getThemeDetails(state, ThemeMode.LIGHT),
@@ -113,6 +129,7 @@ function AppViewer(props: Props) {
branch,
location: props.location,
basePageId,
+ resolvedBasePageId,
});
const hideWatermark = useSelector(getHideWatermark);
const pageDescription = useSelector(getCurrentPageDescription);
@@ -139,22 +156,28 @@ function AppViewer(props: Props) {
useEffect(() => {
const prevBranch = prevValues?.branch;
const prevLocation = prevValues?.location;
- const prevPageBaseId = prevValues?.basePageId;
+ const prevPageBaseId = prevValues?.resolvedBasePageId;
let isBranchUpdated = false;
if (prevBranch && prevLocation) {
isBranchUpdated = getIsBranchUpdated(props.location, prevLocation);
}
- const isPageIdUpdated = basePageId !== prevPageBaseId;
+ const isPageIdUpdated = resolvedBasePageId !== prevPageBaseId;
- if (prevBranch && isBranchUpdated && (baseApplicationId || basePageId)) {
+ if (
+ prevBranch &&
+ isBranchUpdated &&
+ (baseApplicationId || resolvedBasePageId)
+ ) {
dispatch(
initAppViewerAction({
baseApplicationId,
branch,
- basePageId,
+ basePageId: resolvedBasePageId || "",
mode: APP_MODE.PUBLISHED,
+ staticApplicationSlug,
+ staticPageSlug,
}),
);
} else {
@@ -163,15 +186,15 @@ function AppViewer(props: Props) {
* If we don't check for `prevPageId`: fetch page is retriggered
* when redirected to the default page
*/
- if (prevPageBaseId && basePageId && isPageIdUpdated) {
+ if (prevPageBaseId && resolvedBasePageId && isPageIdUpdated) {
const pageId = pages.find(
- (page) => page.basePageId === basePageId,
+ (page) => page.basePageId === resolvedBasePageId,
)?.pageId;
if (pageId) {
dispatch(
fetchPublishedPageResources({
- basePageId,
+ basePageId: resolvedBasePageId,
pageId,
branch,
}),
@@ -179,15 +202,28 @@ function AppViewer(props: Props) {
}
}
}
- }, [branch, basePageId, baseApplicationId, pathname]);
+ }, [
+ baseApplicationId,
+ branch,
+ dispatch,
+ pages,
+ prevValues?.resolvedBasePageId,
+ prevValues?.branch,
+ prevValues?.location,
+ props.location,
+ resolvedBasePageId,
+ staticApplicationSlug,
+ staticPageSlug,
+ pathname,
+ ]);
useEffect(() => {
- urlBuilder.setCurrentBasePageId(basePageId);
+ urlBuilder.setCurrentBasePageId(resolvedBasePageId);
return () => {
urlBuilder.setCurrentBasePageId(null);
};
- }, [basePageId]);
+ }, [resolvedBasePageId]);
useEffect(() => {
const header = document.querySelector(".js-appviewer-header");
diff --git a/app/client/src/pages/AppViewer/loader.tsx b/app/client/src/pages/AppViewer/loader.tsx
index a2a4d21b4004..2048e2d83c28 100644
--- a/app/client/src/pages/AppViewer/loader.tsx
+++ b/app/client/src/pages/AppViewer/loader.tsx
@@ -13,7 +13,12 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
type Props = {
initAppViewer: (payload: InitAppViewerPayload) => void;
clearCache: () => void;
-} & RouteComponentProps<{ basePageId: string; baseApplicationId?: string }>;
+} & RouteComponentProps<{
+ basePageId: string;
+ baseApplicationId?: string;
+ staticPageSlug?: string;
+ staticApplicationSlug?: string;
+}>;
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -49,7 +54,12 @@ class AppViewerLoader extends React.PureComponent {
location: { search },
match: { params },
} = this.props;
- const { baseApplicationId, basePageId } = params;
+ const {
+ baseApplicationId,
+ basePageId,
+ staticApplicationSlug,
+ staticPageSlug,
+ } = params;
const branch = getSearchQuery(search, GIT_BRANCH_QUERY_KEY);
// onMount initPage
@@ -59,6 +69,8 @@ class AppViewerLoader extends React.PureComponent {
branch,
basePageId,
mode: APP_MODE.PUBLISHED,
+ staticApplicationSlug,
+ staticPageSlug,
});
}
}
diff --git a/app/client/src/pages/Editor/DataSourceEditor/index.tsx b/app/client/src/pages/Editor/DataSourceEditor/index.tsx
index 6900870ef25e..a34c19573a87 100644
--- a/app/client/src/pages/Editor/DataSourceEditor/index.tsx
+++ b/app/client/src/pages/Editor/DataSourceEditor/index.tsx
@@ -41,6 +41,7 @@ import EntityNotFoundPane from "pages/Editor/EntityNotFoundPane";
import DatasourceSaasForm from "../SaaSEditor/DatasourceForm";
import {
getCurrentApplicationId,
+ getCurrentBasePageId,
selectURLSlugs,
} from "selectors/editorSelectors";
import { saasEditorDatasourceIdURL } from "ee/RouteBuilder";
@@ -102,7 +103,6 @@ import {
import DatasourceTabs from "../DatasourceInfo/DatasorceTabs";
import DatasourceInformation, { ViewModeWrapper } from "./DatasourceSection";
import { convertToPageIdSelector } from "selectors/pageListSelectors";
-import { getApplicationByIdFromWorkspaces } from "ee/selectors/applicationSelectors";
import {
getIsAiAgentApp,
getIsCreatingAgent,
@@ -1125,13 +1125,10 @@ const mapStateToProps = (
props: any,
): ReduxStateProps => {
const applicationId = props.applicationId ?? getCurrentApplicationId(state);
- const application = getApplicationByIdFromWorkspaces(state, applicationId);
- const basePageIdFromUrl = props?.match?.params?.basePageId;
- const pageIdFromUrl = convertToPageIdSelector(state, basePageIdFromUrl);
+ const basePageId = getCurrentBasePageId(state);
+ const pageIdFromUrl = convertToPageIdSelector(state, basePageId);
const pageId = props.pageId || pageIdFromUrl;
- const basePageId =
- application?.pages?.find((page) => page.id === pageId)?.baseId ?? "";
const datasourceId = props.datasourceId ?? props.match?.params?.datasourceId;
const { datasourcePane } = state.ui;
diff --git a/app/client/src/pages/Editor/SaaSEditor/DatasourceForm.tsx b/app/client/src/pages/Editor/SaaSEditor/DatasourceForm.tsx
index a5646e022634..d5ec6b9774b1 100644
--- a/app/client/src/pages/Editor/SaaSEditor/DatasourceForm.tsx
+++ b/app/client/src/pages/Editor/SaaSEditor/DatasourceForm.tsx
@@ -31,6 +31,7 @@ import { JSONtoForm } from "../DataSourceEditor/JSONtoForm";
import { normalizeValues, validate } from "components/formControls/utils";
import {
getCurrentApplicationId,
+ getCurrentBasePageId,
getGsheetProjectID,
getGsheetToken,
} from "selectors/editorSelectors";
@@ -762,7 +763,7 @@ const mapStateToProps = (state: DefaultRootState, props: any) => {
? currentApplicationIdForCreateNewApp
: getCurrentApplicationId(state);
- const basePageId = props.match?.params?.basePageId;
+ const basePageId = getCurrentBasePageId(state);
const pageIdFromUrl = convertToPageIdSelector(state, basePageId);
const pageId = props.pageId || pageIdFromUrl;
diff --git a/app/client/src/pages/Editor/WidgetsEditor/components/LayoutSystemBasedPageViewer.tsx b/app/client/src/pages/Editor/WidgetsEditor/components/LayoutSystemBasedPageViewer.tsx
index 31a8377f3200..f88f90a217dd 100644
--- a/app/client/src/pages/Editor/WidgetsEditor/components/LayoutSystemBasedPageViewer.tsx
+++ b/app/client/src/pages/Editor/WidgetsEditor/components/LayoutSystemBasedPageViewer.tsx
@@ -1,7 +1,7 @@
import SnapShotBannerCTA from "pages/Editor/CanvasLayoutConversion/SnapShotBannerCTA";
import React from "react";
import { MainContainerWrapper } from "./MainContainerWrapper";
-import { AppSettingsTabs } from "pages/AppIDE/components/AppSettings/AppSettings";
+import { AppSettingsTabs } from "pages/AppIDE/components/AppSettings/types";
import { useSelector } from "react-redux";
import {
getCanvasWidth,
diff --git a/app/client/src/reducers/entityReducers/appReducer.ts b/app/client/src/reducers/entityReducers/appReducer.ts
index 63f00a927a9a..f90111f1af6b 100644
--- a/app/client/src/reducers/entityReducers/appReducer.ts
+++ b/app/client/src/reducers/entityReducers/appReducer.ts
@@ -35,6 +35,8 @@ export interface AppDataState {
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows: Record;
+ pageSlug: Record;
+ pageSlugValidation: { isValidating: boolean; isValid: boolean };
}
const initialState: AppDataState = {
@@ -59,6 +61,8 @@ const initialState: AppDataState = {
currentPosition: {},
},
workflows: {},
+ pageSlug: {},
+ pageSlugValidation: { isValidating: false, isValid: true },
};
const appReducer = createReducer(initialState, {
@@ -110,6 +114,84 @@ const appReducer = createReducer(initialState, {
},
};
},
+ [ReduxActionTypes.PERSIST_PAGE_SLUG]: (
+ state: AppDataState,
+ action: ReduxAction<{ pageId: string; slug: string }>,
+ ) => {
+ return {
+ ...state,
+ pageSlug: {
+ ...state.pageSlug,
+ [action.payload.pageId]: {
+ isPersisting: true,
+ isError: false,
+ },
+ },
+ };
+ },
+ [ReduxActionTypes.PERSIST_PAGE_SLUG_SUCCESS]: (
+ state: AppDataState,
+ action: ReduxAction<{ pageId: string; slug: string }>,
+ ) => {
+ return {
+ ...state,
+ pageSlug: {
+ ...state.pageSlug,
+ [action.payload.pageId]: {
+ isPersisting: false,
+ isError: false,
+ },
+ },
+ };
+ },
+ [ReduxActionTypes.PERSIST_PAGE_SLUG_ERROR]: (
+ state: AppDataState,
+ action: ReduxAction<{ pageId: string; slug: string; error: unknown }>,
+ ) => {
+ return {
+ ...state,
+ pageSlug: {
+ ...state.pageSlug,
+ [action.payload.pageId]: {
+ isPersisting: false,
+ isError: true,
+ },
+ },
+ };
+ },
+ [ReduxActionTypes.VALIDATE_PAGE_SLUG]: (state: AppDataState) => {
+ return {
+ ...state,
+ pageSlugValidation: {
+ isValidating: true,
+ isValid: true, // Reset to valid while validating
+ },
+ };
+ },
+ [ReduxActionTypes.VALIDATE_PAGE_SLUG_SUCCESS]: (
+ state: AppDataState,
+ action: ReduxAction<{ slug: string; isValid: boolean }>,
+ ) => {
+ return {
+ ...state,
+ pageSlugValidation: {
+ isValidating: false,
+ isValid: action.payload.isValid,
+ },
+ };
+ },
+ [ReduxActionTypes.VALIDATE_PAGE_SLUG_ERROR]: (
+ state: AppDataState,
+ action: ReduxAction<{ slug: string; isValid: boolean }>,
+ ) => {
+ return {
+ ...state,
+ pageSlugValidation: {
+ isValidating: false,
+ isValid: action.payload.isValid,
+ },
+ };
+ },
});
export default appReducer;
diff --git a/app/client/src/reducers/entityReducers/pageListReducer.tsx b/app/client/src/reducers/entityReducers/pageListReducer.tsx
index c8786f318475..839d7d6e1243 100644
--- a/app/client/src/reducers/entityReducers/pageListReducer.tsx
+++ b/app/client/src/reducers/entityReducers/pageListReducer.tsx
@@ -244,6 +244,19 @@ export const pageListReducer = createReducer(initialState, {
},
};
},
+ [ReduxActionTypes.PERSIST_PAGE_SLUG_SUCCESS]: (
+ state: PageListReduxState,
+ action: ReduxAction<{ pageId: string; slug: string }>,
+ ) => {
+ return {
+ ...state,
+ pages: state.pages.map((page) =>
+ page.pageId === action.payload.pageId
+ ? { ...page, uniqueSlug: action.payload.slug }
+ : page,
+ ),
+ };
+ },
[ReduxActionTypes.SET_GENERATE_PAGE_MODAL_OPEN]: (
state: PageListReduxState,
action: ReduxAction,
diff --git a/app/client/src/reducers/uiReducers/appSettingsPaneReducer.ts b/app/client/src/reducers/uiReducers/appSettingsPaneReducer.ts
index c769ee9fbaa9..8e422064fb48 100644
--- a/app/client/src/reducers/uiReducers/appSettingsPaneReducer.ts
+++ b/app/client/src/reducers/uiReducers/appSettingsPaneReducer.ts
@@ -1,6 +1,6 @@
import type { ReduxAction } from "actions/ReduxActionTypes";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
-import { AppSettingsTabs } from "pages/AppIDE/components/AppSettings/AppSettings";
+import { AppSettingsTabs } from "pages/AppIDE/components/AppSettings/types";
import { createReducer } from "utils/ReducerUtils";
const initialState: AppSettingsPaneReduxState = {
diff --git a/app/client/src/sagas/InitSagas.ts b/app/client/src/sagas/InitSagas.ts
index 83699129de47..6188f2ae23cf 100644
--- a/app/client/src/sagas/InitSagas.ts
+++ b/app/client/src/sagas/InitSagas.ts
@@ -56,9 +56,10 @@ import {
isEditorPath,
isViewerPath,
matchEditorPath,
+ matchViewerPathTyped,
} from "ee/pages/Editor/Explorer/helpers";
import { APP_MODE } from "../entities/App";
-import { GIT_BRANCH_QUERY_KEY, matchViewerPath } from "../constants/routes";
+import { GIT_BRANCH_QUERY_KEY } from "../constants/routes";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { getAppMode } from "ee/selectors/applicationSelectors";
import { getDebuggerErrors } from "selectors/debuggerSelectors";
@@ -209,7 +210,7 @@ export function* reportSWStatus() {
}
}
-function* executeActionDuringUserDetailsInitialisation(
+export function* executeActionDuringUserDetailsInitialisation(
actionType: string,
shouldInitialiseUserDetails?: boolean,
) {
@@ -226,17 +227,25 @@ export function* getInitResponses({
branch,
mode,
shouldInitialiseUserDetails,
+ staticApplicationSlug,
+ staticPageSlug,
}: {
applicationId?: string;
basePageId?: string;
mode?: APP_MODE;
shouldInitialiseUserDetails?: boolean;
branch?: string;
+ staticApplicationSlug?: string;
+ staticPageSlug?: string;
}) {
+ const isStaticPageUrl = staticApplicationSlug && staticPageSlug;
+
const params = {
applicationId,
defaultPageId: basePageId,
branchName: branch,
+ staticApplicationSlug,
+ staticPageSlug,
};
let response: InitConsolidatedApi | undefined;
@@ -248,10 +257,26 @@ export function* getInitResponses({
);
const rootSpan = startRootSpan("fetch-consolidated-api");
+ const consolidatedApiParams = isStaticPageUrl
+ ? {
+ branchName: branch,
+ applicationId: staticApplicationSlug,
+ defaultPageId: staticPageSlug,
+ }
+ : {
+ applicationId,
+ defaultPageId: basePageId,
+ branchName: branch,
+ };
+
const initConsolidatedApiResponse: ApiResponse =
yield mode === APP_MODE.EDIT
- ? ConsolidatedPageLoadApi.getConsolidatedPageLoadDataEdit(params)
- : ConsolidatedPageLoadApi.getConsolidatedPageLoadDataView(params);
+ ? ConsolidatedPageLoadApi.getConsolidatedPageLoadDataEdit(
+ consolidatedApiParams,
+ )
+ : ConsolidatedPageLoadApi.getConsolidatedPageLoadDataView(
+ consolidatedApiParams,
+ );
endSpan(rootSpan);
@@ -310,11 +335,6 @@ export function* getInitResponses({
yield put(getCurrentOrganization(false, organizationConfig));
yield put(fetchProductAlertInit(productAlert));
- yield call(
- executeActionDuringUserDetailsInitialisation,
- ReduxActionTypes.END_CONSOLIDATED_PAGE_LOAD,
- shouldInitialiseUserDetails,
- );
return rest;
}
@@ -325,7 +345,11 @@ export function* startAppEngine(action: ReduxAction) {
pageId: action.payload.basePageId,
applicationId: action.payload.applicationId,
branch: action.payload.branch,
+ staticApplicationSlug: action.payload.staticApplicationSlug,
+ staticPageSlug: action.payload.staticPageSlug,
});
+ const isStaticPageUrl =
+ action.payload.staticApplicationSlug && action.payload.staticPageSlug;
try {
const engine: AppEngine = AppEngineFactory.create(
@@ -347,6 +371,8 @@ export function* startAppEngine(action: ReduxAction) {
endSpan(getInitResponsesSpan);
yield put({ type: ReduxActionTypes.LINT_SETUP });
+
+ // First, load app data to stabilize page states
const { applicationId, toLoadBasePageId, toLoadPageId } = yield call(
engine.loadAppData,
action.payload,
@@ -354,11 +380,20 @@ export function* startAppEngine(action: ReduxAction) {
rootSpan,
);
- yield call(engine.loadAppURL, {
- basePageId: toLoadBasePageId,
- basePageIdInUrl: action.payload.basePageId,
- rootSpan,
- });
+ yield call(
+ executeActionDuringUserDetailsInitialisation,
+ ReduxActionTypes.END_CONSOLIDATED_PAGE_LOAD,
+ action.payload.shouldInitialiseUserDetails,
+ );
+
+ if (!isStaticPageUrl) {
+ // Defer the load actions until after page states are stabilized
+ yield call(engine.loadAppURL, {
+ basePageId: toLoadBasePageId,
+ basePageIdInUrl: action.payload.basePageId,
+ rootSpan,
+ });
+ }
yield call(
engine.loadAppEntities,
@@ -373,6 +408,12 @@ export function* startAppEngine(action: ReduxAction) {
} catch (e) {
log.error(e);
+ yield call(
+ executeActionDuringUserDetailsInitialisation,
+ ReduxActionTypes.END_CONSOLIDATED_PAGE_LOAD,
+ action.payload.shouldInitialiseUserDetails,
+ );
+
if (e instanceof AppEngineApiError) return;
appsmithTelemetry.captureException(e, { errorName: "AppEngineError" });
@@ -485,11 +526,17 @@ function* eagerPageInitSaga() {
if (matchedEditorParams) {
const {
- params: { baseApplicationId, basePageId },
+ params: {
+ baseApplicationId,
+ basePageId,
+ staticApplicationSlug,
+ staticPageSlug,
+ },
} = matchedEditorParams;
const branch = getSearchQuery(search, GIT_BRANCH_QUERY_KEY);
+ const isStaticPageUrl = staticApplicationSlug && staticPageSlug;
- if (basePageId) {
+ if (basePageId || isStaticPageUrl) {
yield put(
initEditorAction({
basePageId,
@@ -497,6 +544,8 @@ function* eagerPageInitSaga() {
branch,
mode: APP_MODE.EDIT,
shouldInitialiseUserDetails: true,
+ staticApplicationSlug,
+ staticPageSlug,
}),
);
@@ -504,15 +553,21 @@ function* eagerPageInitSaga() {
}
}
} else if (isViewerPath(url)) {
- const matchedViewerParams = matchViewerPath(url);
+ const matchedViewerParams = matchViewerPathTyped(url);
if (matchedViewerParams) {
const {
- params: { baseApplicationId, basePageId },
+ params: {
+ baseApplicationId,
+ basePageId,
+ staticApplicationSlug,
+ staticPageSlug,
+ },
} = matchedViewerParams;
const branch = getSearchQuery(search, GIT_BRANCH_QUERY_KEY);
+ const isStaticPageUrl = staticApplicationSlug && staticPageSlug;
- if (baseApplicationId || basePageId) {
+ if (baseApplicationId || basePageId || isStaticPageUrl) {
yield put(
initAppViewerAction({
baseApplicationId,
@@ -520,6 +575,8 @@ function* eagerPageInitSaga() {
basePageId,
mode: APP_MODE.PUBLISHED,
shouldInitialiseUserDetails: true,
+ staticApplicationSlug,
+ staticPageSlug,
}),
);
@@ -533,6 +590,11 @@ function* eagerPageInitSaga() {
shouldInitialiseUserDetails: true,
mode: APP_MODE.PUBLISHED,
});
+ yield call(
+ executeActionDuringUserDetailsInitialisation,
+ ReduxActionTypes.END_CONSOLIDATED_PAGE_LOAD,
+ true,
+ );
} catch (e) {}
}
diff --git a/app/client/src/sagas/__tests__/initSagas.test.ts b/app/client/src/sagas/__tests__/initSagas.test.ts
index c6b2a0393c5e..356205ca9ce6 100644
--- a/app/client/src/sagas/__tests__/initSagas.test.ts
+++ b/app/client/src/sagas/__tests__/initSagas.test.ts
@@ -2,8 +2,11 @@ import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
import { type ReduxAction } from "actions/ReduxActionTypes";
import { APP_MODE } from "entities/App";
import AppEngineFactory from "entities/Engine/factory";
-import { getInitResponses } from "sagas/InitSagas";
-import { startAppEngine } from "sagas/InitSagas";
+import {
+ getInitResponses,
+ startAppEngine,
+ executeActionDuringUserDetailsInitialisation,
+} from "sagas/InitSagas";
import type { AppEnginePayload } from "entities/Engine";
import { testSaga } from "redux-saga-test-plan";
import { generateAutoHeightLayoutTreeAction } from "actions/autoHeightActions";
@@ -70,6 +73,12 @@ describe("tests the sagas in initSagas", () => {
toLoadPageId: pageId,
toLoadBasePageId: basePageId,
})
+ .call(
+ executeActionDuringUserDetailsInitialisation,
+ ReduxActionTypes.END_CONSOLIDATED_PAGE_LOAD,
+ action.payload.shouldInitialiseUserDetails,
+ )
+ .next()
.call(engine.loadAppURL, {
basePageId: action.payload.basePageId,
basePageIdInUrl: action.payload.basePageId,
diff --git a/app/client/src/selectors/appSettingsPaneSelectors.tsx b/app/client/src/selectors/appSettingsPaneSelectors.tsx
index 9b350821ae29..8075e46eed87 100644
--- a/app/client/src/selectors/appSettingsPaneSelectors.tsx
+++ b/app/client/src/selectors/appSettingsPaneSelectors.tsx
@@ -1,4 +1,4 @@
-import { AppSettingsTabs } from "pages/AppIDE/components/AppSettings/AppSettings";
+import { AppSettingsTabs } from "pages/AppIDE/components/AppSettings/types";
import type { DefaultRootState } from "react-redux";
import type { AppSettingsPaneReduxState } from "reducers/uiReducers/appSettingsPaneReducer";
import { createSelector } from "reselect";
diff --git a/app/client/src/selectors/editorSelectors.tsx b/app/client/src/selectors/editorSelectors.tsx
index 5dc52c29b4e9..fdbe693ba807 100644
--- a/app/client/src/selectors/editorSelectors.tsx
+++ b/app/client/src/selectors/editorSelectors.tsx
@@ -176,6 +176,27 @@ export const getPageByBaseId = (basePageId: string) =>
export const getCurrentBasePageId = (state: DefaultRootState) =>
state.entities.pageList.currentBasePageId;
+export const getBasePageIdFromStaticSlug = createSelector(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [getPageList, (_: any, staticPageSlug: string) => staticPageSlug],
+ (pages: Page[], staticPageSlug: string) => {
+ if (!staticPageSlug || !pages.length) {
+ return null;
+ }
+
+ // Find page by matching uniqueSlug property
+ const matchingPage = pages.find(
+ (page) => page.uniqueSlug === staticPageSlug,
+ );
+
+ if (matchingPage) {
+ return matchingPage.basePageId;
+ }
+
+ return null;
+ },
+);
+
export const getCurrentPagePermissions = createSelector(
getCurrentPageId,
getPageList,
@@ -229,6 +250,9 @@ export const selectApplicationVersion = (state: DefaultRootState) =>
state.ui.applications.currentApplication?.applicationVersion ||
ApplicationVersion.DEFAULT;
+export const getIsStaticUrlEnabled = (state: DefaultRootState) =>
+ !!state.ui.applications.currentApplication?.staticUrlSettings?.enabled;
+
export const selectPageSlugById = (pageId: string) =>
createSelector(getPageList, (pages) => {
const page = pages.find((page) => page.pageId === pageId);
@@ -261,6 +285,22 @@ export const getRenderMode = (state: DefaultRootState) => {
export const getIsViewMode = (state: DefaultRootState) =>
state.entities.app.mode === APP_MODE.PUBLISHED;
+export const getIsPersistingPageSlug = (
+ state: DefaultRootState,
+ pageId: string,
+) => state.entities.app.pageSlug[pageId]?.isPersisting || false;
+
+export const getIsErrorPersistingPageSlug = (
+ state: DefaultRootState,
+ pageId: string,
+) => state.entities.app.pageSlug[pageId]?.isError || false;
+
+export const getIsValidatingPageSlug = (state: DefaultRootState) =>
+ state.entities.app.pageSlugValidation.isValidating;
+
+export const getIsPageSlugValid = (state: DefaultRootState) =>
+ state.entities.app.pageSlugValidation.isValid;
+
export const getViewModePageList = createSelector(
getPageList,
getCurrentPageId,
diff --git a/app/client/src/utils/helpers.tsx b/app/client/src/utils/helpers.tsx
index 6e3955aad903..b52fc837a1ac 100644
--- a/app/client/src/utils/helpers.tsx
+++ b/app/client/src/utils/helpers.tsx
@@ -25,6 +25,7 @@ import {
VIEWER_CUSTOM_PATH,
VIEWER_PATH,
VIEWER_PATH_DEPRECATED,
+ VIEWER_PATH_STATIC,
} from "constants/routes";
import history from "./history";
import { APPSMITH_GLOBAL_FUNCTIONS } from "components/editorComponents/ActionCreator/constants";
@@ -1031,6 +1032,13 @@ export const matchPath_ViewerCustomSlug = (path: string) =>
path: trimQueryString(VIEWER_CUSTOM_PATH),
});
+export const matchPath_ViewerStatic = (path: string) =>
+ matchPath<{ staticApplicationSlug: string; staticPageSlug: string }>(path, {
+ path: trimQueryString(VIEWER_PATH_STATIC),
+ strict: false,
+ exact: false,
+ });
+
export const getUpdatedRoute = (
path: string,
params: Record,
@@ -1041,6 +1049,7 @@ export const getUpdatedRoute = (
const matchBuilderCustomPath = matchPath_BuilderCustomSlug(path);
const matchViewerSlugPath = matchPath_ViewerSlug(path);
const matchViewerCustomPath = matchPath_ViewerCustomSlug(path);
+ const matchViewerStaticPath = matchPath_ViewerStatic(path);
/*
* Note: When making changes to the order of these conditions
@@ -1073,6 +1082,13 @@ export const getUpdatedRoute = (
matchViewerCustomPath.params.customSlug,
params,
);
+ } else if (matchViewerStaticPath?.params) {
+ return getUpdateRouteForStaticPath(
+ path,
+ matchViewerStaticPath.params.staticApplicationSlug,
+ matchViewerStaticPath.params.staticPageSlug,
+ params,
+ );
}
return updatedPath;
@@ -1105,6 +1121,20 @@ const getUpdateRouteForSlugPath = (
) => {
let updatedPath = path;
+ // If static slugs are provided, convert to static URL format (remove -basePageId)
+ if (params.staticApplicationSlug && params.staticPageSlug) {
+ // Match the pattern: applicationSlug/pageSlug-basePageId
+ // Replace with: staticApplicationSlug/staticPageSlug (no basePageId)
+ const pattern = `${applicationSlug}/${pageSlug}${params.basePageId}`;
+
+ updatedPath = updatedPath.replace(
+ pattern,
+ `${params.staticApplicationSlug}/${params.staticPageSlug}`,
+ );
+
+ return updatedPath;
+ }
+
if (params.customSlug) {
updatedPath = updatedPath.replace(
`${applicationSlug}/${pageSlug}`,
@@ -1123,6 +1153,30 @@ const getUpdateRouteForSlugPath = (
return updatedPath;
};
+const getUpdateRouteForStaticPath = (
+ path: string,
+ staticApplicationSlug: string,
+ staticPageSlug: string,
+ params: Record,
+) => {
+ let updatedPath = path;
+
+ // Update static application slug if provided
+ if (params.staticApplicationSlug) {
+ updatedPath = updatedPath.replace(
+ staticApplicationSlug,
+ params.staticApplicationSlug,
+ );
+ }
+
+ // Update static page slug if provided
+ if (params.staticPageSlug) {
+ updatedPath = updatedPath.replace(staticPageSlug, params.staticPageSlug);
+ }
+
+ return updatedPath;
+};
+
// to split relative url into array, so specific parts can be bolded on UI preview
export const splitPathPreview = (
url: string,
@@ -1138,6 +1192,11 @@ export const splitPathPreview = (
VIEWER_CUSTOM_PATH,
);
+ const staticUrlMatch = matchPath<{
+ applicationSlug: string;
+ pageSlug: string;
+ }>(url, VIEWER_PATH_STATIC);
+
if (!customSlug && slugMatch?.isExact) {
const { pageSlug } = slugMatch.params;
const splitUrl = url.split(pageSlug);
@@ -1161,6 +1220,13 @@ export const splitPathPreview = (
customSlug.slice(customSlug.length - 1),
);
+ return splitUrl;
+ } else if (staticUrlMatch?.isExact) {
+ const { pageSlug } = staticUrlMatch.params;
+ const splitUrl = url.split(pageSlug);
+
+ splitUrl.splice(1, 0, pageSlug);
+
return splitUrl;
}