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 && (
- {PAGE_SETTINGS_PAGE_URL_VERSION_UPDATE_1()}{" "} + {createMessage(PAGE_SETTINGS_PAGE_URL_VERSION_UPDATE_1)}{" "} - {PAGE_SETTINGS_PAGE_URL_VERSION_UPDATE_2()} + {createMessage(PAGE_SETTINGS_PAGE_URL_VERSION_UPDATE_2)} {" "} - {PAGE_SETTINGS_PAGE_URL_VERSION_UPDATE_3()} + {createMessage(PAGE_SETTINGS_PAGE_URL_VERSION_UPDATE_3)} +
+ )} + {!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; }