diff --git a/cypress-tests/cypress/commands/commands.js b/cypress-tests/cypress/commands/commands.js index 2699b35b70..8cd4fffd62 100644 --- a/cypress-tests/cypress/commands/commands.js +++ b/cypress-tests/cypress/commands/commands.js @@ -506,8 +506,8 @@ Cypress.Commands.add("backToApps", () => { cy.get(commonSelectors.editorPageLogo).click(); cy.get(commonSelectors.backToAppOption).click(); cy.intercept("GET", API_ENDPOINT).as("library_apps"); - cy.get(commonSelectors.homePageLogo, { timeout: 10000 }); cy.wait("@library_apps"); + cy.get(commonSelectors.homePageLogo, { timeout: 10000 }); cy.wait(2000); }); diff --git a/cypress-tests/cypress/constants/selectors/dashboard.js b/cypress-tests/cypress/constants/selectors/dashboard.js index a7e8f1a817..55a06de4a6 100644 --- a/cypress-tests/cypress/constants/selectors/dashboard.js +++ b/cypress-tests/cypress/constants/selectors/dashboard.js @@ -20,7 +20,7 @@ export const dashboardSelector = { changeIconTitle: "[data-cy=change-icon-title]", appCardDefaultIcon: "[data-cy=app-card-apps-icon]", changeButton: "[data-cy=change-button]", - addToFolderTitle: "[data-cy=add-to-folder-title]", + updateFolderTitle: "[data-cy=update-folder-title]", moveAppText: "[data-cy=move-selected-app-to-text]", selectFolder: '[data-cy="select-folder"]>.css-nwhe5y-container > .react-select__control > .react-select__value-container', addToFolderButton: "[data-cy=add-to-folder-button]", diff --git a/cypress-tests/cypress/constants/texts/dashboard.js b/cypress-tests/cypress/constants/texts/dashboard.js index c33b37ab36..54e810c811 100644 --- a/cypress-tests/cypress/constants/texts/dashboard.js +++ b/cypress-tests/cypress/constants/texts/dashboard.js @@ -36,14 +36,14 @@ export const dashboardText = { dragHandleIcon: "drag-handle", }, seeAllAppsTemplateButton: "See all templates", - addToFolderTitle: "Add to folder", + updateFolderTitle: "Update folder", appClonedToast: "App cloned successfully!", darkModeText: "Dark Mode", lightModeText: "Light Mode", dashboardAppsHeaderLabel: "All apps", moveAppText: (appName) => { - return `Move "${appName}" to`; + return `Update ${appName}'s folderto`; }, addToFolderButton: "Add to folder", folderName: (folderName) => { diff --git a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/workspace/workspaceUiTestcases/dashboard.cy.js b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/workspace/workspaceUiTestcases/dashboard.cy.js index c1c6710c43..320f359ef6 100644 --- a/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/workspace/workspaceUiTestcases/dashboard.cy.js +++ b/cypress-tests/cypress/e2e/happyPath/platform/commonTestcases/workspace/workspaceUiTestcases/dashboard.cy.js @@ -224,7 +224,7 @@ describe("dashboard", () => { commonSelectors.appCardOptions(commonText.addToFolderOption) ).click(); verifyModal( - dashboardText.addToFolderTitle, + dashboardText.updateFolderTitle, dashboardText.addToFolderButton, dashboardSelector.selectFolder ); diff --git a/db-git-sync.drawio.svg b/db-git-sync.drawio.svg new file mode 100644 index 0000000000..5d19100970 --- /dev/null +++ b/db-git-sync.drawio.svg @@ -0,0 +1,4 @@ + + + +
selects a branch
Yes
No
check if branch exists on data sources
Switch to brach
load apps 
no import export needed
Pull data sources and apps
Available on local db
User pulls data
Update local db
Not available on local db
Pull branch
How to populate apps on pull from dashboard
Same
Different
Compare SHA-256 hash of last pulled meta.json and new 
Skip app update
Update apps
Data source update
Store meta_hash on app_versions table

Hash Output Size

SHA-256 always produces a fixed 64-character hex string (256 bits), regardless of input size:

function generateJsonHash(data: unknown): string {
const normalized = JSON.stringify(data, Object.keys(data as object).sort());
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
}
Create branch from dashboard
Get the details from main branch connected versions
clone data sources from main version 
Create new rows for data source versions -> insert data
Create new rows for app versions -> Don't sync data
Create branch/draft version from app builder
Clone from version from (no need to get it from git)
Find data source version connected with app version from
clone data for data sources and app
Apps and data sources relations
App type - branch
organization_git_sync_branches - branch available
Connect with data_source_versions -> branch id
App type - saved (tag)
Connect with data_source_versions -> version id
find data sources on same branch
How to list data sources on data sources page
list - removed deleted
How to list data sources on app builder
find data sources on same branch
list - remove deleted
Data query execution
find data query connected from data_queries table 
find app_version_id from data_queries
Yes
No
version is a branch (not saved)
get branch_id from app_versions and connect with data_source_versions on branch_id
connect with data_source_versions on app_version_id
For saved versions for each app - there should be a snapshot of data source saved and connected with app_version_id
For draft version (not branch) - connect with app_version_id
Once draft version converts to branch - remove app_version_id and update branch_id
Data source table changes on saving a version (tag create)
create a new version from main
connect with app_version_id
old branch version still exists
\ No newline at end of file diff --git a/frontend/src/AppBuilder/AppCanvas/PageMenu/PagesSidebarNavigation.jsx b/frontend/src/AppBuilder/AppCanvas/PageMenu/PagesSidebarNavigation.jsx index af6e913b4e..8f5616198d 100644 --- a/frontend/src/AppBuilder/AppCanvas/PageMenu/PagesSidebarNavigation.jsx +++ b/frontend/src/AppBuilder/AppCanvas/PageMenu/PagesSidebarNavigation.jsx @@ -326,21 +326,23 @@ export const PagesSidebarNavigation = ({ } const computedStyles = { - '--nav-item-label-color': !styles.textColor.isDefault ? styles.textColor.value : 'var(--text-placeholder, #6A727C)', - '--nav-item-icon-color': !styles.iconColor.isDefault ? styles.iconColor.value : 'var(--cc-default-icon, #6A727C)', - '--selected-nav-item-label-color': !styles.selectedTextColor.isDefault - ? styles.selectedTextColor.value + '--nav-item-label-color': !styles.textColor?.isDefault + ? styles.textColor?.value + : 'var(--text-placeholder, #6A727C)', + '--nav-item-icon-color': !styles.iconColor?.isDefault ? styles.iconColor?.value : 'var(--cc-default-icon, #6A727C)', + '--selected-nav-item-label-color': !styles.selectedTextColor?.isDefault + ? styles.selectedTextColor?.value : 'var(--cc-primary-text, #1B1F24)', - '--selected-nav-item-icon-color': !styles.selectedIconColor.isDefault - ? styles.selectedIconColor.value + '--selected-nav-item-icon-color': !styles.selectedIconColor?.isDefault + ? styles.selectedIconColor?.value : 'var(--cc-default-icon, #6A727C)', - '--hovered-nav-item-pill-bg': !styles.pillHoverBackgroundColor.isDefault - ? styles.pillHoverBackgroundColor.value + '--hovered-nav-item-pill-bg': !styles.pillHoverBackgroundColor?.isDefault + ? styles.pillHoverBackgroundColor?.value : 'var(--cc-surface2-surface, #F6F8FA)', - '--selected-nav-item-pill-bg': !styles.pillSelectedBackgroundColor.isDefault - ? styles.pillSelectedBackgroundColor.value + '--selected-nav-item-pill-bg': !styles.pillSelectedBackgroundColor?.isDefault + ? styles.pillSelectedBackgroundColor?.value : 'var(--cc-appBackground-surface, #F6F6F6)', - '--nav-item-pill-radius': `${styles.pillRadius.value}px`, + '--nav-item-pill-radius': `${styles.pillRadius?.value}px`, }; const handleSidebarClick = (e) => { diff --git a/frontend/src/AppBuilder/Header/BranchDropdown.jsx b/frontend/src/AppBuilder/Header/BranchDropdown.jsx index cb332829da..9842c28469 100644 --- a/frontend/src/AppBuilder/Header/BranchDropdown.jsx +++ b/frontend/src/AppBuilder/Header/BranchDropdown.jsx @@ -11,6 +11,7 @@ import { Tooltip } from 'react-tooltip'; import { gitSyncService } from '@/_services'; import OverflowTooltip from '@/_components/OverflowTooltip'; import { AlertTriangle } from 'lucide-react'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; export function BranchDropdown({ appId, organizationId }) { const [showDropdown, setShowDropdown] = useState(false); @@ -112,6 +113,16 @@ export function BranchDropdown({ appId, organizationId }) { } }; + // Ensure workspace branch store is initialized (Layout doesn't render in app editor route) + const workspaceActiveBranch = useWorkspaceBranchesStore((state) => state.currentBranch); + const isWsBranchStoreInitialized = useWorkspaceBranchesStore((state) => state.isInitialized); + + useEffect(() => { + if (!isWsBranchStoreInitialized && organizationId) { + useWorkspaceBranchesStore.getState().actions.initialize(organizationId); + } + }, [isWsBranchStoreInitialized, organizationId]); + // Zustand state const { currentBranch, @@ -124,8 +135,10 @@ export function BranchDropdown({ appId, organizationId }) { switchBranch, switchToDefaultBranch, setCurrentBranch, + createBranch, orgGit, selectedVersion, + developmentVersions, } = useStore((state) => ({ currentBranch: state.currentBranch, allBranches: state.allBranches, @@ -137,8 +150,10 @@ export function BranchDropdown({ appId, organizationId }) { switchBranch: state.switchBranch, switchToDefaultBranch: state.switchToDefaultBranch, setCurrentBranch: state.setCurrentBranch, + createBranch: state.createBranch, orgGit: state.orgGit, selectedVersion: state.selectedVersion, + developmentVersions: state.developmentVersions, })); const darkMode = localStorage.getItem('darkMode') === 'true' || false; @@ -168,7 +183,7 @@ export function BranchDropdown({ appId, organizationId }) { } }, [showDropdown]); - // Fetch branches and PRs on mount and when dropdown opens + // Fetch branches and PRs on mount and when branchingEnabled changes useEffect(() => { if (branchingEnabled && appId && organizationId) { handleRefresh(); @@ -176,9 +191,80 @@ export function BranchDropdown({ appId, organizationId }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [branchingEnabled, appId, organizationId]); + // Auto-switch to the correct branch version on initial load. + // Uses branchId from the current version to find the matching branch-type version. + // switchBranch() handles everything: version, environment, editability, banner, push/pull. + const initialBranchSwitchDone = useRef(false); + useEffect(() => { + if (initialBranchSwitchDone.current) return; + if (!branchingEnabled || !appId) return; + if (!developmentVersions?.length) return; + + const isBranchTypeVersionForSwitch = + selectedVersion?.versionType === 'branch' || selectedVersion?.version_type === 'branch'; + + // Already on a branch version — no version switch needed, but sync workspace branch context + // so the header shows the correct branch name and localStorage is up to date. + if (isBranchTypeVersionForSwitch) { + const versionBranchId = selectedVersion?.branchId || selectedVersion?.branch_id; + // allBranches from gitSyncService.getAllBranches has no workspace branch UUID field, + // so we compare workspaceActiveBranch.id against versionBranchId directly. + if (versionBranchId && workspaceActiveBranch?.id !== versionBranchId) { + initialBranchSwitchDone.current = true; + useWorkspaceBranchesStore.getState().actions.switchBranch(versionBranchId); + } + return; + } + + const defaultBranch = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; + const currentVersionBranchId = selectedVersion?.branchId || selectedVersion?.branch_id; + + // Get all branch-type versions + const branchVersions = developmentVersions.filter((v) => v.versionType === 'branch' || v.version_type === 'branch'); + + if (branchVersions.length === 0) return; + + // Determine target branch name: + // 1. If current version has branchId, find the branch version with same branchId + // 2. Fallback: use workspace store's active branch + // 3. Fallback: if exactly one branch version exists, use it + let targetBranchName = null; + + if (currentVersionBranchId) { + const matchByBranchId = branchVersions.find((v) => (v.branchId || v.branch_id) === currentVersionBranchId); + if (matchByBranchId) { + targetBranchName = matchByBranchId.name; + } + } + + if (!targetBranchName && workspaceActiveBranch?.name && workspaceActiveBranch.name !== defaultBranch) { + const matchByWs = branchVersions.find((v) => v.name === workspaceActiveBranch.name); + if (matchByWs) { + targetBranchName = matchByWs.name; + } + } + + // Only fall back to the single branch version when the workspace active branch is + // a feature branch (non-default). On the default/main workspace branch the user is + // intentionally on the canonical version — do not auto-switch. + const isOnDefaultWorkspaceBranch = + !workspaceActiveBranch || workspaceActiveBranch.is_default || workspaceActiveBranch.isDefault; + if (!targetBranchName && branchVersions.length === 1 && !isOnDefaultWorkspaceBranch) { + targetBranchName = branchVersions[0].name; + } + + if (!targetBranchName || targetBranchName === defaultBranch) { + return; + } + + initialBranchSwitchDone.current = true; + switchBranch(appId, targetBranchName).catch((err) => console.error('Branch switch failed:', err?.message || err)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [branchingEnabled, appId, workspaceActiveBranch, developmentVersions, selectedVersion, orgGit, allBranches]); + // Manual fetch last commit function const fetchLastCommit = async () => { - const currentBranchName = selectedVersion?.name || currentBranch?.name; + const currentBranchName = workspaceActiveBranch?.name || selectedVersion?.name || currentBranch?.name; const defaultBranchName = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; const isOnDefaultBranch = currentBranchName === defaultBranchName; @@ -291,16 +377,29 @@ export function BranchDropdown({ appId, organizationId }) { // Check if current branch is the default branch const defaultBranchName = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; - // Use selectedVersion.name as the current branch (ToolJet's version/branch name) - const currentBranchName = selectedVersion?.name || currentBranch?.name; + // Branch-type versions have UUID names (intentional) — never use them as branch display name. + // Use workspace branch name first, then AppBuilder currentBranch, then version name only for non-branch versions. + const isBranchTypeVersion = selectedVersion?.versionType === 'branch' || selectedVersion?.version_type === 'branch'; + const currentBranchName = + workspaceActiveBranch?.name || currentBranch?.name || (isBranchTypeVersion ? undefined : selectedVersion?.name); // Determine if on default branch: - // - If versionType is 'version', we're on a regular version (show default branch UI) - // - If versionType is 'branch', we're on a feature branch (show branch commit UI) - const isOnDefaultBranch = selectedVersion?.versionType === 'version' || selectedVersion?.versionType !== 'branch'; + // For platform git sync: use workspace branch context (all versions have versionType='version') + // For per-app branching: fall back to versionType check + const isOnDefaultBranch = workspaceActiveBranch + ? workspaceActiveBranch.is_default || + workspaceActiveBranch.isDefault || + workspaceActiveBranch.name === defaultBranchName + : selectedVersion?.versionType === 'version' || selectedVersion?.versionType !== 'branch'; - // Display name: show default branch name when on a version, otherwise show current branch name - const displayBranchName = isOnDefaultBranch ? defaultBranchName : currentBranchName; + // Display name: use workspace branch name if available, otherwise derive from version/branch state + const displayBranchName = workspaceActiveBranch?.name || (isOnDefaultBranch ? defaultBranchName : currentBranchName); + + // For platform git sync: the UUID-named branch-type version (currentBranch) has no created_by, + // but the matching human-readable git branch entry in allBranches does. + // Prefer the workspaceActiveBranch name lookup to get the enriched entry with author/time. + const activeBranchInfo = + (workspaceActiveBranch?.name && allBranches.find((b) => b.name === workspaceActiveBranch.name)) || currentBranch; // Filter PRs based on active tab // Check both 'state' and 'status' fields to support different API responses @@ -380,15 +479,15 @@ export function BranchDropdown({ appId, organizationId }) {
{displayBranchName || 'No branch selected'}
- Created by {currentBranch?.created_by || currentBranch?.author || 'Unknown'} + Created by {activeBranchInfo?.created_by || activeBranchInfo?.author || 'Unknown'} {getRelativeTime( - selectedVersion?.createdAt || - selectedVersion?.created_at || - currentBranch?.createdAt || - currentBranch?.created_at + activeBranchInfo?.created_at || + activeBranchInfo?.updated_at || + selectedVersion?.createdAt || + selectedVersion?.created_at )}
@@ -566,20 +665,19 @@ export function BranchDropdown({ appId, organizationId }) { Create new branch - {console.log('BranchDropdown - allBranches:', allBranches, 'length:', allBranches.length) || - (allBranches.length > 0 && ( - - ))} + {allBranches.length > 0 && ( + + )} ) : ( <> diff --git a/frontend/src/AppBuilder/Header/CreateBranchModal.jsx b/frontend/src/AppBuilder/Header/CreateBranchModal.jsx index 21933be537..f6e1286870 100644 --- a/frontend/src/AppBuilder/Header/CreateBranchModal.jsx +++ b/frontend/src/AppBuilder/Header/CreateBranchModal.jsx @@ -1,61 +1,63 @@ import React, { useState, useEffect, useRef } from 'react'; import useStore from '@/AppBuilder/_stores/store'; -import { useVersionManagerStore } from '@/_stores/versionManagerStore'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { workspaceBranchesService } from '@/_services/workspace_branches.service'; +import { gitSyncService } from '@/_services'; import { toast } from 'react-hot-toast'; import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import SolidIcon from '@/_ui/Icon/SolidIcons'; -import { DraftVersionWarningModal } from './DraftVersionWarningModal'; import { Alert } from '@/_ui/Alert'; import AlertDialog from '@/_ui/AlertDialog'; import cx from 'classnames'; import '@/_styles/create-branch-modal.scss'; +const LATEST_MAIN_OPTION = { label: 'Latest (main)', commitSha: null }; + export function CreateBranchModal({ onClose, onSuccess, appId, organizationId }) { const [branchName, setBranchName] = useState(''); - const [createFrom, setCreateFrom] = useState(''); - const [autoCommit, setAutoCommit] = useState(true); + const [selectedOption, setSelectedOption] = useState(LATEST_MAIN_OPTION); const [isCreating, setIsCreating] = useState(false); const [validationError, setValidationError] = useState(''); - const [showDraftWarning, setShowDraftWarning] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [tags, setTags] = useState([]); + const [isLoadingTags, setIsLoadingTags] = useState(true); const dropdownRef = useRef(null); - const { - allBranches, - isDraftVersionActive, - createBranch, - switchBranch, - fetchBranches, - lazyLoadAppVersions, - fetchDevelopmentVersions, - editingVersion, - currentBranch, - releasedVersionId, - } = useStore((state) => ({ + const { allBranches, isDraftVersionActive } = useStore((state) => ({ allBranches: state.allBranches || [], isDraftVersionActive: state.isDraftVersionActive, - createBranch: state.createBranch, - switchBranch: state.switchBranch, - fetchBranches: state.fetchBranches, - lazyLoadAppVersions: state.lazyLoadAppVersions, - fetchDevelopmentVersions: state.fetchDevelopmentVersions, - editingVersion: state.editingVersion, - currentBranch: state.currentBranch, - releasedVersionId: state.releasedVersionId, })); - // Get versions from versionManagerStore - const { versions, fetchVersions } = useVersionManagerStore((state) => ({ - versions: state.versions || [], - fetchVersions: state.fetchVersions, - })); + const workspaceActions = useWorkspaceBranchesStore((state) => state.actions); + const workspaceBranches = useWorkspaceBranchesStore((state) => state.branches); - // Load versions when modal opens - always refresh to get latest versions + // Fetch app-specific tags using existing checkForUpdates API useEffect(() => { - if (appId) { - fetchVersions(appId); + if (!appId) { + setIsLoadingTags(false); + return; } - }, [appId, fetchVersions]); + setIsLoadingTags(true); + gitSyncService + .checkForUpdates(appId) + .then((data) => { + const appTags = data?.meta_data?.tags || []; + setTags( + appTags.map((tag) => { + const [, version] = tag.name.split('/'); + return { + label: version || tag.name, + commitSha: tag.commit?.sha, + }; + }) + ); + }) + .catch((err) => { + console.error('Failed to fetch tags:', err); + setTags([]); + }) + .finally(() => setIsLoadingTags(false)); + }, [appId]); // Close dropdown when clicking outside useEffect(() => { @@ -64,81 +66,33 @@ export function CreateBranchModal({ onClose, onSuccess, appId, organizationId }) setIsDropdownOpen(false); } }; - document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // Get status badge for version - const getVersionStatusBadge = (version) => { - const status = version.status || ''; - // Use releasedVersionId to determine if version is released (same pattern as VersionDropdownItem) - const isReleased = version.id === releasedVersionId; - - if (status === 'DRAFT') { - return { label: 'Draft', className: 'status-badge-draft' }; - } else if (isReleased) { - return { label: 'Released', className: 'status-badge-released' }; - } - return null; - }; - - // Get parent version name for "Created from" text - const getCreatedFromText = (version) => { - if (version.parentVersionId) { - // Look up the parent version to get its name - const parentVersion = versions.find((v) => v.id === version.parentVersionId); - const parentName = parentVersion?.name || `v${version.parentVersionId}`; - return `Created from ${parentName}`; - } - return version.description || ''; - }; - - const selectedVersion = versions.find((v) => v.id === createFrom); - const selectedBadge = selectedVersion ? getVersionStatusBadge(selectedVersion) : null; + const dropdownOptions = [LATEST_MAIN_OPTION, ...tags]; const validateBranchName = (name) => { - if (!name || name.trim().length === 0) { - return 'Branch name is required'; - } - - // No spaces allowed - if (/\s/.test(name)) { - return 'Branch name cannot contain spaces'; - } - - // Only alphanumeric, hyphens, and underscores - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + if (!name || name.trim().length === 0) return 'Branch name is required'; + if (/\s/.test(name)) return 'Branch name cannot contain spaces'; + if (!/^[a-zA-Z0-9_-]+$/.test(name)) return 'Branch name can only contain letters, numbers, hyphens, and underscores'; - } - // Check for uniqueness - const existingBranch = allBranches.find((b) => b.name?.toLowerCase() === name.toLowerCase()); - if (existingBranch) { - return 'A branch with this name already exists'; - } + const existsInWorkspace = workspaceBranches.some((b) => b.name?.toLowerCase() === name.toLowerCase()); + const existsInApp = allBranches.some((b) => b.name?.toLowerCase() === name.toLowerCase()); + if (existsInWorkspace || existsInApp) return 'A branch with this name already exists'; - // Reserved names const reservedNames = ['main', 'master', 'head', 'origin']; - if (reservedNames.includes(name.toLowerCase())) { - return 'This branch name is reserved'; - } - + if (reservedNames.includes(name.toLowerCase())) return 'This branch name is reserved'; return ''; }; const handleBranchNameChange = (e) => { - const newName = e.target.value; - setBranchName(newName); - - // Clear validation error when user starts typing - if (validationError) { - setValidationError(''); - } + setBranchName(e.target.value); + if (validationError) setValidationError(''); }; const handleCreateBranch = async () => { - // Validate branch name const error = validateBranchName(branchName); if (error) { setValidationError(error); @@ -146,58 +100,39 @@ export function CreateBranchModal({ onClose, onSuccess, appId, organizationId }) } setIsCreating(true); - - // Determine the base branch - use current branch name or default to 'main' - const baseBranchName = currentBranch?.name || 'main'; - - const branchData = { - branchName: branchName.trim(), - versionFromId: createFrom, - baseBranch: baseBranchName, - autoCommit: autoCommit, - }; - try { - const result = await createBranch(appId, organizationId, branchData); + // Find the default (main) branch ID to use as source + const defaultBranch = workspaceBranches.find((b) => b.is_default || b.isDefault); + const sourceBranchId = defaultBranch?.id || null; - if (result.success) { - toast.success(`Branch "${branchName}" created successfully`); + const newBranch = await workspaceActions.createBranch( + branchName.trim(), + sourceBranchId, + selectedOption.commitSha || undefined + ); - // Refresh branches list and versions BEFORE switching - // This ensures the new branch version is available when we call switchBranch - await Promise.all([ - fetchBranches(appId, organizationId), - lazyLoadAppVersions(appId), - // Also fetch development versions since the new branch is in Development environment - fetchDevelopmentVersions(appId), - ]); + toast.success('Branch was created successfully'); - console.log('CreateBranchModal - versions refreshed, now switching to branch:', branchName.trim()); + // Switch to the new branch using the backend API to get resolvedAppId + const switchResult = await workspaceBranchesService.switchBranch(newBranch.id, appId); + workspaceActions.switchBranch(newBranch.id); - // Switch to the newly created branch (similar to version creation) - try { - const switchResult = await switchBranch(appId, branchName.trim()); - console.log('CreateBranchModal - switchBranch result:', switchResult); - } catch (switchError) { - console.error('Error switching to new branch:', switchError); - toast.error('Branch created but failed to switch to it'); - } + onClose(); - onSuccess?.(result.data); - onClose(); + // Navigate based on whether app exists on the new branch + const pathParts = window.location.pathname.split('/'); + const resolvedAppId = switchResult?.resolvedAppId; + if (resolvedAppId) { + window.location.href = `/${pathParts[1]}/apps/${resolvedAppId}`; } else { - // Handle specific errors - if (result.error === 'DRAFT_EXISTS') { - setShowDraftWarning(true); - } else { - setValidationError(result.error || 'Failed to create branch'); - toast.error(result.error || 'Failed to create branch'); - } + sessionStorage.setItem('git_sync_toast', 'This app does not exist for this branch on ToolJet'); + window.location.href = `/${pathParts[1]}`; } } catch (error) { console.error('Error creating branch:', error); - setValidationError('An unexpected error occurred'); - toast.error('Failed to create branch'); + const msg = error?.data?.message || error?.message || 'Failed to create branch'; + setValidationError(msg); + toast.error(msg); } finally { setIsCreating(false); } @@ -211,186 +146,130 @@ export function CreateBranchModal({ onClose, onSuccess, appId, organizationId }) } }; - // Set default "Create from" on mount - useEffect(() => { - if (versions.length > 0 && !createFrom) { - // Filter to only version-type versions (exclude branches) - const versionTypeVersions = versions.filter((v) => { - const versionType = v.versionType || v.version_type; - return versionType === 'version'; - }); - - if (versionTypeVersions.length === 0) { - return; // No valid versions to select from - } - - // If editingVersion is a version-type, use it; otherwise use first valid version - if (editingVersion?.id) { - const editingVersionType = editingVersion.versionType || editingVersion.version_type; - if (editingVersionType === 'version') { - setCreateFrom(editingVersion.id); - } else { - // Current editing version is a branch, use first version-type version - setCreateFrom(versionTypeVersions[0].id); - } - } else { - setCreateFrom(versionTypeVersions[0].id); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [versions]); - return ( - <> - -
- {/* Draft warning message */} - {isDraftVersionActive && ( -
- - A draft version exists. Commit or discard it before creating a new branch. -
- )} - - {/* Create from dropdown */} -
- -
- - {isDropdownOpen && ( -
- {versions - .filter((version) => { - // Only show versions with versionType === 'version' (exclude branch-type versions) - const versionType = version.versionType || version.version_type; - return versionType === 'version'; - }) - .map((version) => { - const badge = getVersionStatusBadge(version); - const createdFrom = getCreatedFromText(version); - const isSelected = version.id === createFrom; - - return ( -
{ - setCreateFrom(version.id); - setIsDropdownOpen(false); - }} - > - {isSelected && ( -
- -
- )} - {!isSelected &&
} -
-
- {version.name} - {badge && {badge.label}} -
- {createdFrom &&
{createdFrom}
} -
-
- ); - })} -
- )} -
+ +
+ {/* Draft warning message */} + {isDraftVersionActive && ( +
+ + A draft version exists. Commit or discard it before creating a new branch.
+ )} - {/* Branch name input */} -
- - - {validationError &&
{validationError}
} -
Branch name must be unique and max 50 characters
-
- - {/* Auto-commit checkbox */} -
- -
- - {/* Info message about branch creation */} - - Branch can only be created from master - - - {/* Footer buttons */} -
- - Cancel - - + +
+ + {isDropdownOpen && ( +
+ {dropdownOptions.map((option, idx) => { + const isSelected = option.label === selectedOption.label; + return ( +
{ + setSelectedOption(option); + setIsDropdownOpen(false); + }} + > + {isSelected && ( +
+ +
+ )} + {!isSelected &&
} +
+
+ {option.label} + {!option.commitSha && ( + Default + )} +
+
+
+ ); + })} +
+ )}
- - {/* Draft Version Warning Modal */} - {showDraftWarning && setShowDraftWarning(false)} />} - + {/* Branch name input */} +
+ + + {validationError &&
{validationError}
} +
Branch name must be unique and max 50 characters
+
+ + {/* Auto-commit checkbox */} +
+ +
+ + {/* Info message */} + + Branch can only be created from master + + + {/* Footer buttons */} +
+ + Cancel + + + Create branch + +
+
+ ); } diff --git a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx index 09e88d8983..3ef052e8bb 100644 --- a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx +++ b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx @@ -218,7 +218,11 @@ const CreateVersionModal = ({ versionDescription || `Version ${versionName.trim()} created` ) .catch((error) => { - toast.error(error?.data?.message || 'Tag creation failed'); + const message = error?.data?.message || error?.message || ''; + // Suppress "already exists" — version was saved successfully and was + // already tagged in a previous save. No user action needed. + if (message.toLowerCase().includes('already exist')) return; + toast.error(message || 'Tag creation failed'); }); } diff --git a/frontend/src/AppBuilder/Header/EditAppName.jsx b/frontend/src/AppBuilder/Header/EditAppName.jsx index acaaa7c0f1..dcae80212d 100644 --- a/frontend/src/AppBuilder/Header/EditAppName.jsx +++ b/frontend/src/AppBuilder/Header/EditAppName.jsx @@ -7,9 +7,10 @@ import { shallow } from 'zustand/shallow'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { AppModal } from '@/_components/AppModal'; import { PenLine } from 'lucide-react'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; function EditAppName() { - const { moduleId } = useModuleContext(); + const { moduleId, isModuleEditor } = useModuleContext(); const [appId, appName, setAppName, appCreationMode, selectedVersion, orgGit, appGit] = useStore( (state) => [ state.appStore.modules[moduleId].app.appId, @@ -23,10 +24,18 @@ function EditAppName() { shallow ); + const workspaceActiveBranch = useWorkspaceBranchesStore((state) => state.currentBranch); + const defaultBranchName = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; + const isDraftVersion = selectedVersion?.status === 'DRAFT'; - const isGitSyncEnabled = orgGit?.git_ssh?.is_enabled || orgGit?.git_https?.is_enabled || orgGit?.git_lab?.is_enabled; + const isGitSyncEnabled = + !isModuleEditor && (orgGit?.git_ssh?.is_enabled || orgGit?.git_https?.is_enabled || orgGit?.git_lab?.is_enabled); const isAppCommittedToGit = !!appGit?.id; - const isOnDefaultBranch = selectedVersion?.versionType !== 'branch'; + const isOnDefaultBranch = workspaceActiveBranch + ? workspaceActiveBranch.is_default || + workspaceActiveBranch.isDefault || + workspaceActiveBranch.name === defaultBranchName + : selectedVersion?.versionType !== 'branch'; const isRenameDisabled = !isGitSyncEnabled ? false : !isAppCommittedToGit diff --git a/frontend/src/AppBuilder/Header/EditorHeader.jsx b/frontend/src/AppBuilder/Header/EditorHeader.jsx index f1feeb9076..98108ea194 100644 --- a/frontend/src/AppBuilder/Header/EditorHeader.jsx +++ b/frontend/src/AppBuilder/Header/EditorHeader.jsx @@ -10,6 +10,7 @@ import RightTopHeaderButtons, { PreviewAndShareIcons } from './RightTopHeaderBut import { ModuleEditorBanner } from '@/modules/Modules/components'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { BranchDropdown } from './BranchDropdown'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; import './styles/style.scss'; import SaveIndicator from './SaveIndicator'; @@ -28,6 +29,10 @@ export const EditorHeader = ({ darkMode }) => { shallow ); + const workspaceActiveBranch = useWorkspaceBranchesStore((state) => state.currentBranch); + const isOnWorkspaceFeatureBranch = + workspaceActiveBranch && !workspaceActiveBranch.is_default && !workspaceActiveBranch.isDefault; + return (
@@ -83,8 +88,8 @@ export const EditorHeader = ({ darkMode }) => { <> {} - {/* Hide version dropdown when on a feature branch */} - {selectedVersion?.versionType !== 'branch' && ( + {/* Hide version dropdown when on a feature branch (per-app or platform git sync) */} + {selectedVersion?.versionType !== 'branch' && !isOnWorkspaceFeatureBranch && ( diff --git a/frontend/src/AppBuilder/Header/LifecycleCTAButton.jsx b/frontend/src/AppBuilder/Header/LifecycleCTAButton.jsx index dde2b6b7f9..fc50bff022 100644 --- a/frontend/src/AppBuilder/Header/LifecycleCTAButton.jsx +++ b/frontend/src/AppBuilder/Header/LifecycleCTAButton.jsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button/Button'; import SolidIcon from '@/_ui/Icon/SolidIcons'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { shallow } from 'zustand/shallow'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; /** * LifecycleCTAButton - Dynamic button that shows git operations based on branch type @@ -36,9 +37,14 @@ const LifecycleCTAButton = () => { } // Determine if we're on default branch or feature branch - // - versionType === 'version' means default branch - // - versionType === 'branch' means feature branch - const isOnDefaultBranch = selectedVersion?.versionType === 'version' || selectedVersion?.versionType !== 'branch'; + // For platform git sync: use workspace branch context + // For per-app branching: fall back to versionType check + const workspaceActiveBranch = useWorkspaceBranchesStore((state) => state.currentBranch); + const orgGit = useStore((state) => state.orgGit); + const defaultBranchName = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; + const isOnDefaultBranch = workspaceActiveBranch + ? workspaceActiveBranch.is_default || workspaceActiveBranch.isDefault || workspaceActiveBranch.name === defaultBranchName + : selectedVersion?.versionType === 'version' || selectedVersion?.versionType !== 'branch'; // Determine button state based on git configuration and branch type const getButtonConfig = () => { diff --git a/frontend/src/AppBuilder/Header/LockedBranchBanner.jsx b/frontend/src/AppBuilder/Header/LockedBranchBanner.jsx index 47d858c35d..c7d13a6ff6 100644 --- a/frontend/src/AppBuilder/Header/LockedBranchBanner.jsx +++ b/frontend/src/AppBuilder/Header/LockedBranchBanner.jsx @@ -1,5 +1,6 @@ import React from 'react'; import '@/_styles/locked-branch-banner.scss'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; /** * LockedBranchBanner - Displays a full-width warning banner when viewing a read-only branch @@ -9,22 +10,23 @@ import '@/_styles/locked-branch-banner.scss'; * @param {string} branchName - Name of the locked branch * @param {string} reason - Reason why branch is locked (e.g., "merged", "released") */ -const LockedBranchBanner = ({ isVisible = false, branchName = '', reason = 'merged' }) => { +const LockedBranchBanner = ({ isVisible = false, branchName = '', reason = 'merged', pageContext = '' }) => { if (!isVisible) { return null; } + const pageContextText = pageContext ? `edit ${pageContext}` : ''; const reasonText = reason === 'released' ? 'This branch has been released and is now read-only' : reason === 'main_config_branch' - ? `${branchName} is locked. Create a branch to make edits.` + ? `Master is locked. Create a branch to add or ${pageContextText}.` : 'This branch has been merged and is now read-only'; return (
- + */} +
{reasonText} - {branchName && ( + {/* {branchName && ( Branch: {branchName} - )} + )} */}
diff --git a/frontend/src/AppBuilder/Header/RightTopHeaderButtons/RightTopHeaderButtons.jsx b/frontend/src/AppBuilder/Header/RightTopHeaderButtons/RightTopHeaderButtons.jsx index 648bc97369..a65a759f3a 100644 --- a/frontend/src/AppBuilder/Header/RightTopHeaderButtons/RightTopHeaderButtons.jsx +++ b/frontend/src/AppBuilder/Header/RightTopHeaderButtons/RightTopHeaderButtons.jsx @@ -12,12 +12,15 @@ import PromoteReleaseButton from '@/modules/Appbuilder/components/PromoteRelease import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; const RightTopHeaderButtons = ({ isModuleEditor }) => { - const { selectedVersion, selectedEnvironment } = useStore((state) => ({ + const { moduleId } = useModuleContext(); + const { selectedVersion, selectedEnvironment, creationMode } = useStore((state) => ({ selectedVersion: state.selectedVersion, selectedEnvironment: state.selectedEnvironment, + creationMode: state.appStore.modules[moduleId]?.app?.creationMode, })); const isNotPromotedOrReleased = selectedEnvironment?.name === 'development' && !selectedVersion?.isReleased; + const isWorkspaceGitApp = creationMode === 'GIT'; return (
@@ -25,7 +28,7 @@ const RightTopHeaderButtons = ({ isModuleEditor }) => {
{/* */} - {isNotPromotedOrReleased && } + {(isNotPromotedOrReleased || isWorkspaceGitApp) && } {/* need to review if we need this or not */} {/* {!isModuleEditor && } */}
diff --git a/frontend/src/AppBuilder/Header/SwitchBranchModal.jsx b/frontend/src/AppBuilder/Header/SwitchBranchModal.jsx index db9180b84c..a8236f3337 100644 --- a/frontend/src/AppBuilder/Header/SwitchBranchModal.jsx +++ b/frontend/src/AppBuilder/Header/SwitchBranchModal.jsx @@ -4,6 +4,9 @@ import SolidIcon from '@/_ui/Icon/SolidIcons'; import useStore from '@/AppBuilder/_stores/store'; import { toast } from 'react-hot-toast'; import { CreateBranchModal } from './CreateBranchModal'; +import { workspaceBranchesService } from '@/_services/workspace_branches.service'; +import { setActiveBranch } from '@/_helpers/active-branch'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; import '@/_styles/switch-branch-modal.scss'; export function SwitchBranchModal({ show, onClose, appId, organizationId }) { @@ -14,6 +17,7 @@ export function SwitchBranchModal({ show, onClose, appId, organizationId }) { const { allBranches, selectedVersion, + appName, currentBranch, fetchBranches, switchBranch, @@ -23,9 +27,11 @@ export function SwitchBranchModal({ show, onClose, appId, organizationId }) { lazyLoadAppVersions, fetchDevelopmentVersions, appVersions, + branchingEnabled, } = useStore((state) => ({ allBranches: state.allBranches, selectedVersion: state.selectedVersion, + appName: state.appStore?.modules?.canvas?.app?.appName, currentBranch: state.currentBranch, fetchBranches: state.fetchBranches, switchBranch: state.switchBranch, @@ -35,59 +41,100 @@ export function SwitchBranchModal({ show, onClose, appId, organizationId }) { lazyLoadAppVersions: state.lazyLoadAppVersions, fetchDevelopmentVersions: state.fetchDevelopmentVersions, appVersions: state.appVersions, + branchingEnabled: state.branchingEnabled, })); const defaultBranchName = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; - // Determine current branch name: use selectedVersion.name for branches, or default branch name for versions - const currentBranchName = - selectedVersion?.versionType === 'branch' || selectedVersion?.version_type === 'branch' - ? selectedVersion?.name - : selectedVersion?.versionType === 'version' || selectedVersion?.version_type === 'version' - ? defaultBranchName - : currentBranch?.name || defaultBranchName; + const { workspaceActiveBranch, wsBranches, wsActions } = useWorkspaceBranchesStore((state) => ({ + workspaceActiveBranch: state.currentBranch, + wsBranches: state.branches, + wsActions: state.actions, + })); + + // Determine current branch name: + // For platform git sync: use workspace active branch name + // For per-app branching: use selectedVersion.name for branches, or default branch name for versions + const currentBranchName = workspaceActiveBranch?.name + ? workspaceActiveBranch.name + : selectedVersion?.versionType === 'branch' || selectedVersion?.version_type === 'branch' + ? selectedVersion?.name + : currentBranch?.name || defaultBranchName; useEffect(() => { if (show && appId && organizationId) { setIsLoading(true); - // Fetch branches, versions, and development versions for proper branch switching - Promise.all([ - fetchBranches(appId, organizationId), - lazyLoadAppVersions(appId), - fetchDevelopmentVersions(appId), - ]).finally(() => setIsLoading(false)); + if (branchingEnabled) { + // Platform git sync: fetch workspace branches from DB only (no remote call) + wsActions.fetchBranches().finally(() => setIsLoading(false)); + } else { + // Per-app branching: fetch from remote git + Promise.all([ + fetchBranches(appId, organizationId), + lazyLoadAppVersions(appId), + fetchDevelopmentVersions(appId), + ]).finally(() => setIsLoading(false)); + } } - }, [show, appId, organizationId, fetchBranches, lazyLoadAppVersions, fetchDevelopmentVersions]); + }, [show, appId, organizationId, branchingEnabled, fetchBranches, lazyLoadAppVersions, fetchDevelopmentVersions, wsActions]); - // Filter branches: exclude branches that are version names (versionType === 'version') - const filteredBranches = allBranches.filter((branch) => { - // Apply search filter + // Branch list: workspace branches for platform git sync, per-app branches otherwise + const branchList = branchingEnabled ? wsBranches : allBranches; + const filteredBranches = branchList.filter((branch) => { if (!branch.name.toLowerCase().includes(searchTerm.toLowerCase())) { return false; } - - // Check if this branch name corresponds to a version with versionType === 'version' - // If so, exclude it (it's a version name, not an actual branch) - const isVersionName = appVersions?.some( - (v) => v.name === branch.name && (v.versionType === 'version' || v.version_type === 'version') - ); - - // Show the branch only if it's NOT a version name - return !isVersionName; + // For per-app branching: exclude version names from the list + if (!branchingEnabled) { + const isVersionName = appVersions?.some( + (v) => v.name === branch.name && (v.versionType === 'version' || v.version_type === 'version') + ); + return !isVersionName; + } + return true; }); + const [switchingBranchName, setSwitchingBranchName] = useState(null); + const handleBranchClick = async (branch) => { if (branch.name === currentBranchName) { onClose(); return; } + setSwitchingBranchName(branch.name); try { - // Determine if this is the default branch - const defaultBranchName = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.github_branch || 'main'; - const isDefaultBranch = branch.name === defaultBranchName; + // Platform git sync: use workspace-level switching (navigates to resolved app) + const wsBranches = useWorkspaceBranchesStore.getState().branches; + if (wsBranches?.length > 0) { + const targetWsBranch = wsBranches.find((b) => b.name === branch.name); + if (targetWsBranch) { + const result = await workspaceBranchesService.switchBranch(targetWsBranch.id, appId); + const resolvedAppId = result?.resolvedAppId || result?.resolved_app_id; + // Persist branch to localStorage + update store + const branchObj = targetWsBranch; + setActiveBranch(branchObj); + useWorkspaceBranchesStore.setState({ + activeBranchId: targetWsBranch.id, + currentBranch: branchObj, + }); + // Don't close modal — let the dimmed/spinner state persist until page navigates + const pathParts = window.location.pathname.split('/'); + if (resolvedAppId) { + // Navigate to the corresponding app on the target branch + toast.success(`Switched to ${branch.name}`); + window.location.href = `/${pathParts[1]}/apps/${resolvedAppId}`; + } else { + // App doesn't exist on target branch — go to dashboard + sessionStorage.setItem('git_sync_toast', `${appName || 'This app'} does not exist on this branch`); + window.location.href = `/${pathParts[1]}`; + } + return; + } + } + // Fallback: per-app branch switching (changes version in-place) + const isDefaultBranch = branch.name === defaultBranchName; if (isDefaultBranch) { - // Switch to default branch (finds active draft or latest version) const result = await switchToDefaultBranch(appId, branch.name); if (result.success) { setCurrentBranch(branch); @@ -97,7 +144,6 @@ export function SwitchBranchModal({ show, onClose, appId, organizationId }) { onClose(); } } else { - // Switch to feature branch await switchBranch(appId, branch.name); setCurrentBranch(branch); onClose(); @@ -106,6 +152,7 @@ export function SwitchBranchModal({ show, onClose, appId, organizationId }) { console.error('Error switching branch:', error); const errorMessage = error?.error || error?.message || 'Failed to switch branch'; toast.error(errorMessage); + setSwitchingBranchName(null); } }; @@ -195,10 +242,10 @@ export function SwitchBranchModal({ show, onClose, appId, organizationId }) { {/* Branch List */}
- {isLoading ? ( + {isLoading || switchingBranchName ? (
- Loading branches... + {switchingBranchName ? `Switching to ${switchingBranchName}...` : 'Loading branches...'}
) : filteredBranches.length === 0 ? (
diff --git a/frontend/src/AppBuilder/Header/VersionManager/VersionDropdownItem.jsx b/frontend/src/AppBuilder/Header/VersionManager/VersionDropdownItem.jsx index ed3c55e652..48c22df6ba 100644 --- a/frontend/src/AppBuilder/Header/VersionManager/VersionDropdownItem.jsx +++ b/frontend/src/AppBuilder/Header/VersionManager/VersionDropdownItem.jsx @@ -108,17 +108,19 @@ const VersionDropdownItem = ({
{ e.stopPropagation(); - if (!isDraft) return; // disable when not a draft + // if (!isDraft) return; // disable when not a draft onEdit?.(version); document.body.click(); // Close popover }} - aria-disabled={!isDraft} + // aria-disabled={!isDraft} + aria-disabled={false} data-cy={`${version.name.toLowerCase().replace(/\s+/g, '-')}-edit-version-button`} > Edit details diff --git a/frontend/src/AppBuilder/Header/VersionManager/VersionManagerDropdown.jsx b/frontend/src/AppBuilder/Header/VersionManager/VersionManagerDropdown.jsx index ad11c2fe8f..abeed44e93 100644 --- a/frontend/src/AppBuilder/Header/VersionManager/VersionManagerDropdown.jsx +++ b/frontend/src/AppBuilder/Header/VersionManager/VersionManagerDropdown.jsx @@ -269,7 +269,7 @@ const VersionManagerDropdown = ({ darkMode = false, ...props }) => { }, (error) => { toast.dismiss(deletingToast); - toast.error(error?.message || 'Failed to delete version'); + toast.error(error?.error || error?.message || 'Failed to delete version'); resetDeleteModal(); } ); diff --git a/frontend/src/AppBuilder/Widgets/ModalV2/Components/Header.jsx b/frontend/src/AppBuilder/Widgets/ModalV2/Components/Header.jsx index 16656e0ca3..dcce4c171f 100644 --- a/frontend/src/AppBuilder/Widgets/ModalV2/Components/Header.jsx +++ b/frontend/src/AppBuilder/Widgets/ModalV2/Components/Header.jsx @@ -21,7 +21,6 @@ export const ModalHeader = React.memo( isFullScreen, }) => { const canvasHeaderHeight = getCanvasHeight(headerHeight); - // console.log(headerMaxHeight, 'headerMaxHeight'); return (
diff --git a/frontend/src/AppBuilder/_hooks/useAppData.js b/frontend/src/AppBuilder/_hooks/useAppData.js index 554b20e844..41c97a35ce 100644 --- a/frontend/src/AppBuilder/_hooks/useAppData.js +++ b/frontend/src/AppBuilder/_hooks/useAppData.js @@ -26,7 +26,6 @@ import { useLocation, useParams } from 'react-router-dom'; import { useMounted } from '@/_hooks/use-mount'; import useThemeAccess from './useThemeAccess'; import toast from 'react-hot-toast'; - /** * this is to normalize the query transformation options to match the expected schema. Takes care of corrupted data. * This will get redundanted once api response for appdata is made uniform across all the endpoints. @@ -323,7 +322,7 @@ const useAppData = ( } } - if (mode === 'edit') { + if (mode === 'edit' && editorEnvironment?.id) { constantsResp = await orgEnvironmentConstantService.getConstantsFromEnvironment(editorEnvironment?.id); } // get the constants for specific environment @@ -360,7 +359,7 @@ const useAppData = ( setApp( { - appName: appData.name, + appName: appData.branch_app_name || appData.name, appId: appId || appData?.appId || appData?.app_id, slug: appData.slug, currentAppEnvironmentId: editorEnvironment.id, @@ -556,6 +555,8 @@ const useAppData = ( } if (!moduleMode) { useStore.getState().updateEditingVersion(appData.editing_version?.id || appData.current_version_id); //check if this is needed + // On workspace feature branches, set releasedVersionId to null so that + // selectedVersionId === releasedVersionId doesn't falsely trigger freeze updateReleasedVersionId(appData.current_version_id); } @@ -566,9 +567,9 @@ const useAppData = ( document.title = retrieveWhiteLabelText(); }; }) - .catch((error) => { + .catch((_error) => { + setEditorLoading(false, moduleId); if (moduleMode) { - setEditorLoading(false, moduleId); toast.error('Error fetching module data'); } }); @@ -648,7 +649,7 @@ const useAppData = ( setPreviewData(null); const isReleasedApp = appId && appSlug && !environmentId && !versionId ? true : false; //Condition based on response from validate-private-app-access and validate-released-app-access apis setApp({ - appName: appData.name, + appName: appData.branch_app_name || appData.name, appId: appData.id, slug: appData.slug, creationMode: appData.creationMode, @@ -702,6 +703,9 @@ const useAppData = ( fetchGlobalDataSources(organizationId, currentVersionId, selectedEnvironment.id); setResolvedConstants(orgConstants); setSecrets(orgSecrets); + } else if (isVersionChanged) { + // Re-fetch datasources on version/branch switch (branch may have different active datasources) + fetchGlobalDataSources(organizationId, currentVersionId, selectedEnvironment.id); } const queryData = await dataqueryService.getAll(currentVersionId, mode); diff --git a/frontend/src/AppBuilder/_stores/slices/appVersionSlice.js b/frontend/src/AppBuilder/_stores/slices/appVersionSlice.js index 7e040f260f..a01c8b765a 100644 --- a/frontend/src/AppBuilder/_stores/slices/appVersionSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/appVersionSlice.js @@ -62,11 +62,12 @@ export const createAppVersionSlice = (set, get) => ({ getShouldFreeze: (skipIsEditorFreezedCheck = false, isModuleEditor = false) => { if (isModuleEditor) return false; - return ( - get().isVersionReleased || - (!skipIsEditorFreezedCheck && get().isEditorFreezed) || - get().selectedVersion?.id === get().releasedVersionId - ); + const isVersionReleased = get().isVersionReleased; + const isEditorFreezed = get().isEditorFreezed; + const selectedVersionId = get().selectedVersion?.id; + const releasedVersionId = get().releasedVersionId; + const result = isVersionReleased || (!skipIsEditorFreezedCheck && isEditorFreezed) || selectedVersionId === releasedVersionId; + return result; }, setRestoredAppHistoryId: (id) => { diff --git a/frontend/src/AppBuilder/_stores/slices/branchSlice.js b/frontend/src/AppBuilder/_stores/slices/branchSlice.js index 21b16dca0d..e7a4572aa7 100644 --- a/frontend/src/AppBuilder/_stores/slices/branchSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/branchSlice.js @@ -1,5 +1,7 @@ import { gitSyncService } from '@/_services'; +import { setActiveBranch } from '@/_helpers/active-branch'; import useStore from '@/AppBuilder/_stores/store'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; const initialState = { currentBranch: null, @@ -10,6 +12,7 @@ const initialState = { isLoadingBranches: false, isLoadingPRs: false, branchError: null, + initialAutoSwitchDone: false, }; export const createBranchSlice = (set, get) => ({ @@ -34,9 +37,28 @@ export const createBranchSlice = (set, get) => ({ let defaultBranch = get().currentBranch; if (isOnBranch) { - const matchingBranch = branches.find((b) => b.name === selectedVersion.name); + // Branch-type versions use UUID names — match by branchId first, name as fallback. + const versionBranchId = selectedVersion?.branchId || selectedVersion?.branch_id; + const matchingBranch = + (versionBranchId ? branches.find((b) => b.id === versionBranchId) : null) || + branches.find((b) => b.name === selectedVersion.name); if (matchingBranch) { defaultBranch = matchingBranch; + if (matchingBranch.id) { + // matchingBranch already has a workspace branch UUID — persist directly. + setActiveBranch(matchingBranch); + } else if (versionBranchId) { + // Branches from gitSyncService.getAllBranches don't carry the workspace branch UUID. + // Calling setActiveBranch with a branch that has no id stores { id: undefined } + // in localStorage, which breaks getActiveBranchId() for all subsequent API calls. + // Instead, look up the proper workspace branch from the workspace store. + const wsBranch = useWorkspaceBranchesStore.getState().branches?.find((b) => b.id === versionBranchId); + if (wsBranch) { + setActiveBranch(wsBranch); + } + // If workspace store hasn't loaded yet, leave localStorage unchanged — + // workspaceBranchesStore.initialize() will set it correctly when it completes. + } } else if (!defaultBranch && branches.length) { defaultBranch = branches.find((b) => b.name === 'main') || branches.find((b) => b.name === 'master') || branches[0]; @@ -178,12 +200,19 @@ export const createBranchSlice = (set, get) => ({ return { success: true, data: state.selectedVersion }; } - // Update current branch first + // Update current branch first + mark auto-switch as done to prevent revert const targetBranch = state.allBranches.find((b) => b.name === branchName) || { name: branchName }; + + // Save branch to localStorage (no longer writing to DB) + if (targetBranch.id) { + setActiveBranch(targetBranch); + } + set( (state) => ({ ...state, currentBranch: targetBranch, + initialAutoSwitchDone: true, }), false, 'switchBranch:updating-branch' @@ -269,9 +298,6 @@ export const createBranchSlice = (set, get) => ({ try { const state = get(); - // Get feature branch names (exclude default branch) to help with filtering - const featureBranchNames = state.allBranches.filter((b) => b.name !== defaultBranchName).map((b) => b.name); - // Branches always work in Development environment - ALWAYS use developmentVersions // This matches the PRD scenarios where all branch work happens in Development const developmentVersions = state.developmentVersions || []; @@ -329,9 +355,6 @@ export const createBranchSlice = (set, get) => ({ // If no version found, error if (!targetVersion) { - console.error('switchToDefaultBranch - no versions found!'); - console.error('switchToDefaultBranch - developmentVersions:', developmentVersions); - console.error('switchToDefaultBranch - defaultBranchVersions:', defaultBranchVersions); throw new Error('No versions found for the default branch. Please create a version first.'); } @@ -344,7 +367,6 @@ export const createBranchSlice = (set, get) => ({ // Check if already on this version AND in Development environment const alreadyOnVersion = state.selectedVersion?.id === targetVersion.id; const alreadyInDevelopment = state.selectedEnvironment?.id === developmentEnv.id; - if (alreadyOnVersion && alreadyInDevelopment) { const defaultBranch = state.allBranches.find((b) => b.name === defaultBranchName) || { name: defaultBranchName, @@ -354,6 +376,7 @@ export const createBranchSlice = (set, get) => ({ (state) => ({ ...state, currentBranch: defaultBranch, + initialAutoSwitchDone: true, }), false, 'switchToDefaultBranch:already-on-version' @@ -362,15 +385,21 @@ export const createBranchSlice = (set, get) => ({ return { success: true, data: state.selectedVersion, version: targetVersion }; } - // Update current branch first + // Update current branch first + mark auto-switch as done to prevent revert const defaultBranch = state.allBranches.find((b) => b.name === defaultBranchName) || { name: defaultBranchName, }; + // Save branch to localStorage (no longer writing to DB) + if (defaultBranch.id) { + setActiveBranch(defaultBranch); + } + set( (state) => ({ ...state, currentBranch: defaultBranch, + initialAutoSwitchDone: true, }), false, 'switchToDefaultBranch:updating-branch' @@ -517,6 +546,19 @@ export const createBranchSlice = (set, get) => ({ 'setCurrentBranch' ), + /** + * Set initial auto-switch done flag (survives component remounts) + * @param {boolean} done + */ + setInitialAutoSwitchDone: (done) => + set( + () => ({ + initialAutoSwitchDone: done, + }), + false, + 'setInitialAutoSwitchDone' + ), + /** * Clear branch error */ diff --git a/frontend/src/AppBuilder/_stores/slices/gitSyncSlice.js b/frontend/src/AppBuilder/_stores/slices/gitSyncSlice.js index 05f5630649..2c3d542e09 100644 --- a/frontend/src/AppBuilder/_stores/slices/gitSyncSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/gitSyncSlice.js @@ -11,14 +11,10 @@ const initialState = { }; export const createGitSyncSlice = (set, get) => ({ ...initialState, - toggleGitSyncModal: (creationMode) => { + toggleGitSyncModal: () => { const featureAccess = useStore.getState()?.license?.featureAccess; - const selectedEnvironment = useStore.getState()?.selectedEnvironment; - const isEditorFreezed = useStore.getState()?.isEditorFreezed; - - return featureAccess?.gitSync && selectedEnvironment?.priority === 1 - ? set((state) => ({ showGitSyncModal: !state.showGitSyncModal }), false, 'toggleGitSyncModal') - : () => {}; + if (!featureAccess?.gitSync) return; + set((state) => ({ showGitSyncModal: !state.showGitSyncModal }), false, 'toggleGitSyncModal'); }, fetchAppGit: async (currentOrganizationId, currentAppVersionId) => { set((state) => ({ appLoading: true }), false, 'setAppLoading'); diff --git a/frontend/src/HomePage/AppCard.jsx b/frontend/src/HomePage/AppCard.jsx index c5be717c56..7e48500f35 100644 --- a/frontend/src/HomePage/AppCard.jsx +++ b/frontend/src/HomePage/AppCard.jsx @@ -257,7 +257,7 @@ export default function AppCard({ AppName ); } - + const isStub = app?.app_versions?.[0]?.is_stub; return (
- {(canUpdate || appType === 'module') && ( + {(canUpdate || appType === 'module' || isStub) && (
diff --git a/frontend/src/HomePage/AppMenu.jsx b/frontend/src/HomePage/AppMenu.jsx index e6d41e3c7e..550ba50994 100644 --- a/frontend/src/HomePage/AppMenu.jsx +++ b/frontend/src/HomePage/AppMenu.jsx @@ -80,8 +80,6 @@ export const AppMenu = function AppMenu({ ); }; - console.log('AppMenu render', { appId, canCreateApp, canDeleteApp, canUpdateApp }); - return ( toast.error(gitSyncToast), 500); + } this.handleAiOnboarding(); if (this.props.appType === 'workflow') { if (!this.canViewWorkflow()) { @@ -217,6 +229,14 @@ class HomePageComponent extends React.Component { // Check module access permission this.props.checkModuleAccess(); + // Re-fetch apps when workspace branch changes (client-side branch switching) + this._branchStoreUnsubscribe = useWorkspaceBranchesStore.subscribe((state, prevState) => { + if (state.activeBranchId && state.activeBranchId !== prevState.activeBranchId) { + this.fetchApps(1, this.state.currentFolder.id); + this.fetchFolders(); + } + }); + const hasClosedBanner = localStorage.getItem('hasClosedGroupMigrationBanner'); //Only show the banner once @@ -225,6 +245,12 @@ class HomePageComponent extends React.Component { } } + componentWillUnmount() { + if (this._branchStoreUnsubscribe) { + this._branchStoreUnsubscribe(); + } + } + componentDidUpdate(prevProps, prevState) { if (prevProps.appType != this.props.appType) { this.fetchFolders(); @@ -616,11 +642,13 @@ class HomePageComponent extends React.Component { const id = selectedApp.id; this.setState({ deploying: true }); try { + const { activeBranchId } = useWorkspaceBranchesStore.getState(); const data = await libraryAppService.deploy( id, appName, this.state.dependentPlugins, - this.state.shouldAutoImportPlugin + this.state.shouldAutoImportPlugin, + activeBranchId ); this.setState({ deploying: false, showAIOnboardingLoadingScreen: false }); toast.success(`${this.getAppType()} created successfully!`, { position: 'top-center' }); @@ -767,6 +795,14 @@ class HomePageComponent extends React.Component { return authenticationService.currentSessionValue?.user_permissions?.folder_create; }; + isWorkspaceBranchLocked = () => { + const state = useWorkspaceBranchesStore.getState(); + if (!state.isInitialized || !state.orgGitConfig) return false; + const isBranchingEnabled = state.orgGitConfig?.is_branching_enabled || state.orgGitConfig?.isBranchingEnabled; + const isDefault = state.currentBranch?.is_default || state.currentBranch?.isDefault; + return !!(isBranchingEnabled && isDefault); + }; + cancelDeleteAppDialog = () => { this.setState({ isDeletingApp: false, @@ -833,10 +869,17 @@ class HomePageComponent extends React.Component { }); }; - fetchRepoApps = () => { - this.setState({ fetchingAppsFromRepos: true, selectedAppRepo: null, importingGitAppOperations: {} }); + fetchRepoApps = (branch) => { + this.setState({ + fetchingAppsFromRepos: true, + selectedAppRepo: null, + importingGitAppOperations: {}, + latestCommitData: null, + selectedVersionOption: null, + }); + const currentBranchId = useWorkspaceBranchesStore.getState().activeBranchId; gitSyncService - .gitPull() + .gitPull(branch, currentBranchId) .then((data) => { this.setState({ appsFromRepos: data?.meta_data }); }) @@ -849,7 +892,15 @@ class HomePageComponent extends React.Component { }; importGitApp = () => { - const { appsFromRepos, selectedAppRepo, orgGit, importedAppName, selectedVersionOption, tags } = this.state; + const { + appsFromRepos, + selectedAppRepo, + orgGit, + importedAppName, + selectedVersionOption, + tags, + selectedImportBranch, + } = this.state; const appToImport = appsFromRepos[selectedAppRepo]; const { git_app_name, @@ -884,6 +935,7 @@ class HomePageComponent extends React.Component { } this.setState({ importingApp: true }); + const currentWorkspaceBranchId = useWorkspaceBranchesStore.getState().activeBranchId; const body = { gitAppId: selectedAppRepo, gitAppName: git_app_name, @@ -892,6 +944,8 @@ class HomePageComponent extends React.Component { organizationGitId: orgGit?.id, appName: importedAppName?.trim().replace(/\s+/g, ' '), allowEditing: this.state.isAppImportEditable, + ...(selectedImportBranch && { gitBranchName: selectedImportBranch }), + ...(currentWorkspaceBranchId && { workspaceBranchId: currentWorkspaceBranchId }), ...(commitHash && { commitHash, appCoRelationId: app_co_relation_id }), ...(commitMessage && { lastCommitMessage: commitMessage }), ...(commitUser && { lastCommitUser: commitUser }), @@ -1083,9 +1137,30 @@ class HomePageComponent extends React.Component { handleCommitEnableChange = (e) => { this.setState({ commitEnabled: e.target.checked }); }; - toggleGitRepositoryImportModal = (e) => { - if (!this.state.showGitRepositoryImportModal) this.fetchRepoApps(); - this.setState({ showGitRepositoryImportModal: !this.state.showGitRepositoryImportModal }); + toggleGitRepositoryImportModal = () => { + if (!this.state.showGitRepositoryImportModal) { + // Show modal immediately, then fetch branches in the background + this.setState({ + showGitRepositoryImportModal: true, + fetchingRemoteBranches: true, + selectedImportBranch: null, + appsFromRepos: {}, + selectedAppRepo: null, + importingGitAppOperations: {}, + latestCommitData: null, + selectedVersionOption: null, + }); + useWorkspaceBranchesStore + .getState() + .actions.fetchRemoteBranches() + .then((branches) => this.setState({ remoteBranches: branches || [], fetchingRemoteBranches: false })) + .catch(() => { + toast.error('Failed to fetch remote branches'); + this.setState({ fetchingRemoteBranches: false }); + }); + } else { + this.setState({ showGitRepositoryImportModal: false }); + } }; openCreateAppFromTemplateModal = async (template) => { @@ -1180,6 +1255,11 @@ class HomePageComponent extends React.Component { appType !== 'workflow' ); }; + handleImportBranchChange = (newVal) => { + this.setState({ selectedImportBranch: newVal }); + this.fetchRepoApps(newVal); + }; + handleAppNameChange = (e) => { const newAppName = e.target.value; const { appsFromRepos } = this.state; @@ -1208,7 +1288,7 @@ class HomePageComponent extends React.Component { }; handleAppRepoChange = async (newVal) => { - const { appsFromRepos, orgGit } = this.state; + const { appsFromRepos, selectedImportBranch } = this.state; const selectedApp = appsFromRepos[newVal]; this.setState({ selectedAppRepo: newVal, @@ -1228,17 +1308,15 @@ class HomePageComponent extends React.Component { selectedVersionOption: null, }); } - const selectedBranch = orgGit?.git_https?.github_branch || orgGit?.git_ssh?.git_branch || orgGit?.git_lab_branch; try { - const data = await gitSyncService.checkForUpdatesByAppName(selectedApp?.git_app_name, selectedBranch); + const data = await gitSyncService.checkForUpdatesByAppName(selectedApp?.git_app_name, selectedImportBranch); this.setState({ latestCommitData: data?.metaData, tags: data?.metaData.tags, fetchingLatestCommitData: false, selectedVersionOption: 'latest', }); - // TODO: Handle the response data } catch (error) { console.error('Failed to check for updates:', error); this.setState({ fetchingLatestCommitData: false }); @@ -1394,6 +1472,9 @@ class HomePageComponent extends React.Component { importingApp, selectedVersionOption, importingGitAppOperations, + selectedImportBranch, + remoteBranches, + fetchingRemoteBranches, featuresLoaded, showCreateAppModal, showImportAppModal, @@ -1504,6 +1585,7 @@ class HomePageComponent extends React.Component { return (
+ {/* */} {/* this needs more revamp and conditions---> currently added this for testing*/} {showInsufficentPermissionModal && ( )} + this.setState({ showSwitchBranchForCreate: false })} + onBranchSwitch={() => { + this.fetchApps(1, this.state.currentFolder.id); + this.setState({ showSwitchBranchForCreate: false, showCreateAppModal: true }); + }} + /> - {fetchingAppsFromRepos ? ( + {fetchingRemoteBranches ? (
) : ( <> + {/* BRANCH SELECT */}
-
+ + {selectedAppRepo && ( +
+ {/* APP NAME */} +
+ +
+ +
+
+
+ {importingGitAppOperations?.message} +
+
+
+ + {/* EDITABLE CHECKBOX */} +
+ + this.setState((prevState) => ({ isAppImportEditable: !prevState.isAppImportEditable })) + } + /> + Make application editable +
+
+
+ Enabling this allows editing and git sync push/pull access in development. +
+
+
+ + {/* VERSION/TAG SELECT */} +
+ +
+ localStorage.removeItem(key)); + } catch { + // ignore localStorage errors + } +} diff --git a/frontend/src/_helpers/auth-header.js b/frontend/src/_helpers/auth-header.js index cae96e2c12..a70ba99b5e 100644 --- a/frontend/src/_helpers/auth-header.js +++ b/frontend/src/_helpers/auth-header.js @@ -1,6 +1,7 @@ import { authenticationService } from '@/_services'; import { handleUnSubscription } from './utils'; import { getPatToken } from '@/AppBuilder/EmbedApp'; +import { getActiveBranchId } from './active-branch'; export function authHeader(isMultipartData = false, current_organization_id) { let session = authenticationService.currentSessionValue; @@ -15,6 +16,8 @@ export function authHeader(isMultipartData = false, current_organization_id) { const wid = current_organization_id || session?.current_organization_id; + const branchId = getActiveBranchId(); + const headers = { ...(!isMultipartData && { 'Content-Type': 'application/json', @@ -22,6 +25,9 @@ export function authHeader(isMultipartData = false, current_organization_id) { ...(wid && { 'tj-workspace-id': wid, }), + ...(branchId && { + 'x-branch-id': branchId, + }), }; // ✅ Explicitly remove PAT on login or logout routes diff --git a/frontend/src/_helpers/constants.js b/frontend/src/_helpers/constants.js index df899d3617..91893a807b 100644 --- a/frontend/src/_helpers/constants.js +++ b/frontend/src/_helpers/constants.js @@ -38,6 +38,7 @@ export const ERROR_TYPES = { USERS_EXCEEDING_LICENSE_LIMIT: 'user-count-exceeding', WORKSPACE_LOGIN_RESTRICTED: 'ws-login-restricted', RESTRICTED_PREVIEW: 'restricted-preview', + APP_NOT_ON_BRANCH: 'app-not-on-branch', }; export const ERROR_MESSAGES = { @@ -89,6 +90,14 @@ export const ERROR_MESSAGES = { cta: 'Back to home page', queryParams: [], }, + 'app-not-on-branch': { + title: 'App not available', + message: + 'This app is not available on the current branch. Switch to the correct branch or go back to the dashboard.', + cta: 'Back to home page', + retry: false, + queryParams: [], + }, 'no-active-workspace': { title: 'No active workspaces', message: 'No active workspace were found for this user. Kindly contact admin to know more.', diff --git a/frontend/src/_helpers/handle-response.js b/frontend/src/_helpers/handle-response.js index e84eeb5db6..4703b17bfb 100644 --- a/frontend/src/_helpers/handle-response.js +++ b/frontend/src/_helpers/handle-response.js @@ -4,7 +4,7 @@ import LegalReasonsErrorModal from '../_components/LegalReasonsErrorModal'; import SolidIcon from '../_ui/Icon/SolidIcons'; import { copyToClipboard } from '@/_helpers/appUtils'; import { sessionService } from '@/_services'; -import { redirectToSwitchOrArchivedAppPage } from './routes'; +import { redirectToSwitchOrArchivedAppPage, redirectToErrorPage } from './routes'; import { handleError } from './handleAppAccess'; import { fetchEdition } from '@/modules/common/helpers/utils'; import { ERROR_TYPES } from './constants'; @@ -103,6 +103,8 @@ export function handleResponse( } } else if ([400].indexOf(response.status) !== -1) { redirectToSwitchOrArchivedAppPage(data); + } else if (response.status === 404 && data?.message === 'App is not available on this branch') { + redirectToErrorPage(ERROR_TYPES.APP_NOT_ON_BRANCH); } const error = (data && data.message) || response.statusText; return Promise.reject({ error, data, statusCode: response?.status }); diff --git a/frontend/src/_helpers/utils.js b/frontend/src/_helpers/utils.js index bad050be6c..3eecc528ba 100644 --- a/frontend/src/_helpers/utils.js +++ b/frontend/src/_helpers/utils.js @@ -1172,7 +1172,7 @@ export const deepEqual = (obj1, obj2, excludedKeys = []) => { if (!deepEqual(obj1[key], obj2[key], excludedKeys)) { return false; } - } else if (obj1[key] != obj2[key]) { + } else if (obj1[key] !== obj2[key]) { return false; } } diff --git a/frontend/src/_services/app.service.js b/frontend/src/_services/app.service.js index ecc5d198d7..9890cf49fc 100644 --- a/frontend/src/_services/app.service.js +++ b/frontend/src/_services/app.service.js @@ -1,5 +1,6 @@ import config from 'config'; import { authHeader, handleResponse, handleResponseWithoutValidation } from '@/_helpers'; +import { getActiveBranchId } from '@/_helpers/active-branch'; export const appService = { getConfig, @@ -51,7 +52,10 @@ function createApp(body = {}) { if (body.type === 'workflow') { return createWorkflow(body); } - const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(body) }; + // Include active branch ID so backend creates the app on the correct branch + const branchId = getActiveBranchId(); + const payload = { ...body, ...(branchId && { branchId }) }; + const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(payload) }; return fetch(`${config.apiUrl}/apps`, requestOptions).then(handleResponse); } diff --git a/frontend/src/_services/appVersion.service.js b/frontend/src/_services/appVersion.service.js index 71e121c8b2..42ef19774f 100644 --- a/frontend/src/_services/appVersion.service.js +++ b/frontend/src/_services/appVersion.service.js @@ -118,17 +118,6 @@ function autoSaveApp( isUserSwitchedVersion = false, isComponentCutProcess = false ) { - // console.log('autoSaveApp-->', { - // appId, - // versionId, - // diff, - // type, - // pageId, - // operation, - // isUserSwitchedVersion, - // isComponentCutProcess, - // }); - const OPERATION = { create: 'POST', update: 'PUT', diff --git a/frontend/src/_services/apps.service.js b/frontend/src/_services/apps.service.js index dd2301c58b..59fc4c8e4b 100644 --- a/frontend/src/_services/apps.service.js +++ b/frontend/src/_services/apps.service.js @@ -1,5 +1,6 @@ import config from 'config'; import { authHeader, handleResponse } from '@/_helpers'; +import { getActiveBranchId } from '@/_helpers/active-branch'; import queryString from 'query-string'; export const appsService = { @@ -59,10 +60,12 @@ function validatePrivateApp(slug, queryParams) { //use default value for type of apps i.e.'front-end' function getAll(page, folder, searchKey, type = 'front-end') { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; - if (page === 0) return fetch(`${config.apiUrl}/apps?type=${type}`, requestOptions).then(handleResponse); + const branchId = getActiveBranchId(); + const branchParam = branchId ? `&branch_id=${branchId}` : ''; + if (page === 0) return fetch(`${config.apiUrl}/apps?type=${type}${branchParam}`, requestOptions).then(handleResponse); else return fetch( - `${config.apiUrl}/apps?page=${page}&folder=${folder || ''}&searchKey=${searchKey}&type=${type}`, + `${config.apiUrl}/apps?page=${page}&folder=${folder || ''}&searchKey=${searchKey}&type=${type}${branchParam}`, requestOptions ).then(handleResponse); } @@ -70,10 +73,14 @@ function getAll(page, folder, searchKey, type = 'front-end') { // get all addable apps function getAllAddableApps() { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; - return fetch(`${config.apiUrl}/apps/addable`, requestOptions).then(handleResponse); + const branchId = getActiveBranchId(); + const branchParam = branchId ? `?branch_id=${branchId}` : ''; + return fetch(`${config.apiUrl}/apps/addable${branchParam}`, requestOptions).then(handleResponse); } function createApp(body = {}) { + const branchId = getActiveBranchId(); + if (branchId) body.branchId = branchId; const requestOptions = { method: 'POST', headers: authHeader(), @@ -221,6 +228,8 @@ function exportResource(body, appType) { } function importResource(body, appType) { + const branchId = getActiveBranchId(); + if (branchId) body.branchId = branchId; const requestOptions = { method: 'POST', headers: authHeader(), diff --git a/frontend/src/_services/dataquery.service.js b/frontend/src/_services/dataquery.service.js index bcc690503f..4b549c8f36 100644 --- a/frontend/src/_services/dataquery.service.js +++ b/frontend/src/_services/dataquery.service.js @@ -1,5 +1,6 @@ import config from 'config'; import { authHeader, handleResponse } from '@/_helpers'; +import { getActiveBranchId } from '@/_helpers/active-branch'; export const dataqueryService = { create, @@ -156,8 +157,10 @@ function invoke(dataSourceId, methodName, environmentId, args) { export function getAllTablesForADataSource(dataSourceId, environmentId) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; - - return fetch(`${config.apiUrl}/data-queries/${dataSourceId}/list-tables/${environmentId}`, requestOptions).then( - handleResponse - ); + let url = `${config.apiUrl}/data-queries/${dataSourceId}/list-tables/${environmentId}`; + const branchId = getActiveBranchId(); + if (branchId) { + url += `?branch_id=${branchId}`; + } + return fetch(url, requestOptions).then(handleResponse); } diff --git a/frontend/src/_services/git_sync.service.js b/frontend/src/_services/git_sync.service.js index d518f3a028..37b3730fe0 100644 --- a/frontend/src/_services/git_sync.service.js +++ b/frontend/src/_services/git_sync.service.js @@ -95,7 +95,9 @@ function getGitStatus(workspaceId) { headers: authHeader(), credentials: 'include', }; - return fetch(`${config.apiUrl}/git-sync/${workspaceId}/status`, requestOptions).then(handleResponse); + return fetch(`${config.apiUrl}/git-sync/${workspaceId}/status`, requestOptions).then((response) => + handleResponse(response, false, null, true) + ); } function syncAppVersion(appGitId, versionId) { @@ -169,13 +171,19 @@ function checkForUpdatesByAppName(appName, branchName = '') { return fetch(`${config.apiUrl}/app-git/gitpull/app?${params.toString()}`, requestOptions).then(handleResponse); } -function gitPull() { +function gitPull(branch, currentBranchId) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include', }; - return fetch(`${config.apiUrl}/app-git/gitpull`, requestOptions).then(handleResponse); + const queryParams = new URLSearchParams(); + if (branch) queryParams.set('branch', branch); + if (currentBranchId) queryParams.set('currentBranchId', currentBranchId); + const queryString = queryParams.toString(); + return fetch(`${config.apiUrl}/app-git/gitpull${queryString ? `?${queryString}` : ''}`, requestOptions).then( + handleResponse + ); } function confirmPullChanges(body, appId) { diff --git a/frontend/src/_services/globalDatasource.service.js b/frontend/src/_services/globalDatasource.service.js index 14963c19f5..0bd0006bfb 100644 --- a/frontend/src/_services/globalDatasource.service.js +++ b/frontend/src/_services/globalDatasource.service.js @@ -1,5 +1,6 @@ import config from 'config'; import { authHeader, handleResponse } from '@/_helpers'; +import { getActiveBranchId } from '@/_helpers/active-branch'; export const globalDatasourceService = { create, @@ -13,6 +14,13 @@ export const globalDatasourceService = { getQueriesLinkedToMarketplacePlugin, }; +function appendBranchId(url) { + const branchId = getActiveBranchId(); + if (!branchId) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}branch_id=${branchId}`; +} + function getForApp(organizationId, appVersionId, environmentId) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; @@ -22,10 +30,10 @@ function getForApp(organizationId, appVersionId, environmentId) { ).then(handleResponse); } -function getAll(organizationId, appVersionId, environmentId) { +function getAll(organizationId, _appVersionId, _environmentId) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; - return fetch(`${config.apiUrl}/data-sources/${organizationId}`, requestOptions).then(handleResponse); + return fetch(appendBranchId(`${config.apiUrl}/data-sources/${organizationId}`), requestOptions).then(handleResponse); } function create({ plugin_id, name, kind, options, scope, environment_id }) { @@ -39,7 +47,7 @@ function create({ plugin_id, name, kind, options, scope, environment_id }) { }; const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body), credentials: 'include' }; - return fetch(`${config.apiUrl}/data-sources`, requestOptions).then(handleResponse); + return fetch(appendBranchId(`${config.apiUrl}/data-sources`), requestOptions).then(handleResponse); } function save({ id, name, options, environment_id }) { @@ -49,14 +57,15 @@ function save({ id, name, options, environment_id }) { }; const requestOptions = { method: 'PUT', headers: authHeader(), body: JSON.stringify(body), credentials: 'include' }; - return fetch(`${config.apiUrl}/data-sources/${id}?environment_id=${environment_id}`, requestOptions).then( - handleResponse - ); + return fetch( + appendBranchId(`${config.apiUrl}/data-sources/${id}?environment_id=${environment_id}`), + requestOptions + ).then(handleResponse); } function deleteDataSource(id) { const requestOptions = { method: 'DELETE', headers: authHeader(), credentials: 'include' }; - return fetch(`${config.apiUrl}/data-sources/${id}`, requestOptions).then(handleResponse); + return fetch(appendBranchId(`${config.apiUrl}/data-sources/${id}`), requestOptions).then(handleResponse); } function convertToGlobal(id) { @@ -66,9 +75,10 @@ function convertToGlobal(id) { function getDataSourceByEnvironmentId(dataSourceId, environmentId) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; - return fetch(`${config.apiUrl}/data-sources/${dataSourceId}/environment/${environmentId}`, requestOptions).then( - handleResponse - ); + return fetch( + appendBranchId(`${config.apiUrl}/data-sources/${dataSourceId}/environment/${environmentId}`), + requestOptions + ).then(handleResponse); } function getQueriesLinkedToMarketplacePlugin(pluginId) { @@ -80,5 +90,7 @@ function getQueriesLinkedToMarketplacePlugin(pluginId) { function getQueriesLinkedToDatasource(dataSourceId) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; - return fetch(`${config.apiUrl}/data-sources/dependent-queries/${dataSourceId}`, requestOptions).then(handleResponse); + return fetch(appendBranchId(`${config.apiUrl}/data-sources/dependent-queries/${dataSourceId}`), requestOptions).then( + handleResponse + ); } diff --git a/frontend/src/_services/index.js b/frontend/src/_services/index.js index b0d3b5af53..975ea1eaab 100644 --- a/frontend/src/_services/index.js +++ b/frontend/src/_services/index.js @@ -35,4 +35,5 @@ export * from './ai.service'; export * from './appPermission.service'; export * from './ai-onboarding.service'; export * from './workflow_bundles.service'; +export * from './workspace_branches.service'; export * from './custom-domain.service'; diff --git a/frontend/src/_services/library-app.service.js b/frontend/src/_services/library-app.service.js index 815fa560c0..8791a4ad25 100644 --- a/frontend/src/_services/library-app.service.js +++ b/frontend/src/_services/library-app.service.js @@ -8,12 +8,13 @@ export const libraryAppService = { findDependentPluginsInTemplate, }; -function deploy(identifier, appName, dependentPlugins = [], shouldAutoImportPlugin = false) { +function deploy(identifier, appName, dependentPlugins = [], shouldAutoImportPlugin = false, branchId = null) { const body = { identifier, appName, dependentPlugins, shouldAutoImportPlugin, + ...(branchId && { branchId }), }; const requestOptions = { method: 'POST', headers: authHeader(), credentials: 'include', body: JSON.stringify(body) }; diff --git a/frontend/src/_services/workspace_branches.service.js b/frontend/src/_services/workspace_branches.service.js new file mode 100644 index 0000000000..fb14bd7bd9 --- /dev/null +++ b/frontend/src/_services/workspace_branches.service.js @@ -0,0 +1,105 @@ +import config from 'config'; +import { authHeader, handleResponse } from '@/_helpers'; + +export const workspaceBranchesService = { + list, + create, + switchBranch, + deleteBranch, + pushWorkspace, + pullWorkspace, + ensureAppDraft, + checkForUpdates, + listRemoteBranches, + fetchPullRequests, +}; + +function list() { + const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/workspace-branches`, requestOptions).then((response) => + handleResponse(response, false, null, true) + ); +} + +function create(name, sourceBranchId, commitSha) { + const body = { name, ...(sourceBranchId && { sourceBranchId }), ...(commitSha && { commitSha }) }; + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + body: JSON.stringify(body), + }; + return fetch(`${config.apiUrl}/workspace-branches`, requestOptions).then(handleResponse); +} + +function switchBranch(branchId, appId) { + const requestOptions = { + method: 'PUT', + headers: authHeader(), + credentials: 'include', + ...(appId && { body: JSON.stringify({ appId }) }), + }; + return fetch(`${config.apiUrl}/workspace-branches/${branchId}/activate`, requestOptions).then(handleResponse); +} + +function deleteBranch(branchId) { + const requestOptions = { method: 'DELETE', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/workspace-branches/${branchId}`, requestOptions).then(handleResponse); +} + +function pushWorkspace(commitMessage, targetBranch, branchId) { + const body = { commitMessage, ...(targetBranch && { targetBranch }), ...(branchId && { branchId }) }; + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + body: JSON.stringify(body), + }; + return fetch(`${config.apiUrl}/workspace-branches/push`, requestOptions).then(handleResponse); +} + +function pullWorkspace(sourceBranch, branchId) { + const body = { + ...(sourceBranch && { sourceBranch }), + ...(branchId && { branchId }), + }; + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + ...(Object.keys(body).length > 0 && { body: JSON.stringify(body) }), + }; + return fetch(`${config.apiUrl}/workspace-branches/pull`, requestOptions).then(handleResponse); +} + +function ensureAppDraft(appId, branchId, tagSha, tagName) { + const body = { + appId, + ...(branchId && { branchId }), + ...(tagSha && { tagSha }), + ...(tagName && { tagName }), + }; + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + body: JSON.stringify(body), + }; + return fetch(`${config.apiUrl}/workspace-branches/ensure-draft`, requestOptions).then(handleResponse); +} + +function checkForUpdates(branch) { + const params = branch ? `?branch=${encodeURIComponent(branch)}` : ''; + const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/workspace-branches/check-updates${params}`, requestOptions).then(handleResponse); +} + +function listRemoteBranches() { + const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/workspace-branches/remote`, requestOptions).then(handleResponse); +} + +function fetchPullRequests() { + const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; + return fetch(`${config.apiUrl}/workspace-branches/pull-requests`, requestOptions).then(handleResponse); +} diff --git a/frontend/src/_stores/workspaceBranchesStore.js b/frontend/src/_stores/workspaceBranchesStore.js new file mode 100644 index 0000000000..ff0297b4f4 --- /dev/null +++ b/frontend/src/_stores/workspaceBranchesStore.js @@ -0,0 +1,195 @@ +import { create, zustandDevTools } from './utils'; +import { workspaceBranchesService } from '@/_services/workspace_branches.service'; +import { gitSyncService } from '@/_services/git_sync.service'; +import { getActiveBranch, setActiveBranch, cleanupStaleBranchKeys } from '@/_helpers/active-branch'; + +const initialState = { + branches: [], + activeBranchId: null, + currentBranch: null, + isLoading: false, + isInitialized: false, + orgGitConfig: null, + isPushing: false, + isPulling: false, + remoteBranches: [], +}; + +// Helper to resolve current branch from branches list + active ID +function resolveCurrentBranch(branches, activeBranchId) { + if (!branches || branches.length === 0) return null; + // Try matching by activeBranchId + if (activeBranchId) { + const match = branches.find((b) => b.id === activeBranchId); + if (match) return match; + } + // Fallback: find the default branch + const defaultBranch = branches.find((b) => b.is_default || b.isDefault); + if (defaultBranch) return defaultBranch; + // Ultimate fallback: first branch + return branches[0] || null; +} + +export const useWorkspaceBranchesStore = create( + zustandDevTools( + (set, get) => ({ + ...initialState, + + actions: { + async initialize(workspaceId) { + if (get().isInitialized) return; + set({ isLoading: true }); + // Remove stale tj_active_branch_* keys from other orgs / migration dumps + cleanupStaleBranchKeys(); + try { + const [branchData, gitStatus] = await Promise.all([ + workspaceBranchesService.list().catch(() => null), + gitSyncService.getGitStatus(workspaceId).catch(() => null), + ]); + + const branches = branchData?.branches || []; + // Prefer localStorage branch over server-returned activeBranchId + const storedBranch = getActiveBranch(); + const serverActiveBranchId = branchData?.active_branch_id || branchData?.activeBranchId || null; + const effectiveActiveBranchId = storedBranch?.id || serverActiveBranchId; + const currentBranch = resolveCurrentBranch(branches, effectiveActiveBranchId); + + // Persist resolved branch to localStorage + if (currentBranch) { + setActiveBranch(currentBranch); + } + + set({ + branches, + activeBranchId: currentBranch?.id || effectiveActiveBranchId, + currentBranch, + orgGitConfig: gitStatus, + isLoading: false, + isInitialized: true, + }); + } catch (error) { + set({ isLoading: false, isInitialized: true }); + } + }, + + async fetchBranches() { + try { + const data = await workspaceBranchesService.list(); + const branches = data?.branches || []; + // Prefer localStorage branch over server default + const storedBranch = getActiveBranch(); + const serverActiveBranchId = data?.active_branch_id || data?.activeBranchId || null; + const effectiveActiveBranchId = storedBranch?.id || serverActiveBranchId; + const currentBranch = resolveCurrentBranch(branches, effectiveActiveBranchId); + set({ branches, activeBranchId: currentBranch?.id || effectiveActiveBranchId, currentBranch }); + } catch (error) { + console.error('Failed to fetch branches:', error); + } + }, + + async createBranch(name, sourceBranchId, commitSha) { + const newBranch = await workspaceBranchesService.create(name, sourceBranchId, commitSha); + await get().actions.fetchBranches(); + return newBranch; + }, + + async switchBranch(branchId) { + // No longer calling backend — branch is tracked client-side only + const branches = get().branches; + const currentBranch = branches.find((b) => b.id === branchId) || null; + if (currentBranch) { + setActiveBranch(currentBranch); + } + set({ activeBranchId: branchId, currentBranch }); + }, + + async deleteBranch(branchId) { + await workspaceBranchesService.deleteBranch(branchId); + await get().actions.fetchBranches(); + }, + + async pushWorkspace(commitMessage, targetBranch) { + set({ isPushing: true }); + try { + const branchId = get().activeBranchId; + const result = await workspaceBranchesService.pushWorkspace(commitMessage, targetBranch, branchId); + set({ isPushing: false }); + return result; + } catch (error) { + set({ isPushing: false }); + throw error; + } + }, + + async pullWorkspace(sourceBranch) { + set({ isPulling: true }); + try { + const branchId = get().activeBranchId; + const result = await workspaceBranchesService.pullWorkspace(sourceBranch, branchId); + set({ isPulling: false }); + return result; + } catch (error) { + set({ isPulling: false }); + throw error; + } + }, + + async fetchRemoteBranches() { + try { + const result = await workspaceBranchesService.listRemoteBranches(); + set({ remoteBranches: result || [] }); + return result || []; + } catch (error) { + console.error('Failed to fetch remote branches:', error); + return []; + } + }, + + async checkForUpdates(branch) { + try { + const result = await workspaceBranchesService.checkForUpdates(branch); + return result; + } catch (error) { + throw error; + } + }, + + reset() { + set(initialState); + }, + + async reinitialize(workspaceId) { + set({ ...initialState, isLoading: true }); + try { + const [branchData, gitStatus] = await Promise.all([ + workspaceBranchesService.list().catch(() => null), + gitSyncService.getGitStatus(workspaceId).catch(() => null), + ]); + + const branches = branchData?.branches || []; + const storedBranch = getActiveBranch(); + const serverActiveBranchId = branchData?.active_branch_id || branchData?.activeBranchId || null; + const effectiveActiveBranchId = storedBranch?.id || serverActiveBranchId; + const currentBranch = resolveCurrentBranch(branches, effectiveActiveBranchId); + + if (currentBranch) { + setActiveBranch(currentBranch); + } + + set({ + branches, + activeBranchId: currentBranch?.id || effectiveActiveBranchId, + currentBranch, + orgGitConfig: gitStatus, + isLoading: false, + isInitialized: true, + }); + } catch (error) { + set({ isLoading: false, isInitialized: true }); + } + }, + }, + }), + { name: 'Workspace Branches' } + ) +); diff --git a/frontend/src/_styles/locked-branch-banner.scss b/frontend/src/_styles/locked-branch-banner.scss index ddd38b3409..3dcb445490 100644 --- a/frontend/src/_styles/locked-branch-banner.scss +++ b/frontend/src/_styles/locked-branch-banner.scss @@ -2,20 +2,19 @@ // Displays below the editor navigation when branch is locked .locked-branch-banner { - width: 100%; - height: 40px; - background: linear-gradient(90deg, #fef3c7 0%, #fde68a 100%); - border-bottom: 1px solid #fbbf24; - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - position: relative; + width: 100%; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + padding: 12px 20px; + background-color: var(--background-surface-layer-03); + margin: 0; &-content { display: flex; align-items: center; - gap: 12px; + gap: 6px; max-width: 1200px; padding: 0 20px; } @@ -30,15 +29,20 @@ &-text { display: flex; align-items: center; - gap: 8px; + gap: 0px; flex-wrap: wrap; + margin-left: -8px; } &-message { - font-size: 14px; - font-weight: 500; - color: #92400e; - line-height: 20px; + margin: 0; + margin-left: 10px; + color: #000000; + font-family: "IBM Plex Sans", sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 10px; + text-align: center; } &-branch { diff --git a/frontend/src/_ui/Header/index.jsx b/frontend/src/_ui/Header/index.jsx index f252b10c04..13b9410d50 100644 --- a/frontend/src/_ui/Header/index.jsx +++ b/frontend/src/_ui/Header/index.jsx @@ -6,6 +6,9 @@ import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import { ToolTip } from '@/_components'; import LicenseBanner from '@/modules/common/components/LicenseBanner'; import { generateCypressDataCy } from '@/modules/common/helpers/cypressHelpers'; +import { WorkspaceBranchDropdown } from '@/_ui/WorkspaceBranchDropdown'; +import { WorkspaceGitCTA } from '@/_ui/WorkspaceGitCTA'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; function Header({ featureAccess, @@ -14,6 +17,7 @@ function Header({ toggleCollapsibleSidebar = () => {}, }) { const darkMode = localStorage.getItem('darkMode') === 'true'; + const isBranchStoreInitialized = useWorkspaceBranchesStore((s) => s.isInitialized); const routes = (pathEnd, path) => { const pathParts = path.split('/'); @@ -67,6 +71,11 @@ function Header({ const location = useLocation(); const pathname = routes(location?.pathname.split('/').pop(), location?.pathname); + const isWorkspaceGitPage = (pathname) => { + const parts = pathname.split('/').filter(Boolean); + return parts.length === 1 || (parts.length >= 2 && ['data-sources'].includes(parts[1])); + }; + const isGitSupportedPage = isWorkspaceGitPage(location.pathname); return (
@@ -157,6 +166,15 @@ function Header({ 'color-disabled': !darkMode, })} > + {featureAccess?.gitSync && + isBranchStoreInitialized && + pathname !== 'Workspace constants' && + isGitSupportedPage && ( + <> + + + + )} {Object.keys(featureAccess).length > 0 && ( )} diff --git a/frontend/src/_ui/HttpHeaders/SourceEditor.jsx b/frontend/src/_ui/HttpHeaders/SourceEditor.jsx index 1c34a9a1bf..f5ffc45102 100644 --- a/frontend/src/_ui/HttpHeaders/SourceEditor.jsx +++ b/frontend/src/_ui/HttpHeaders/SourceEditor.jsx @@ -95,7 +95,13 @@ export default ({ style={{ gap: '0px', fontSize: '12px', fontWeight: '500', padding: '0px 9px' }} disabled={isDisabled} > - + {/* */} +   Add
diff --git a/frontend/src/_ui/Layout/index.jsx b/frontend/src/_ui/Layout/index.jsx index e85c70c966..2ea255a024 100644 --- a/frontend/src/_ui/Layout/index.jsx +++ b/frontend/src/_ui/Layout/index.jsx @@ -15,6 +15,7 @@ import { hasBuilderRole } from '@/_helpers/utils'; import { LeftNavSideBar } from '@/modules/common/components'; import { useWhiteLabellingStore } from '@/_stores/whiteLabellingStore'; import UnsavedChangesDialog from '@/modules/dataSources/components/DataSourceManager/UnsavedChangesDialog'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; function Layout({ children, @@ -85,6 +86,16 @@ function Layout({ fetchWhiteLabelDetails(authenticationService?.currentSessionValue?.organization_id); }, []); + // Initialize workspace branches store after feature access is available + useEffect(() => { + if (featureAccess?.gitSync) { + const workspaceId = authenticationService?.currentSessionValue?.current_organization_id; + if (workspaceId) { + useWorkspaceBranchesStore.getState().actions.initialize(workspaceId); + } + } + }, [featureAccess]); + useEffect(() => { let licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; setLicenseValid(licenseValid); diff --git a/frontend/src/_ui/WorkspaceBranchDropdown/CreateBranchModal.jsx b/frontend/src/_ui/WorkspaceBranchDropdown/CreateBranchModal.jsx new file mode 100644 index 0000000000..b5c2a0bf50 --- /dev/null +++ b/frontend/src/_ui/WorkspaceBranchDropdown/CreateBranchModal.jsx @@ -0,0 +1,239 @@ +import React, { useState, useEffect, useRef } from 'react'; +import AlertDialog from '@/_ui/AlertDialog'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { toast } from 'react-hot-toast'; +import { Alert } from '@/_ui/Alert'; +import cx from 'classnames'; +import '@/_styles/create-branch-modal.scss'; + +const RESERVED_NAMES = ['main', 'master', 'head', 'origin']; + +export function WorkspaceCreateBranchModal({ onClose, onSuccess }) { + const [branchName, setBranchName] = useState(''); + const [autoCommit, setAutoCommit] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [validationError, setValidationError] = useState(''); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [sourceBranchId, setSourceBranchId] = useState(''); + const dropdownRef = useRef(null); + + const { branches, activeBranchId } = useWorkspaceBranchesStore((state) => ({ + branches: state.branches, + activeBranchId: state.activeBranchId, + })); + const actions = useWorkspaceBranchesStore((state) => state.actions); + + // Always create from the default (main) branch + const defaultBranch = branches.find((b) => b.is_default || b.isDefault); + const selectedSourceBranchId = defaultBranch?.id || sourceBranchId || activeBranchId; + const selectedSourceBranch = branches.find((b) => b.id === selectedSourceBranchId); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Always force source to the default (main) branch + useEffect(() => { + if (branches.length > 0) { + const mainBranch = branches.find((b) => b.is_default || b.isDefault); + if (mainBranch) { + setSourceBranchId(mainBranch.id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [branches]); + + const validateBranchName = (name) => { + if (!name || name.trim().length === 0) return 'Branch name is required'; + if (/\s/.test(name)) return 'Branch name cannot contain spaces'; + if (!/^[a-zA-Z0-9_-]+$/.test(name)) + return 'Branch name can only contain letters, numbers, hyphens, and underscores'; + if (branches.some((b) => b.name?.toLowerCase() === name.toLowerCase())) + return 'A branch with this name already exists'; + if (RESERVED_NAMES.includes(name.toLowerCase())) return 'This branch name is reserved'; + return ''; + }; + + const handleBranchNameChange = (e) => { + const newName = e.target.value; + setBranchName(newName); + if (validationError) setValidationError(''); + }; + + const handleCreate = async () => { + const error = validateBranchName(branchName); + if (error) { + setValidationError(error); + return; + } + + setIsCreating(true); + try { + const newBranch = await actions.createBranch(branchName.trim(), selectedSourceBranchId); + // toast.success(`Branch "${branchName}" created successfully`); + toast.success(`Branch was created successfully`); + await actions.switchBranch(newBranch.id); + onSuccess?.(); + onClose(); + } catch (error) { + console.error('Error creating branch:', error); + setValidationError(error?.message || 'An unexpected error occurred'); + toast.error(error?.message || 'Failed to create branch'); + setIsCreating(false); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !isCreating && !isDropdownOpen) { + handleCreate(); + } else if (e.key === 'Escape' && isDropdownOpen) { + setIsDropdownOpen(false); + } + }; + + return ( + +
+ {/* Create from dropdown */} + {/*
+ +
+ + {isDropdownOpen && ( +
+ {branches.map((branch) => { + const isSelected = branch.id === selectedSourceBranchId; + return ( +
{ + setSourceBranchId(branch.id); + setIsDropdownOpen(false); + }} + > + {isSelected && ( +
+ +
+ )} + {!isSelected &&
} +
+
+ {branch.name} + {(branch.is_default || branch.isDefault) && ( + Default + )} +
+
+
+ ); + })} +
+ )} +
+
*/} + + {/* Branch name input */} +
+ + + {validationError &&
{validationError}
} +
+ {/* Branch name must be unique and contain only letters, numbers, hyphens, and underscores */} + Branch name must be unique and max 50 characters +
+
+ {/* Auto-commit checkbox */} +
+ +
+ {/* Info message */} + + {/* Branch can only be created from the default branch */} + Branch can only be created from the master + + + {/* Footer buttons */} +
+ + Cancel + + + Create branch + +
+
+ + ); +} + +// Keep backward compatibility +export { WorkspaceCreateBranchModal as CreateBranchModal }; +export default WorkspaceCreateBranchModal; diff --git a/frontend/src/_ui/WorkspaceBranchDropdown/SwitchBranchModal.jsx b/frontend/src/_ui/WorkspaceBranchDropdown/SwitchBranchModal.jsx new file mode 100644 index 0000000000..1289048eca --- /dev/null +++ b/frontend/src/_ui/WorkspaceBranchDropdown/SwitchBranchModal.jsx @@ -0,0 +1,227 @@ +import React, { useState, useEffect } from 'react'; +import AlertDialog from '@/_ui/AlertDialog'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { toast } from 'react-hot-toast'; +import { WorkspaceCreateBranchModal } from './CreateBranchModal'; +import { Alert } from '@/_ui/Alert'; +import '@/_styles/switch-branch-modal.scss'; + +export function WorkspaceSwitchBranchModal({ show, onClose, onBranchSwitch }) { + const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + + const { branches, activeBranchId, orgGitConfig, currentBranch } = useWorkspaceBranchesStore((state) => ({ + branches: state.branches, + activeBranchId: state.activeBranchId, + orgGitConfig: state.orgGitConfig, + currentBranch: state.currentBranch, + })); + const actions = useWorkspaceBranchesStore((state) => state.actions); + + const defaultGitBranch = orgGitConfig?.default_git_branch || orgGitConfig?.defaultGitBranch || 'main'; + const isOnDefaultBranch = + currentBranch?.is_default || currentBranch?.isDefault || currentBranch?.name === defaultGitBranch; + + useEffect(() => { + if (show) { + setIsLoading(true); + actions.fetchBranches().finally(() => setIsLoading(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show]); + + // Filter branches by search term + const filteredBranches = branches.filter((branch) => { + if (!branch.name.toLowerCase().includes(searchTerm.toLowerCase())) return false; + // When used for "create app" flow, hide the default branch (user is leaving it) + if (onBranchSwitch && (branch.is_default || branch.isDefault)) return false; + return true; + }); + + const handleBranchClick = async (branch) => { + if (branch.id === activeBranchId) { + onClose(); + return; + } + + try { + await actions.switchBranch(branch.id); + toast.success(`Switched to ${branch.name}`); + if (onBranchSwitch) { + onBranchSwitch(branch); + } else { + onClose(); + window.location.reload(); + } + } catch (error) { + console.error('Error switching branch:', error); + const errorMessage = error?.error || error?.message || 'Failed to switch branch'; + toast.error(errorMessage); + } + }; + + const getRelativeTime = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'today'; + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + return `${Math.floor(diffDays / 30)} months ago`; + }; + + const handleViewInGitRepo = () => { + const repoUrl = orgGitConfig?.repo_url || orgGitConfig?.repoUrl || ''; + + if (!repoUrl) { + toast.error('Git repository URL not available'); + return; + } + + // Extract web URL from repo URL + const githubMatch = repoUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/); + const gitlabMatch = repoUrl.match(/gitlab\.com[:/]([^/]+)\/(.+?)(\.git)?$/); + const bitbucketMatch = repoUrl.match(/bitbucket\.org[:/]([^/]+)\/(.+?)(\.git)?$/); + + let webUrl = null; + if (githubMatch) { + const [, owner, repo] = githubMatch; + webUrl = `https://github.com/${owner}/${repo}`; + } else if (gitlabMatch) { + const [, owner, repo] = gitlabMatch; + webUrl = `https://gitlab.com/${owner}/${repo}`; + } else if (bitbucketMatch) { + const [, owner, repo] = bitbucketMatch; + webUrl = `https://bitbucket.org/${owner}/${repo}`; + } else { + webUrl = repoUrl + .replace(/\.git$/, '') + .replace(/^git@/, 'https://') + .replace(/:([^/])/, '/$1'); + } + + if (webUrl) { + window.open(webUrl, '_blank'); + } else { + toast.error('Could not parse repository URL'); + } + }; + + return ( + +
+ {/* Info message - only shown on default branch */} + {isOnDefaultBranch && ( + + Default branch is locked. Switch branches to make changes. + + )} + + {/* Search Section */} +
+ +
+ + setSearchTerm(e.target.value)} + data-cy="workspace-branch-search-input" + /> +
+
+ + {/* Branch List */} +
+ {isLoading ? ( +
+
+ Loading branches... +
+ ) : filteredBranches.length === 0 ? ( +
+

No branches found

+
+ ) : ( + filteredBranches.map((branch) => { + const isCurrentBranch = branch.id === activeBranchId; + return ( +
handleBranchClick(branch)} + data-cy={`workspace-branch-list-item-${branch.name}`} + > +
+ {isCurrentBranch && } +
+
+
+ {branch.name} + {(branch.is_default || branch.isDefault) && ( + (default) + )} +
+
+ Created by {branch.author || branch.created_by || 'default'},{' '} + {getRelativeTime(branch.createdAt || branch.created_at)} +
+
+
+ ); + }) + )} +
+ + {/* Footer Actions */} +
+ + +
+
+ + {/* Create Branch Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSuccess={() => { + setShowCreateModal(false); + onClose(); + }} + /> + )} +
+ ); +} + +export default WorkspaceSwitchBranchModal; diff --git a/frontend/src/_ui/WorkspaceBranchDropdown/WorkspaceBranchDropdown.scss b/frontend/src/_ui/WorkspaceBranchDropdown/WorkspaceBranchDropdown.scss new file mode 100644 index 0000000000..27c44c3f1b --- /dev/null +++ b/frontend/src/_ui/WorkspaceBranchDropdown/WorkspaceBranchDropdown.scss @@ -0,0 +1,129 @@ +.workspace-branch-dropdown { + position: relative; + + .branch-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--slate5); + border-radius: 6px; + background: var(--base); + cursor: pointer; + font-size: 12px; + color: var(--text-primary); + transition: border-color 0.15s; + + &:hover { + border-color: var(--slate7); + } + + .branch-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + } + } + + .branch-list-popover { + min-width: 240px; + max-width: 300px; + padding: 8px 0; + + .popover-header-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px 8px; + border-bottom: 1px solid var(--slate5); + margin-bottom: 4px; + + .popover-title { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + } + } + + .branch-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-primary); + + &:hover { + background: var(--slate3); + } + + &.active { + background: var(--indigo3); + color: var(--indigo11); + font-weight: 500; + } + + .branch-item-name { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .branch-item-actions { + display: flex; + align-items: center; + gap: 4px; + + .delete-btn { + opacity: 0; + padding: 2px; + border: none; + background: none; + color: var(--tomato9); + cursor: pointer; + border-radius: 4px; + + &:hover { + background: var(--tomato3); + } + } + } + + &:hover .delete-btn { + opacity: 1; + } + } + + .create-branch-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + margin-top: 4px; + border-top: 1px solid var(--slate5); + cursor: pointer; + font-size: 12px; + color: var(--indigo11); + width: 100%; + background: none; + border-left: none; + border-right: none; + border-bottom: none; + + &:hover { + background: var(--slate3); + } + } + } +} diff --git a/frontend/src/_ui/WorkspaceBranchDropdown/index.jsx b/frontend/src/_ui/WorkspaceBranchDropdown/index.jsx new file mode 100644 index 0000000000..460085ee72 --- /dev/null +++ b/frontend/src/_ui/WorkspaceBranchDropdown/index.jsx @@ -0,0 +1,589 @@ +import React, { useState, useRef, useEffect } from 'react'; +import cx from 'classnames'; +import { Overlay, Popover } from 'react-bootstrap'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { workspaceBranchesService } from '@/_services/workspace_branches.service'; +import { WorkspaceCreateBranchModal } from './CreateBranchModal'; +import { WorkspaceSwitchBranchModal } from './SwitchBranchModal'; +import { toast } from 'react-hot-toast'; +import { AlertTriangle } from 'lucide-react'; +import OverflowTooltip from '@/_components/OverflowTooltip'; +import '@/_styles/branch-dropdown.scss'; + +export function WorkspaceBranchDropdown() { + const [showDropdown, setShowDropdown] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showSwitchModal, setShowSwitchModal] = useState(false); + const [activeTab, setActiveTab] = useState('open'); // 'open' or 'closed' + const [lastCommit, setLastCommit] = useState(null); + const [isLoadingCommit, setIsLoadingCommit] = useState(false); + const [hasFetchedPRs, setHasFetchedPRs] = useState(false); + const [hasFetchedBranchInfo, setHasFetchedBranchInfo] = useState(false); + const [isLoadingPRs, setIsLoadingPRs] = useState(false); + const [pullRequests, setPullRequests] = useState([]); + const buttonRef = useRef(null); + const popoverRef = useRef(null); + + const { branches, currentBranch, orgGitConfig } = useWorkspaceBranchesStore((state) => ({ + branches: state.branches, + currentBranch: state.currentBranch, + orgGitConfig: state.orgGitConfig, + })); + + const darkMode = localStorage.getItem('darkMode') === 'true' || false; + + const isBranchingEnabled = orgGitConfig?.is_branching_enabled || orgGitConfig?.isBranchingEnabled; + + // Determine default branch name from git config + const defaultGitBranch = orgGitConfig?.default_git_branch || orgGitConfig?.defaultGitBranch || 'main'; + const isOnDefaultBranch = + currentBranch?.is_default || currentBranch?.isDefault || currentBranch?.name === defaultGitBranch; + const displayBranchName = currentBranch?.name || defaultGitBranch; + + // Helper function to get relative time + const getRelativeTime = (dateString) => { + if (!dateString) return null; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Updated today'; + if (diffDays === 1) return 'Updated yesterday'; + if (diffDays < 7) return `Updated ${diffDays} days ago`; + if (diffDays < 30) return `Updated ${Math.floor(diffDays / 7)} weeks ago`; + return `Updated ${Math.floor(diffDays / 30)} months ago`; + }; + + // Helper function to format commit date (e.g., "25 Sept, 8:45am") + const formatCommitDate = (dateString) => { + if (!dateString) return 'Unknown date'; + const date = new Date(dateString); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec']; + const day = date.getDate(); + const month = months[date.getMonth()]; + let hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'pm' : 'am'; + hours = hours % 12 || 12; + const minutesStr = minutes < 10 ? `0${minutes}` : minutes; + return `${day} ${month}, ${hours}:${minutesStr}${ampm}`; + }; + + // Format PR date + const formatPRDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); + }; + + // Build PR creation URL + const buildPRCreationURL = () => { + const sourceBranch = currentBranch?.name; + const repoUrl = orgGitConfig?.repo_url || orgGitConfig?.repoUrl || ''; + const gitType = orgGitConfig?.git_type || orgGitConfig?.gitType || 'github_https'; + + if (!repoUrl || !sourceBranch) return null; + + const githubMatch = repoUrl.match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/); + const gitlabMatch = repoUrl.match(/gitlab\.com[:/]([^/]+)\/(.+?)(\.git)?$/); + const bitbucketMatch = repoUrl.match(/bitbucket\.org[:/]([^/]+)\/(.+?)(\.git)?$/); + + if (githubMatch) { + const [, owner, repo] = githubMatch; + return `https://github.com/${owner}/${repo}/compare/${defaultGitBranch}...${sourceBranch}?expand=1`; + } else if (gitlabMatch || gitType === 'gitlab') { + let baseUrl = repoUrl; + if (gitlabMatch) { + const [, owner, repo] = gitlabMatch; + baseUrl = `https://gitlab.com/${owner}/${repo}`; + } + return `${baseUrl}/-/merge_requests/new?merge_request[source_branch]=${encodeURIComponent( + sourceBranch + )}&merge_request[target_branch]=${encodeURIComponent(defaultGitBranch)}`; + } else if (bitbucketMatch) { + const [, owner, repo] = bitbucketMatch; + return `https://bitbucket.org/${owner}/${repo}/pull-requests/new?source=${sourceBranch}&dest=${defaultGitBranch}`; + } + + // GitHub SSH fallback + if (gitType === 'github_ssh') { + const match = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); + if (match) { + return `https://github.com/${match[1]}/compare/${defaultGitBranch}...${sourceBranch}?expand=1`; + } + } + + return null; + }; + + // Handle Create PR action + const handleCreatePR = () => { + const prUrl = buildPRCreationURL(); + if (prUrl) { + window.open(prUrl, '_blank', 'noopener,noreferrer'); + setShowDropdown(false); + } else { + toast.error('Unable to determine repository URL for PR creation'); + } + }; + + // Handle click outside to close dropdown + useEffect(() => { + const handleClickOutside = (event) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target) && + buttonRef.current && + !buttonRef.current.contains(event.target) + ) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + // Reset state when dropdown closes + useEffect(() => { + if (!showDropdown) { + setLastCommit(null); + setIsLoadingCommit(false); + setHasFetchedPRs(false); + setHasFetchedBranchInfo(false); + setPullRequests([]); + } + }, [showDropdown]); + + // Workspace-level PR fetch — returns all repo PRs (no app filtering) + const handleFetchPRs = async () => { + setIsLoadingPRs(true); + try { + const data = await workspaceBranchesService.fetchPullRequests(); + setPullRequests(data?.pullRequests || []); + setHasFetchedPRs(true); + toast.success('PRs fetched successfully'); + } catch (error) { + console.error('Error fetching PRs:', error); + toast.error('Failed to fetch PRs'); + setHasFetchedPRs(true); + } finally { + setIsLoadingPRs(false); + } + }; + + // Fetch last commit info for feature branch + const fetchLastCommit = async () => { + const branchName = currentBranch?.name; + if (!branchName || isOnDefaultBranch) { + setLastCommit(null); + setIsLoadingCommit(false); + return; + } + + setIsLoadingCommit(true); + try { + const data = await useWorkspaceBranchesStore.getState().actions.checkForUpdates(branchName); + const latestCommit = data?.latestCommit || data?.latest_commit; + + if (latestCommit) { + setLastCommit({ + message: latestCommit.message || latestCommit.commitMessage, + author: latestCommit.author || latestCommit.author_name, + date: latestCommit.date || latestCommit.committed_date, + }); + } else { + setLastCommit(null); + } + setHasFetchedBranchInfo(true); + } catch (error) { + console.error('Error fetching last commit:', error); + setLastCommit(null); + setHasFetchedBranchInfo(true); + } finally { + setIsLoadingCommit(false); + } + }; + + // Filter PRs based on active tab + const openPRs = pullRequests.filter( + (pr) => pr.state?.toLowerCase() === 'open' || pr.status?.toLowerCase() === 'open' + ); + const closedPRs = pullRequests.filter( + (pr) => + pr.state?.toLowerCase() === 'closed' || + pr.status?.toLowerCase() === 'closed' || + (pr.state?.toLowerCase() !== 'open' && pr.status?.toLowerCase() !== 'open') + ); + const displayPRs = activeTab === 'open' ? openPRs : closedPRs; + + if (!orgGitConfig) return null; + + const renderPopover = (overlayProps) => ( + + +
+ {/* Current Branch Header */} +
+ {isOnDefaultBranch ? ( + <> +
+ +
+
+
{displayBranchName}
+
+ Default branch + {(currentBranch?.updatedAt || currentBranch?.updated_at) && ( + <> + + + {getRelativeTime(currentBranch.updatedAt || currentBranch.updated_at)} + + + )} +
+
+ + ) : ( + <> +
+ +
+
+
{displayBranchName}
+
+ + Created by{' '} + {currentBranch?.createdBy || currentBranch?.created_by || currentBranch?.author || 'Unknown'} + + {(currentBranch?.createdAt || currentBranch?.created_at) && ( + <> + + + {getRelativeTime(currentBranch?.createdAt || currentBranch?.created_at)} + + + )} +
+
+ + )} +
+ + {/* Main Content Area */} + {isOnDefaultBranch ? ( + <> + {/* Old: static info state for default branch */} + {/*
+
+ +
+
Platform-level git sync
+
+ Pull changes from the default branch to sync workspace resources +
+
+
+
*/} + + {/* Fetch PRs Button - Shown at top for default branch, hides after fetching */} + {!hasFetchedPRs && ( +
+ +
+ )} + + {/* PR Tabs and List - Only shown after fetching */} + {hasFetchedPRs && ( + <> + {/* PR Tabs */} +
+ + +
+ + {/* PR List */} +
+ {displayPRs.length === 0 ? ( +
+ +
+
+ {activeTab === 'open' ? 'There are no open PRs' : 'There are no closed PRs'} +
+
+ {activeTab === 'open' + ? 'Create a pull request to contribute your changes' + : 'Merge a pull request to contribute your changes'} +
+
+
+ ) : ( + displayPRs.map((pr) => ( +
+
+ +
+
+ + {pr.title || 'Untitled PR'} + +
+ from {pr.source_branch || pr.sourceBranch} | {formatPRDate(pr.created_at || pr.createdAt)} +
+
+
+ )) + )} +
+ + )} + + ) : ( + <> + {/* Old: static "Push your changes" state for feature branch */} + {/*
+ +
+
Push your changes
+
+ Commit and push workspace changes, then create a pull request +
+
+
*/} + + {/* Fetch Branch Info Button - Only show when not fetched yet */} + {!hasFetchedBranchInfo && ( +
+ +
+ )} + + {/* Latest Commit Section & Empty State - Only show after fetching */} + {hasFetchedBranchInfo && ( + <> + {/* Latest Commit Section - for non-default branches with commits */} + {lastCommit && !isLoadingCommit && ( +
+
+ LATEST COMMIT +
+
+
+ +
+
+
{lastCommit.message || 'No message'}
+
+ By {lastCommit.author || 'Unknown'} | {formatCommitDate(lastCommit.date)} +
+
+
+
+ )} + + {/* Empty state - no commits yet */} + {!lastCommit && !isLoadingCommit && ( +
+ +
+
There are no commits yet
+
+ Commit your changes to create a pull request to contribute them +
+
+
+ )} + + {/* Loading state for commit */} + {isLoadingCommit && ( +
+
+ Loading commit info... +
+ )} + + )} + + )} + + {/* Footer actions */} +
+ {isOnDefaultBranch ? ( + <> + {isBranchingEnabled && ( + + )} + + + ) : ( + <> + {/* Feature branch footer: Create PR + Switch branch */} + + + + )} +
+
+
+
+ ); + + return ( + <> +
+ +
+ + setShowDropdown(false)} + popperConfig={{ + modifiers: [ + { + name: 'preventOverflow', + options: { boundary: 'viewport', padding: 8 }, + }, + { + name: 'flip', + options: { fallbackPlacements: ['bottom-start', 'top-end', 'top-start'] }, + }, + { + name: 'offset', + options: { offset: [0, 4] }, + }, + ], + }} + > + {({ placement: _p, arrowProps: _a, show: _s, popper: _po, ...props }) => ( +
{renderPopover(props)}
+ )} +
+ + {/* Create Branch Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSuccess={() => setShowCreateModal(false)} + /> + )} + + {/* Switch Branch Modal */} + {showSwitchModal && ( + setShowSwitchModal(false)} /> + )} + + ); +} + +export default WorkspaceBranchDropdown; diff --git a/frontend/src/_ui/WorkspaceGitCTA/index.jsx b/frontend/src/_ui/WorkspaceGitCTA/index.jsx new file mode 100644 index 0000000000..02f05e70f7 --- /dev/null +++ b/frontend/src/_ui/WorkspaceGitCTA/index.jsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { Button } from '@/components/ui/Button/Button'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { useLicenseStore } from '@/_stores/licenseStore'; +import { WorkspaceGitSyncModal } from '@/_ui/WorkspaceGitSyncModal'; + +export function WorkspaceGitCTA() { + const [showModal, setShowModal] = useState(false); + const [initialTab, setInitialTab] = useState('push'); + const { currentBranch, orgGitConfig } = useWorkspaceBranchesStore((state) => ({ + currentBranch: state.currentBranch, + orgGitConfig: state.orgGitConfig, + })); + + const featureAccess = useLicenseStore((state) => state.featureAccess); + + if (!featureAccess?.gitSync || !orgGitConfig) return null; + + const defaultGitBranch = orgGitConfig?.default_git_branch || orgGitConfig?.defaultGitBranch || 'main'; + const isOnDefaultBranch = + currentBranch?.is_default || currentBranch?.isDefault || currentBranch?.name === defaultGitBranch; + const openModal = (tab) => { + setInitialTab(tab); + setShowModal(true); + }; + + return ( + <> +
+ {/* */} + +
+ + {/* {showModal && setShowModal(false)} />} */} + {!isOnDefaultBranch && ( +
+ +
+ )} + {showModal && ( + setShowModal(false)} + /> + )} + + ); +} + +export default WorkspaceGitCTA; diff --git a/frontend/src/_ui/WorkspaceGitSyncModal/WorkspaceGitSyncModal.scss b/frontend/src/_ui/WorkspaceGitSyncModal/WorkspaceGitSyncModal.scss new file mode 100644 index 0000000000..fc4a7a1853 --- /dev/null +++ b/frontend/src/_ui/WorkspaceGitSyncModal/WorkspaceGitSyncModal.scss @@ -0,0 +1,373 @@ +// Workspace Git Sync Modal — mirrors the app-level git-sync-modal.scss +.git-sync-modal, +.modal-base { + .create-commit-container, + .commit-info, + .pull-container, + .pushpull-container { + height: 300px !important; + + .pull-section, + .push-section { + display: flex; + align-items: flex-start; + justify-content: center; + height: 100%; + width: 100%; + + &.pull-section--centered { + align-items: center; + } + + .selected-commit-header { + margin-bottom: 8px; + color: var(--slate11); + } + + .selected-commit-info { + display: flex; + gap: 6px; + align-items: flex-start; + + .commit-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + } + + .commit-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + + .commit-title { + font-family: 'Inter', sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 18px; + color: var(--slate12); + } + + .commit-metadata { + font-family: 'Inter', sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--slate11); + } + } + } + } + + .form-control { + font-weight: 400; + font-size: 12px; + line-height: 20px; + color: var(--slate12); + } + + .form-group { + .tj-input-error-state { + border: 1px solid var(--tomato9) !important; + } + + .tj-input-error { + color: var(--tomato10) !important; + } + } + + .info-text { + color: var(--slate10); + } + + .tj-input-error { + color: var(--tomato10); + } + + .form-control.disabled { + background-color: var(--slate3) !important; + color: var(--slate9) !important; + } + + .last-commit-info { + background: var(--slate3); + + .message-info { + display: flex; + justify-content: space-between; + } + + .author-info { + font-size: 10px; + color: var(--slate11); + } + } + + .check-for-updates { + display: flex; + align-items: center; + color: var(--indigo9); + + svg { + path { + fill: var(--indigo9); + } + + rect { + fill: none; + } + } + + .loader-container { + height: unset !important; + + .primary-spin-loader { + width: 18px; + height: 18px; + margin-right: 5px; + } + } + } + } + + .modal-footer { + border-top: 1px solid var(--slate5); + padding: 1rem; + position: relative; + z-index: 3; + + .tj-btn-left-icon { + svg { + width: 20px; + height: 20px; + + path { + fill: var(--indigo1); + } + } + } + + .tj-large-btn { + font-weight: 500; + font-size: 14px; + } + } + + .modal-body { + .loader-container { + display: flex; + justify-content: center; + align-items: center; + height: 180px; + } + } + + .modal-base { + .tj-text-xxsm { + color: var(--slate11); + } + } + + .modal-header { + border-bottom: 1px solid var(--slate5) !important; + + .modal-title { + color: var(--slate12); + } + } +} + +.git-sync-modal { + width: 400px !important; + height: 470px !important; + + .pushpull-container { + height: 230px !important; + } + + .pull-container { + height: 260px !important; + } + + .modal-body { + height: 240px !important; + + .loader-container { + display: flex; + justify-content: center; + align-items: center; + height: 180px; + } + } + + .modal-header { + border-bottom: 1px solid var(--slate5) !important; + height: auto !important; + min-height: 90px !important; + + .modal-title { + color: var(--slate12); + } + } +} + +.dark-theme.git-sync-modal { + .modal-header { + border-bottom: 1px solid var(--slate5) !important; + } +} + +.git-sync-modal .modal-header .modal-title .push-pull-tabs .tab-push.active, +.git-sync-modal .modal-header .modal-title .push-pull-tabs .tab-pull.active { + border-bottom: 2px solid var(--indigo9) !important; +} + +.git-sync-modal .git-sync-title .helper-text { + color: var(--text-placeholder, #6a727c); + font-family: var(--family-regular, Inter); + font-size: var(--size-default, 12px); + font-style: normal; + font-weight: var(--weight-regular, 400); + line-height: var(--line-height-default, 18px); + letter-spacing: var(--letter-spacing-default, -0.24px); + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-skip-ink: auto; + text-decoration-thickness: auto; + text-underline-offset: auto; + text-underline-position: from-font; +} + +.git-sync-modal .pull-info-box { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 12px; + border: 1px dashed var(--border-default, #ccd1d5); + border-radius: 6px; + min-height: 70px; + + svg { + color: var(--text-medium, #2d343b); + flex-shrink: 0; + } +} + +.git-sync-modal .pull-info-content { + display: flex; + flex-direction: column; + gap: 0; + flex: 1; +} + +.git-sync-modal .pull-info-title { + color: var(--text-medium, #2d343b); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 18px; +} + +.git-sync-modal .pull-info-subtitle { + color: var(--text-placeholder, #6a727c); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; +} + +.git-sync-modal .pull-info-box svg { + color: var(--icon-warning, #bf4f03); + flex-shrink: 0; +} + +// Action choice section (Import vs Pull into current branch) +.git-sync-modal .action-choice-section { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 0; + + .action-choice-option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--slate5); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s; + + &:hover, + &.active { + border-color: var(--indigo9); + } + + input[type='radio'] { + margin-top: 2px; + accent-color: var(--indigo9); + flex-shrink: 0; + } + + .option-content { + display: flex; + flex-direction: column; + gap: 2px; + } + + .option-title { + font-size: 14px; + font-weight: 500; + color: var(--slate12); + line-height: 20px; + } + + .option-description { + font-size: 12px; + font-weight: 400; + color: var(--slate11); + line-height: 18px; + } + } +} + +.git-sync-modal .no-commits-empty-state { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px 12px; + border: 1px dashed var(--border-default, #ccd1d5); + border-radius: 6px; + min-height: 70px; + + svg { + color: var(--icon-warning, #bf4f03); + flex-shrink: 0; + } + + .empty-state-content { + display: flex; + flex-direction: column; + gap: 0; + flex: 1; + } + + .empty-state-title { + color: var(--text-medium, #2d343b); + font-size: 12px; + font-weight: 500; + line-height: 18px; + } + + .empty-state-description { + color: var(--text-placeholder, #6a727c); + font-size: 12px; + font-weight: 400; + line-height: 18px; + } +} diff --git a/frontend/src/_ui/WorkspaceGitSyncModal/index.jsx b/frontend/src/_ui/WorkspaceGitSyncModal/index.jsx new file mode 100644 index 0000000000..3c40014130 --- /dev/null +++ b/frontend/src/_ui/WorkspaceGitSyncModal/index.jsx @@ -0,0 +1,595 @@ +import React, { useState, useEffect } from 'react'; +import cx from 'classnames'; +import Modal from 'react-bootstrap/Modal'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { workspaceBranchesService } from '@/_services/workspace_branches.service'; +import { setActiveBranch } from '@/_helpers/active-branch'; +import { toast } from 'react-hot-toast'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import OverflowTooltip from '@/_components/OverflowTooltip'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import Dropdown from '@/components/ui/Dropdown/Index.jsx'; +import './WorkspaceGitSyncModal.scss'; + +const UPDATE_STATUS = { + AVAILABLE: 'AVAILABLE', + UNAVAILABLE: 'UNAVAILABLE', + FETCHING: 'FETCHING', + NONE: 'NONE', +}; + +export function WorkspaceGitSyncModal({ isOnDefaultBranch, initialTab = 'push', onClose }) { + const darkMode = localStorage.getItem('darkMode') === 'true'; + const [commitMessage, setCommitMessage] = useState(''); + const [activeTab, setActiveTab] = useState(initialTab); + const [checkingForUpdate, setCheckingForUpdate] = useState({ + visible: true, + message: 'Check for updates', + status: UPDATE_STATUS.NONE, + }); + const [latestCommitData, setLatestCommitData] = useState(null); + const [pushLatestCommitData, setPushLatestCommitData] = useState(null); + const [pushLatestCommitLoading, setPushLatestCommitLoading] = useState(false); + const [selectedBranch, setSelectedBranch] = useState(''); + const [actionChoiceMode, setActionChoiceMode] = useState(false); + + const { orgGitConfig, branches, remoteBranches, currentBranch, isPushing, isPulling } = useWorkspaceBranchesStore( + (state) => ({ + orgGitConfig: state.orgGitConfig, + branches: state.branches || [], + remoteBranches: state.remoteBranches || [], + currentBranch: state.currentBranch, + isPushing: state.isPushing, + isPulling: state.isPulling, + }) + ); + const actions = useWorkspaceBranchesStore((state) => state.actions); + + const repoUrl = orgGitConfig?.repo_url || orgGitConfig?.repoUrl || ''; + const defaultGitBranch = orgGitConfig?.default_git_branch || orgGitConfig?.defaultGitBranch || 'main'; + const gitType = orgGitConfig?.git_type || orgGitConfig?.gitType || 'github_https'; + const currentBranchName = currentBranch?.name || defaultGitBranch; + + const gitSyncUrl = (() => { + if (gitType === 'gitlab') return repoUrl; + if (gitType === 'github_ssh') { + const match = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); + return match ? `https://github.com/${match[1]}` : repoUrl; + } + return repoUrl; + })(); + + // Set initial selected branch to current branch + useEffect(() => { + if (!selectedBranch && currentBranchName) { + setSelectedBranch(currentBranchName); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentBranchName]); + + // Fetch latest commit for push/commit section + useEffect(() => { + if (activeTab === 'push' && defaultGitBranch && !pushLatestCommitData && !pushLatestCommitLoading) { + setPushLatestCommitLoading(true); + actions + .checkForUpdates(defaultGitBranch) + .then((data) => { + setPushLatestCommitData(data?.latestCommit || null); + }) + .catch(() => { + setPushLatestCommitData(null); + }) + .finally(() => { + setPushLatestCommitLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, defaultGitBranch]); + + // Re-check when selected branch changes (only if we already have results and not in action choice) + useEffect(() => { + if (selectedBranch && checkingForUpdate?.status === UPDATE_STATUS.AVAILABLE && !actionChoiceMode) { + checkForUpdates(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBranch]); + + const checkForUpdates = () => { + setCheckingForUpdate({ + visible: true, + message: 'Checking...', + status: UPDATE_STATUS.FETCHING, + }); + + // Fetch remote branches and check for updates in parallel + Promise.all([actions.checkForUpdates(selectedBranch || currentBranchName), actions.fetchRemoteBranches()]) + .then(([data]) => { + setLatestCommitData(data?.latestCommit || null); + // Always show dropdown after check, regardless of whether updates exist + setCheckingForUpdate({ + visible: false, + message: '', + status: UPDATE_STATUS.AVAILABLE, + }); + }) + .catch((error) => { + toast.error(error?.error || error?.message || 'Failed to check for updates'); + setCheckingForUpdate({ + visible: true, + message: 'Check for updates', + status: UPDATE_STATUS.NONE, + }); + }); + }; + + const handleBranchChange = (branchName) => { + setSelectedBranch(branchName); + // If selected branch differs from current, show confirmation + if (branchName !== currentBranchName) { + setActionChoiceMode(true); + } else { + setActionChoiceMode(false); + } + }; + + const getAppIdFromUrl = () => { + const match = window.location.pathname.match(/\/[^/]+\/apps\/([^/]+)/); + return match ? match[1] : null; + }; + + const handleImportBranch = async () => { + useWorkspaceBranchesStore.setState({ isPulling: true }); + try { + // Check if branch already exists locally — if so, update it; if not, create it + const existingBranch = branches.find((b) => b.name === selectedBranch); + let branchId; + + if (existingBranch) { + branchId = existingBranch.id; + } else { + const newBranch = await actions.createBranch(selectedBranch); + branchId = newBranch.id; + } + + // Switch to the target branch — pass appId for co_relation_id resolution + const appId = getAppIdFromUrl(); + const switchResult = await workspaceBranchesService.switchBranch(branchId, appId); + + // Also update localStorage + workspace store + const branchObj = existingBranch || { id: branchId, name: selectedBranch }; + setActiveBranch(branchObj); + useWorkspaceBranchesStore.setState({ + activeBranchId: branchId, + currentBranch: branchObj, + }); + + // Pull from that branch (now active) — creates stubs for all apps + await actions.pullWorkspace(); + + toast.success(`Imported ${selectedBranch} successfully`); + onClose(); + + // Navigate based on whether the current app exists in the target branch + const resolvedAppId = switchResult?.resolvedAppId || switchResult?.resolved_app_id; + const pathParts = window.location.pathname.split('/'); + if (resolvedAppId && appId) { + window.location.href = `/${pathParts[1]}/apps/${resolvedAppId}`; + } else if (appId) { + // Current app doesn't exist in that branch — go to homepage + window.location.href = `/${pathParts[1]}`; + } else { + // Already on homepage — just reload + window.location.reload(); + } + } catch (error) { + toast.error(error?.error || error?.message || 'Import failed'); + } finally { + useWorkspaceBranchesStore.setState({ isPulling: false }); + } + }; + + const handleContinue = async () => { + await handleImportBranch(); + }; + + const formatCommitDate = (dateString) => { + if (!dateString) return 'Unknown date'; + const date = new Date(dateString); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec']; + const day = date.getDate(); + const month = months[date.getMonth()]; + let hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'pm' : 'am'; + hours = hours % 12 || 12; + const minutesStr = minutes < 10 ? `0${minutes}` : minutes; + return `${day} ${month}, ${hours}:${minutesStr}${ampm}`; + }; + + const handleCommitChange = (e) => setCommitMessage(e.target.value); + + const handlePush = async () => { + if (!commitMessage.trim()) { + toast.error('Commit message is required'); + return; + } + try { + await actions.pushWorkspace(commitMessage); + // toast.success('Changes pushed successfully'); + toast.success('Commit was pushed to git successfully!'); + onClose(); + } catch (error) { + toast.error(error?.message || 'Push failed'); + } + }; + + const handlePull = async () => { + try { + await actions.pullWorkspace(); + toast.success('Commit pulled successfully!'); + onClose(); + window.location.reload(); + } catch (error) { + toast.error(error?.message || 'Pull failed'); + } + }; + + // Use remote branches for dropdown, fall back to local branches + const dropdownBranches = remoteBranches.length > 0 ? remoteBranches : branches; + + // ---- Confirmation view for importing a different branch ---- + const renderImportConfirmation = () => ( +
+

+ {selectedBranch} branch does not exist in ToolJet, pulling this will import it as a new branch + with the latest commit. Do you want to proceed? +

+
+ ); + + // ---- Pull section content ---- + const renderPullSection = () => ( + //
+
+
+ {/* Check for updates button */} + {checkingForUpdate?.visible && ( +
+
checkForUpdates()} className="check-for-updates cursor-pointer"> + {checkingForUpdate?.status === UPDATE_STATUS.FETCHING ? ( +
+
+
+ ) : ( + + )} +
+ {checkingForUpdate?.message} +
+
+
+ )} + + {/* Updates available: branch dropdown + commit info */} + {checkingForUpdate?.status === UPDATE_STATUS.AVAILABLE && ( +
+
+ { + acc[branch.name] = { + value: branch.name, + label: branch.name, + }; + return acc; + }, {})} + value={selectedBranch} + onChange={handleBranchChange} + width="100%" + theme={darkMode ? 'dark' : 'light'} + /> +
+ + {/* Latest commit info or up-to-date message */} + {latestCommitData ? ( +
+
LATEST COMMIT
+
+
+
+ +
+
+ + {latestCommitData.message || 'No message'} + +
+ By {latestCommitData.author || 'Unknown'} | {formatCommitDate(latestCommitData.date)} +
+
+
+
+
+ ) : ( +
+ +
+
Up to date
+
+ Workspace is up to date with the latest changes on this branch +
+
+
+ )} +
+ )} +
+
+ ); + + // ---- Push section content ---- + const renderPushSection = () => ( +
+
+
+
+ +
+ +
+
+ {pushLatestCommitLoading && ( +
+
+
+
+
+ )} + + {!pushLatestCommitLoading && pushLatestCommitData && ( +
+
LATEST COMMIT
+
+
+
+ +
+
+ + {pushLatestCommitData.message || 'No message'} + +
+ By {pushLatestCommitData.author || 'Unknown'} | {formatCommitDate(pushLatestCommitData.date)} +
+
+
+
+
+ )} + + {!pushLatestCommitLoading && !pushLatestCommitData && ( +
+
+
No commits yet
+
This will be your first commit to the repository.
+
+
+ )} +
+
+
+ ); + + // ---- Push/Pull tab header (feature branches only) ---- + const renderPushPullTabs = () => ( +
+
+ +
+
+ +
+
+ ); + + // --- Modal body --- + const renderModalBody = () => { + // Default branch: pull-only + if (isOnDefaultBranch) { + if (actionChoiceMode) { + return
{renderImportConfirmation()}
; + } + return
{renderPullSection()}
; + } + + // Feature branch: push/pull tabs + if (activeTab === 'pull') { + if (actionChoiceMode) { + return
{renderImportConfirmation()}
; + } + return
{renderPullSection()}
; + } + return
{renderPushSection()}
; + }; + + const renderModalFooter = () => { + // Pull tab active (default branch or feature branch pull tab) + if (activeTab === 'pull' || isOnDefaultBranch) { + if (actionChoiceMode) { + return ( + + { + setActionChoiceMode(false); + setSelectedBranch(currentBranchName); + }} + disabled={isPulling} + > + Cancel + + + Continue + + + ); + } + return ( + + + Cancel + + + Pull changes + + + ); + } + // Push tab active + return ( + + + Cancel + + + Commit changes + + + ); + }; + + // Modal title changes based on mode + const modalTitle = (() => { + if (actionChoiceMode) { + return `Import ${selectedBranch} from git`; + } + + if (isOnDefaultBranch) return 'Pull Commit'; + return activeTab === 'pull' ? 'Pull Commit' : 'Push Commit'; + })(); + + return ( + + + +
+
{modalTitle}
+
+ +
+ {gitSyncUrl && !actionChoiceMode && ( +
+ + in + + + {gitSyncUrl} + +
+ )} +
+ {/* {!isOnDefaultBranch && !actionChoiceMode && renderPushPullTabs()} */} +
+
+ {renderModalBody()} + {renderModalFooter()} +
+ ); +} + +export default WorkspaceGitSyncModal; diff --git a/frontend/src/_ui/WorkspaceLockedBanner/index.jsx b/frontend/src/_ui/WorkspaceLockedBanner/index.jsx new file mode 100644 index 0000000000..b5f9fca0ac --- /dev/null +++ b/frontend/src/_ui/WorkspaceLockedBanner/index.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import LockedBranchBanner from '@/AppBuilder/Header/LockedBranchBanner'; + +export function WorkspaceLockedBanner({ pageContext = '' }) { + const { currentBranch, orgGitConfig, isInitialized } = useWorkspaceBranchesStore((state) => ({ + currentBranch: state.currentBranch, + orgGitConfig: state.orgGitConfig, + isInitialized: state.isInitialized, + })); + + if (!isInitialized || !orgGitConfig) return null; + + const isBranchingEnabled = orgGitConfig?.is_branching_enabled || orgGitConfig?.isBranchingEnabled; + const isOnDefaultBranch = currentBranch?.is_default || currentBranch?.isDefault; + const isVisible = isBranchingEnabled && isOnDefaultBranch; + + return ( + + ); +} + +export default WorkspaceLockedBanner; diff --git a/frontend/src/modules/common/components/BaseManageOrgConstants/BaseManageOrgConstants.jsx b/frontend/src/modules/common/components/BaseManageOrgConstants/BaseManageOrgConstants.jsx index 99c168b099..d1f2920ecd 100644 --- a/frontend/src/modules/common/components/BaseManageOrgConstants/BaseManageOrgConstants.jsx +++ b/frontend/src/modules/common/components/BaseManageOrgConstants/BaseManageOrgConstants.jsx @@ -230,16 +230,27 @@ const BaseManageOrgConstants = ({ updateTableData(constants, envName, start, end, false, activeTab, searchTerm); }; + const isWorkspaceBranchLocked = false; + const canCreateVariable = () => { - return authenticationService.currentSessionValue.user_permissions.org_constant_c_r_u_d || super_admin || admin; + return ( + (authenticationService.currentSessionValue.user_permissions.org_constant_c_r_u_d || super_admin || admin) && + !isWorkspaceBranchLocked + ); }; const canUpdateVariable = () => { - return authenticationService.currentSessionValue.user_permissions.org_constant_c_r_u_d || super_admin || admin; + return ( + (authenticationService.currentSessionValue.user_permissions.org_constant_c_r_u_d || super_admin || admin) && + !isWorkspaceBranchLocked + ); }; const canDeleteVariable = () => { - return authenticationService.currentSessionValue.user_permissions.org_constant_c_r_u_d || super_admin || admin; + return ( + (authenticationService.currentSessionValue.user_permissions.org_constant_c_r_u_d || super_admin || admin) && + !isWorkspaceBranchLocked + ); }; const fetchEnvironments = () => { @@ -476,8 +487,8 @@ const BaseManageOrgConstants = ({
-
-
+
+
{capitalize(activeTabEnvironment?.name)} ({globalCount + secretCount}) diff --git a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx index 0431cb6e05..bf6664b948 100644 --- a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx +++ b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx @@ -472,6 +472,7 @@ class DataSourceManagerComponent extends React.Component { showValidationErrors={showValidationErrors} clearValidationErrorBanner={() => this.setState({ validationError: [] })} elementsProps={this.props.formProps?.[kind]} + isWorkspaceBranchLocked={this.props.isWorkspaceBranchLocked} /> ); }; @@ -958,9 +959,8 @@ class DataSourceManagerComponent extends React.Component { : {}; const sampleDBmodalFooterStyle = isSampleDb ? { paddingTop: '8px' } : {}; const isSaveDisabled = selectedDataSource - ? (deepEqual(options, selectedDataSource?.options, ['encrypted']) && - selectedDataSource?.name === datasourceName) || - !isEmpty(validationMessages) + ? deepEqual(options, selectedDataSource?.options, ['encrypted', 'credential_id']) && + selectedDataSource?.name === datasourceName : true; this.props.setGlobalDataSourceStatus({ isEditing: !isSaveDisabled }); const docLink = isSampleDb @@ -1034,7 +1034,7 @@ class DataSourceManagerComponent extends React.Component { data-cy="data-source-name-input-field" autoFocus autoComplete="off" - disabled={!canUpdateDataSource(selectedDataSource.id)} + disabled={this.props.isWorkspaceBranchLocked || !canUpdateDataSource(selectedDataSource.id)} /> {!this.props.isEditing && ( @@ -1225,7 +1225,7 @@ class DataSourceManagerComponent extends React.Component { appId={this.state.appId} />
- {!isSampleDb && ( + {!isSampleDb && this.props.showSaveBtn !== false && (
-
- - {isSaving - ? this.props.t('editor.queryManager.dataSourceManager.saving' + '...', 'Saving...') - : this.props.t('globals.save', 'Save')} - -
+ {this.props.showSaveBtn !== false && ( +
+ + {isSaving + ? this.props.t('editor.queryManager.dataSourceManager.saving' + '...', 'Saving...') + : this.props.t('globals.save', 'Save')} + +
+ )} )} diff --git a/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx b/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx index 23291c9b7e..7f630730df 100644 --- a/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx +++ b/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx @@ -21,6 +21,9 @@ import SolidIcon from '@/_ui/Icon/SolidIcons'; import { BreadCrumbContext } from '@/App'; import { ToolTip } from '@/_components/ToolTip'; import { canDeleteDataSource, canCreateDataSource, canUpdateDataSource } from '@/_helpers'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { WorkspaceLockedBanner } from '@/_ui/WorkspaceLockedBanner'; +import { WorkspaceSwitchBranchModal } from '@/_ui/WorkspaceBranchDropdown/SwitchBranchModal'; import { fetchAndSetWindowTitle, pageTitles } from '@white-label/whiteLabelling'; import HeaderSkeleton from '@/_ui/FolderSkeleton/HeaderSkeleton'; import Skeleton from 'react-loading-skeleton'; @@ -36,6 +39,10 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource } const [queryString, setQueryString] = useState(''); const [addingDataSource, setAddingDataSource] = useState(false); const [suggestingDataSource, setSuggestingDataSource] = useState(false); + const [showSwitchBranchModal, setShowSwitchBranchModal] = useState(false); + const [pendingAddDataSource, setPendingAddDataSource] = useState(null); + const [pendingCreateDS, setPendingCreateDS] = useState(null); + const loadingSeenRef = useRef(false); const { t } = useTranslation(); const { admin } = authenticationService.currentSessionValue; const marketplaceEnabled = admin; @@ -102,6 +109,21 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDataSource, isEditing]); + // After branch switch + refetch, trigger the deferred add + useEffect(() => { + if (!pendingCreateDS) return; + if (isLoading) { + loadingSeenRef.current = true; + } + if (!isLoading && loadingSeenRef.current) { + const ds = pendingCreateDS; + loadingSeenRef.current = false; + setPendingCreateDS(null); + createDataSource(ds); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSources, isLoading, pendingCreateDS]); + const handleHideModal = (ds) => { if (dataSources?.length) { if (!isEditing) { @@ -322,16 +344,35 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource } ); }; + const isWorkspaceBranchLocked = useWorkspaceBranchesStore((state) => { + if (!state.isInitialized || !state.orgGitConfig) return false; + const isBranchingEnabled = state.orgGitConfig?.is_branching_enabled || state.orgGitConfig?.isBranchingEnabled; + const isDefault = state.currentBranch?.is_default || state.currentBranch?.isDefault; + return !!(isBranchingEnabled && isDefault); + }); + const renderCardGroup = (source, type) => { - const canAddDataSource = canCreateDataSource(); + const hasCreatePermission = canCreateDataSource(); + const canAddDataSource = hasCreatePermission && !isWorkspaceBranchLocked; const addDataSourceBtn = (item) => ( - +
createDataSource(item)} + onClick={() => { + if (isWorkspaceBranchLocked) { + setPendingAddDataSource(item); + setShowSwitchBranchModal(true); + } else { + createDataSource(item); + } + }} data-cy={`${item.title.toLowerCase().replace(/\s+/g, '-')}-add-button`} > @@ -482,6 +523,7 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource }
+ {containerRef && containerRef?.current && selectedDataSource && ( @@ -507,6 +550,23 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource } {isLoading && loadingState()} {!selectedDataSource && activeDatasourceList && !isLoading && segregateDataSources()}
+ {showSwitchBranchModal && ( + { + setShowSwitchBranchModal(false); + setPendingAddDataSource(null); + }} + onBranchSwitch={() => { + if (pendingAddDataSource) { + loadingSeenRef.current = false; + setPendingCreateDS(pendingAddDataSource); + setPendingAddDataSource(null); + } + setShowSwitchBranchModal(false); + }} + /> + )}
); }; diff --git a/frontend/src/modules/dataSources/components/List/index.jsx b/frontend/src/modules/dataSources/components/List/index.jsx index 8e3b8ef482..c5cf242303 100644 --- a/frontend/src/modules/dataSources/components/List/index.jsx +++ b/frontend/src/modules/dataSources/components/List/index.jsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { toast } from 'react-hot-toast'; import { GlobalDataSourcesContext } from '../../pages/GlobalDataSourcesPage'; import { ListItem } from '../LIstItem'; @@ -11,6 +11,8 @@ import { DATA_SOURCE_TYPE } from '@/_helpers/constants'; import FolderSkeleton from '@/_ui/FolderSkeleton/FolderSkeleton'; import Modal from '@/HomePage/Modal'; import { Button } from '@/components/ui/Button/Button'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; +import { WorkspaceSwitchBranchModal } from '@/_ui/WorkspaceBranchDropdown/SwitchBranchModal'; export const List = ({ updateSelectedDatasource }) => { const { @@ -31,9 +33,21 @@ export const List = ({ updateSelectedDatasource }) => { const [filteredData, setFilteredData] = useState(dataSources); const [showInput, setShowInput] = useState(false); const [showDependentQueriesInfo, setShowDependentQueriesInfo] = useState(false); + const [showSwitchBranchModal, setShowSwitchBranchModal] = useState(false); + const [pendingDeleteSource, setPendingDeleteSource] = useState(null); + const pendingDeleteAfterSwitchRef = useRef(null); const darkMode = localStorage.getItem('darkMode') === 'true'; + const isBranchingEnabled = useWorkspaceBranchesStore((state) => { + if (!state.isInitialized || !state.orgGitConfig) return false; + return !!(state.orgGitConfig?.is_branching_enabled || state.orgGitConfig?.isBranchingEnabled); + }); + + const isOnDefaultBranch = useWorkspaceBranchesStore((state) => { + return !!(state.currentBranch?.is_default || state.currentBranch?.isDefault); + }); + useEffect(() => { environments?.length && fetchDataSources(false).catch(() => { @@ -47,7 +61,22 @@ export const List = ({ updateSelectedDatasource }) => { setFilteredData([...dataSources]); }, [dataSources]); + // After branch switch + refetch, trigger the deferred delete flow + useEffect(() => { + if (pendingDeleteAfterSwitchRef.current && !isLoading && dataSources.length) { + const source = pendingDeleteAfterSwitchRef.current; + pendingDeleteAfterSwitchRef.current = null; + deleteDataSource(source); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSources, isLoading]); + const deleteDataSource = (selectedSource) => { + if (isBranchingEnabled && isOnDefaultBranch) { + setPendingDeleteSource(selectedSource); + setShowSwitchBranchModal(true); + return; + } setActiveDatasourceList(''); setSelectedDataSource(selectedSource); setCurrentEnvironment(environments[0]); @@ -204,13 +233,35 @@ export const List = ({ updateSelectedDatasource }) => { executeDataSourceDeletion()} onCancel={() => cancelDeleteDataSource()} darkMode={darkMode} backdropClassName="delete-modal" /> + {showSwitchBranchModal && ( + { + setShowSwitchBranchModal(false); + setPendingDeleteSource(null); + }} + onBranchSwitch={() => { + if (pendingDeleteSource) { + pendingDeleteAfterSwitchRef.current = pendingDeleteSource; + setPendingDeleteSource(null); + } + setShowSwitchBranchModal(false); + }} + /> + )} ); }; diff --git a/frontend/src/modules/dataSources/pages/GlobalDataSourcesPage/index.jsx b/frontend/src/modules/dataSources/pages/GlobalDataSourcesPage/index.jsx index 7a4cf5f201..eb90938b96 100644 --- a/frontend/src/modules/dataSources/pages/GlobalDataSourcesPage/index.jsx +++ b/frontend/src/modules/dataSources/pages/GlobalDataSourcesPage/index.jsx @@ -10,6 +10,7 @@ import _ from 'lodash'; import { DATA_SOURCE_TYPE } from '@/_helpers/constants'; import { fetchAndSetWindowTitle, pageTitles } from '@white-label/whiteLabelling'; import { fetchEdition } from '@/modules/common/helpers/utils'; +import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore'; export const GlobalDataSourcesContext = createContext({ showDataSourceManagerModal: false, @@ -37,6 +38,21 @@ export const GlobalDataSourcesPage = (props) => { const [featureAccess, setFeatureAccess] = useState({}); const initialUrlSelectionHandled = useRef(false); + const activeBranchId = useWorkspaceBranchesStore((state) => state.activeBranchId); + const prevBranchIdRef = useRef(activeBranchId); + + // Refetch datasources when the active branch changes (without hard reload) + useEffect(() => { + if (prevBranchIdRef.current !== activeBranchId && activeBranchId && environments?.length) { + prevBranchIdRef.current = activeBranchId; + setSelectedDataSource(null); + toggleDataSourceManagerModal(false); + setActiveDatasourceList('#commonlyused'); + fetchDataSources(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeBranchId, environments]); + useEffect(() => { if (dataSources?.length == 0) updateSidebarNAV('Commonly used'); fetchFeatureAccess(); diff --git a/server/data-migrations/1773229179000-SeedWorkspaceBranchData.ts b/server/data-migrations/1773229179000-SeedWorkspaceBranchData.ts new file mode 100644 index 0000000000..f2f30e041b --- /dev/null +++ b/server/data-migrations/1773229179000-SeedWorkspaceBranchData.ts @@ -0,0 +1,167 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedWorkspaceBranchData1773229179000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. Create default branch for every org that has git sync configured + // Uses the actual branch name from the provider config (HTTPS / SSH / GitLab), + // falling back to 'main' only if no provider config exists. + await queryRunner.query(` + INSERT INTO organization_git_sync_branches (organization_id, branch_name, is_default) + SELECT + ogs.organization_id, + COALESCE( + ogh.github_branch, + ogsh.git_branch, + ogl.gitlab_branch, + 'main' + ), + true + FROM organization_git_sync ogs + LEFT JOIN organization_git_https ogh ON ogh.config_id = ogs.id + LEFT JOIN organization_git_ssh ogsh ON ogsh.config_id = ogs.id + LEFT JOIN organization_gitlab ogl ON ogl.config_id = ogs.id + ON CONFLICT (organization_id, branch_name) DO NOTHING; + `); + + // 1b. Create workspace branches for pre-existing app-scoped branches. + // Old model: branches were app_versions with version_type='branch' and name = branch name. + // New model: workspace-scoped branches in organization_git_sync_branches. + // For each distinct (org, branch_name) combination, create a workspace branch entry + // with source_branch_id pointing to the default branch. + // ON CONFLICT skips if the name matches the default branch already created above. + await queryRunner.query(` + INSERT INTO organization_git_sync_branches (organization_id, branch_name, is_default, source_branch_id) + SELECT DISTINCT + a.organization_id, + av.name, + false, + wb_default.id + FROM app_versions av + JOIN apps a ON a.id = av.app_id + JOIN organization_git_sync ogs ON ogs.organization_id = a.organization_id + JOIN organization_git_sync_branches wb_default + ON wb_default.organization_id = a.organization_id AND wb_default.is_default = true + WHERE av.version_type = 'branch' + ON CONFLICT (organization_id, branch_name) DO NOTHING; + `); + + // 2. Create the isDefault DSV (branch_id = NULL) for every global DS. + // This is the license-expiry fallback — always available regardless of + // branch state. Runtime code expects isDefault DSVs to have branch_id = NULL. + await queryRunner.query(` + INSERT INTO data_source_versions (data_source_id, branch_id, name, is_default, is_active) + SELECT ds.id, NULL, ds.name, true, true + FROM data_sources ds + WHERE ds.scope = 'global' + AND ds.app_version_id IS NULL + ON CONFLICT DO NOTHING; + `); + + // 2b. Create main branch DSV (branch_id = default branch UUID, is_default = false). + // This is the branch-aware DSV for the main branch, separate from the + // isDefault fallback. Both start with the same options from migration. + await queryRunner.query(` + INSERT INTO data_source_versions (data_source_id, branch_id, name, is_default, is_active) + SELECT ds.id, wb.id, ds.name, false, true + FROM data_sources ds + JOIN organization_git_sync_branches wb ON wb.organization_id = ds.organization_id AND wb.is_default = true + WHERE ds.scope = 'global' + AND ds.app_version_id IS NULL + ON CONFLICT (data_source_id, branch_id) DO NOTHING; + `); + + // 2c. Create feature branch DSVs for all global DS on non-default branches. + // Each feature branch gets its own DSV copied from the isDefault DSV. + // This enables per-branch DS configuration for old app-scoped branches. + await queryRunner.query(` + INSERT INTO data_source_versions (data_source_id, branch_id, name, is_default, is_active, version_from_id) + SELECT ds.id, wb.id, ds.name, false, true, default_dsv.id + FROM data_sources ds + JOIN organization_git_sync_branches wb + ON wb.organization_id = ds.organization_id AND wb.is_default = false + JOIN data_source_versions default_dsv + ON default_dsv.data_source_id = ds.id AND default_dsv.is_default = true + WHERE ds.scope = 'global' + AND ds.app_version_id IS NULL + ON CONFLICT (data_source_id, branch_id) DO NOTHING; + `); + + // 3. Copy data_source_options → data_source_version_options for the isDefault DSVs + await queryRunner.query(` + INSERT INTO data_source_version_options (data_source_version_id, environment_id, options) + SELECT dsv.id, dso.environment_id, COALESCE(dso.options, '{}'::json)::jsonb + FROM data_source_options dso + JOIN data_sources ds ON ds.id = dso.data_source_id + JOIN data_source_versions dsv ON dsv.data_source_id = ds.id AND dsv.is_default = true + WHERE ds.scope = 'global' + AND ds.app_version_id IS NULL + ON CONFLICT (data_source_version_id, environment_id) DO NOTHING; + `); + + // 3b. Copy DSVO from isDefault DSVs into main branch DSVs. + // Main branch starts with the same options as the isDefault fallback. + await queryRunner.query(` + INSERT INTO data_source_version_options (data_source_version_id, environment_id, options) + SELECT main_dsv.id, default_dsvo.environment_id, default_dsvo.options + FROM data_source_versions main_dsv + JOIN organization_git_sync_branches wb + ON wb.id = main_dsv.branch_id AND wb.is_default = true + JOIN data_source_versions default_dsv + ON default_dsv.data_source_id = main_dsv.data_source_id AND default_dsv.is_default = true + JOIN data_source_version_options default_dsvo + ON default_dsvo.data_source_version_id = default_dsv.id + WHERE main_dsv.is_default = false + AND main_dsv.app_version_id IS NULL + ON CONFLICT (data_source_version_id, environment_id) DO NOTHING; + `); + + // 3c. Copy DSVO from isDefault DSVs into feature branch DSVs. + // Each feature branch starts with the same DS options as the default, + // matching the pre-migration behavior where all branches shared options. + // After migration, users can independently modify DS config per branch. + await queryRunner.query(` + INSERT INTO data_source_version_options (data_source_version_id, environment_id, options) + SELECT branch_dsv.id, default_dsvo.environment_id, default_dsvo.options + FROM data_source_versions branch_dsv + JOIN organization_git_sync_branches wb + ON wb.id = branch_dsv.branch_id AND wb.is_default = false + JOIN data_source_versions default_dsv + ON default_dsv.data_source_id = branch_dsv.data_source_id AND default_dsv.is_default = true + JOIN data_source_version_options default_dsvo + ON default_dsvo.data_source_version_id = default_dsv.id + WHERE branch_dsv.is_default = false + AND branch_dsv.app_version_id IS NULL + ON CONFLICT (data_source_version_id, environment_id) DO NOTHING; + `); + + // 4a. Backfill branch_id on branch-type app_versions by matching name → workspace branch. + // This preserves branch identity: feature-auth → feature-auth workspace branch. + await queryRunner.query(` + UPDATE app_versions av + SET branch_id = wb.id + FROM apps a, organization_git_sync_branches wb + WHERE av.app_id = a.id + AND wb.organization_id = a.organization_id + AND wb.branch_name = av.name + AND av.version_type = 'branch' + AND av.branch_id IS NULL; + `); + + // 4b. Assign remaining app_versions (version-type, or any unmatched) to the default branch. + await queryRunner.query(` + UPDATE app_versions av + SET branch_id = wb.id + FROM apps a + JOIN organization_git_sync_branches wb + ON wb.organization_id = a.organization_id AND wb.is_default = true + WHERE av.app_id = a.id + AND av.branch_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM data_source_version_options`); + await queryRunner.query(`DELETE FROM data_source_versions`); + await queryRunner.query(`DELETE FROM organization_git_sync_branches`); + } +} diff --git a/server/data-migrations/1773229180000-SeedDefaultDataSourceVersionsForAll.ts b/server/data-migrations/1773229180000-SeedDefaultDataSourceVersionsForAll.ts new file mode 100644 index 0000000000..dd7690f6f4 --- /dev/null +++ b/server/data-migrations/1773229180000-SeedDefaultDataSourceVersionsForAll.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedDefaultDataSourceVersionsForAll1773229180000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create default data_source_versions for ALL data sources that don't already have one. + // The prior seed (1772568627000) only covered global DS in git-sync enabled orgs. + // This ensures every data source has a default version entry, enabling the + // eventual transition from data_source_options to data_source_version_options. + await queryRunner.query(` + INSERT INTO data_source_versions (data_source_id, name, is_default, is_active, branch_id) + SELECT ds.id, ds.name, true, true, NULL::uuid + FROM data_sources ds + WHERE NOT EXISTS ( + SELECT 1 FROM data_source_versions dsv + WHERE dsv.data_source_id = ds.id AND dsv.is_default = true + ) + ON CONFLICT DO NOTHING; + `); + + // Copy data_source_options → data_source_version_options for the newly created default versions + await queryRunner.query(` + INSERT INTO data_source_version_options (data_source_version_id, environment_id, options) + SELECT dsv.id, dso.environment_id, COALESCE(dso.options, '{}'::json)::jsonb + FROM data_source_options dso + JOIN data_source_versions dsv + ON dsv.data_source_id = dso.data_source_id AND dsv.is_default = true + WHERE NOT EXISTS ( + SELECT 1 FROM data_source_version_options dsvo + WHERE dsvo.data_source_version_id = dsv.id AND dsvo.environment_id = dso.environment_id + ) + ON CONFLICT (data_source_version_id, environment_id) DO NOTHING; + `); + + // Create version-specific DSVs for global data sources referenced by each VERSION-type app version. + // Branch-type app_versions resolve DS options through their branch_id → branch DSV, + // so they should NOT get app_version_id-based DSVs. + // Only VERSION-type versions get their own DSV snapshots (frozen DS config at publish time). + await queryRunner.query(` + INSERT INTO data_source_versions (data_source_id, app_version_id, name, is_default, is_active, branch_id, version_from_id) + SELECT DISTINCT ds.id, av.id, ds.name, false, true, NULL::uuid, def_dsv.id + FROM app_versions av + JOIN data_queries dq ON dq.app_version_id = av.id + JOIN data_sources ds ON ds.id = dq.data_source_id AND ds.scope = 'global' + JOIN data_source_versions def_dsv ON def_dsv.data_source_id = ds.id AND def_dsv.is_default = true + WHERE av.version_type != 'branch' + AND NOT EXISTS ( + SELECT 1 FROM data_source_versions dsv + WHERE dsv.data_source_id = ds.id AND dsv.app_version_id = av.id + ) + ON CONFLICT DO NOTHING; + `); + + // Copy default DSV options into the newly created version-specific DSVs + await queryRunner.query(` + INSERT INTO data_source_version_options (data_source_version_id, environment_id, options) + SELECT new_dsv.id, def_dsvo.environment_id, def_dsvo.options + FROM data_source_versions new_dsv + JOIN data_source_versions def_dsv + ON def_dsv.data_source_id = new_dsv.data_source_id AND def_dsv.is_default = true + JOIN data_source_version_options def_dsvo + ON def_dsvo.data_source_version_id = def_dsv.id + WHERE new_dsv.app_version_id IS NOT NULL + AND new_dsv.is_default = false + AND NOT EXISTS ( + SELECT 1 FROM data_source_version_options dsvo + WHERE dsvo.data_source_version_id = new_dsv.id AND dsvo.environment_id = def_dsvo.environment_id + ) + ON CONFLICT (data_source_version_id, environment_id) DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove options for version-specific DSVs (created by this migration) + await queryRunner.query(` + DELETE FROM data_source_version_options + WHERE data_source_version_id IN ( + SELECT id FROM data_source_versions WHERE app_version_id IS NOT NULL AND is_default = false AND branch_id IS NULL + ); + `); + await queryRunner.query(` + DELETE FROM data_source_versions WHERE app_version_id IS NOT NULL AND is_default = false AND branch_id IS NULL; + `); + + // Remove version options for default versions with no branch (created by this migration) + await queryRunner.query(` + DELETE FROM data_source_version_options + WHERE data_source_version_id IN ( + SELECT id FROM data_source_versions WHERE is_default = true AND branch_id IS NULL + ); + `); + await queryRunner.query(` + DELETE FROM data_source_versions WHERE is_default = true AND branch_id IS NULL; + `); + } +} diff --git a/server/data-migrations/1773229181000-enforce-unique-data-source-names-per-branch.ts b/server/data-migrations/1773229181000-enforce-unique-data-source-names-per-branch.ts new file mode 100644 index 0000000000..62a5292475 --- /dev/null +++ b/server/data-migrations/1773229181000-enforce-unique-data-source-names-per-branch.ts @@ -0,0 +1,86 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EnforceUniqueDataSourceNamesPerBranch1773229181000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const manager = queryRunner.manager; + + const duplicateGroups = await manager.query(` + SELECT + LOWER(name) as lower_name, + COALESCE(branch_id, '00000000-0000-0000-0000-000000000000') as effective_branch_id + FROM data_source_versions + WHERE is_active = true AND is_default = false + GROUP BY LOWER(name), COALESCE(branch_id, '00000000-0000-0000-0000-000000000000') + HAVING COUNT(*) > 1 + `); + + for (const { lower_name, effective_branch_id } of duplicateGroups) { + const records = await manager.query( + ` + SELECT id, name + FROM data_source_versions + WHERE LOWER(name) = $1 + AND COALESCE(branch_id, '00000000-0000-0000-0000-000000000000') = $2 + AND is_active = true + AND is_default = false + ORDER BY created_at ASC + `, + [lower_name, effective_branch_id] + ); + + if (records.length <= 1) continue; + + for (let i = 1; i < records.length; i++) { + const record = records[i]; + const baseName = record.name.replace(/_\d+$/, ''); + let counter = 2; + + while (true) { + const candidate = `${baseName}_${counter}`; + + const exists = await manager.query( + ` + SELECT 1 + FROM data_source_versions + WHERE LOWER(name) = LOWER($1) + AND COALESCE(branch_id, '00000000-0000-0000-0000-000000000000') = $2 + AND is_active = true + AND is_default = false + LIMIT 1 + `, + [candidate, effective_branch_id] + ); + + if (!exists.length) { + await manager.query( + ` + UPDATE data_source_versions + SET name = $1, updated_at = now() + WHERE id = $2 + `, + [candidate, record.id] + ); + break; + } + + counter++; + } + } + } + + await manager.query(` + CREATE UNIQUE INDEX idx_unique_active_name_branch + ON data_source_versions ( + LOWER(name), + COALESCE(branch_id, '00000000-0000-0000-0000-000000000000') + ) + WHERE is_active = true AND is_default = false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS idx_unique_active_name_branch + `); + } +} \ No newline at end of file diff --git a/server/data-migrations/1773229182000-RelaxAppNameUniqueForGitApps.ts b/server/data-migrations/1773229182000-RelaxAppNameUniqueForGitApps.ts new file mode 100644 index 0000000000..4c6ff384d7 --- /dev/null +++ b/server/data-migrations/1773229182000-RelaxAppNameUniqueForGitApps.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Relax app name uniqueness for GIT-created apps. + * + * With workspace branches, the same app (same co_relation_id) exists as + * separate App entities on each branch — they legitimately share the same name. + * Replace the blanket UNIQUE(name, org, type) constraint with a partial index + * that only enforces uniqueness for DEFAULT-created apps. + * + * GIT app uniqueness is enforced at the application level through + * app_versions.branch_id and apps.co_relation_id. + * + * The index keeps the same name so existing catchDbException(APP_NAME_UNIQUE) + * calls continue to work for DEFAULT apps. + * + * This lives in data-migrations/ (not migrations/) so it runs AFTER the older + * data-migrations that create/modify the same constraint: + * - 1684157120658-AddUniqueConstraintToAppName + * - 1705379107714-AddAppNameAppTypeWorkspaceConstraint + */ +export class RelaxAppNameUniqueForGitApps1773229182000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE apps DROP CONSTRAINT IF EXISTS app_name_organization_id_unique; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS app_name_organization_id_unique; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX app_name_organization_id_unique + ON apps (name, organization_id, type) + WHERE creation_mode = 'DEFAULT'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS app_name_organization_id_unique;`); + await queryRunner.query(` + ALTER TABLE apps ADD CONSTRAINT app_name_organization_id_unique UNIQUE (name, organization_id, type); + `); + } +} diff --git a/server/data-migrations/1773300000000-DropDataSourceOptionsTable.ts b/server/data-migrations/1773300000000-DropDataSourceOptionsTable.ts new file mode 100644 index 0000000000..ee0096d02d --- /dev/null +++ b/server/data-migrations/1773300000000-DropDataSourceOptionsTable.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropDataSourceOptionsTable1773300000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // All data has been migrated to data_source_version_options via the seed migration. + // All application code now reads/writes exclusively from data_source_version_options. + await queryRunner.query(`DROP TABLE IF EXISTS "data_source_options" CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "data_source_options" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "data_source_id" uuid NOT NULL, + "environment_id" uuid NOT NULL, + "options" jsonb DEFAULT '{}', + "co_relation_id" uuid, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_data_source_options" PRIMARY KEY ("id"), + CONSTRAINT "FK_data_source_options_data_source" FOREIGN KEY ("data_source_id") REFERENCES "data_sources"("id") ON DELETE CASCADE, + CONSTRAINT "FK_data_source_options_environment" FOREIGN KEY ("environment_id") REFERENCES "app_environments"("id") ON DELETE CASCADE + ) + `); + } +} diff --git a/server/data-migrations/1773400000000-BackfillAppCoRelationId.ts b/server/data-migrations/1773400000000-BackfillAppCoRelationId.ts new file mode 100644 index 0000000000..eda0b11da9 --- /dev/null +++ b/server/data-migrations/1773400000000-BackfillAppCoRelationId.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BackfillAppCoRelationId1773400000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: For app_versions with NULL co_relation_id, copy from a sibling version + // of the same app that already has it set (e.g. a branch version that was pulled). + const versionResult = await queryRunner.query(` + UPDATE app_versions av + SET co_relation_id = sibling.co_relation_id + FROM ( + SELECT DISTINCT ON (app_id) app_id, co_relation_id + FROM app_versions + WHERE co_relation_id IS NOT NULL + ) sibling + WHERE av.app_id = sibling.app_id + AND av.co_relation_id IS NULL; + `); + console.log(`[BackfillAppCoRelationId] Updated ${versionResult?.[1] ?? 'unknown'} app_versions from sibling versions`); + + // Step 2: For apps with NULL co_relation_id, copy from any of their versions + // that has co_relation_id set. + const appResult = await queryRunner.query(` + UPDATE apps a + SET co_relation_id = v.co_relation_id + FROM ( + SELECT DISTINCT ON (app_id) app_id, co_relation_id + FROM app_versions + WHERE co_relation_id IS NOT NULL + ) v + WHERE a.id = v.app_id + AND a.co_relation_id IS NULL; + `); + console.log(`[BackfillAppCoRelationId] Updated ${appResult?.[1] ?? 'unknown'} apps from their versions`); + + // Step 3: For remaining apps still with NULL co_relation_id (no versions have it), + // fall back to apps.id. These are apps that were never pushed to git. + const fallbackResult = await queryRunner.query(` + UPDATE apps + SET co_relation_id = id + WHERE co_relation_id IS NULL; + `); + console.log(`[BackfillAppCoRelationId] Fallback: updated ${fallbackResult?.[1] ?? 'unknown'} apps with co_relation_id = id`); + } + + public async down(queryRunner: QueryRunner): Promise { + // No-op: we can't distinguish which apps originally had null co_relation_id + } +} diff --git a/server/migrations/1772568626000-CreateWorkspaceBranchTables.ts b/server/migrations/1772568626000-CreateWorkspaceBranchTables.ts new file mode 100644 index 0000000000..12b9fb003b --- /dev/null +++ b/server/migrations/1772568626000-CreateWorkspaceBranchTables.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWorkspaceBranchTables1772568626000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. organization_git_sync_branches (renamed from workspace_branches) + await queryRunner.query(` + CREATE TABLE organization_git_sync_branches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + branch_name VARCHAR(255) NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT false, + source_branch_id UUID REFERENCES organization_git_sync_branches(id) ON DELETE SET NULL, + app_meta_hash VARCHAR(64) DEFAULT NULL, + data_source_meta_hash VARCHAR(64) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + UNIQUE(organization_id, branch_name) + ); + `); + + // 2. data_source_versions + await queryRunner.query(` + CREATE TABLE data_source_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + data_source_id UUID NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE, + version_from_id UUID REFERENCES data_source_versions(id) ON DELETE SET NULL, + is_default BOOLEAN NOT NULL DEFAULT false, + name VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + app_version_id UUID REFERENCES app_versions(id) ON DELETE CASCADE, + meta_timestamp NUMERIC(15) DEFAULT NULL, + branch_id UUID REFERENCES organization_git_sync_branches(id) ON DELETE CASCADE, + pulled_at TIMESTAMP DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + UNIQUE(data_source_id, branch_id) + ); + `); + + // Partial unique index: only one default version per data source + await queryRunner.query(` + CREATE UNIQUE INDEX idx_data_source_versions_one_default + ON data_source_versions (data_source_id) WHERE is_default = true; + `); + + // 4. data_source_version_options + await queryRunner.query(` + CREATE TABLE data_source_version_options ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + data_source_version_id UUID NOT NULL REFERENCES data_source_versions(id) ON DELETE CASCADE, + environment_id UUID NOT NULL REFERENCES app_environments(id) ON DELETE CASCADE, + options JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + UNIQUE(data_source_version_id, environment_id) + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS data_source_version_options`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_data_source_versions_one_default`); + await queryRunner.query(`DROP TABLE IF EXISTS data_source_versions`); + await queryRunner.query(`DROP TABLE IF EXISTS organization_git_sync_branches`); + } +} diff --git a/server/migrations/1773100000000-AddPlatformGitSyncSupport.ts b/server/migrations/1773100000000-AddPlatformGitSyncSupport.ts new file mode 100644 index 0000000000..9677caccb9 --- /dev/null +++ b/server/migrations/1773100000000-AddPlatformGitSyncSupport.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPlatformGitSyncSupport1773100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. Create app_branch_state table + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS app_branch_state ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + branch_id UUID NOT NULL, + app_id UUID, + co_relation_id UUID NOT NULL, + app_name VARCHAR NOT NULL, + meta_timestamp NUMERIC(15), + pulled_at TIMESTAMP, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now(), + + CONSTRAINT fk_app_branch_state_organization + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + CONSTRAINT fk_app_branch_state_branch + FOREIGN KEY (branch_id) REFERENCES organization_git_sync_branches(id) ON DELETE CASCADE, + CONSTRAINT fk_app_branch_state_app + FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE, + + CONSTRAINT uq_app_branch_state_org_branch_corel + UNIQUE (organization_id, branch_id, co_relation_id) + ); + `); + + // 2. Add is_stub column to app_versions (branch-level stub tracking) + await queryRunner.query(` + ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS is_stub BOOLEAN NOT NULL DEFAULT false; + `); + + // 3. Add branch_id and is_stub to app_versions (branch-level tracking) + await queryRunner.query(` + ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS branch_id UUID; + `); + + await queryRunner.query(` + ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS is_stub BOOLEAN NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE app_versions + ADD CONSTRAINT fk_app_versions_branch + FOREIGN KEY (branch_id) REFERENCES organization_git_sync_branches(id) ON DELETE SET NULL; + `); + + // 4. Relax app name uniqueness for GIT-created apps. + // See data-migration 1773100000001 — runs after older data-migrations + // that create/modify the app_name_organization_id_unique constraint. + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE app_versions DROP CONSTRAINT IF EXISTS fk_app_versions_branch;`); + await queryRunner.query(`ALTER TABLE app_versions DROP COLUMN IF EXISTS is_stub;`); + await queryRunner.query(`ALTER TABLE app_versions DROP COLUMN IF EXISTS branch_id;`); + // apps.is_stub removed — stub status is derived from app_versions + await queryRunner.query(`DROP TABLE IF EXISTS app_branch_state;`); + } +} diff --git a/server/migrations/1773200000000-RemoveAppBranchStateTable.ts b/server/migrations/1773200000000-RemoveAppBranchStateTable.ts new file mode 100644 index 0000000000..d848a2e8ef --- /dev/null +++ b/server/migrations/1773200000000-RemoveAppBranchStateTable.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveAppBranchStateTable1773200000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. Add pulled_at column to app_versions for git sync tracking + await queryRunner.query(` + ALTER TABLE app_versions ADD COLUMN IF NOT EXISTS pulled_at TIMESTAMP DEFAULT NULL; + `); + + // 2. Migrate pulled_at data from app_branch_state into app_versions (if table still exists) + const tableExists = await queryRunner.query(` + SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'app_branch_state') AS exists; + `); + if (tableExists[0]?.exists) { + await queryRunner.query(` + UPDATE app_versions av + SET pulled_at = abs.pulled_at + FROM app_branch_state abs + WHERE av.app_id = abs.app_id + AND av.branch_id = abs.branch_id; + `); + } + + // 3. Drop the app_branch_state table + await queryRunner.query(`DROP TABLE IF EXISTS app_branch_state;`); + + // 4. Drop app_meta_timestamp if it exists (redundant — updatedAt is read fresh from appMeta.json) + await queryRunner.query(`ALTER TABLE app_versions DROP COLUMN IF EXISTS app_meta_timestamp;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // 1. Recreate app_branch_state table + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS app_branch_state ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL, + branch_id UUID NOT NULL, + app_id UUID, + co_relation_id UUID NOT NULL, + app_name VARCHAR NOT NULL, + meta_timestamp NUMERIC(15), + pulled_at TIMESTAMP, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now(), + + CONSTRAINT fk_app_branch_state_organization + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + CONSTRAINT fk_app_branch_state_branch + FOREIGN KEY (branch_id) REFERENCES organization_git_sync_branches(id) ON DELETE CASCADE, + CONSTRAINT fk_app_branch_state_app + FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE, + + CONSTRAINT uq_app_branch_state_org_branch_corel + UNIQUE (organization_id, branch_id, co_relation_id) + ); + `); + + // 2. Migrate data back from app_versions into app_branch_state + await queryRunner.query(` + INSERT INTO app_branch_state (organization_id, branch_id, app_id, co_relation_id, app_name, pulled_at) + SELECT a.organization_id, av.branch_id, av.app_id, COALESCE(a.co_relation_id, a.id), a.name, av.pulled_at + FROM app_versions av + INNER JOIN apps a ON a.id = av.app_id + WHERE av.branch_id IS NOT NULL + AND av.pulled_at IS NOT NULL + ON CONFLICT (organization_id, branch_id, co_relation_id) DO NOTHING; + `); + + // 3. Drop the pulled_at column from app_versions + await queryRunner.query(`ALTER TABLE app_versions DROP COLUMN IF EXISTS pulled_at;`); + } +} diff --git a/server/migrations/1773300000000-AddCreatedByToWorkspaceBranch.ts b/server/migrations/1773300000000-AddCreatedByToWorkspaceBranch.ts new file mode 100644 index 0000000000..8cfa5acf7e --- /dev/null +++ b/server/migrations/1773300000000-AddCreatedByToWorkspaceBranch.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCreatedByToWorkspaceBranch1773300000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE organization_git_sync_branches + ADD COLUMN IF NOT EXISTS created_by VARCHAR(255) DEFAULT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE organization_git_sync_branches + DROP COLUMN IF EXISTS created_by; + `); + } +} diff --git a/server/scripts/migration/generate_app_meta.sh b/server/scripts/migration/generate_app_meta.sh new file mode 100755 index 0000000000..1745b20d09 --- /dev/null +++ b/server/scripts/migration/generate_app_meta.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +# generate_app_meta.sh +# Scans all app.json files under the apps/ directory and generates .meta/appMeta.json + +APPS_DIR="apps" +META_DIR=".meta" +OUTPUT_FILE="$META_DIR/appMeta.json" + +# Ensure the .meta directory exists +mkdir -p "$META_DIR" + +# Get current timestamp in ISO 8601 format +UPDATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") + +# Start building the JSON object +json="{" +first=true + +# Find all app.json files recursively and write paths to a temp file +TMPFILE=$(mktemp) +find "$APPS_DIR" -name "app.json" > "$TMPFILE" 2>/dev/null + +while IFS= read -r app_json_path; do + # Get the app directory (two levels up from app.json: apps/app-1/app/app.json → apps/app-1) + app_dir=$(dirname "$(dirname "$app_json_path")") + + # Extract the id field from app.json using grep + sed (no jq dependency) + app_id=$(grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' "$app_json_path" | sed 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | head -n 1) + + # Skip if no id found + if [ -z "$app_id" ]; then + echo "WARNING: No 'id' found in $app_json_path — skipping." >&2 + continue + fi + + # Add comma separator between entries + if [ "$first" = true ]; then + first=false + else + json="$json," + fi + + # Append entry to JSON + json="$json + \"$app_id\": { + \"appPath\": \"$app_dir\", + \"updatedAt\": \"$UPDATED_AT\" + }" + +done < "$TMPFILE" + +# Clean up temp file +rm -f "$TMPFILE" + +# Close the JSON object +json="$json +}" + +# Write to output file +echo "$json" > "$OUTPUT_FILE" + +echo "Generated: $OUTPUT_FILE" +echo "$json" \ No newline at end of file diff --git a/server/src/dto/import-resources.dto.ts b/server/src/dto/import-resources.dto.ts index 3ad6d2cee0..190ed7b380 100644 --- a/server/src/dto/import-resources.dto.ts +++ b/server/src/dto/import-resources.dto.ts @@ -32,6 +32,10 @@ export class ImportResourcesDto { @IsOptional() @IsBoolean() skip_permissions_group_check?: boolean; + + @IsOptional() + @IsUUID() + branchId?: string; } export class ImportAppDto { diff --git a/server/src/entities/app.entity.ts b/server/src/entities/app.entity.ts index dd1cfdb92b..b062c7eba7 100644 --- a/server/src/entities/app.entity.ts +++ b/server/src/entities/app.entity.ts @@ -167,4 +167,5 @@ export class App extends BaseEntity { aiConversations: AiConversation[]; public editingVersion; + public isStub: boolean; } diff --git a/server/src/entities/app_version.entity.ts b/server/src/entities/app_version.entity.ts index 61e9a12269..acce06256e 100644 --- a/server/src/entities/app_version.entity.ts +++ b/server/src/entities/app_version.entity.ts @@ -23,6 +23,7 @@ import { Page } from './page.entity'; import { EventHandler } from './event_handler.entity'; import { WorkflowSchedule } from './workflow_schedule.entity'; import { User } from './user.entity'; +import { WorkspaceBranch } from './workspace_branch.entity'; export enum AppVersionType { VERSION = 'version', @@ -101,6 +102,22 @@ export class AppVersion extends BaseEntity { @Column({ name: 'released_at', type: 'timestamp', nullable: true }) releasedAt: Date; + @Column({ name: 'is_stub', default: false }) + isStub: boolean; + + @Column({ name: 'branch_id', nullable: true }) + branchId: string; + + @ManyToOne(() => WorkspaceBranch, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'branch_id' }) + branch: WorkspaceBranch; + + @Column({ name: 'pulled_at', type: 'timestamp', nullable: true, default: null }) + pulledAt: Date; + + @Column({ name: 'source_tag', type: 'varchar', length: 256, nullable: true }) + sourceTag: string; + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) createdAt: Date; diff --git a/server/src/entities/data_source.entity.ts b/server/src/entities/data_source.entity.ts index 65443eabfc..5a2df043e6 100644 --- a/server/src/entities/data_source.entity.ts +++ b/server/src/entities/data_source.entity.ts @@ -16,11 +16,11 @@ import { App } from './app.entity'; import { AppVersion } from './app_version.entity'; import { DataQuery } from './data_query.entity'; import { DataSourceGroupPermission } from './data_source_group_permission.entity'; -import { DataSourceOptions } from './data_source_options.entity'; import { GroupPermission } from './group_permission.entity'; import { Plugin } from './plugin.entity'; import { GroupDataSources } from './group_data_source.entity'; import { DataSourceTypes } from '@modules/data-sources/constants'; +import { DataSourceVersion } from './data_source_version.entity'; @Entity({ name: 'data_sources' }) export class DataSource extends BaseEntity { @@ -109,8 +109,8 @@ export class DataSource extends BaseEntity { @JoinColumn({ name: 'plugin_id' }) plugin: Plugin; - @OneToMany(() => DataSourceOptions, (dso) => dso.dataSource) - dataSourceOptions: DataSourceOptions[]; + @OneToMany(() => DataSourceVersion, (dsv) => dsv.dataSource) + dataSourceVersions: DataSourceVersion[]; @OneToMany(() => DataQuery, (dq) => dq.dataSource) dataQueries: DataQuery[]; diff --git a/server/src/entities/data_source_version.entity.ts b/server/src/entities/data_source_version.entity.ts new file mode 100644 index 0000000000..3ba96a1d6d --- /dev/null +++ b/server/src/entities/data_source_version.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { DataSource } from './data_source.entity'; +import { WorkspaceBranch } from './workspace_branch.entity'; +import { DataSourceVersionOptions } from './data_source_version_options.entity'; +import { AppVersion } from './app_version.entity'; + +@Entity({ name: 'data_source_versions' }) +@Unique(['dataSourceId', 'branchId']) +export class DataSourceVersion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'data_source_id' }) + dataSourceId: string; + + @Column({ name: 'version_from_id', nullable: true }) + versionFromId: string; + + @Column({ name: 'is_default', default: false }) + isDefault: boolean; + + @Column() + name: string; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @Column({ name: 'app_version_id', nullable: true }) + appVersionId: string; + + @Column({ name: 'meta_timestamp', type: 'numeric', precision: 15, nullable: true, default: null }) + metaTimestamp: number; + + @Column({ name: 'branch_id', nullable: true }) + branchId: string; + + @Column({ name: 'pulled_at', type: 'timestamp', nullable: true, default: null }) + pulledAt: Date; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => DataSource, (ds) => ds.dataSourceVersions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'data_source_id' }) + dataSource: DataSource; + + @ManyToOne(() => DataSourceVersion, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'version_from_id' }) + versionFrom: DataSourceVersion; + + @ManyToOne(() => AppVersion, (av) => av.id, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'app_version_id' }) + appVersion: AppVersion; + + @ManyToOne(() => WorkspaceBranch, (wb) => wb.id, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'branch_id' }) + branch: WorkspaceBranch; + + @OneToMany(() => DataSourceVersionOptions, (dsvo) => dsvo.dataSourceVersion) + dataSourceVersionOptions: DataSourceVersionOptions[]; +} diff --git a/server/src/entities/data_source_version_options.entity.ts b/server/src/entities/data_source_version_options.entity.ts new file mode 100644 index 0000000000..44666632fc --- /dev/null +++ b/server/src/entities/data_source_version_options.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { DataSourceVersion } from './data_source_version.entity'; +import { AppEnvironment } from './app_environments.entity'; + +@Entity({ name: 'data_source_version_options' }) +@Unique(['dataSourceVersionId', 'environmentId']) +export class DataSourceVersionOptions { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'data_source_version_id' }) + dataSourceVersionId: string; + + @Column({ name: 'environment_id' }) + environmentId: string; + + @Column('simple-json', { name: 'options', default: '{}' }) + options: any; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => DataSourceVersion, (dsv) => dsv.id, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'data_source_version_id' }) + dataSourceVersion: DataSourceVersion; + + @ManyToOne(() => AppEnvironment, (ae) => ae.id, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'environment_id' }) + appEnvironment: AppEnvironment; +} diff --git a/server/src/entities/workspace_branch.entity.ts b/server/src/entities/workspace_branch.entity.ts new file mode 100644 index 0000000000..eb7559f1fb --- /dev/null +++ b/server/src/entities/workspace_branch.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + BaseEntity, + Unique, +} from 'typeorm'; +import { Organization } from './organization.entity'; + +@Entity({ name: 'organization_git_sync_branches' }) +@Unique(['organizationId', 'name']) +export class WorkspaceBranch extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'organization_id' }) + organizationId: string; + + @Column({ name: 'branch_name' }) + name: string; + + @Column({ name: 'is_default', default: false }) + isDefault: boolean; + + @Column({ name: 'source_branch_id', nullable: true }) + sourceBranchId: string; + + @Column({ name: 'created_by', nullable: true, default: null }) + createdBy: string; + + @Column({ name: 'app_meta_hash', type: 'varchar', length: 64, nullable: true, default: null }) + appMetaHash: string; + + @Column({ name: 'data_source_meta_hash', type: 'varchar', length: 64, nullable: true, default: null }) + dataSourceMetaHash: string; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => Organization, (org) => org.id, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization: Organization; + + @ManyToOne(() => WorkspaceBranch, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'source_branch_id' }) + sourceBranch: WorkspaceBranch; +} diff --git a/server/src/helpers/platform-git-pull-registry.ts b/server/src/helpers/platform-git-pull-registry.ts new file mode 100644 index 0000000000..4caea5640b --- /dev/null +++ b/server/src/helpers/platform-git-pull-registry.ts @@ -0,0 +1,20 @@ +/** + * Service registry for PlatformGitPullService. + * + * Avoids circular dependency: AppsModule → ImportExportResourcesModule → AppsModule. + * The EE PlatformGitPullService registers itself on init; AppsService reads it here. + */ + +export interface IPlatformGitPullService { + hydrateStubApp(stubApp: any, user: any, branchId?: string): Promise; +} + +let _pullService: IPlatformGitPullService | null = null; + +export function registerPlatformGitPullService(service: IPlatformGitPullService): void { + _pullService = service; +} + +export function getPlatformGitPullService(): IPlatformGitPullService | null { + return _pullService; +} diff --git a/server/src/modules/app-environments/interfaces/IUtilService.ts b/server/src/modules/app-environments/interfaces/IUtilService.ts index db3523b05b..5919e5acec 100644 --- a/server/src/modules/app-environments/interfaces/IUtilService.ts +++ b/server/src/modules/app-environments/interfaces/IUtilService.ts @@ -1,6 +1,6 @@ import { EntityManager } from 'typeorm'; import { AppEnvironment } from 'src/entities/app_environments.entity'; -import { DataSourceOptions } from '@entities/data_source_options.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; import { IAppEnvironmentResponse } from './IAppEnvironmentResponse'; import { AppVersion } from '@entities/app_version.entity'; @@ -31,7 +31,13 @@ export interface IAppEnvironmentUtilService { licenseCheck?: boolean ): Promise; getAll(organizationId: string, appId?: string, manager?: EntityManager): Promise; - getOptions(dataSourceId: string, organizationId: string, environmentId?: string): Promise; + getOptions( + dataSourceId: string, + organizationId: string, + environmentId?: string, + branchId?: string, + appVersionId?: string + ): Promise; init( editorVersion: Partial, organizationId: string, diff --git a/server/src/modules/app-environments/service.ts b/server/src/modules/app-environments/service.ts index d62dcd1a31..1f5c4e8cd3 100644 --- a/server/src/modules/app-environments/service.ts +++ b/server/src/modules/app-environments/service.ts @@ -222,6 +222,7 @@ export class AppEnvironmentService implements IAppEnvironmentService { 'parentVersionId', 'promotedFrom', 'versionType', + 'branchId', 'createdAt', 'updatedAt', 'publishedAt', diff --git a/server/src/modules/app-environments/util.service.ts b/server/src/modules/app-environments/util.service.ts index 267a61cb3b..f0900b5d65 100644 --- a/server/src/modules/app-environments/util.service.ts +++ b/server/src/modules/app-environments/util.service.ts @@ -1,7 +1,6 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { EntityManager, FindOptionsOrderValue } from 'typeorm'; import { AppEnvironment } from 'src/entities/app_environments.entity'; -import { DataSourceOptions } from 'src/entities/data_source_options.entity'; import { dbTransactionWrap } from 'src/helpers/database.helper'; import { IAppEnvironmentUtilService } from './interfaces/IUtilService'; import { AppVersion } from '@entities/app_version.entity'; @@ -11,17 +10,48 @@ import { defaultAppEnvironments } from '@helpers/utils.helper'; import { LICENSE_FIELD } from '@modules/licensing/constants'; import { LicenseTermsService } from '@modules/licensing/interfaces/IService'; import { IAppEnvironmentResponse } from './interfaces/IAppEnvironmentResponse'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; @Injectable() export class AppEnvironmentUtilService implements IAppEnvironmentUtilService { - constructor(protected readonly licenseTermsService: LicenseTermsService) { } + constructor(protected readonly licenseTermsService: LicenseTermsService) {} async updateOptions(options: object, environmentId: string, dataSourceId: string, manager?: EntityManager) { + await dbTransactionWrap(async (manager: EntityManager) => { + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId, isDefault: true }, + }); + if (defaultDsv) { + const dsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId }, + }); + if (dsvo) { + await manager.update(DataSourceVersionOptions, { id: dsvo.id }, { options, updatedAt: new Date() }); + } else { + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: defaultDsv.id, + environmentId, + options, + }) + ); + } + } + }, manager); + } + + async updateVersionOptions( + options: object, + dataSourceVersionId: string, + environmentId: string, + manager?: EntityManager + ) { await dbTransactionWrap(async (manager: EntityManager) => { await manager.update( - DataSourceOptions, + DataSourceVersionOptions, { + dataSourceVersionId, environmentId, - dataSourceId, }, { options, updatedAt: new Date() } ); @@ -203,7 +233,13 @@ export class AppEnvironmentUtilService implements IAppEnvironmentUtilService { }, manager); } - async getOptions(dataSourceId: string, organizationId: string, environmentId?: string): Promise { + async getOptions( + dataSourceId: string, + organizationId: string, + environmentId?: string, + branchId?: string, + appVersionId?: string + ): Promise { return await dbTransactionWrap(async (manager: EntityManager) => { let envId: string = environmentId; let envName: string; @@ -221,14 +257,79 @@ export class AppEnvironmentUtilService implements IAppEnvironmentUtilService { envName = environment?.name || 'unknown'; } - const dataSourceOptions = await manager.findOneOrFail(DataSourceOptions, { - where: { environmentId: envId, dataSourceId }, + // Branch-aware path: read from data_source_version_options for a specific branch + if (branchId) { + const dsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId, branchId, isActive: true }, + }); + if (dsv) { + const dsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: dsv.id, environmentId: envId }, + }); + if (dsvo) { + const result = { + id: dsvo.id, + options: dsvo.options, + environmentId: envId, + dataSourceId, + createdAt: dsvo.createdAt, + updatedAt: dsvo.updatedAt, + environmentName: envName, + } as any; + return result; + } + } + } + + // Saved/tagged version path: read from data_source_version_options via appVersionId + if (appVersionId) { + const dsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId, appVersionId, isActive: true }, + }); + if (dsv) { + const dsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: dsv.id, environmentId: envId }, + }); + if (dsvo) { + const result = { + id: dsvo.id, + options: dsvo.options, + environmentId: envId, + dataSourceId, + createdAt: dsvo.createdAt, + updatedAt: dsvo.updatedAt, + environmentName: envName, + } as any; + return result; + } + } + } + + // Default version path: read from data_source_version_options via default DSV + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId, isDefault: true }, }); + if (defaultDsv) { + const dsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: envId }, + }); + if (dsvo) { + const result = { + id: dsvo.id, + options: dsvo.options, + environmentId: envId, + dataSourceId, + createdAt: dsvo.createdAt, + updatedAt: dsvo.updatedAt, + environmentName: envName, + } as any; + return result; + } + } - // Add environment name to the returned object - (dataSourceOptions as any).environmentName = envName; - - return dataSourceOptions; + throw new ForbiddenException( + `No data source version options found for dataSourceId=${dataSourceId}, environmentId=${envId}` + ); }); } diff --git a/server/src/modules/app-git/dto/index.ts b/server/src/modules/app-git/dto/index.ts index 4808fe02a5..8b1f59baf0 100644 --- a/server/src/modules/app-git/dto/index.ts +++ b/server/src/modules/app-git/dto/index.ts @@ -86,6 +86,14 @@ export class AppGitPullDto { @IsString() @IsOptional() commitHash?: string; + + @IsString() + @IsOptional() + gitBranchName?: string; + + @IsString() + @IsOptional() + workspaceBranchId?: string; } export class AppGitPullUpdateDto { diff --git a/server/src/modules/app-git/module.ts b/server/src/modules/app-git/module.ts index 82f4c06eb7..c7bdf23a1d 100644 --- a/server/src/modules/app-git/module.ts +++ b/server/src/modules/app-git/module.ts @@ -65,7 +65,7 @@ export class AppGitModule extends SubModule { FeatureAbilityFactory, ...(isMainImport ? [AppVersionRenameListener] : []), ], - exports: [SSHAppGitUtilityService, HTTPSAppGitUtilityService, GitLabAppGitUtilityService], + exports: [SourceControlProviderService, SSHAppGitUtilityService, HTTPSAppGitUtilityService, GitLabAppGitUtilityService], }; } } diff --git a/server/src/modules/app/constants/module-info.ts b/server/src/modules/app/constants/module-info.ts index 2ef2ccc80d..f64f0a35b4 100644 --- a/server/src/modules/app/constants/module-info.ts +++ b/server/src/modules/app/constants/module-info.ts @@ -45,6 +45,7 @@ import { FEATURES as APP_HISTORY_FEATURES } from '@modules/app-history/constants import { FEATURES as CRM_FEATURES } from '@modules/CRM/constants/feature'; import { FEATURES as METRICS } from '@modules/metrices/constants/features'; import { FEATURES as SCIM_FEATURES } from '@modules/scim/constants/feature'; +import { FEATURES as WORKSPACE_BRANCHES_FEATURES } from '@modules/workspace-branches/constants/feature'; import { FEATURES as CUSTOM_DOMAINS_FEATURES } from '@modules/custom-domains/constant/feature'; const tooljetEdition = getTooljetEdition(); @@ -99,5 +100,6 @@ export const MODULE_INFO: { [key: string]: any } = { ...APP_HISTORY_FEATURES, ...CRM_FEATURES, ...SCIM_FEATURES, + ...WORKSPACE_BRANCHES_FEATURES, ...CUSTOM_DOMAINS_FEATURES, }; diff --git a/server/src/modules/app/constants/modules.ts b/server/src/modules/app/constants/modules.ts index 0acad32ad6..73a7213b1f 100644 --- a/server/src/modules/app/constants/modules.ts +++ b/server/src/modules/app/constants/modules.ts @@ -43,6 +43,7 @@ export enum MODULES { MODULES = 'Modules', APP_GIT = 'AppGit', GIT_SYNC = 'GitSync', + WORKSPACE_BRANCHES = 'WorkspaceBranches', APP_HISTORY = 'AppHistory', CRM = 'CRM', SCIM = 'SCIM', diff --git a/server/src/modules/app/module.ts b/server/src/modules/app/module.ts index 2ff8518d2c..93be82da8e 100644 --- a/server/src/modules/app/module.ts +++ b/server/src/modules/app/module.ts @@ -47,6 +47,8 @@ import { EventsModule } from '@modules/events/module'; import { ExternalApiModule } from '@modules/external-apis/module'; import { GitSyncModule } from '@modules/git-sync/module'; import { AppGitModule } from '@modules/app-git/module'; +import { WorkspaceBranchesModule } from '@modules/workspace-branches/module'; +import { BranchContextModule } from '@modules/branch-context/module'; import { OrganizationPaymentModule } from '@modules/organization-payments/module'; import { CrmModule } from '@modules/CRM/module'; import { ClearSSOResponseScheduler } from '@modules/auth/schedulers/clear-sso-response.scheduler'; @@ -97,6 +99,7 @@ export class AppModule implements OnModuleInit, NestModule { * ████████████████████████████████████████████████████████████████████ */ const baseImports = [ + await BranchContextModule.register(configs), await AbilityModule.forRoot(configs), await LicenseModule.forRoot(configs), await FilesModule.register(configs, true), @@ -139,6 +142,7 @@ export class AppModule implements OnModuleInit, NestModule { await ExternalApiModule.register(configs, true), await GitSyncModule.register(configs, true), await AppGitModule.register(configs, true), + await WorkspaceBranchesModule.register(configs, true), await CrmModule.register(configs, true), await OrganizationPaymentModule.register(configs, true), await EmailListenerModule.register(configs), diff --git a/server/src/modules/apps/controller.ts b/server/src/modules/apps/controller.ts index 17914a249a..a3ecb3ade0 100644 --- a/server/src/modules/apps/controller.ts +++ b/server/src/modules/apps/controller.ts @@ -2,7 +2,7 @@ import { InitModule } from '@modules/app/decorators/init-module'; import { AppsService } from './service'; import { MODULES } from '@modules/app/constants/modules'; import { JwtAuthGuard } from '@modules/session/guards/jwt-auth.guard'; -import { Body, Controller, Delete, Get, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; import { AppCountGuard } from '@modules/licensing/guards/app.guard'; import { User } from '@modules/app/decorators/user.decorator'; import { User as UserEntity } from '@entities/user.entity'; @@ -108,12 +108,13 @@ export class AppsController implements IAppsController { @InitFeature(FEATURE_KEY.GET) @UseGuards(JwtAuthGuard, FeatureAbilityGuard) @Get() - index(@User() user, @Query() query) { + index(@User() user, @Query() query, @Headers('x-branch-id') headerBranchId?: string) { const AppListDto: AppListDto = { page: query.page, folderId: query.folder, searchKey: query.searchKey || '', type: query.type ?? 'front-end', + branchId: query.branch_id || headerBranchId, }; return this.appsService.getAllApps(user, AppListDto, false); } @@ -121,7 +122,7 @@ export class AppsController implements IAppsController { @InitFeature(FEATURE_KEY.GET) @UseGuards(JwtAuthGuard, FeatureAbilityGuard) @Get('/addable') - indexAddable(@User() user: UserEntity) { + indexAddable(@User() user: UserEntity, @Query('branch_id') branchId?: string) { return this.appsService.getAllApps( user, { @@ -129,6 +130,7 @@ export class AppsController implements IAppsController { folderId: null, searchKey: '', type: 'front-end', + branchId, }, true ); @@ -155,8 +157,8 @@ export class AppsController implements IAppsController { @InitFeature(FEATURE_KEY.GET_ONE) @UseGuards(JwtAuthGuard, ValidAppGuard, FeatureAbilityGuard) @Get(':id') - show(@User() user: UserEntity, @App() app: AppEntity) { - return this.appsService.getOne(app, user); + show(@User() user: UserEntity, @App() app: AppEntity, @Headers('x-branch-id') branchId?: string) { + return this.appsService.getOne(app, user, branchId); } @InitFeature(FEATURE_KEY.GET_BY_SLUG) diff --git a/server/src/modules/apps/dto/index.ts b/server/src/modules/apps/dto/index.ts index 0df0d624e0..366d5cb94d 100644 --- a/server/src/modules/apps/dto/index.ts +++ b/server/src/modules/apps/dto/index.ts @@ -27,6 +27,10 @@ export class AppCreateDto { @IsOptional() @IsString() prompt?: string; + + @IsOptional() + @IsUUID() + branchId?: string; } export class AppUpdateDto { @@ -133,6 +137,10 @@ export class AppListDto { @IsString() @IsOptional() type: string; + + @IsOptional() + @IsUUID() + branchId?: string; } export class VersionReleaseDto { diff --git a/server/src/modules/apps/guards/valid-slug.guard.ts b/server/src/modules/apps/guards/valid-slug.guard.ts index 12a8a791ec..aae1847792 100644 --- a/server/src/modules/apps/guards/valid-slug.guard.ts +++ b/server/src/modules/apps/guards/valid-slug.guard.ts @@ -17,8 +17,11 @@ export class ValidSlugGuard implements CanActivate { throw new BadRequestException('Slug or User is missing'); } + // Extract active branch from query param or header (client-side branch tracking) + const branchId = request.query?.branch_id || request.headers['x-branch-id']; + // Fetch the app associated with the provided slug for the user's organization - const app = await this.appsUtilService.findAppWithIdOrSlug(slug, user.organizationId); + const app = await this.appsUtilService.findAppWithIdOrSlug(slug, user.organizationId, branchId); // If no app is found, throw a BadRequestException if (!app) { diff --git a/server/src/modules/apps/interfaces/IController.ts b/server/src/modules/apps/interfaces/IController.ts index af3c18af4e..b26e3740ef 100644 --- a/server/src/modules/apps/interfaces/IController.ts +++ b/server/src/modules/apps/interfaces/IController.ts @@ -39,7 +39,7 @@ export interface IAppsController { tables(user: UserEntity, app: AppEntity): Promise<{ tables: any[] }>; - show(user: UserEntity, app: AppEntity): Promise; + show(user: UserEntity, app: AppEntity, branchId?: string): Promise; appFromSlug(user: UserEntity, app: AppEntity): Promise; diff --git a/server/src/modules/apps/interfaces/IService.ts b/server/src/modules/apps/interfaces/IService.ts index 476ea99c18..81dd7691b0 100644 --- a/server/src/modules/apps/interfaces/IService.ts +++ b/server/src/modules/apps/interfaces/IService.ts @@ -17,6 +17,6 @@ export interface IAppsService { delete(app: App, user: User): Promise; getAllApps(user: User, appListDto: AppListDto, isGetAll: boolean): Promise; findTooljetDbTables(appId: string): Promise<{ table_id: string }[]>; - getOne(app: App, user: User): Promise; + getOne(app: App, user: User, branchId?: string): Promise; getBySlug(app: App, user: User): Promise; } diff --git a/server/src/modules/apps/interfaces/IUtilService.ts b/server/src/modules/apps/interfaces/IUtilService.ts index 4d180d97cf..aca35a8cba 100644 --- a/server/src/modules/apps/interfaces/IUtilService.ts +++ b/server/src/modules/apps/interfaces/IUtilService.ts @@ -16,7 +16,7 @@ export interface IAppsUtilService { ): Promise; getAppOrganizationDetails(app: App): Promise; update(app: App, appUpdateDto: AppUpdateDto, organizationId?: string, manager?: EntityManager): Promise; - all(user: User, page: number, searchKey: string, type: string, isGetAll: boolean): Promise; - count(user: User, searchKey: string, type: string): Promise; + all(user: User, page: number, searchKey: string, type: string, isGetAll: boolean, branchId?: string): Promise; + count(user: User, searchKey: string, type: string, branchId?: string): Promise; mergeDefaultComponentData(pages: any[]): any[]; } diff --git a/server/src/modules/apps/service.ts b/server/src/modules/apps/service.ts index aadf0370a5..eabe3cf38a 100644 --- a/server/src/modules/apps/service.ts +++ b/server/src/modules/apps/service.ts @@ -28,7 +28,7 @@ import { AppEnvironmentUtilService } from '@modules/app-environments/util.servic import { plainToClass } from 'class-transformer'; import { AppAbility } from '@modules/app/decorators/ability.decorator'; import { VersionRepository } from '@modules/versions/repository'; -import { AppVersionStatus, AppVersionType } from '@entities/app_version.entity'; +import { AppVersionStatus } from '@entities/app_version.entity'; import { AppsRepository } from './repository'; import { FoldersUtilService } from '@modules/folders/util.service'; import { FolderAppsUtilService } from '@modules/folder-apps/util.service'; @@ -48,6 +48,7 @@ import { AppGitRepository } from '@modules/app-git/repository'; import { WorkflowSchedule } from '@entities/workflow_schedule.entity'; import { AbilityService } from '@modules/ability/interfaces/IService'; import { OrganizationGitSyncRepository } from '@modules/git-sync/repository'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; @Injectable() export class AppsService implements IAppsService { @@ -72,7 +73,19 @@ export class AppsService implements IAppsService { async create(user: User, appCreateDto: AppCreateDto) { const { name, icon, type, prompt } = appCreateDto; return await dbTransactionWrap(async (manager: EntityManager) => { - const app = await this.appsUtilService.create(name, user, type as APP_TYPES, !!prompt, manager); + // Use branchId from DTO (passed by frontend from localStorage), or fall back to default branch + let branchId = appCreateDto.branchId; + if (!branchId) { + const orgGit = await this.organizationGitRepository?.findOrgGitByOrganizationId(user.organizationId); + if (orgGit) { + const defaultBranch = await manager.findOne(WorkspaceBranch, { + where: { organizationId: user.organizationId, isDefault: true }, + }); + branchId = defaultBranch?.id; + } + } + + const app = await this.appsUtilService.create(name, user, type as APP_TYPES, !!prompt, manager, branchId); const appUpdateDto = new AppUpdateDto(); appUpdateDto.name = name; @@ -241,7 +254,7 @@ export class AppsService implements IAppsService { // Check if name is being changed - require draft version to exist if (name && name !== app.name) { // Block rename if git sync is enabled and app has been pushed - if (isGitSyncEnabled) { + if (isGitSyncEnabled && app.type === APP_TYPES.FRONT_END) { const appGitSync = await this.appGitRepository.findAppGitByAppId(app.id); if (appGitSync) { // Check if on default branch (not a feature branch) @@ -349,7 +362,7 @@ export class AppsService implements IAppsService { let apps = []; let totalFolderCount = 0; - const { folderId, page, searchKey, type } = appListDto; + const { folderId, page, searchKey, type, branchId } = appListDto; return dbTransactionWrap(async (manager: EntityManager) => { if (appListDto.folderId) { @@ -359,13 +372,14 @@ export class AppsService implements IAppsService { folder, parseInt(page || '1'), searchKey, - type as APP_TYPES + type as APP_TYPES, + branchId ); apps = viewableApps; totalFolderCount = totalCount; console.log(`Fetched apps for folder ${folderId}:`, apps); } else { - apps = await this.appsUtilService.all(user, parseInt(page || '1'), searchKey, type, isGetAll); + apps = await this.appsUtilService.all(user, parseInt(page || '1'), searchKey, type, isGetAll, branchId); } if (isGetAll) { @@ -406,7 +420,7 @@ export class AppsService implements IAppsService { } } - const totalCount = await this.appsUtilService.count(user, searchKey, type as APP_TYPES); + const totalCount = await this.appsUtilService.count(user, searchKey, type as APP_TYPES, branchId); const totalPageCount = folderId ? totalFolderCount : totalCount; @@ -430,7 +444,7 @@ export class AppsService implements IAppsService { return await this.appsUtilService.findTooljetDbTables(appId); //moved to util } - async getOne(app: App, user: User): Promise { + async getOne(app: App, user: User, branchId?: string): Promise { const response = decamelizeKeys(app); const seralizedQueries = []; @@ -493,7 +507,11 @@ export class AppsService implements IAppsService { const editingVersion = response['editing_version']; const isDraft = editingVersion?.status === 'DRAFT'; - const appGit = await this.appGitRepository.findAppGitByAppId(app.id); + let appGit = await this.appGitRepository.findAppGitByAppId(app.id); + // Branch-copy apps (platform git sync) don't have their own app_git_sync record + if (!appGit && app.co_relation_id && app.co_relation_id !== app.id) { + appGit = await this.appGitRepository.findAppGitByAppId(app.co_relation_id); + } if (appGit && !isDraft) { // Only apply git-based freezing for non-draft versions response['should_freeze_editor'] = !appGit.allowEditing || shouldFreezeEditor; diff --git a/server/src/modules/apps/services/app-import-export.service.ts b/server/src/modules/apps/services/app-import-export.service.ts index 5cf477a444..dbceb457e7 100644 --- a/server/src/modules/apps/services/app-import-export.service.ts +++ b/server/src/modules/apps/services/app-import-export.service.ts @@ -2,10 +2,12 @@ import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nes import { isEmpty, set } from 'lodash'; import { App } from 'src/entities/app.entity'; import { AppEnvironment } from 'src/entities/app_environments.entity'; -import { AppVersion, AppVersionStatus } from 'src/entities/app_version.entity'; +import { AppVersion, AppVersionStatus, AppVersionType } from 'src/entities/app_version.entity'; import { DataQuery } from 'src/entities/data_query.entity'; import { DataSource } from 'src/entities/data_source.entity'; -import { DataSourceOptions } from 'src/entities/data_source_options.entity'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; +import { Credential } from '@entities/credential.entity'; import { User } from 'src/entities/user.entity'; import { EntityManager, In, DeepPartial } from 'typeorm'; import { @@ -45,6 +47,7 @@ import { QueryUser } from '@entities/query_users.entity'; import { ComponentPermission } from '@entities/component_permissions.entity'; import { ComponentUser } from '@entities/component_users.entity'; import { OrganizationGitSync } from '@entities/organization_git_sync.entity'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; interface AppResourceMappings { defaultDataSourceIdMapping: Record; dataQueryMapping: Record; @@ -273,7 +276,7 @@ export class AppImportExportService { .getMany(); let dataQueries: DataQuery[] = []; - let dataSourceOptions: DataSourceOptions[] = []; + let dataSourceOptions: any[] = []; const globalQueries: DataQuery[] = await manager .createQueryBuilder(DataQuery, 'data_query') @@ -305,17 +308,29 @@ export class AppImportExportService { .orderBy('data_queries.created_at', 'ASC') .getMany(); - dataSourceOptions = await manager - .createQueryBuilder(DataSourceOptions, 'data_source_options') - .where( - 'data_source_options.environmentId IN(:...environmentId) AND data_source_options.dataSourceId IN(:...dataSourceId)', - { - environmentId: appEnvironments.map((v) => v.id), - dataSourceId: dataSources.map((v) => v.id), - } - ) - .orderBy('data_source_options.createdAt', 'ASC') - .getMany(); + const rawAndEntities = await manager + .createQueryBuilder(DataSourceVersionOptions, 'dsvo') + .innerJoin(DataSourceVersion, 'dsv', 'dsv.id = dsvo.dataSourceVersionId AND dsv.isDefault = true') + .where('dsvo.environmentId IN(:...environmentId) AND dsv.dataSourceId IN(:...dataSourceId)', { + environmentId: appEnvironments.map((v) => v.id), + dataSourceId: dataSources.map((v) => v.id), + }) + .addSelect('dsv.dataSourceId', 'dataSourceId') + .orderBy('dsvo.createdAt', 'ASC') + .getRawAndEntities(); + + // Map DSVO records to the legacy export shape (with dataSourceId) + dataSourceOptions = rawAndEntities.raw.map((raw, i) => { + const entity = rawAndEntities.entities[i]; + return { + id: entity.id, + options: entity.options, + environmentId: entity.environmentId, + dataSourceId: raw.dsv_dataSourceId || raw.dataSourceId, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + }); dataSourceOptions?.forEach((dso) => { delete dso?.options?.tokenData; @@ -542,7 +557,8 @@ export class AppImportExportService { isGitApp = false, tooljetVersion = '', cloning = false, - manager?: EntityManager + manager?: EntityManager, + branchId?: string ): Promise<{ newApp: App; resourceMapping: AppResourceMappings }> { return await dbTransactionWrap(async (manager: EntityManager) => { if (typeof appParamsObj !== 'object') { @@ -594,14 +610,23 @@ export class AppImportExportService { externalResourceMappings, isNormalizedAppDefinitionSchema, currentTooljetVersion, - moduleResourceMappings + moduleResourceMappings, + undefined, + branchId ); - await this.updateEntityReferencesForImportedApp(manager, resourceMapping); + await this.updateEntityReferencesForImportedApp(manager, resourceMapping, isGitApp); // Update latest version as editing version const { importingAppVersions } = this.extractImportDataFromAppParams(appParams); - await this.setEditingVersionAsLatestVersion(manager, resourceMapping.appVersionMapping, importingAppVersions); + // When multiple versions are imported, branch-type versions are excluded. + // Filter here to match so the editing version is set to a version that was actually created. + const importedAppVersions = + importingAppVersions.length > 1 + ? importingAppVersions.filter((v: any) => !v.versionType || v.versionType === AppVersionType.VERSION) + : importingAppVersions; + + await this.setEditingVersionAsLatestVersion(manager, resourceMapping.appVersionMapping, importedAppVersions); // NOTE: App slug updation callback doesn't work while wrapped in transaction // hence updating slug explicitly @@ -637,7 +662,6 @@ export class AppImportExportService { const newDataSourceIds = Object.values(resourceMapping.dataSourceMapping); const newDsoIds = Object.values(resourceMapping.dataSourceOptionsMapping); const newLayoutIds = Object.values(resourceMapping.layoutMapping); - const appVersionIds = Object.values(resourceMapping.appVersionMapping); // Pages if (newPageIds.length > 0) { @@ -675,10 +699,10 @@ export class AppImportExportService { } } - // DataSourceOptions + // DataSourceOptions (now stored in DataSourceVersionOptions) if (newDsoIds.length > 0) { const dataSourceOptions = await manager - .createQueryBuilder(DataSourceOptions, 'dso') + .createQueryBuilder(DataSourceVersionOptions, 'dso') .where('dso.id IN(:...dsoIds)', { dsoIds: newDsoIds }) .select(['dso.id']) .getMany(); @@ -711,22 +735,10 @@ export class AppImportExportService { } } - if (appVersionIds.length > 0) { - const appVersions = await manager - .createQueryBuilder(AppVersion, 'av') - .where('av.id IN(:...avIds)', { avIds: appVersionIds }) - .select(['av.id']) - .getMany(); - - const toUpdateVersions = appVersions.map((av) => { - this.setCoRelationId(av, resourceMapping.appVersionMapping); - return av; - }); - - if (!isEmpty(toUpdateVersions)) { - await manager.save(toUpdateVersions); - } - } + // AppVersion.co_relation_id is intentionally NOT updated here. + // It is set at creation time to importedApp.co_relation_id (the app's stable cross-workspace + // identifier). Using the old source version UUID as the stable identity would be wrong — + // all versions of the same app must share the app's co_relation_id. } async updateEntityReferencesForImportedApp( @@ -880,7 +892,7 @@ export class AppImportExportService { importingDataQueries: DataQuery[]; importingAppVersions: AppVersion[]; importingAppEnvironments: AppEnvironment[]; - importingDataSourceOptions: DataSourceOptions[]; + importingDataSourceOptions: any[]; importingDefaultAppEnvironmentId: string; importingPages: Page[]; importingComponents: Component[]; @@ -925,7 +937,8 @@ export class AppImportExportService { isNormalizedAppDefinitionSchema: boolean, tooljetVersion: string | null, moduleResourceMappings?: Record, - createNewVersion?: boolean + createNewVersion?: boolean, + branchId?: string ): Promise { // Old version without app version // Handle exports prior to 0.12.0 @@ -961,14 +974,23 @@ export class AppImportExportService { importingEvents, } = this.extractImportDataFromAppParams(appParams); + // When importing multiple versions, skip branch-type versions — only import regular versions. + // When importing a single branch-type version, allow it through (it will be adapted to + // the target branch context inside createAppVersionsForImportedApp). + const filteredAppVersions = + importingAppVersions.length > 1 + ? importingAppVersions.filter((v: any) => !v.versionType || v.versionType === AppVersionType.VERSION) + : importingAppVersions; + const { appDefaultEnvironmentMapping, appVersionMapping } = await this.createAppVersionsForImportedApp( manager, user, importedApp, - importingAppVersions, + filteredAppVersions, appResourceMappings, isNormalizedAppDefinitionSchema, - createNewVersion + createNewVersion, + branchId ); appResourceMappings.appDefaultEnvironmentMapping = appDefaultEnvironmentMapping; appResourceMappings.appVersionMapping = appVersionMapping; @@ -981,7 +1003,7 @@ export class AppImportExportService { appResourceMappings = await this.setupAppVersionAssociations( manager, - importingAppVersions, + filteredAppVersions, user, appResourceMappings, externalResourceMappings, @@ -994,7 +1016,8 @@ export class AppImportExportService { importingComponents, importingEvents, tooljetVersion, - moduleResourceMappings + moduleResourceMappings, + branchId ); const importedAppVersionIds = Object.values(appResourceMappings.appVersionMapping); @@ -1003,7 +1026,7 @@ export class AppImportExportService { } if (!isNormalizedAppDefinitionSchema) { - for (const importingAppVersion of importingAppVersions) { + for (const importingAppVersion of filteredAppVersions) { const updatedDefinition: DeepPartial = this.replaceDataQueryIdWithinDefinitions( importingAppVersion.definition, appResourceMappings.dataQueryMapping @@ -1221,14 +1244,15 @@ export class AppImportExportService { externalResourceMappings: Record, importingAppEnvironments: AppEnvironment[], importingDataSources: DataSource[], - importingDataSourceOptions: DataSourceOptions[], + importingDataSourceOptions: any[], importingDataQueries: DataQuery[], importingDefaultAppEnvironmentId: string, importingPages: Page[], importingComponents: Component[], importingEvents: EventHandler[], tooljetVersion: string | null, - moduleResourceMappings?: any + moduleResourceMappings?: any, + branchId?: string ): Promise { appResourceMappings = { ...appResourceMappings }; @@ -1289,21 +1313,43 @@ export class AppImportExportService { true, manager ); - const dsOption = manager.create(DataSourceOptions, { - environmentId: envId, - dataSourceId: dataSourceForAppVersion.id, - options: newOptions, - createdAt: new Date(), - updatedAt: new Date(), + // Find-or-create default DSV, then create DSVO + let defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSourceForAppVersion.id, isDefault: true }, }); - const savedDsOption = await manager.save(dsOption); + if (!defaultDsv) { + defaultDsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: dataSourceForAppVersion.id, + name: dataSourceForAppVersion.name || importingDataSource.name || 'v1', + isDefault: true, + isActive: true, + branchId: null, + }) + ); + } + const existingDsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: envId }, + }); + let savedDsvo; + if (!existingDsvo) { + savedDsvo = await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: defaultDsv.id, + environmentId: envId, + options: newOptions, + }) + ); + } else { + savedDsvo = existingDsvo; + } // Find the matching old dataSourceOption ID for this environment const oldDsOption = importingDataSourceOptions.find( (dso) => dso.dataSourceId === importingDataSource.id && dso.environmentId === envId ); if (oldDsOption) { - appResourceMappings.dataSourceOptionsMapping[oldDsOption.id] = savedDsOption.id; + appResourceMappings.dataSourceOptionsMapping[oldDsOption.id] = savedDsvo.id; } }) ); @@ -1323,6 +1369,12 @@ export class AppImportExportService { ); } + // Ensure branch-scoped DSV exists so the DS appears on the global DS page + // for the active branch. This handles both newly created and existing DS. + if (!isDefaultDatasource) { + await this.ensureBranchDsvForDataSource(manager, dataSourceForAppVersion, user.organizationId, branchId); + } + const { dataQueryMapping } = await this.createDataQueriesForAppVersion( manager, user?.organizationId, @@ -1788,7 +1840,7 @@ export class AppImportExportService { manager: EntityManager, appVersion: AppVersion, dataSourceForAppVersion: DataSource, - dataSourceOptions: DataSourceOptions[], + dataSourceOptions: any[], importingDataSource: DataSource, appEnvironments: AppEnvironment[], appResourceMappings: AppResourceMappings, @@ -1810,13 +1862,16 @@ export class AppImportExportService { (dso) => dso.environmentId === defaultAppEnvironmentId ); for (const otherEnvironmentId of otherEnvironmentsIds) { - const existingDataSourceOptions = await manager.findOne(DataSourceOptions, { - where: { - dataSourceId: dataSourceForAppVersion.id, - environmentId: otherEnvironmentId, - }, + // Check if DSVO already exists for this env + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSourceForAppVersion.id, isDefault: true }, }); - if (!existingDataSourceOptions) { + const existing = defaultDsv + ? await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: otherEnvironmentId }, + }) + : null; + if (!existing) { await this.createDatasourceOption( manager, defaultEnvDsOption.options, @@ -1830,18 +1885,21 @@ export class AppImportExportService { // create datasource options only for newly created datasources for (const importingDataSourceOption of importingDatasourceOptionsForAppVersion) { if (importingDataSourceOption?.environmentId in appResourceMappings.appEnvironmentMapping) { - const existingDataSourceOptions = await manager.findOne(DataSourceOptions, { - where: { - dataSourceId: dataSourceForAppVersion.id, - environmentId: appResourceMappings.appEnvironmentMapping[importingDataSourceOption.environmentId], - }, + const mappedEnvId = appResourceMappings.appEnvironmentMapping[importingDataSourceOption.environmentId]; + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSourceForAppVersion.id, isDefault: true }, }); + const existing = defaultDsv + ? await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: mappedEnvId }, + }) + : null; - if (!existingDataSourceOptions) { + if (!existing) { await this.createDatasourceOption( manager, importingDataSourceOption.options, - appResourceMappings.appEnvironmentMapping[importingDataSourceOption.environmentId], + mappedEnvId, dataSourceForAppVersion.id ); } @@ -1893,6 +1951,18 @@ export class AppImportExportService { }, }); }; + // Git exports replace id with co_relation_id, so the imported dataSource.id + // is actually the source's co_relation_id. Look up by co_relation_id to + // find existing DS that were previously imported or created locally. + const globalDataSourceByCoRelationId = async (dataSource: DataSource) => { + return await manager.findOne(DataSource, { + where: { + co_relation_id: dataSource.id, + scope: DataSourceScopes.GLOBAL, + organizationId: user.organizationId, + }, + }); + }; const globalDataSourceWithSameNameExists = async (dataSource: DataSource) => { return await manager.findOne(DataSource, { where: { @@ -1905,7 +1975,9 @@ export class AppImportExportService { }); }; const existingDatasource = - (await globalDataSourceWithSameIdExists(dataSource)) || (await globalDataSourceWithSameNameExists(dataSource)); + (await globalDataSourceWithSameIdExists(dataSource)) || + (await globalDataSourceByCoRelationId(dataSource)) || + (await globalDataSourceWithSameNameExists(dataSource)); if (existingDatasource) return existingDatasource; @@ -1922,11 +1994,16 @@ export class AppImportExportService { name: dataSource.name, kind: dataSource.kind, type: DataSourceTypes.DEFAULT, - scope: DataSourceScopes.GLOBAL, // No appVersionId for global data sources + scope: DataSourceScopes.GLOBAL, pluginId: plugin.id, }); await manager.save(newDataSource); + // Set co_relation_id so workspace git sync can identify this DS. + // Use the imported id (source's co_relation_id) to maintain identity across branches. + newDataSource.co_relation_id = dataSource.id || (null as any); + await manager.update(DataSource, { id: newDataSource.id }, { co_relation_id: newDataSource.co_relation_id }); + return newDataSource; } }; @@ -1937,11 +2014,16 @@ export class AppImportExportService { name: dataSource.name, kind: dataSource.kind, type: DataSourceTypes.DEFAULT, - scope: DataSourceScopes.GLOBAL, // No appVersionId for global data sources + scope: DataSourceScopes.GLOBAL, pluginId: null, }); await manager.save(newDataSource); + // Set co_relation_id so workspace git sync can identify this DS. + // Use the imported id (source's co_relation_id) to maintain identity across branches. + newDataSource.co_relation_id = dataSource.id || (null as any); + await manager.update(DataSource, { id: newDataSource.id }, { co_relation_id: newDataSource.co_relation_id }); + return newDataSource; }; @@ -1952,6 +2034,83 @@ export class AppImportExportService { } } + /** + * For a newly created data source, create a branch-specific DSV record + * so that workspace git sync recognizes it on the active branch. + * Copies options from the default DSV to the branch DSV. + */ + private async ensureBranchDsvForDataSource( + manager: EntityManager, + dataSource: DataSource, + organizationId: string, + branchId?: string + ): Promise { + // Resolve target branch: use explicit branchId, or fall back to default branch + let targetBranchId = branchId; + if (!targetBranchId) { + const defaultBranch = await manager.findOne(WorkspaceBranch, { + where: { organizationId, isDefault: true }, + select: ['id'], + }); + if (!defaultBranch) return; + targetBranchId = defaultBranch.id; + } + + // Check if a branch-specific DSV already exists + const existingBranchDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, branchId: targetBranchId }, + }); + if (existingBranchDsv) return; + + // Find the default DSV to copy from + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, isDefault: true }, + }); + if (!defaultDsv) return; + + // Create branch-specific DSV + const branchDsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: dataSource.id, + branchId: targetBranchId, + name: defaultDsv.name, + isActive: true, + }) + ); + + // Copy options from default DSV to branch DSV, cloning credentials + const defaultOptions = await manager.find(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id }, + }); + for (const dOpt of defaultOptions) { + const clonedOptions = JSON.parse(JSON.stringify(dOpt.options || {})); + for (const key of Object.keys(clonedOptions)) { + const opt = clonedOptions[key]; + if (opt?.credential_id && opt?.encrypted) { + const srcCredential = await manager.findOne(Credential, { + where: { id: opt.credential_id }, + }); + if (srcCredential) { + const newCredential = manager.create(Credential, { + valueCiphertext: srcCredential.valueCiphertext, + createdAt: new Date(), + updatedAt: new Date(), + }); + const savedCred = await manager.save(Credential, newCredential); + clonedOptions[key] = { ...opt, credential_id: savedCred.id }; + } + } + } + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: branchDsv.id, + environmentId: dOpt.environmentId, + options: clonedOptions, + }) + ); + } + } + async associateAppEnvironmentsToAppVersion( manager: EntityManager, user: User, @@ -2180,7 +2339,8 @@ export class AppImportExportService { appVersions: AppVersion[], appResourceMappings: AppResourceMappings, isNormalizedAppDefinitionSchema: boolean, - createNewVersion?: boolean + createNewVersion?: boolean, + branchId?: string ) { appResourceMappings = { ...appResourceMappings }; const { appVersionMapping, appDefaultEnvironmentMapping } = appResourceMappings; @@ -2196,6 +2356,17 @@ export class AppImportExportService { }); const isGitSyncConfigured = !!orgGitSync; + // Determine whether we are importing into a sub-branch (non-default). + // Sub-branch versions must use BRANCH type so the canvas stays editable. + let isSubBranch = false; + if (branchId) { + const targetBranch = await manager.findOne(WorkspaceBranch, { + where: { id: branchId }, + select: ['id', 'isDefault'], + }); + isSubBranch = !!targetBranch && !targetBranch.isDefault; + } + // Find the latest draft version // When git sync is configured, only the latest draft should remain as DRAFT, others become PUBLISHED let latestDraftId: string | null = null; @@ -2243,7 +2414,11 @@ export class AppImportExportService { let versionStatus: AppVersionStatus; const isDraftVersion = appVersion.status === AppVersionStatus.DRAFT || !appVersion.status; - if (isGitSyncConfigured && isDraftVersion) { + if (isSubBranch) { + // On sub-branches, all versions must be DRAFT so the canvas stays editable. + // PUBLISHED status would freeze the editor regardless of version type. + versionStatus = AppVersionStatus.DRAFT; + } else if (isGitSyncConfigured && isDraftVersion) { // Only the latest draft should remain as DRAFT, others become PUBLISHED versionStatus = appVersion.id === latestDraftId ? AppVersionStatus.DRAFT : AppVersionStatus.PUBLISHED; } else { @@ -2259,10 +2434,11 @@ export class AppImportExportService { createdAt: new Date(), updatedAt: new Date(), status: versionStatus, - versionType: appVersion.versionType, + versionType: isSubBranch ? AppVersionType.BRANCH : AppVersionType.VERSION, parent_version_id: appVersion?.id || null, createdById: user.id, - co_relation_id: appVersion.id, + co_relation_id: importedApp.co_relation_id || null, + branchId, }); } if (isNormalizedAppDefinitionSchema) { @@ -2325,14 +2501,41 @@ export class AppImportExportService { ) { const convertedOptions = this.convertToArrayOfKeyValuePairs(options); const newOptions = await this.dataSourcesUtilService.parseOptionsForCreate(convertedOptions, true, manager); - const dsOption = manager.create(DataSourceOptions, { - options: newOptions, - environmentId, - dataSourceId, - createdAt: new Date(), - updatedAt: new Date(), + + // Find-or-create default DSV, then create DSVO + let defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId, isDefault: true }, }); - await manager.save(dsOption); + if (!defaultDsv) { + const ds = await manager.findOne(DataSource, { where: { id: dataSourceId }, select: ['id', 'name'] }); + defaultDsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId, + name: ds?.name || 'v1', + isDefault: true, + isActive: true, + branchId: null, + }) + ); + } + const existingDsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId }, + }); + if (!existingDsvo) { + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: defaultDsv.id, + environmentId, + options: newOptions, + }) + ); + } else { + await manager.update( + DataSourceVersionOptions, + { id: existingDsvo.id }, + { options: newOptions, updatedAt: new Date() } + ); + } } convertToArrayOfKeyValuePairs(options: Record): Array { @@ -2508,14 +2711,33 @@ export class AppImportExportService { newOptions = await this.dataSourcesUtilService.parseOptionsForCreate(convertedOptions, true, manager); } - const dsOption = manager.create(DataSourceOptions, { - environmentId: envId, - dataSourceId: newSource.id, - options: newOptions, - createdAt: new Date(), - updatedAt: new Date(), + // Find-or-create default DSV, then create DSVO + let defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: newSource.id, isDefault: true }, }); - await manager.save(dsOption); + if (!defaultDsv) { + defaultDsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: newSource.id, + name: newSource.name || source.name || 'v1', + isDefault: true, + isActive: true, + branchId: null, + }) + ); + } + const existingDsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: envId }, + }); + if (!existingDsvo) { + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: defaultDsv.id, + environmentId: envId, + options: newOptions, + }) + ); + } }) ); } diff --git a/server/src/modules/apps/subscribers/apps.subscriber.ts b/server/src/modules/apps/subscribers/apps.subscriber.ts index 63ff4db85b..aedcec6de8 100644 --- a/server/src/modules/apps/subscribers/apps.subscriber.ts +++ b/server/src/modules/apps/subscribers/apps.subscriber.ts @@ -1,10 +1,12 @@ -import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; +import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent, Not } from 'typeorm'; import { App } from 'src/entities/app.entity'; +import { AppBase } from 'src/entities/app_base.entity'; +import { AppVersionType } from 'src/entities/app_version.entity'; import { VersionRepository } from '@modules/versions/repository'; import { AppsRepository } from '@modules/apps/repository'; @EventSubscriber() -export class AppsSubscriber implements EntitySubscriberInterface { +export class AppsSubscriber implements EntitySubscriberInterface { constructor( private readonly appVersionRepository: VersionRepository, private readonly appRepository: AppsRepository, @@ -13,25 +15,38 @@ export class AppsSubscriber implements EntitySubscriberInterface { datasourceRepository.subscribers.push(this); } - listenTo() { - return App; - } - - async afterInsert(event: InsertEvent): Promise { - const app = event.entity; - if (!app.slug) { - await this.appRepository.update(app.id, { slug: app.id }); + async afterInsert(event: InsertEvent): Promise { + const entity = event.entity; + if (!(entity instanceof App)) return; + if (!entity.slug) { + await this.appRepository.update(entity.id, { slug: entity.id }); } } - async afterLoad(app: App): Promise { + async afterLoad(app: any): Promise { + if (!(app instanceof App) && !(app instanceof AppBase)) return; if (!app || (app as any).__loaded) return; (app as any).__loaded = true; // mark entity as processed - app.editingVersion = await this.appVersionRepository.findOne({ - where: { appId: app.id }, + // Prefer VERSION-type versions (canonical, user-named) over BRANCH-type versions. + // With the new single-App-per-logical-app model, multiple branches share one App + // entity. Without this filter the most-recently-updated BRANCH-type version from + // any branch could be returned here, giving the wrong context to callers that have + // no branch information (e.g. background jobs, non-git-sync paths). + // Branch-specific context is layered on top by the service layer when a branchId + // is available (see EE AppsService.getOne). + const editingVersion = await this.appVersionRepository.findOne({ + where: { appId: app.id, versionType: Not(AppVersionType.BRANCH), isStub: false }, order: { updatedAt: 'DESC' }, }); + + if (!editingVersion) { + (app as any).isStub = true; + return; + } + + (app as any).isStub = false; + (app as any).editingVersion = editingVersion; } } diff --git a/server/src/modules/apps/util.service.ts b/server/src/modules/apps/util.service.ts index 028bd5b2b3..3860c6f397 100644 --- a/server/src/modules/apps/util.service.ts +++ b/server/src/modules/apps/util.service.ts @@ -35,6 +35,7 @@ import { IAppsUtilService } from './interfaces/IUtilService'; import { AppVersionUpdateDto } from '@dto/app-version-update.dto'; import { APP_TYPES } from './constants'; import { Component } from 'src/entities/component.entity'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; import { Layout } from 'src/entities/layout.entity'; import { WorkspaceAppsResponseDto } from '@modules/external-apis/dto'; import { DataQuery } from '@entities/data_query.entity'; @@ -49,13 +50,14 @@ export class AppsUtilService implements IAppsUtilService { protected readonly licenseTermsService: LicenseTermsService, protected readonly organizationRepository: OrganizationRepository, protected readonly abilityService: AbilityService - ) { } + ) {} async create( name: string, user: User, type: APP_TYPES, isInitialisedFromPrompt: boolean = false, - manager: EntityManager + manager: EntityManager, + branchId?: string ): Promise { return await dbTransactionWrap(async (manager: EntityManager) => { const app = await catchDbException(() => { @@ -77,83 +79,184 @@ export class AppsUtilService implements IAppsUtilService { ); }, [{ dbConstraint: DataBaseConstraints.APP_NAME_UNIQUE, message: 'This app name is already taken.' }]); - //create default app version const firstPriorityEnv = await this.appEnvironmentUtilService.get(user.organizationId, null, true, manager); - const appVersion = await this.versionRepository.createOne('v1', app.id, firstPriorityEnv.id, null, manager); - const defaultHomePage = await manager.save( - manager.create(Page, { - name: 'Home', - handle: 'home', - appVersionId: appVersion.id, - index: 1, - autoComputeLayout: true, - appId: app.id, - }) - ); + // Resolve workspace branch once — used for both version creation and co_relation_id. + let workspaceBranch: WorkspaceBranch | null = null; + if (branchId) { + workspaceBranch = await manager.findOne(WorkspaceBranch, { where: { id: branchId } }); + } + const isNonDefaultBranch = !!(branchId && workspaceBranch && !workspaceBranch.isDefault); - if (type === 'module') { - const moduleContainer = await manager.save( - manager.create(Component, { - name: 'ModuleContainer', - type: 'ModuleContainer', - pageId: defaultHomePage.id, - properties: { - inputItems: { value: [] }, - outputItems: { value: [] }, - visibility: { value: '{{true}}' }, - }, - styles: { - backgroundColor: { value: '#fff' }, - }, - displayPreferences: { - showOnDesktop: { value: '{{true}}' }, - showOnMobile: { value: '{{true}}' }, - }, + if (isNonDefaultBranch) { + // Non-default workspace branch: create ONLY the branch-specific version. + // No base version (branch_id=NULL) should exist for sub-branch apps — + // it would incorrectly appear in the default branch's version dropdown. + // The default branch gets its version via git pull/hydration. + const defaultSettings = { + appInMaintenance: false, + canvasMaxWidth: 100, + canvasMaxWidthType: '%', + canvasMaxHeight: 2400, + canvasBackgroundColor: 'var(--cc-appBackground-surface)', + backgroundFxQuery: '', + appMode: 'light', + }; + const branchVersion = await manager.save( + AppVersion, + manager.create(AppVersion, { + // name: uuidv4(), + name: (type === APP_TYPES.WORKFLOW || type === APP_TYPES.MODULE) ? 'v1' : uuidv4(), + appId: app.id, + definition: {}, + currentEnvironmentId: firstPriorityEnv.id, + status: AppVersionStatus.DRAFT, + // versionType: AppVersionType.BRANCH, + versionType: (type === APP_TYPES.WORKFLOW || type === APP_TYPES.MODULE) ? AppVersionType.VERSION : AppVersionType.BRANCH, + branchId: branchId, + showViewerNavigation: type === 'module' ? false : true, + globalSettings: defaultSettings, + pageSettings: {}, + createdAt: new Date(), + updatedAt: new Date(), }) ); - await manager.save( - manager.create(Layout, { - component: moduleContainer, - type: 'desktop', - top: 50, - left: 6, - height: 400, - width: 38, + const branchHomePage = await manager.save( + manager.create(Page, { + name: 'Home', + handle: 'home', + appVersionId: branchVersion.id, + index: 1, + autoComputeLayout: true, + appId: app.id, }) ); - await manager.save( - manager.create(Layout, { - component: moduleContainer, - type: 'mobile', - top: 50, - left: 6, - height: 400, - width: 38, + if (type === 'module') { + const moduleContainer = await manager.save( + manager.create(Component, { + name: 'ModuleContainer', + type: 'ModuleContainer', + pageId: branchHomePage.id, + properties: { + inputItems: { value: [] }, + outputItems: { value: [] }, + visibility: { value: '{{true}}' }, + }, + styles: { backgroundColor: { value: '#fff' } }, + displayPreferences: { + showOnDesktop: { value: '{{true}}' }, + showOnMobile: { value: '{{true}}' }, + }, + }) + ); + await manager.save( + manager.create(Layout, { + component: moduleContainer, + type: 'desktop', + top: 50, + left: 6, + height: 400, + width: 38, + }) + ); + await manager.save( + manager.create(Layout, { + component: moduleContainer, + type: 'mobile', + top: 50, + left: 6, + height: 400, + width: 38, + }) + ); + } + + branchVersion.homePageId = branchHomePage.id; + await manager.save(branchVersion); + } else { + // Default branch or no git sync: standard creation flow. + // Base version gets 'v1' — user-visible, renameable via version manager. + const appVersion = await this.versionRepository.createOne('v1', app.id, firstPriorityEnv.id, null, manager); + + const defaultHomePage = await manager.save( + manager.create(Page, { + name: 'Home', + handle: 'home', + appVersionId: appVersion.id, + index: 1, + autoComputeLayout: true, + appId: app.id, }) ); + + if (type === 'module') { + const moduleContainer = await manager.save( + manager.create(Component, { + name: 'ModuleContainer', + type: 'ModuleContainer', + pageId: defaultHomePage.id, + properties: { + inputItems: { value: [] }, + outputItems: { value: [] }, + visibility: { value: '{{true}}' }, + }, + styles: { backgroundColor: { value: '#fff' } }, + displayPreferences: { + showOnDesktop: { value: '{{true}}' }, + showOnMobile: { value: '{{true}}' }, + }, + }) + ); + await manager.save( + manager.create(Layout, { + component: moduleContainer, + type: 'desktop', + top: 50, + left: 6, + height: 400, + width: 38, + }) + ); + await manager.save( + manager.create(Layout, { + component: moduleContainer, + type: 'mobile', + top: 50, + left: 6, + height: 400, + width: 38, + }) + ); + } + + appVersion.showViewerNavigation = type === 'module' ? false : true; + appVersion.homePageId = defaultHomePage.id; + appVersion.globalSettings = { + appInMaintenance: false, + canvasMaxWidth: 100, + canvasMaxWidthType: '%', + canvasMaxHeight: 2400, + canvasBackgroundColor: 'var(--cc-appBackground-surface)', + backgroundFxQuery: '', + appMode: 'light', + }; + await manager.save(appVersion); + } + + // Set co_relation_id for git sync workspaces — always a fresh UUID, never app.id. + if (branchId) { + const coRelationId = uuidv4(); + await manager.update(App, { id: app.id }, { co_relation_id: coRelationId }); + app.co_relation_id = coRelationId; } - // Set default values for app version - appVersion.showViewerNavigation = type === 'module' ? false : true; - appVersion.homePageId = defaultHomePage.id; - appVersion.globalSettings = { - appInMaintenance: false, - canvasMaxWidth: 100, - canvasMaxWidthType: '%', - canvasMaxHeight: 2400, - canvasBackgroundColor: 'var(--cc-appBackground-surface)', - backgroundFxQuery: '', - appMode: 'light', - }; - await manager.save(appVersion); return app; }, manager); } - async findAppWithIdOrSlug(slug: string, organizationId: string): Promise { + async findAppWithIdOrSlug(slug: string, organizationId: string, branchId?: string): Promise { let app: App; if (isUUID(slug)) { @@ -365,7 +468,14 @@ export class AppsUtilService implements IAppsUtilService { .getOne(); } - async all(user: User, page: number, searchKey: string, type: string, isGetAll: boolean): Promise { + async all( + user: User, + page: number, + searchKey: string, + type: string, + isGetAll: boolean, + branchId?: string + ): Promise { //Migrate it to app utility files let resourceType: MODULES; @@ -393,12 +503,26 @@ export class AppsUtilService implements IAppsUtilService { manager, searchKey, isGetAll ? ['id', 'slug', 'name', 'currentVersionId'] : undefined, - type + type, + branchId ); // Eagerly load appVersions for modules if (type === APP_TYPES.MODULE && !isGetAll) { viewableAppsQb.leftJoinAndSelect('apps.appVersions', 'appVersions'); + // } else if (branchId) { + // // If branchId is provided -> Gitsync -> need to load app versions of the branch. + // // Inner joining -> show on dashboard only if there is a version on the branch, which means the app is gitsynced to the branch. + // viewableAppsQb.innerJoinAndSelect('apps.appVersions', 'appVersions', 'appVersions.branchId = :branchId', { + // branchId, + // }); + } else if (branchId && type === APP_TYPES.FRONT_END) { + // If branchId is provided -> Gitsync -> need to load app versions of the branch. + // Inner joining -> show on dashboard only if there is a version on the branch, which means the app is gitsynced to the branch. + // Modules and workflows are common across all branches - no branch filter applied. + viewableAppsQb.innerJoinAndSelect('apps.appVersions', 'appVersions', 'appVersions.branchId = :branchId', { + branchId, + }); } if (isGetAll) { @@ -418,7 +542,8 @@ export class AppsUtilService implements IAppsUtilService { manager: EntityManager, searchKey?: string, select?: Array, - type?: string + type?: string, + branchId?: string ): SelectQueryBuilder { const viewableAppsQb = manager .createQueryBuilder(AppBase, 'apps') @@ -520,7 +645,7 @@ export class AppsUtilService implements IAppsUtilService { return !!(user?.userType === USER_TYPE.INSTANCE); } - async count(user: User, searchKey, type: APP_TYPES): Promise { + async count(user: User, searchKey, type: APP_TYPES, branchId?: string): Promise { let resourceType: MODULES; switch (type) { @@ -544,7 +669,8 @@ export class AppsUtilService implements IAppsUtilService { manager, searchKey, undefined, - type + type, + branchId ).getCount(); }); } @@ -647,10 +773,10 @@ export class AppsUtilService implements IAppsUtilService { const modules = moduleAppIds.length > 0 ? await manager - .createQueryBuilder(App, 'app') - .where('app.id IN (:...moduleAppIds)', { moduleAppIds }) - .distinct(true) - .getMany() + .createQueryBuilder(App, 'app') + .where('app.id IN (:...moduleAppIds)', { moduleAppIds }) + .distinct(true) + .getMany() : []; return modules; }); @@ -719,27 +845,25 @@ export class AppsUtilService implements IAppsUtilService { let shouldFreezeEditor = false; // Check version status and type if (editingVersion?.status === AppVersionStatus.PUBLISHED) { - // Published versions are always frozen shouldFreezeEditor = true; } else if ( editingVersion?.versionType === AppVersionType.VERSION && editingVersion?.status === AppVersionStatus.DRAFT && (!orgGit || !orgGit?.isBranchingEnabled) ) { - // Draft versions should never be frozen by git config, only by environment - // Keep existing shouldFreezeEditor value from environment priority check + // Draft VERSION without branching — not frozen } else if ( editingVersion?.versionType === AppVersionType.VERSION && editingVersion?.status !== AppVersionStatus.DRAFT ) { - // Non-draft version types are frozen shouldFreezeEditor = true; } else { - // For branch versions, check git config - if (appGit && editingVersion?.status !== AppVersionStatus.DRAFT) { + // Workspace branching takes precedence: if branching is enabled, VERSION-type drafts on the + // default branch are always frozen (edits must happen on feature branches). + if (orgGit && orgGit?.isBranchingEnabled && editingVersion?.versionType === AppVersionType.VERSION) { + shouldFreezeEditor = true; + } else if (appGit) { shouldFreezeEditor = !appGit?.allowEditing || shouldFreezeEditor; - } else if (orgGit && orgGit?.isBranchingEnabled && editingVersion?.versionType === AppVersionType.VERSION) { - shouldFreezeEditor = orgGit?.isBranchingEnabled || shouldFreezeEditor; } } diff --git a/server/src/modules/branch-context/module.ts b/server/src/modules/branch-context/module.ts new file mode 100644 index 0000000000..9b16e88542 --- /dev/null +++ b/server/src/modules/branch-context/module.ts @@ -0,0 +1,35 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { SubModule } from '@modules/app/sub-module'; +import { BranchContextService } from '@modules/workspace-branches/branch-context.service'; + +/** + * Global module that provides BranchContextService to all modules. + * + * BranchContextService is a cross-cutting concern: many modules (DataSources, + * Folders, FolderApps, OrganizationConstants) need it to resolve the active + * workspace branch. Keeping it in a standalone global module avoids circular + * dependencies that would arise if those modules imported WorkspaceBranchesModule. + * + * CE stub always returns null; EE version queries OrganizationGitSync / WorkspaceBranch. + * + * The static import of BranchContextService serves as the injection token that all + * consumer modules reference. In EE mode, getProviders() loads the EE subclass; + * useClass ensures NestJS instantiates it under the CE token so DI resolves correctly. + */ +@Module({}) +export class BranchContextModule extends SubModule { + static async register(configs?: { IS_GET_CONTEXT: boolean }): Promise { + const { BranchContextService: BranchContextServiceImpl } = await this.getProviders( + configs, + 'workspace-branches', + ['branch-context.service'] + ); + + return { + module: BranchContextModule, + global: true, + providers: [{ provide: BranchContextService, useClass: BranchContextServiceImpl }], + exports: [BranchContextService], + }; + } +} diff --git a/server/src/modules/data-queries/controller.ts b/server/src/modules/data-queries/controller.ts index 398b065fcf..b4408a3ef9 100644 --- a/server/src/modules/data-queries/controller.ts +++ b/server/src/modules/data-queries/controller.ts @@ -7,7 +7,13 @@ import { App } from 'src/entities/app.entity'; import { Response } from 'express'; import { InitModule } from '@modules/app/decorators/init-module'; import { MODULES } from '@modules/app/constants/modules'; -import { CreateDataQueryDto, ListTablesDto, UpdateDataQueryDto, UpdateSourceDto, UpdatingReferencesOptionsDto } from './dto'; +import { + CreateDataQueryDto, + ListTablesDto, + UpdateDataQueryDto, + UpdateSourceDto, + UpdatingReferencesOptionsDto, +} from './dto'; import { ValidateQueryAppGuard } from './guards/validate-query-app.guard'; import { InitFeature } from '@modules/app/decorators/init-feature.decorator'; import { FEATURE_KEY } from './constants'; @@ -153,9 +159,10 @@ export class DataQueriesController implements IDataQueriesController { @User() user: UserEntity, @DataSource() dataSource: DataSourceEntity, @Param('environmentId') environmentId, + @Query('branch_id') branchId?: string, @Query() listTablesOptions?: ListTablesDto ) { - return this.dataQueriesService.listTablesForApp(user, dataSource, environmentId, listTablesOptions); + return this.dataQueriesService.listTablesForApp(user, dataSource, environmentId, branchId, listTablesOptions); } @InitFeature(FEATURE_KEY.PREVIEW) diff --git a/server/src/modules/data-queries/service.ts b/server/src/modules/data-queries/service.ts index 92411447d3..9d2a85bffa 100644 --- a/server/src/modules/data-queries/service.ts +++ b/server/src/modules/data-queries/service.ts @@ -233,6 +233,7 @@ export class DataQueriesService implements IDataQueriesService { const dataQuery = await this.dataQueryRepository.getOneById(dataQueryId, { dataSource: true, + appVersion: true, }); if (ability.can(FEATURE_KEY.UPDATE_ONE, DataSource, dataSource.id) && !isEmpty(options)) { @@ -254,6 +255,7 @@ export class DataQueriesService implements IDataQueriesService { const dataQuery = await this.dataQueryRepository.getOneById(dataQueryId, { dataSource: true, + appVersion: true, }); return this.runAndGetResult(user, dataQuery, resolvedOptions, response, undefined, 'view', app); @@ -308,11 +310,17 @@ export class DataQueriesService implements IDataQueriesService { return result; } - async listTablesForApp(user: User, dataSource: DataSource, environmentId: string, listTablesOptions?: ListTablesDto) { + async listTablesForApp( + user: User, + dataSource: DataSource, + environmentId: string, + branchId?: string, + listTablesOptions?: ListTablesDto + ) { let result = {}; try { - result = await this.dataQueryUtilService.listTables(user, dataSource, environmentId, listTablesOptions); - } catch (error) { + result = await this.dataQueryUtilService.listTables(user, dataSource, environmentId, branchId, listTablesOptions); + } catch (error) { if (error.constructor.name === 'QueryError') { result = { status: 'failed', diff --git a/server/src/modules/data-queries/util.service.ts b/server/src/modules/data-queries/util.service.ts index 66dbd565b7..1346feb51b 100644 --- a/server/src/modules/data-queries/util.service.ts +++ b/server/src/modules/data-queries/util.service.ts @@ -20,6 +20,7 @@ import { AUDIT_LOGS_REQUEST_CONTEXT_KEY } from '@modules/app/constants'; import { getQueryVariables } from 'lib/utils'; import { DataQueryExecutionOptions } from './interfaces/IUtilService'; import { AbortControllerHandler } from '@helpers/abortqueryhandler.helper'; +import { AppVersion, AppVersionType } from '@entities/app_version.entity'; import { ListTablesDto } from './dto'; @Injectable() @@ -90,7 +91,30 @@ export class DataQueriesUtilService implements IDataQueriesUtilService { } const organizationId = user ? user.organizationId : appToUse.organizationId; - const dataSourceOptions = await this.appEnvironmentUtilService.getOptions(dataSource.id, organizationId, envId); + // Lazy-load appVersion if relation not loaded but appVersionId is available + if (!dataQuery.appVersion && dataQuery.appVersionId) { + dataQuery.appVersion = await dbTransactionWrap(async (manager: EntityManager) => { + return manager.findOne(AppVersion, { + where: { id: dataQuery.appVersionId }, + select: ['id', 'versionType', 'branchId'], + }); + }); + } + + // Branch-aware: resolve branchId from appVersion when version type is 'branch' + const branchId = + dataQuery?.appVersion?.versionType === AppVersionType.BRANCH ? dataQuery.appVersion.branchId : undefined; + // Saved/tagged version: resolve appVersionId for non-branch versions + const appVersionId = + dataQuery?.appVersion?.versionType !== AppVersionType.BRANCH ? dataQuery?.appVersion?.id : undefined; + + const dataSourceOptions = await this.appEnvironmentUtilService.getOptions( + dataSource.id, + organizationId, + envId, + branchId, + appVersionId + ); const environmentId = dataSourceOptions.environmentId; dataSource.options = dataSourceOptions.options; @@ -247,7 +271,8 @@ export class DataQueriesUtilService implements IDataQueriesUtilService { const dataSourceOptions = await this.appEnvironmentUtilService.getOptions( dataSource.id, user.organizationId, - environmentId + environmentId, + branchId ); dataSource.options = dataSourceOptions.options; @@ -287,7 +312,7 @@ export class DataQueriesUtilService implements IDataQueriesUtilService { dataSource.kind === 'graphql' || dataSource.kind === 'googlesheets' || dataSource.kind === 'slack' || - dataSource.kind === 'zendesk'|| + dataSource.kind === 'zendesk' || dataSource.kind === 'googlesheetsv2' ) { queryStatus.setSuccess('needs_oauth'); @@ -362,7 +387,13 @@ export class DataQueriesUtilService implements IDataQueriesUtilService { } } - async listTables(user: User, dataSource: DataSource, environmentId: string, listTablesOptions?: ListTablesDto): Promise { + async listTables( + user: User, + dataSource: DataSource, + environmentId: string, + branchId?: string, + listTablesOptions?: ListTablesDto + ): Promise { if (!dataSource) { throw new UnauthorizedException(); } @@ -371,7 +402,8 @@ export class DataQueriesUtilService implements IDataQueriesUtilService { const dataSourceOptions = await this.appEnvironmentUtilService.getOptions( dataSource.id, organizationId, - environmentId + environmentId, + branchId ); dataSource.options = dataSourceOptions.options; @@ -389,11 +421,11 @@ export class DataQueriesUtilService implements IDataQueriesUtilService { sourceOptions, `${dataSource.id}-${dataSourceOptions.environmentId}`, dataSourceOptions.updatedAt, - { - schema: listTablesOptions?.schema, - search: listTablesOptions?.search, - page: listTablesOptions?.page, - limit: listTablesOptions?.limit + { + schema: listTablesOptions?.schema, + search: listTablesOptions?.search, + page: listTablesOptions?.page, + limit: listTablesOptions?.limit, } ); } diff --git a/server/src/modules/data-sources/controller.ts b/server/src/modules/data-sources/controller.ts index ed86831708..31390e9cb6 100644 --- a/server/src/modules/data-sources/controller.ts +++ b/server/src/modules/data-sources/controller.ts @@ -40,8 +40,12 @@ export class DataSourcesController implements IDataSourcesController { @InitFeature(FEATURE_KEY.GET) @Get(':organizationId') @UseGuards(OrganizationValidateGuard, FeatureAbilityGuard) - async fetchGlobalDataSources(@User() user: UserEntity, @UserPermissionsDecorator() userPermissions: UserPermissions) { - return this.dataSourcesService.getAll({}, user, userPermissions); + async fetchGlobalDataSources( + @User() user: UserEntity, + @UserPermissionsDecorator() userPermissions: UserPermissions, + @Query('branch_id') branchId?: string + ) { + return this.dataSourcesService.getAll({ branchId }, user, userPermissions); } // TODO: Add guard to validate environmentId & version id @@ -52,11 +56,12 @@ export class DataSourcesController implements IDataSourcesController { @User() user: UserEntity, @Param('versionId') appVersionId, @Param('environmentId') environmentId, - @UserPermissionsDecorator() userPermissions: UserPermissions + @UserPermissionsDecorator() userPermissions: UserPermissions, + @Query('branch_id') branchId?: string ) { const shouldIncludeWorkflows = getTooljetEdition() === TOOLJET_EDITIONS.EE; return this.dataSourcesService.getForApp( - { appVersionId, environmentId, shouldIncludeWorkflows }, + { appVersionId, environmentId, shouldIncludeWorkflows, branchId }, user, userPermissions ); @@ -65,8 +70,12 @@ export class DataSourcesController implements IDataSourcesController { @InitFeature(FEATURE_KEY.CREATE) @UseGuards(FeatureAbilityGuard) @Post() - async createGlobalDataSources(@User() user: UserEntity, @Body() createDataSourceDto: CreateDataSourceDto) { - return this.dataSourcesService.create(createDataSourceDto, user); + async createGlobalDataSources( + @User() user: UserEntity, + @Body() createDataSourceDto: CreateDataSourceDto, + @Query('branch_id') branchId?: string + ) { + return this.dataSourcesService.create(createDataSourceDto, user, branchId); } @InitFeature(FEATURE_KEY.UPDATE) @@ -76,17 +85,18 @@ export class DataSourcesController implements IDataSourcesController { @User() user, @Param('id') dataSourceId, @Query('environment_id') environmentId, - @Body() updateDataSourceDto: UpdateDataSourceDto + @Body() updateDataSourceDto: UpdateDataSourceDto, + @Query('branch_id') branchId?: string ) { - await this.dataSourcesService.update(updateDataSourceDto, user, { dataSourceId, environmentId }); + await this.dataSourcesService.update(updateDataSourceDto, user, { dataSourceId, environmentId }, branchId); return; } @InitFeature(FEATURE_KEY.DELETE) @UseGuards(ValidateDataSourceGuard, FeatureAbilityGuard) @Delete(':id') - async delete(@User() user: UserEntity, @Param('id') dataSourceId) { - await this.dataSourcesService.delete(dataSourceId, user); + async delete(@User() user: UserEntity, @Param('id') dataSourceId, @Query('branch_id') branchId?: string) { + await this.dataSourcesService.delete(dataSourceId, user, branchId); return; } @@ -104,9 +114,10 @@ export class DataSourcesController implements IDataSourcesController { getDataSourceByEnvironment( @User() user: UserEntity, @Param('id') dataSourceId, - @Param('environment_id') environmentId + @Param('environment_id') environmentId, + @Query('branch_id') branchId?: string ) { - return this.dataSourcesService.findOneByEnvironment(dataSourceId, user.organizationId, environmentId); + return this.dataSourcesService.findOneByEnvironment(dataSourceId, user.organizationId, environmentId, branchId); } @InitFeature(FEATURE_KEY.TEST_CONNECTION_SAMPLE_DB) @@ -153,8 +164,12 @@ export class DataSourcesController implements IDataSourcesController { @InitFeature(FEATURE_KEY.QUERIES_LINKED_TO_DATASOURCE) @UseGuards(FeatureAbilityGuard) @Get('dependent-queries/:datasource_id') - async findQueriesLinkedToDatasource(@User() user: UserEntity, @Param('datasource_id') datasourceId: string) { - return await this.dataSourcesService.findQueriesLinkedToDatasource(datasourceId); + async findQueriesLinkedToDatasource( + @User() user: UserEntity, + @Param('datasource_id') datasourceId: string, + @Query('branch_id') branchId?: string + ) { + return await this.dataSourcesService.findQueriesLinkedToDatasource(datasourceId, user.organizationId, branchId); } @InitFeature(FEATURE_KEY.AUTHORIZE) @@ -170,14 +185,16 @@ export class DataSourcesController implements IDataSourcesController { async invokeDataSourceMethod( @User() user: UserEntity, @Body() invokeDto: InvokeDataSourceMethodDto, - @DataSource() dataSource: DataSourceEntity + @DataSource() dataSource: DataSourceEntity, + @Query('branch_id') branchId?: string ): Promise { const result = await this.dataSourcesService.invokeMethod( dataSource, invokeDto.method, user, invokeDto.environmentId, - invokeDto.args + invokeDto.args, + branchId ); return result; diff --git a/server/src/modules/data-sources/interfaces/IController.ts b/server/src/modules/data-sources/interfaces/IController.ts index 91f8d10f2d..cf955ecabe 100644 --- a/server/src/modules/data-sources/interfaces/IController.ts +++ b/server/src/modules/data-sources/interfaces/IController.ts @@ -13,25 +13,27 @@ import { UserPermissions } from '@modules/ability/types'; import { QueryResult } from '@tooljet/plugins/dist/packages/common/lib'; export interface IDataSourcesController { - fetchGlobalDataSources(user: User, userPermissions: UserPermissions): Promise<{ data_sources: object[] }>; + fetchGlobalDataSources(user: User, userPermissions: UserPermissions, branchId?: string): Promise<{ data_sources: object[] }>; fetchGlobalDataSourcesForVersion( user: User, appVersionId: string, environmentId: string, - userPermissions: UserPermissions + userPermissions: UserPermissions, + branchId?: string ): Promise<{ data_sources: object[] }>; - createGlobalDataSources(user: User, createDataSourceDto: CreateDataSourceDto): Promise; + createGlobalDataSources(user: User, createDataSourceDto: CreateDataSourceDto, branchId?: string): Promise; update( user: User, dataSourceId: string, environmentId: string, - updateDataSourceDto: UpdateDataSourceDto + updateDataSourceDto: UpdateDataSourceDto, + branchId?: string ): Promise; - delete(user: User, dataSourceId: string): Promise; + delete(user: User, dataSourceId: string, branchId?: string): Promise; changeScope(user: User, dataSourceId: string): Promise; @@ -55,6 +57,7 @@ export interface IDataSourcesController { invokeDataSourceMethod( user: User, invokeDto: InvokeDataSourceMethodDto, - dataSource: DataSourceEntity + dataSource: DataSourceEntity, + branchId?: string ): Promise; } diff --git a/server/src/modules/data-sources/interfaces/IService.ts b/server/src/modules/data-sources/interfaces/IService.ts index 844e97fd73..9859296b67 100644 --- a/server/src/modules/data-sources/interfaces/IService.ts +++ b/server/src/modules/data-sources/interfaces/IService.ts @@ -21,15 +21,25 @@ export interface IDataSourcesService { getAll(query: GetQueryVariables, user: User, userPermissions: UserPermissions): Promise<{ data_sources: object[] }>; - create(createDataSourceDto: CreateDataSourceDto, user: User): Promise; + create(createDataSourceDto: CreateDataSourceDto, user: User, branchId?: string): Promise; - update(updateDataSourceDto: UpdateDataSourceDto, user: User, updateOptions: UpdateOptions): Promise; + update( + updateDataSourceDto: UpdateDataSourceDto, + user: User, + updateOptions: UpdateOptions, + branchId?: string + ): Promise; - delete(dataSourceId: string, user: User): Promise; + delete(dataSourceId: string, user: User, branchId?: string): Promise; changeScope(dataSourceId: string, user: User): Promise; - findOneByEnvironment(dataSourceId: string, environmentId: string, organizationId?: string): Promise; + findOneByEnvironment( + dataSourceId: string, + environmentId: string, + organizationId?: string, + branchId?: string + ): Promise; testConnection(testDataSourceDto: TestDataSourceDto, organization_id: string): Promise; @@ -48,6 +58,8 @@ export interface IDataSourcesService { dataSource: DataSource, methodName: string, user: User, - environmentId: string + environmentId: string, + args?: any, + branchId?: string ): Promise; } diff --git a/server/src/modules/data-sources/repository.ts b/server/src/modules/data-sources/repository.ts index b5efb0e5c3..f908b149ec 100644 --- a/server/src/modules/data-sources/repository.ts +++ b/server/src/modules/data-sources/repository.ts @@ -19,7 +19,7 @@ export class DataSourcesRepository extends Repository { organizationId: string, queryVars: GetQueryVariables ): Promise { - const { appVersionId, environmentId, types } = queryVars; + const { appVersionId, environmentId, types, branchId } = queryVars; // Data source options are attached only if selectedEnvironmentId is passed // Returns global data sources + sample data sources // If version Id is passed, then data queries under each are also returned @@ -37,8 +37,94 @@ export class DataSourcesRepository extends Repository { .leftJoinAndSelect('plugin.manifestFile', 'manifestFile') .leftJoinAndSelect('plugin.operationsFile', 'operationsFile'); - if (environmentId) { - query.innerJoinAndSelect('data_source.dataSourceOptions', 'data_source_options'); + const useBranchPath = !!(branchId && environmentId); + + if (useBranchPath) { + // Branch-aware: prefer branch-specific DSV, fall back to default DSV + // query.leftJoin( + // 'data_source_versions', + // 'dsv', + // `dsv.data_source_id = data_source.id AND ( + // (dsv.branch_id = :branchId AND dsv.is_active = true) + // OR ( + // dsv.is_default = true + // AND NOT EXISTS ( + // SELECT 1 FROM data_source_versions dsv2 + // WHERE dsv2.data_source_id = data_source.id + // AND dsv2.branch_id = :branchId + // ) + // ) + // )`, + // { branchId } + // ); + query.leftJoin( + 'data_source_versions', + 'dsv', + `dsv.data_source_id = data_source.id + AND dsv.branch_id = :branchId + AND dsv.is_active = true`, + { branchId } + ); + query.leftJoin( + 'data_source_version_options', + 'dsvo', + 'dsvo.data_source_version_id = dsv.id AND dsvo.environment_id = :environmentId', + { environmentId } + ); + // Select version-specific columns so they appear in raw results + query.addSelect(['dsv.id', 'dsv.name', 'dsvo.options']); + } else if (branchId) { + // Branch-aware but no environmentId: prefer branch DSV, fall back to default + query.leftJoin( + 'data_source_versions', + 'dsv', + `dsv.data_source_id = data_source.id AND ( + (dsv.branch_id = :branchId AND dsv.is_active = true) + OR ( + dsv.is_default = true + AND NOT EXISTS ( + SELECT 1 FROM data_source_versions dsv2 + WHERE dsv2.data_source_id = data_source.id + AND dsv2.branch_id = :branchId + ) + ) + )`, + { branchId } + ); + query.addSelect(['dsv.id', 'dsv.name']); + } else if (environmentId) { + // Join through data_source_versions → data_source_version_options + // Prefer version-specific DSV (by appVersionId), fall back to default DSV + if (appVersionId) { + query.leftJoin( + 'data_source_versions', + 'dsv', + `dsv.data_source_id = data_source.id AND ( + dsv.app_version_id = :appVersionId + OR ( + dsv.is_default = true + AND NOT EXISTS ( + SELECT 1 FROM data_source_versions dsv2 + WHERE dsv2.data_source_id = data_source.id AND dsv2.app_version_id = :appVersionId + ) + ) + )`, + { appVersionId } + ); + } else { + query.leftJoin( + 'data_source_versions', + 'dsv', + 'dsv.data_source_id = data_source.id AND dsv.is_default = true' + ); + } + query.leftJoin( + 'data_source_version_options', + 'dsvo', + 'dsvo.data_source_version_id = dsv.id AND dsvo.environment_id = :environmentId', + { environmentId } + ); + query.addSelect(['dsv.id', 'dsv.name', 'dsvo.options']); } query.where('data_source.type != :sampleType', { sampleType: DataSourceTypes.SAMPLE }); @@ -70,10 +156,29 @@ export class DataSourcesRepository extends Repository { if (types && types.length > 0) { query.andWhere('data_source.type IN (:...types)', { types }); } - if (environmentId) { - query.andWhere('data_source_options.environmentId = :environmentId', { environmentId }); + if (environmentId && !useBranchPath && !branchId) { + // Filter: DS must have options in version table for this env + query.andWhere('dsvo.id IS NOT NULL'); } - const result = await query.getMany(); + if (useBranchPath || branchId) { + // Filter: DS must have at least a DSV (branch-specific or default fallback) + // Static data sources don't have DSV entries, so always allow them through + query.andWhere('(dsv.id IS NOT NULL OR data_source.type = :staticType)', { + staticType: DataSourceTypes.STATIC, + }); + } + let result: DataSource[]; + let rawResults: any[] = []; + + if (useBranchPath || branchId || environmentId) { + // Use getRawAndEntities to get both entity data and raw dsv/dsvo columns + const rawAndEntities = await query.getRawAndEntities(); + result = rawAndEntities.entities; + rawResults = rawAndEntities.raw; + } else { + result = await query.getMany(); + } + result.forEach((dataSource) => { if (dataSource.plugin) { if (dataSource.plugin.iconFile) { @@ -103,16 +208,59 @@ export class DataSourcesRepository extends Repository { //remove tokenData from restapi datasources const dataSources = dataSourceList?.map((ds) => { - if (ds.kind === 'restapi') { - const options = {}; - Object.keys(ds.dataSourceOptions?.[0]?.options || {}).filter((key) => { - if (key !== 'tokenData') { - return (options[key] = ds.dataSourceOptions[0].options[key]); + if (useBranchPath || branchId) { + // Find the matching raw row to get dsv/dsvo columns + const rawRow = rawResults.find((r) => r.data_source_id === ds.id); + if (rawRow) { + // Overlay version-specific name from data_source_versions + if (rawRow.dsv_name) { + ds.name = rawRow.dsv_name; } - }); - ds.options = options; + // Attach version id for frontend reference + (ds as any).versionId = rawRow.dsv_id || null; + } + + if (useBranchPath && rawRow) { + // Options from data_source_version_options + const rawOptions = rawRow.dsvo_options || {}; + const parsedOptions = typeof rawOptions === 'string' ? JSON.parse(rawOptions) : rawOptions; + if (ds.kind === 'restapi') { + const options = {}; + Object.keys(parsedOptions).filter((key) => { + if (key !== 'tokenData') { + return (options[key] = parsedOptions[key]); + } + }); + ds.options = options; + } else { + ds.options = { ...parsedOptions }; + } + } + } else if (environmentId) { + // Non-branch with environmentId: use version options + const rawRow = rawResults.find((r) => r.data_source_id === ds.id); + const rawOptions = rawRow?.dsvo_options; + if (rawOptions) { + const parsedOptions = typeof rawOptions === 'string' ? JSON.parse(rawOptions) : rawOptions; + if (ds.kind === 'restapi') { + const options = {}; + Object.keys(parsedOptions).filter((key) => { + if (key !== 'tokenData') { + return (options[key] = parsedOptions[key]); + } + }); + ds.options = options; + } else { + ds.options = { ...parsedOptions }; + } + if (rawRow?.dsv_name) { + ds.name = rawRow.dsv_name; + } + } else { + ds.options = {}; + } } else { - ds.options = { ...(ds.dataSourceOptions?.[0]?.options || {}) }; + ds.options = {}; } delete ds['dataSourceOptions']; return ds; @@ -126,7 +274,7 @@ export class DataSourcesRepository extends Repository { return await dbTransactionWrap(async (manager: EntityManager) => { return await manager.findOneOrFail(DataSource, { where: { id: dataSourceId, organizationId }, - relations: ['plugin', 'apps', 'dataSourceOptions'], + relations: ['plugin', 'apps'], }); }, manager || this.manager); } @@ -194,12 +342,23 @@ export class DataSourcesRepository extends Repository { }); } - getQueriesByDatasourceId(datasourceId) { - return dbTransactionWrap((manager: EntityManager) => { + getQueriesByDatasourceId(datasourceId: string, branchId?: string | null) { + return dbTransactionWrap(async (manager: EntityManager) => { + if (branchId) { + return manager + .createQueryBuilder(DataSource, 'ds') + .leftJoinAndSelect( + 'ds.dataQueries', + 'dq', + 'dq.app_version_id IN (SELECT av.id FROM app_versions av WHERE av.branch_id = :branchId)', + { branchId } + ) + .where('ds.id = :datasourceId', { datasourceId }) + .getMany(); + } + return manager.find(DataSource, { - where: { - id: datasourceId, - }, + where: { id: datasourceId }, relations: ['dataQueries'], }); }); diff --git a/server/src/modules/data-sources/service.ts b/server/src/modules/data-sources/service.ts index 09e2d8c2cb..c3965d7ee5 100644 --- a/server/src/modules/data-sources/service.ts +++ b/server/src/modules/data-sources/service.ts @@ -5,7 +5,7 @@ import { User } from '@entities/user.entity'; import { decode } from 'js-base64'; import { AppEnvironmentUtilService } from '@modules/app-environments/util.service'; import { decamelizeKeys } from 'humps'; -import { DataSourceTypes } from './constants'; +import { DataSourceScopes, DataSourceTypes } from './constants'; import { AuthorizeDataSourceOauthDto, CreateDataSourceDto, @@ -23,6 +23,10 @@ import { AUDIT_LOGS_REQUEST_CONTEXT_KEY } from '@modules/app/constants'; import * as fs from 'fs'; import { UserPermissions } from '@modules/ability/types'; import { QueryResult } from '@tooljet/plugins/dist/packages/common/lib'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; +import { AppVersion } from '@entities/app_version.entity'; +import { dbTransactionWrap } from '@helpers/database.helper'; +import { EntityManager } from 'typeorm'; @Injectable() export class DataSourcesService implements IDataSourcesService { @@ -40,6 +44,19 @@ export class DataSourcesService implements IDataSourcesService { ): Promise<{ data_sources: object[] }> { const shouldIncludeWorkflows = query.shouldIncludeWorkflows ?? true; + // Derive branchId from the AppVersion if not explicitly provided + if (!query.branchId && query.appVersionId) { + const appVersion = await dbTransactionWrap(async (manager: EntityManager) => { + return manager.findOne(AppVersion, { + where: { id: query.appVersionId }, + select: ['id', 'branchId'], + }); + }); + if (appVersion?.branchId) { + query = { ...query, branchId: appVersion.branchId }; + } + } + let dataSources = await this.dataSourcesRepository.allGlobalDS(userPermissions, user.organizationId, query ?? {}); if (!shouldIncludeWorkflows) { @@ -62,6 +79,7 @@ export class DataSourcesService implements IDataSourcesService { appVersionId: query.appVersionId, environmentId: selectedEnvironmentId, types: [DataSourceTypes.DEFAULT, DataSourceTypes.SAMPLE], + branchId: query.branchId, }); for (const dataSource of dataSources) { const parseIfNeeded = (data: any) => { @@ -110,7 +128,7 @@ export class DataSourcesService implements IDataSourcesService { return { data_sources: decamelizedDatasources }; } - async create(createDataSourceDto: CreateDataSourceDto, user: User): Promise { + async create(createDataSourceDto: CreateDataSourceDto, user: User, branchId?: string): Promise { const { kind, name, options, plugin_id: pluginId, environment_id } = createDataSourceDto; if (kind === 'grpc') { @@ -129,7 +147,8 @@ export class DataSourcesService implements IDataSourcesService { pluginId, environmentId: environment_id, }, - user + user, + branchId ); // Setting data for audit logs @@ -154,14 +173,22 @@ export class DataSourcesService implements IDataSourcesService { return dataSource; } - async update(updateDataSourceDto: UpdateDataSourceDto, user: User, updateOptions: UpdateOptions) { + async update(updateDataSourceDto: UpdateDataSourceDto, user: User, updateOptions: UpdateOptions, branchId?: string) { const { name, options } = updateDataSourceDto; const { dataSourceId, environmentId } = updateOptions; // Fetch datasource details for audit log const dataSource = await this.dataSourcesRepository.findById(dataSourceId, user.organizationId); - await this.dataSourcesUtilService.update(dataSourceId, user.organizationId, user.id, name, options, environmentId); + await this.dataSourcesUtilService.update( + dataSourceId, + user.organizationId, + user.id, + name, + options, + environmentId, + branchId + ); // Setting data for audit logs RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, { @@ -186,7 +213,7 @@ export class DataSourcesService implements IDataSourcesService { return await this.dataSourcesUtilService.decrypt(options); } - async delete(dataSourceId: string, user: User) { + async delete(dataSourceId: string, user: User, branchId?: string) { const dataSource = await this.dataSourcesRepository.findById(dataSourceId, user.organizationId); if (!dataSource) { return; @@ -195,12 +222,25 @@ export class DataSourcesService implements IDataSourcesService { throw new BadRequestException('Cannot delete sample data source'); } - const result = await this.findQueriesLinkedToDatasource(dataSourceId); + const result = await this.findQueriesLinkedToDatasource(dataSourceId, user.organizationId, branchId); if (result.dependent_queries) { throw new BadRequestException(`Datasource can't be deleted, queries are in use`); } - await this.dataSourcesRepository.delete(dataSourceId); + // Branch-aware: soft-delete via DataSourceVersion.isActive = false + const effectiveBranchId = dataSource.scope === DataSourceScopes.GLOBAL ? branchId || null : null; + + if (effectiveBranchId) { + await dbTransactionWrap(async (manager: EntityManager) => { + await manager.update( + DataSourceVersion, + { dataSourceId, branchId: effectiveBranchId }, + { isActive: false, updatedAt: new Date() } + ); + }); + } else { + await this.dataSourcesRepository.delete(dataSourceId); + } // Setting data for audit logs RequestContext.setLocals(AUDIT_LOGS_REQUEST_CONTEXT_KEY, { @@ -228,14 +268,15 @@ export class DataSourcesService implements IDataSourcesService { async findOneByEnvironment( dataSourceId: string, organizationId: string, - environmentId?: string + environmentId?: string, + branchId?: string ): Promise { const dataSource = await this.dataSourcesUtilService.findOneByEnvironment( dataSourceId, environmentId, - organizationId + organizationId, + branchId ); - delete dataSource['dataSourceOptions']; return dataSource; } @@ -281,8 +322,8 @@ export class DataSourcesService implements IDataSourcesService { return; } - async findQueriesLinkedToDatasource(datasourceId: string) { - const dataSourceDetails = await this.dataSourcesRepository.getQueriesByDatasourceId(datasourceId); + async findQueriesLinkedToDatasource(datasourceId: string, organizationId?: string, branchId?: string) { + const dataSourceDetails = await this.dataSourcesRepository.getQueriesByDatasourceId(datasourceId, branchId || null); if (dataSourceDetails.length == 0) return { datasources: 0, dependent_queries: 0 }; const queries = []; @@ -312,7 +353,8 @@ export class DataSourcesService implements IDataSourcesService { methodName: string, user: User, environmentId: string, - args?: any + args?: any, + branchId?: string ): Promise { const service = await this.pluginsServiceSelector.getService(dataSource.pluginId, dataSource.kind); @@ -320,10 +362,14 @@ export class DataSourcesService implements IDataSourcesService { throw new BadRequestException(`Plugin ${dataSource.kind} does not support method invocation`); } + // Branch-aware: pass branchId for global DS option resolution + const effectiveBranchId = dataSource.scope === DataSourceScopes.GLOBAL ? branchId || null : null; + const dataSourceOptions = await this.appEnvironmentsUtilService.getOptions( dataSource.id, user.organizationId, - environmentId + environmentId, + effectiveBranchId ); const sourceOptions = await this.dataSourcesUtilService.parseSourceOptions( @@ -367,7 +413,8 @@ export class DataSourcesService implements IDataSourcesService { const updatedDataSourceOptions = await this.appEnvironmentsUtilService.getOptions( dataSource.id, user.organizationId, - environmentId + environmentId, + branchId ); const updatedSourceOptions = await this.dataSourcesUtilService.parseSourceOptions( diff --git a/server/src/modules/data-sources/services/sample-ds.service.ts b/server/src/modules/data-sources/services/sample-ds.service.ts index bf6d341e8b..8b0d9720c0 100644 --- a/server/src/modules/data-sources/services/sample-ds.service.ts +++ b/server/src/modules/data-sources/services/sample-ds.service.ts @@ -6,7 +6,8 @@ import { dbTransactionWrap } from '@helpers/database.helper'; import { ConfigService } from '@nestjs/config'; import { DataSourcesUtilService } from '../util.service'; import { AppEnvironment } from '@entities/app_environments.entity'; -import { DataSourceOptions } from '@entities/data_source_options.entity'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; import { Injectable } from '@nestjs/common'; @Injectable() @@ -128,13 +129,23 @@ export class SampleDataSourceService { const allEnvs: AppEnvironment[] = await this.appEnvironmentUtilService.getAll(organizationId, null, manager); + // Create default DataSourceVersion + DataSourceVersionOptions + const dsv = manager.create(DataSourceVersion, { + dataSourceId: dataSource.id, + name: dataSource.name, + isDefault: true, + isActive: true, + branchId: null, + }); + const savedDsv = await manager.save(DataSourceVersion, dsv); + await Promise.all( allEnvs?.map(async (env) => { const parsedOptions = await this.dataSourceUtilService.parseOptionsForCreate(options); await manager.save( - manager.create(DataSourceOptions, { + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: savedDsv.id, environmentId: env.id, - dataSourceId: dataSource.id, options: parsedOptions, }) ); diff --git a/server/src/modules/data-sources/types/index.ts b/server/src/modules/data-sources/types/index.ts index 7612f8b443..c34931a968 100644 --- a/server/src/modules/data-sources/types/index.ts +++ b/server/src/modules/data-sources/types/index.ts @@ -53,6 +53,7 @@ export interface GetQueryVariables { environmentId?: string; types?: DataSourceTypes[]; shouldIncludeWorkflows?: boolean; + branchId?: string; } export interface UpdateOptions { diff --git a/server/src/modules/data-sources/util.service.ts b/server/src/modules/data-sources/util.service.ts index 0de470c49c..4e5696a99b 100644 --- a/server/src/modules/data-sources/util.service.ts +++ b/server/src/modules/data-sources/util.service.ts @@ -18,9 +18,11 @@ import { EncryptionService } from '@modules/encryption/service'; import { OrganizationConstantType } from '@modules/organization-constants/constants'; import { PluginsServiceSelector } from './services/plugin-selector.service'; import { OrganizationConstantsUtilService } from '@modules/organization-constants/util.service'; -import { DataSourceOptions } from '@entities/data_source_options.entity'; import { IDataSourcesUtilService } from './interfaces/IUtilService'; import { InMemoryCacheService } from '@modules/inMemoryCache/in-memory-cache.service'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; @Injectable() export class DataSourcesUtilService implements IDataSourcesUtilService { @@ -34,10 +36,47 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { protected readonly organizationConstantsUtilService: OrganizationConstantsUtilService, protected readonly inMemoryCacheService: InMemoryCacheService ) {} - async create(createArgumentsDto: CreateArgumentsDto, user: User): Promise { + + /** + * Validates that workspace constants are not being added directly on the default (master) branch. + * PRD requires that workspace constants in encrypted fields must go through a PR workflow. + */ + private async validateNoConstantsOnDefaultBranch(branchId: string, options: Array): Promise { + const hasConstantReference = options.some((opt) => { + if (opt['workspace_constant']) return true; + if ( + opt['encrypted'] && + typeof opt['value'] === 'string' && + (opt['value'].includes('{{constants') || opt['value'].includes('{{secrets')) + ) { + return true; + } + return false; + }); + + if (!hasConstantReference) return; + + const branch = await dbTransactionWrap(async (manager: EntityManager) => { + return manager.findOne(WorkspaceBranch, { where: { id: branchId } }); + }); + + if (branch?.isDefault) { + throw new BadRequestException( + 'Constants cannot be added directly on master branch and must go through PR approval flow.' + ); + } + } + + async create(createArgumentsDto: CreateArgumentsDto, user: User, branchId?: string): Promise { + // Validate: workspace constants cannot be added directly on the default (master) branch + if (branchId && createArgumentsDto.options?.length) { + await this.validateNoConstantsOnDefaultBranch(branchId, createArgumentsDto.options); + } + return await dbTransactionWrap(async (manager: EntityManager) => { + const finalName = await this.generateUniqueName(createArgumentsDto.name, manager); const newDataSource = manager.create(DataSource, { - name: createArgumentsDto.name, + name: finalName, kind: createArgumentsDto.kind, pluginId: createArgumentsDto.pluginId, organizationId: user.organizationId, @@ -47,38 +86,59 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { }); const dataSource = await manager.save(newDataSource); - // Creating empty options mapping - await this.createDataSourceInAllEnvironments(user.organizationId, dataSource.id, manager); + // Set co_relation_id = id so git sync serialization can identify this DS + if (!dataSource.co_relation_id) { + dataSource.co_relation_id = dataSource.id; + await manager.update(DataSource, { id: dataSource.id }, { co_relation_id: dataSource.id }); + } - // Find the environment to be updated + const allEnvs = await this.appEnvironmentUtilService.getAll(user.organizationId, null, manager); const envToUpdate = await this.appEnvironmentUtilService.get( user.organizationId, createArgumentsDto.environmentId, false, manager ); - await this.appEnvironmentUtilService.updateOptions( - await this.parseOptionsForCreate(createArgumentsDto.options, false, manager), - envToUpdate.id, - dataSource.id, - manager - ); - // Find other environments to be updated - const allEnvs = await this.appEnvironmentUtilService.getAll(user.organizationId, null, manager); - if (allEnvs?.length) { - const envsToUpdate = allEnvs.filter((env) => env.id !== envToUpdate.id); - await Promise.all( - envsToUpdate?.map(async (env) => { - await this.appEnvironmentUtilService.updateOptions( - await this.parseOptionsForCreate(createArgumentsDto.options, true, manager), - env.id, - dataSource.id, - manager - ); - }) + if (branchId) { + // Branch-aware: create ONLY the branch-specific DSV (no default DSV). + // The default DSV will be created when this branch is merged to main + // via git pull/deserialize. This prevents the DS from appearing on main + // before the branch is merged. + await this.createDataSourceVersionForBranchWithOptions( + dataSource, + branchId, + envToUpdate, + allEnvs, + createArgumentsDto.options, + manager ); + } else { + // No branch: create the default DSV and write options to it + await this.createDataSourceInAllEnvironments(user.organizationId, dataSource.id, manager); + + await this.appEnvironmentUtilService.updateOptions( + await this.parseOptionsForCreate(createArgumentsDto.options, false, manager), + envToUpdate.id, + dataSource.id, + manager + ); + + if (allEnvs?.length) { + const envsToUpdate = allEnvs.filter((env) => env.id !== envToUpdate.id); + await Promise.all( + envsToUpdate?.map(async (env) => { + await this.appEnvironmentUtilService.updateOptions( + await this.parseOptionsForCreate(createArgumentsDto.options, true, manager), + env.id, + dataSource.id, + manager + ); + }) + ); + } } + return dataSource; }); } @@ -213,7 +273,8 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { userId: string, name: string, options: Array, - environmentId?: string + environmentId?: string, + branchId?: string ): Promise { const dataSource = await this.dataSourceRepository.findById(dataSourceId, organizationId); @@ -221,6 +282,11 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { throw new BadRequestException('Cannot update configuration of sample data source'); } + // Validate: workspace constants cannot be added directly on the default (master) branch + if (branchId && options?.length) { + await this.validateNoConstantsOnDefaultBranch(branchId, options); + } + try { await dbTransactionWrap(async (manager: EntityManager) => { const isMultiEnvEnabled = await this.licenseTermsService.getLicenseTerms( @@ -236,9 +302,29 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { encrypted: false, }); + // Determine if we should update the isDefault DSV. + // isDefault = "snapshot of main" — only update it when: + // - No branchId (no git sync / license expired) + // - branchId is the default (main) branch + // Skip for feature branches — their edits shouldn't alter the main fallback. + let shouldUpdateDefault = true; + if (branchId) { + const branch = await manager.findOne(WorkspaceBranch, { + where: { id: branchId }, + select: ['id', 'isDefault'], + }); + shouldUpdateDefault = !!branch?.isDefault; + } + if (isMultiEnvEnabled) { + const effectiveBranchId = dataSource.scope === DataSourceScopes.GLOBAL ? branchId || null : null; dataSource.options = ( - await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, envToUpdate.id) + await this.appEnvironmentUtilService.getOptions( + dataSourceId, + organizationId, + envToUpdate.id, + effectiveBranchId + ) ).options; const newOptions = await this.parseOptionsForUpdate( @@ -249,7 +335,63 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { organizationId, envToUpdate.id ); - await this.appEnvironmentUtilService.updateOptions(newOptions, envToUpdate.id, dataSource.id, manager); + if (shouldUpdateDefault) { + await this.appEnvironmentUtilService.updateOptions(newOptions, envToUpdate.id, dataSource.id, manager); + } + + // Branch-aware: also update data_source_version_options + if (effectiveBranchId) { + let dsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, branchId: effectiveBranchId, isActive: true }, + }); + if (!dsv) { + // Auto-create branch DSV (DS was likely created before git sync was enabled) + dsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: dataSource.id, + branchId: effectiveBranchId, + name: name || dataSource.name, + isActive: true, + }) + ); + // Seed DSVOs for all environments from default DSV, cloning credentials + const allEnvs = await this.appEnvironmentUtilService.getAll(organizationId, null, manager); + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, isDefault: true }, + }); + for (const env of allEnvs) { + let sourceOptions: any = {}; + if (defaultDsv) { + const defaultDsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: env.id }, + }); + sourceOptions = defaultDsvo?.options ? JSON.parse(JSON.stringify(defaultDsvo.options)) : {}; + } + // Clone credential rows so branches don't share credential references + for (const key of Object.keys(sourceOptions)) { + const opt = sourceOptions[key]; + if (opt?.credential_id && opt?.encrypted) { + const originalValue = await this.credentialService.getValue(opt.credential_id); + const newCredential = await this.credentialService.create(originalValue || '', manager); + sourceOptions[key] = { ...opt, credential_id: newCredential.id }; + } + } + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: dsv.id, + environmentId: env.id, + options: sourceOptions, + }) + ); + } + } + await this.appEnvironmentUtilService.updateVersionOptions(newOptions, dsv.id, envToUpdate.id, manager); + // Also update DSV name if DS name changed + if (name) { + await this.ensureUniqueActiveNameForUpdate(name, dsv.id, manager); + await manager.update(DataSourceVersion, dsv.id, { name, updatedAt: new Date() }); + } + } } else { const allEnvs = await this.appEnvironmentUtilService.getAll(organizationId); /* @@ -257,9 +399,57 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { this will help us to run the queries successfully when the user buys enterprise plan */ + // Get branchId once for all environments + const nonMultiEnvBranchId = dataSource.scope === DataSourceScopes.GLOBAL ? branchId || null : null; + let dsv = nonMultiEnvBranchId + ? await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, branchId: nonMultiEnvBranchId, isActive: true }, + }) + : null; + + // Auto-create branch DSV if missing (DS created before git sync) + if (nonMultiEnvBranchId && !dsv) { + dsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: dataSource.id, + branchId: nonMultiEnvBranchId, + name: name || dataSource.name, + isActive: true, + }) + ); + const defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, isDefault: true }, + }); + for (const env of allEnvs) { + let sourceOptions: any = {}; + if (defaultDsv) { + const defaultDsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: env.id }, + }); + sourceOptions = defaultDsvo?.options ? JSON.parse(JSON.stringify(defaultDsvo.options)) : {}; + } + // Clone credential rows so branches don't share credential references + for (const key of Object.keys(sourceOptions)) { + const opt = sourceOptions[key]; + if (opt?.credential_id && opt?.encrypted) { + const originalValue = await this.credentialService.getValue(opt.credential_id); + const newCredential = await this.credentialService.create(originalValue || '', manager); + sourceOptions[key] = { ...opt, credential_id: newCredential.id }; + } + } + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: dsv.id, + environmentId: env.id, + options: sourceOptions, + }) + ); + } + } + for (const env of allEnvs) { dataSource.options = ( - await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, env.id) + await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, env.id, nonMultiEnvBranchId) ).options; const newOptions = await this.parseOptionsForUpdate( dataSource, @@ -269,7 +459,19 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { organizationId, env.id ); - await this.appEnvironmentUtilService.updateOptions(newOptions, env.id, dataSource.id, manager); + if (shouldUpdateDefault) { + await this.appEnvironmentUtilService.updateOptions(newOptions, env.id, dataSource.id, manager); + } + + // Branch-aware: also update version options + if (dsv) { + await this.appEnvironmentUtilService.updateVersionOptions(newOptions, dsv.id, env.id, manager); + } + } + + // Update DSV name if needed + if (dsv && name) { + await manager.update(DataSourceVersion, dsv.id, { name, updatedAt: new Date() }); } } const updatableParams = { @@ -364,14 +566,12 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { } parsedOptions[key].workspace_constant = credentialValue; } else { - if ( - existingCredentialId && - credentialValue !== undefined && - credentialValue !== (await this.credentialService.getValue(existingCredentialId)) - ) { - if (parsedOptions[key]) { - delete parsedOptions[key].workspace_constant; - } + // The new value is not a constant reference — always clear workspace_constant + // if it was previously set. The old check compared against the stored credential + // value, but in multi-environment loops the credential gets updated on the first + // iteration, making subsequent comparisons see equal values and skip the delete. + if (parsedOptions[key]?.workspace_constant && credentialValue !== undefined) { + delete parsedOptions[key].workspace_constant; } } @@ -425,13 +625,13 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { async findOneByEnvironment( dataSourceId: string, environmentId: string, - organizationId?: string + organizationId?: string, + branchId?: string ): Promise { const dataSource = await this.dataSourceRepository.findOneOrFail({ where: { id: dataSourceId, organizationId }, relations: [ 'apps', - 'dataSourceOptions', 'appVersion', 'appVersion.app', 'plugin', @@ -441,12 +641,11 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { ], }); - if (!environmentId && dataSource.dataSourceOptions?.length > 1) { + if (!environmentId) { //fix for env id issue when importing cloud/enterprise apps to CE - if (dataSource.dataSourceOptions?.length > 1) { - const env = await this.appEnvironmentUtilService.get(organizationId, null); - environmentId = env?.id; - } else { + const env = await this.appEnvironmentUtilService.get(organizationId, null); + environmentId = env?.id; + if (!environmentId) { throw new NotAcceptableException('Environment id should not be empty'); } } @@ -459,13 +658,13 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { ); } - if (environmentId) { - dataSource.options = ( - await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, environmentId) - ).options; - } else { - dataSource.options = dataSource.dataSourceOptions?.[0]?.options || {}; - } + // Branch-aware option resolution for global data sources + const effectiveBranchId = dataSource.scope === DataSourceScopes.GLOBAL ? branchId || null : null; + + dataSource.options = ( + await this.appEnvironmentUtilService.getOptions(dataSourceId, organizationId, environmentId, effectiveBranchId) + ).options; + return dataSource; } @@ -661,6 +860,15 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { ]; } await this.updateOptions(dataSource.id, tokenOptions, organizationId, environmentId); + + // Propagate OAuth token to all branch/version DSVs + const updatedTokenData = + tokenOptions.find((opt: any) => opt.key === 'tokenData')?.value ?? + tokenOptions.find((opt: any) => opt.key === 'access_token')?.value; + if (updatedTokenData !== undefined) { + await this.propagateTokenToAllBranches(dataSource.id, environmentId, updatedTokenData); + } + return; } @@ -668,10 +876,11 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { dataSourceId: string, optionsToMerge: any, organizationId: string, - environmentId?: string + environmentId?: string, + branchId?: string ): Promise { await dbTransactionWrap(async (manager: EntityManager) => { - const dataSource = await this.findOneByEnvironment(dataSourceId, environmentId, organizationId); + const dataSource = await this.findOneByEnvironment(dataSourceId, environmentId, organizationId, branchId); const parsedOptions = await this.parseOptionsForUpdate(dataSource, optionsToMerge, manager); const envToUpdate = await this.appEnvironmentUtilService.get(organizationId, environmentId, false, manager); const oldOptions = dataSource.options || {}; @@ -681,13 +890,41 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { organizationId ); + // Branch-aware: also update version options + const effectiveUpdateBranchId = dataSource.scope === DataSourceScopes.GLOBAL ? branchId || null : null; + const dsv = effectiveUpdateBranchId + ? await manager.findOne(DataSourceVersion, { + where: { dataSourceId, branchId: effectiveUpdateBranchId, isActive: true }, + }) + : null; + + // Only update isDefault DSV when not on a feature branch + let shouldUpdateDefault = true; + if (branchId) { + const branch = await manager.findOne(WorkspaceBranch, { + where: { id: branchId }, + select: ['id', 'isDefault'], + }); + shouldUpdateDefault = !!branch?.isDefault; + } + if (isMultiEnvEnabled) { - await this.appEnvironmentUtilService.updateOptions(updatedOptions, envToUpdate.id, dataSourceId, manager); + if (shouldUpdateDefault) { + await this.appEnvironmentUtilService.updateOptions(updatedOptions, envToUpdate.id, dataSourceId, manager); + } + if (dsv) { + await this.appEnvironmentUtilService.updateVersionOptions(updatedOptions, dsv.id, envToUpdate.id, manager); + } } else { const allEnvs = await this.appEnvironmentUtilService.getAll(organizationId); await Promise.all( - allEnvs.map(async (envToUpdate) => { - await this.appEnvironmentUtilService.updateOptions(updatedOptions, envToUpdate.id, dataSourceId, manager); + allEnvs.map(async (env) => { + if (shouldUpdateDefault) { + await this.appEnvironmentUtilService.updateOptions(updatedOptions, env.id, dataSourceId, manager); + } + if (dsv) { + await this.appEnvironmentUtilService.updateVersionOptions(updatedOptions, dsv.id, env.id, manager); + } }) ); } @@ -975,11 +1212,48 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { }, ]; await this.updateOptions(dataSourceId, tokenOptions, organizationId, environmentId); + + // Propagate OAuth token to ALL branches (tokens are branch-invariant) + await this.propagateTokenToAllBranches(dataSourceId, environmentId, updatedTokenData); } } + /** + * When an OAuth token refreshes, propagate the tokenData to all branch versions + * of this data source so tokens stay in sync across branches. + */ + protected async propagateTokenToAllBranches( + dataSourceId: string, + environmentId: string, + updatedTokenData: any + ): Promise { + await dbTransactionWrap(async (manager: EntityManager) => { + // Find all branch versions for this DS + const allDSVs = await manager.find(DataSourceVersion, { + where: { dataSourceId, isActive: true }, + }); + + for (const dsv of allDSVs) { + const dsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: dsv.id, environmentId }, + }); + if (dsvo) { + const opts = dsvo.options || {}; + opts['tokenData'] = { value: updatedTokenData, encrypted: false }; + await manager.update(DataSourceVersionOptions, { id: dsvo.id }, { options: opts, updatedAt: new Date() }); + } + } + }); + } + async getAuthUrl(getDataSourceOauthUrlDto: GetDataSourceOauthUrlDto): Promise<{ url: string }> { - const { provider, source_options = {}, plugin_id = null, environment_id, organization_id } = getDataSourceOauthUrlDto; + const { + provider, + source_options = {}, + plugin_id = null, + environment_id, + organization_id, + } = getDataSourceOauthUrlDto; const service = await this.pluginsServiceSelector.getService(plugin_id || null, provider); if (organization_id && environment_id) { @@ -999,17 +1273,139 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { ): Promise { await dbTransactionWrap(async (manager: EntityManager) => { const allEnvs = await this.appEnvironmentUtilService.getAllEnvironments(organizationId, manager); + + // Create default DataSourceVersion + DataSourceVersionOptions + const dataSource = await manager.findOne(DataSource, { where: { id: dataSourceId }, select: ['id', 'name'] }); + const dsv = manager.create(DataSourceVersion, { + dataSourceId, + name: dataSource?.name || 'v1', + isDefault: true, + isActive: true, + branchId: null, + }); + const savedDsv = await manager.save(DataSourceVersion, dsv); + await Promise.all( allEnvs.map((env) => { - const options = manager.create(DataSourceOptions, { + const dsvo = manager.create(DataSourceVersionOptions, { + dataSourceVersionId: savedDsv.id, environmentId: env.id, - dataSourceId, - createdAt: new Date(), - updatedAt: new Date(), + options: {}, }); - return manager.save(options); + return manager.save(DataSourceVersionOptions, dsvo); }) ); }, manager); } + + /** + * Creates a branch-specific DSV with options written directly (no default DSV needed). + * Used when creating a DS on a feature branch — the default DSV is not created + * until the branch is merged to main via git pull. + */ + protected async createDataSourceVersionForBranchWithOptions( + dataSource: DataSource, + branchId: string, + envToUpdate: any, + allEnvs: any[], + rawOptions: any[], + manager: EntityManager + ): Promise { + const dsv = manager.create(DataSourceVersion, { + dataSourceId: dataSource.id, + branchId, + name: dataSource.name, + isActive: true, + }); + const savedDsv = await manager.save(DataSourceVersion, dsv); + + // Write parsed options directly to the branch DSV for the selected environment + const parsedOptions = await this.parseOptionsForCreate(rawOptions, false, manager); + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: savedDsv.id, + environmentId: envToUpdate.id, + options: parsedOptions, + }) + ); + + // Write credential-stripped options for remaining environments + const otherEnvs = allEnvs.filter((env) => env.id !== envToUpdate.id); + for (const env of otherEnvs) { + const strippedOptions = await this.parseOptionsForCreate(rawOptions, true, manager); + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: savedDsv.id, + environmentId: env.id, + options: strippedOptions, + }) + ); + } + + return savedDsv; + } + private escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + async generateUniqueName(baseName: string, manager: EntityManager): Promise { + const escapedBase = baseName.replace(/[%_\\]/g, '\\$&'); + + const qb = manager + .createQueryBuilder(DataSourceVersion, 'dsv') + .where('dsv.isActive = true') + .andWhere('dsv.name LIKE :name', { name: `${escapedBase}%` }); + + const existing = await qb.getMany(); + + if (!existing.length) return baseName; + + const exactMatch = existing.some((dsv) => dsv.name === baseName); + if (!exactMatch) return baseName; + + const usedNumbers = new Set( + existing + .map((dsv) => { + const match = dsv.name.match(new RegExp(`^${this.escapeRegExp(baseName)}_(\\d+)$`)); + return match ? parseInt(match[1], 10) : null; + }) + .filter((n): n is number => n !== null) + ); + + let counter = 2; + while (usedNumbers.has(counter)) { + counter++; + } + + return `${baseName}_${counter}`; + } + + private async ensureUniqueActiveNameForUpdate( + name: string, + currentDsvId: string, + manager: EntityManager + ): Promise { + const currentDsv = await manager.findOne(DataSourceVersion, { + where: { id: currentDsvId }, + select: ['id', 'branchId'], + }); + + const qb = manager + .createQueryBuilder(DataSourceVersion, 'dsv') + .where('LOWER(dsv.name) = LOWER(:name)', { name }) + .andWhere('dsv.isActive = true') + .andWhere('dsv.id != :currentDsvId', { currentDsvId }); + + if (currentDsv?.branchId) { + qb.andWhere('dsv.branchId = :branchId', { branchId: currentDsv.branchId }); + } else { + qb.andWhere('dsv.branchId IS NULL'); + } + + const existing = await qb.getOne(); + + if (existing) { + throw new BadRequestException(`An active data source with name "${name}" already exists in this branch`); + } + } } diff --git a/server/src/modules/folder-apps/controller.ts b/server/src/modules/folder-apps/controller.ts index f40c5f75d3..9f67c50f08 100644 --- a/server/src/modules/folder-apps/controller.ts +++ b/server/src/modules/folder-apps/controller.ts @@ -1,4 +1,4 @@ -import { Controller, Param, Post, Put, UseGuards, Get, Query, Body } from '@nestjs/common'; +import { Controller, Param, Post, Put, UseGuards, Get, Query, Body, Headers } from '@nestjs/common'; import { decamelizeKeys } from 'humps'; import { JwtAuthGuard } from '@modules/session/guards/jwt-auth.guard'; import { FolderAppsService } from './service'; @@ -19,9 +19,14 @@ export class FolderAppsController { @InitFeature(FEATURE_KEY.GET_FOLDERS) @Get() - async index(@User() user: UserEntity, @Query() query, @UserPermissionsDecorator() userPermissions: UserPermissions) { + async index( + @User() user: UserEntity, + @Query() query, + @UserPermissionsDecorator() userPermissions: UserPermissions, + @Headers('x-branch-id') branchId?: string + ) { user.roleGroup = userPermissions.isEndUser ? USER_ROLE.END_USER : undefined; - return await this.folderAppsService.getFolders(user, query); + return await this.folderAppsService.getFolders(user, { ...query, branchId }); } @InitFeature(FEATURE_KEY.CREATE_FOLDER_APP) diff --git a/server/src/modules/folder-apps/interfaces/IService.ts b/server/src/modules/folder-apps/interfaces/IService.ts index 0268e5b4d6..f88ca03c83 100644 --- a/server/src/modules/folder-apps/interfaces/IService.ts +++ b/server/src/modules/folder-apps/interfaces/IService.ts @@ -2,5 +2,5 @@ import { FolderApp } from '@entities/folder_app.entity'; export interface IFolderAppsService { create(folderId: string, appId: string): Promise; remove(folderId: string, appId: string): Promise; - getFolders(user: { organizationId: string }, query: { type: string; searchKey?: string }): Promise; + getFolders(user: { organizationId: string }, query: { type: string; searchKey?: string; branchId?: string }): Promise; } diff --git a/server/src/modules/folder-apps/interfaces/IUtilService.ts b/server/src/modules/folder-apps/interfaces/IUtilService.ts index e073a62420..4b6b5321e2 100644 --- a/server/src/modules/folder-apps/interfaces/IUtilService.ts +++ b/server/src/modules/folder-apps/interfaces/IUtilService.ts @@ -11,13 +11,15 @@ export interface IFolderAppsUtilService { userAppPermissions: UserAppsPermissions | UserWorkflowPermissions, manager: EntityManager, type?: string, - searchKey?: string + searchKey?: string, + branchId?: string ): Promise; getAppsFor( user: User, folder: Folder, page: number, searchKey: string, - type?: APP_TYPES + type?: APP_TYPES, + branchId?: string ): Promise<{ viewableApps: AppBase[]; totalCount: number }>; } diff --git a/server/src/modules/folder-apps/service.ts b/server/src/modules/folder-apps/service.ts index 8a97feb0a1..bec255c73a 100644 --- a/server/src/modules/folder-apps/service.ts +++ b/server/src/modules/folder-apps/service.ts @@ -56,6 +56,7 @@ export class FolderAppsService implements IFolderAppsService { return dbTransactionWrap(async (manager: EntityManager) => { const type = query.type; const searchKey = query.searchKey; + const branchId = query.branchId; const resourceType = this.getResourceTypefromAppType(type as APP_TYPES); const userPermissions = await this.abilityService.resourceActionsPermission(user, { resources: [{ resource: resourceType }, { resource: MODULES.FOLDER }], @@ -73,7 +74,8 @@ export class FolderAppsService implements IFolderAppsService { userAppPermissions, manager, type, - searchKey + searchKey, + branchId ); allFolderList.forEach((folder, index) => { const currentFolder = folders.find((f) => f.id === folder.id); @@ -94,7 +96,12 @@ export class FolderAppsService implements IFolderAppsService { userFolderPermissions ); - return decamelizeKeys({ folders: visibleFolders }); + // When branch filtering is active, hide folders with 0 visible apps + const result = branchId + ? visibleFolders.filter((folder) => folder.folderApps && folder.folderApps.length > 0) + : visibleFolders; + + return decamelizeKeys({ folders: result }); }); } diff --git a/server/src/modules/folder-apps/util.service.ts b/server/src/modules/folder-apps/util.service.ts index f04bbf9e85..33d003219c 100644 --- a/server/src/modules/folder-apps/util.service.ts +++ b/server/src/modules/folder-apps/util.service.ts @@ -20,9 +20,10 @@ export class FolderAppsUtilService implements IFolderAppsUtilService { userAppPermissions: UserAppsPermissions | UserWorkflowPermissions, manager: EntityManager, type = APP_TYPES.FRONT_END, - searchKey?: string + searchKey?: string, + branchId?: string ): Promise { - return this.getFolderQuery(user.organizationId, manager, userAppPermissions as UserAppsPermissions, type, searchKey) + return this.getFolderQuery(user.organizationId, manager, userAppPermissions as UserAppsPermissions, type, searchKey, branchId) .distinct() .getMany(); } @@ -31,7 +32,8 @@ export class FolderAppsUtilService implements IFolderAppsUtilService { organizationId: string, manager: EntityManager, type: APP_TYPES, - searchKey?: string + searchKey?: string, + branchId?: string ): SelectQueryBuilder { const query = manager.createQueryBuilder(Folder, 'folders'); query.leftJoinAndSelect('folders.folderApps', 'folder_apps'); @@ -60,7 +62,8 @@ export class FolderAppsUtilService implements IFolderAppsUtilService { manager: EntityManager, userAppPermissions: UserAppsPermissions, type = APP_TYPES.FRONT_END, - searchKey?: string + searchKey?: string, + branchId?: string ): SelectQueryBuilder { const { isAllEditable, isAllViewable, hideAll } = userAppPermissions; @@ -84,7 +87,7 @@ export class FolderAppsUtilService implements IFolderAppsUtilService { ), ]; - const query = this.getBaseFolderQuery(organizationId, manager, type, searchKey); + const query = this.getBaseFolderQuery(organizationId, manager, type, searchKey, branchId); if (!isAllEditable) { // Not all apps are editable - filter with view privilege @@ -137,7 +140,8 @@ export class FolderAppsUtilService implements IFolderAppsUtilService { folder: Folder, page: number, searchKey: string, - type: APP_TYPES + type: APP_TYPES, + branchId?: string ): Promise<{ viewableApps: AppBase[]; totalCount: number; @@ -168,6 +172,24 @@ export class FolderAppsUtilService implements IFolderAppsUtilService { const viewableAppsInFolder = this.getBaseAppsQuery(manager, folderAppIds, searchKey); this.addViewableFrontendFilter(viewableAppsInFolder, folderAppIds, userAppPermissions); + if (branchId) { + viewableAppsInFolder.andWhere( + `( + NOT EXISTS ( + SELECT 1 FROM app_versions av + WHERE av.app_id = apps.id + AND av.branch_id IS NOT NULL + ) + OR EXISTS ( + SELECT 1 FROM app_versions av + WHERE av.app_id = apps.id + AND av.branch_id = :folderAppsBranchId + ) + )`, + { folderAppsBranchId: branchId } + ); + } + const [viewableApps, totalCount] = await Promise.all([ viewableAppsInFolder .take(9) diff --git a/server/src/modules/folders/util.service.ts b/server/src/modules/folders/util.service.ts index 5de3da6bd4..f75b9801d7 100644 --- a/server/src/modules/folders/util.service.ts +++ b/server/src/modules/folders/util.service.ts @@ -10,6 +10,7 @@ import { DataBaseConstraints } from '@helpers/db_constraints.constants'; import { decamelizeKeys } from 'humps'; @Injectable() export class FoldersUtilService implements IFoldersUtilService { + constructor() {} async allFolders(user: User, manager: EntityManager, type = 'front-end'): Promise { return this.getAllFoldersQuery(user.organizationId, manager, type).getMany(); } diff --git a/server/src/modules/git-sync/module.ts b/server/src/modules/git-sync/module.ts index 90e70aa9b0..d81a00b7f9 100644 --- a/server/src/modules/git-sync/module.ts +++ b/server/src/modules/git-sync/module.ts @@ -1,8 +1,10 @@ import { DynamicModule } from '@nestjs/common'; +import { EncryptionModule } from '@modules/encryption/module'; import { ImportExportResourcesModule } from '@modules/import-export-resources/module'; import { TooljetDbModule } from '@modules/tooljet-db/module'; import { AppsModule } from '@modules/apps/module'; import { VersionModule } from '@modules/versions/module'; +import { EncryptionService } from '@modules/encryption/service'; import { OrganizationGitSyncRepository } from './repository'; import { VersionRepository } from '@modules/versions/repository'; import { AppGitRepository } from '@modules/app-git/repository'; @@ -24,6 +26,7 @@ export class GitSyncModule extends SubModule { BaseGitUtilService, BaseGitSyncService, GitSyncAdapter, + WorkspaceGitSyncAdapter, } = await this.getProviders(configs, 'git-sync', [ 'controller', 'service', @@ -37,11 +40,13 @@ export class GitSyncModule extends SubModule { 'base-git-util.service', 'base-git.service', 'git-sync-adapter', + 'workspace-git-sync-adapter', ]); return { module: GitSyncModule, imports: [ + await EncryptionModule.register(configs), await ImportExportResourcesModule.register(configs), await TooljetDbModule.register(configs), await AppsModule.register(configs), @@ -64,6 +69,8 @@ export class GitSyncModule extends SubModule { SourceControlProviderService, FeatureAbilityFactory, GitSyncAdapter, + WorkspaceGitSyncAdapter, + EncryptionService, ], exports: [ HTTPSGitSyncUtilityService, @@ -72,7 +79,9 @@ export class GitSyncModule extends SubModule { BaseGitSyncService, BaseGitUtilService, GitSyncAdapter, + WorkspaceGitSyncAdapter, OrganizationGitSyncRepository, + SourceControlProviderService, ], }; } diff --git a/server/src/modules/git-sync/workspace-git-sync-adapter.ts b/server/src/modules/git-sync/workspace-git-sync-adapter.ts new file mode 100644 index 0000000000..961922be6c --- /dev/null +++ b/server/src/modules/git-sync/workspace-git-sync-adapter.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class WorkspaceGitSyncAdapter { + async serializeWorkspaceResources(_organizationId: string, _branchId: string, _repoPath: string): Promise { + // CE stub — no-op + } + + async deserializeWorkspaceResources( + _organizationId: string, + _branchId: string, + _repoPath: string, + _dsMeta?: Record + ): Promise { + // CE stub — no-op + } +} diff --git a/server/src/modules/import-export-resources/service.ts b/server/src/modules/import-export-resources/service.ts index 6f19ac8a4f..6dbd04d819 100644 --- a/server/src/modules/import-export-resources/service.ts +++ b/server/src/modules/import-export-resources/service.ts @@ -127,7 +127,8 @@ export class ImportExportResourcesService { isGitApp, importResourcesDto.tooljet_version, cloning, - manager + manager, + importResourcesDto.branchId ); imports.app.push({ id: createdApp.newApp.id, name: createdApp.newApp.name }); diff --git a/server/src/modules/modules/module.ts b/server/src/modules/modules/module.ts index 30e33bee8a..2d6da1a025 100644 --- a/server/src/modules/modules/module.ts +++ b/server/src/modules/modules/module.ts @@ -65,6 +65,7 @@ export class ModulesModule { ValidAppGuard, OrganizationGitSyncRepository, ], + exports: [AppsUtilService], }; } } diff --git a/server/src/modules/organization-constants/module.ts b/server/src/modules/organization-constants/module.ts index 83b40f56b4..cec7f3e828 100644 --- a/server/src/modules/organization-constants/module.ts +++ b/server/src/modules/organization-constants/module.ts @@ -24,7 +24,10 @@ export class OrganizationConstantModule extends SubModule { return { module: OrganizationConstantModule, - imports: [await AppEnvironmentsModule.register(configs), await EncryptionModule.register(configs)], + imports: [ + await AppEnvironmentsModule.register(configs), + await EncryptionModule.register(configs), + ], controllers: isMainImport ? [OrganizationConstantController] : [], providers: [ EnvironmentConstantsService, diff --git a/server/src/modules/organization-constants/service.ts b/server/src/modules/organization-constants/service.ts index 50b3e480a1..3383193490 100644 --- a/server/src/modules/organization-constants/service.ts +++ b/server/src/modules/organization-constants/service.ts @@ -34,10 +34,12 @@ export class OrganizationConstantsService implements IOrganizationConstantsServi name: constant.constantName, }; } + const values = await Promise.all( appEnvironments.map(async (env) => { - const value = constant.orgEnvironmentConstantValues.find((value) => value.environmentId === env.id); let resolvedValue = ''; + + const value = constant.orgEnvironmentConstantValues.find((value) => value.environmentId === env.id); if (value) { if (constant.type === OrganizationConstantType.SECRET) { resolvedValue = decryptSecretValue @@ -47,7 +49,7 @@ export class OrganizationConstantsService implements IOrganizationConstantsServi resolvedValue = await this.organizationConstantsUtilService.decryptSecret( organizationId, value.value - ); // Constant type values are always decrypted + ); } } @@ -68,7 +70,7 @@ export class OrganizationConstantsService implements IOrganizationConstantsServi }) ); - return constantsWithValues; + return constantsWithValues.filter(Boolean); }); } diff --git a/server/src/modules/organization-constants/util.service.ts b/server/src/modules/organization-constants/util.service.ts index 6a6afb0bc4..21fab32662 100644 --- a/server/src/modules/organization-constants/util.service.ts +++ b/server/src/modules/organization-constants/util.service.ts @@ -3,6 +3,7 @@ import { IOrganizationConstantsUtilService } from './interfaces/IUtilService'; import { OrganizationConstantRepository } from './repository'; import { EntityManager, DeleteResult } from 'typeorm'; import { OrgEnvironmentConstantValue } from 'src/entities/org_environment_constant_values.entity'; +import { OrganizationConstant } from '@entities/organization_constants.entity'; import { AppEnvironmentUtilService } from '@modules/app-environments/util.service'; import { dbTransactionWrap } from '@helpers/database.helper'; import { Injectable } from '@nestjs/common'; @@ -75,6 +76,7 @@ export class OrganizationConstantsUtilService implements IOrganizationConstantsU constantName, organizationId ); + return manager.findOneOrFail(OrgEnvironmentConstantValue, { where: { organizationConstantId: constant.id, environmentId }, }); diff --git a/server/src/modules/platform-git-sync/pull.service.ts b/server/src/modules/platform-git-sync/pull.service.ts new file mode 100644 index 0000000000..c9bac2c541 --- /dev/null +++ b/server/src/modules/platform-git-sync/pull.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class PlatformGitPullService { + async pullApps(..._args: any[]): Promise<{ imported: number; skipped: number; stale: number }> { + return { imported: 0, skipped: 0, stale: 0 }; + } + + async pullDataSources(..._args: any[]): Promise { + return; + } + + async hydrateStubApp(..._args: any[]): Promise { + return null; + } +} diff --git a/server/src/modules/platform-git-sync/push.service.ts b/server/src/modules/platform-git-sync/push.service.ts new file mode 100644 index 0000000000..597377deb3 --- /dev/null +++ b/server/src/modules/platform-git-sync/push.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class PlatformGitPushService { + async pushApp(..._args: any[]): Promise { + return; + } + + async writeDataSourceMeta(..._args: any[]): Promise { + return; + } + + readAppMeta(_repoPath: string): Record { + return {}; + } + + writeAppMeta(_repoPath: string, _meta: Record): void { + return; + } +} diff --git a/server/src/modules/templates/controller.ts b/server/src/modules/templates/controller.ts index 98a85c71ce..565c7b1cba 100644 --- a/server/src/modules/templates/controller.ts +++ b/server/src/modules/templates/controller.ts @@ -23,14 +23,16 @@ export class TemplateAppsController { @Body('identifier') identifier, @Body('appName') appName, @Body('dependentPlugins') dependentPlugins, - @Body('shouldAutoImportPlugin') shouldAutoImportPlugin + @Body('shouldAutoImportPlugin') shouldAutoImportPlugin, + @Body('branchId') branchId?: string ) { const newApp = await this.templatesService.perform( user, identifier, appName, dependentPlugins, - shouldAutoImportPlugin + shouldAutoImportPlugin, + branchId ); return newApp; diff --git a/server/src/modules/templates/service.ts b/server/src/modules/templates/service.ts index 81483e1514..fe1900d726 100644 --- a/server/src/modules/templates/service.ts +++ b/server/src/modules/templates/service.ts @@ -28,12 +28,13 @@ export class TemplatesService { identifier: string, appName: string, dependentPlugins: Array, - shouldAutoImportPlugin: boolean + shouldAutoImportPlugin: boolean, + branchId?: string ) { const templateDefinition = this.findTemplateDefinition(identifier); if (dependentPlugins.length) await this.pluginsService.autoInstallPluginsForTemplates(dependentPlugins, shouldAutoImportPlugin); - return this.importTemplate(currentUser, templateDefinition, appName, identifier); + return this.importTemplate(currentUser, templateDefinition, appName, identifier, branchId); } async createSampleApp(currentUser: User) { @@ -57,12 +58,13 @@ export class TemplatesService { return this.importTemplate(currentUser, sampleAppDef, name); } - async importTemplate(currentUser: User, templateDefinition: any, appName: string, identifier?: string) { + async importTemplate(currentUser: User, templateDefinition: any, appName: string, identifier?: string, branchId?: string) { const importDto = new ImportResourcesDto(); importDto.organization_id = currentUser.organizationId; importDto.app = templateDefinition.app || templateDefinition.appV2; importDto.tooljet_database = templateDefinition.tooljet_database; importDto.tooljet_version = templateDefinition.tooljet_version; + if (branchId) importDto.branchId = branchId; if (isVersionGreaterThanOrEqual(templateDefinition.tooljet_version, '2.16.0')) { importDto.app[0].appName = appName; diff --git a/server/src/modules/versions/controller.ts b/server/src/modules/versions/controller.ts index 5a1a47ba2a..3457469384 100644 --- a/server/src/modules/versions/controller.ts +++ b/server/src/modules/versions/controller.ts @@ -1,6 +1,6 @@ import { InitModule } from '@modules/app/decorators/init-module'; import { VersionService } from './service'; -import { Body, Controller, Delete, Get, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, Post, UseGuards } from '@nestjs/common'; import { MODULES } from '@modules/app/constants/modules'; import { JwtAuthGuard } from '@modules/session/guards/jwt-auth.guard'; import { ValidAppGuard } from '@modules/apps/guards/valid-app.guard'; @@ -28,7 +28,13 @@ export class VersionController implements IVersionController { @InitFeature(FEATURE_KEY.APP_VERSION_CREATE) @UseGuards(JwtAuthGuard, ValidAppGuard, FeatureAbilityGuard) @Post(':id/versions') - createVersion(@User() user, @App() app: AppEntity, @Body() versionCreateDto: VersionCreateDto) { + createVersion( + @User() user, + @App() app: AppEntity, + @Body() versionCreateDto: VersionCreateDto, + @Headers('x-branch-id') branchId?: string + ) { + versionCreateDto.branchId = branchId; return this.versionService.createVersion(app, user, versionCreateDto); } diff --git a/server/src/modules/versions/dto/index.ts b/server/src/modules/versions/dto/index.ts index 93590b9189..5655a20c50 100644 --- a/server/src/modules/versions/dto/index.ts +++ b/server/src/modules/versions/dto/index.ts @@ -23,6 +23,10 @@ export class VersionCreateDto { @IsOptional() versionType?: AppVersionType; + + @IsUUID() + @IsOptional() + branchId?: string; } export class PromoteVersionDto { diff --git a/server/src/modules/versions/find-relations.util.ts b/server/src/modules/versions/find-relations.util.ts index 4c6ac99d55..fcea7cd678 100644 --- a/server/src/modules/versions/find-relations.util.ts +++ b/server/src/modules/versions/find-relations.util.ts @@ -5,7 +5,8 @@ import { App } from '@entities/app.entity'; import { AppEnvironment } from '@entities/app_environments.entity'; import { Component } from '@entities/component.entity'; import { DataQuery } from '@entities/data_query.entity'; -import { DataSourceOptions } from '@entities/data_source_options.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; import { EventHandler } from '@entities/event_handler.entity'; import { Page } from '@entities/page.entity'; import { User } from '@entities/user.entity'; @@ -112,13 +113,14 @@ async function getUuidFieldsForExport( const dataQueryIds = dataQueries.map((dq) => dq.id); - // Get Data Source Option IDs only + // Get Data Source Option IDs only (from data_source_version_options via default DSV) const dataSourceOptions = allDataSourceIds.length ? await manager - .createQueryBuilder(DataSourceOptions, 'data_source_options') - .select('data_source_options.id') + .createQueryBuilder(DataSourceVersionOptions, 'dsvo') + .select('dsvo.id') + .innerJoin(DataSourceVersion, 'dsv', 'dsv.id = dsvo.dataSourceVersionId AND dsv.isDefault = true') .where( - 'data_source_options.environmentId IN(:...environmentId) AND data_source_options.dataSourceId IN(:...dataSourceId)', + 'dsvo.environmentId IN(:...environmentId) AND dsv.dataSourceId IN(:...dataSourceId)', { environmentId: environmentIds, dataSourceId: allDataSourceIds, diff --git a/server/src/modules/versions/repository.ts b/server/src/modules/versions/repository.ts index 24d94ea51f..46e1f8e576 100644 --- a/server/src/modules/versions/repository.ts +++ b/server/src/modules/versions/repository.ts @@ -20,7 +20,8 @@ export class VersionRepository extends Repository { appId: string, firstPriorityEnvId: string, definition?: any, - manager?: EntityManager + manager?: EntityManager, + branchId?: string ): Promise { return dbTransactionWrap(async (manager: EntityManager) => { return catchDbException(() => { @@ -34,6 +35,7 @@ export class VersionRepository extends Repository { status: AppVersionStatus.DRAFT, createdAt: new Date(), updatedAt: new Date(), + ...(branchId && { branchId }), }) ); }, [{ dbConstraint: DataBaseConstraints.APP_VERSION_NAME_UNIQUE, message: 'Version name already exists.' }]); diff --git a/server/src/modules/versions/service.ts b/server/src/modules/versions/service.ts index 28fbfd5adc..c8d7e91a22 100644 --- a/server/src/modules/versions/service.ts +++ b/server/src/modules/versions/service.ts @@ -212,7 +212,11 @@ export class VersionService implements IVersionService { user.organizationId, editingVersion?.globalSettings?.theme?.id ); - const appGit = await this.appGitRepository.findAppGitByAppId(app.id); + let appGit = await this.appGitRepository.findAppGitByAppId(app.id); + // Branch-copy apps (platform git sync) don't have their own app_git_sync record + if (!appGit && app.co_relation_id && app.co_relation_id !== app.id) { + appGit = await this.appGitRepository.findAppGitByAppId(app.co_relation_id); + } if (appGit) { shouldFreezeEditor = !appGit.allowEditing || shouldFreezeEditor; } diff --git a/server/src/modules/versions/services/create.service.ts b/server/src/modules/versions/services/create.service.ts index 68761d3298..928b882d10 100644 --- a/server/src/modules/versions/services/create.service.ts +++ b/server/src/modules/versions/services/create.service.ts @@ -3,7 +3,8 @@ import { AppEnvironment } from '@entities/app_environments.entity'; import { AppVersion } from '@entities/app_version.entity'; import { DataQuery } from '@entities/data_query.entity'; import { DataSource } from '@entities/data_source.entity'; -import { DataSourceOptions } from '@entities/data_source_options.entity'; +import { DataSourceVersion } from '@entities/data_source_version.entity'; +import { DataSourceVersionOptions } from '@entities/data_source_version_options.entity'; import { EventHandler, Target } from '@entities/event_handler.entity'; import { dbTransactionWrap } from '@helpers/database.helper'; import { EntityManager } from 'typeorm'; @@ -33,7 +34,7 @@ export class VersionsCreateService implements IVersionsCreateService { protected readonly dataSourceUtilService: DataSourcesUtilService, protected readonly dataSourceRepository: DataSourcesRepository, protected readonly dataQueryRepository: DataQueryRepository - ) { } + ) {} async setupNewVersion( appVersion: AppVersion, versionFrom: AppVersion, @@ -211,22 +212,101 @@ export class VersionsCreateService implements IVersionsCreateService { for (const appEnvironment of appEnvironments) { for (const dataSource of dataSources) { - const dataSourceOption = await manager.findOneOrFail(DataSourceOptions, { - where: { dataSourceId: dataSource.id, environmentId: appEnvironment.id }, + // Read source options from the default DataSourceVersion + const sourceDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: dataSource.id, isDefault: true }, }); + const sourceDsvo = sourceDsv + ? await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: sourceDsv.id, environmentId: appEnvironment.id }, + }) + : null; + const sourceOptions = sourceDsvo?.options || {}; - const convertedOptions = this.convertToArrayOfKeyValuePairs(dataSourceOption.options); + const convertedOptions = this.convertToArrayOfKeyValuePairs(sourceOptions); const newOptions = await this.dataSourceUtilService.parseOptionsForCreate(convertedOptions, false, manager); await this.setNewCredentialValueFromOldValue(newOptions, convertedOptions, manager); - await manager.save( - manager.create(DataSourceOptions, { - options: newOptions, - dataSourceId: dataSourceMapping[dataSource.id], - environmentId: appEnvironment.id, - // co_relation_id: dataSourceOption?.co_relation_id, need to review if we need this - }) - ); + // Create default DSV + DSVO for the new version's data source + const newDsId = dataSourceMapping[dataSource.id]; + let defaultDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: newDsId, isDefault: true }, + }); + if (!defaultDsv) { + const ds = await manager.findOne(DataSource, { where: { id: newDsId }, select: ['id', 'name'] }); + defaultDsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: newDsId, + name: ds?.name || 'v1', + isDefault: true, + isActive: true, + branchId: null, + }) + ); + } + const existingDsvo = await manager.findOne(DataSourceVersionOptions, { + where: { dataSourceVersionId: defaultDsv.id, environmentId: appEnvironment.id }, + }); + if (!existingDsvo) { + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: defaultDsv.id, + environmentId: appEnvironment.id, + options: newOptions, + }) + ); + } + } + } + + // Create version-specific DSVs for global data sources + for (const globalDs of globalDataSources) { + const dsvName = globalDs.name || 'v1'; + + // The idx_unique_active_name_branch constraint enforces one active non-default + // DSV per (name, branch_id). DSVs are branch-scoped, not app-version-scoped, + // so skip creation if one already exists for this datasource+name+branch. + const existingDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: globalDs.id, name: dsvName, branchId: null, isActive: true, isDefault: false }, + }); + if (existingDsv) { + continue; + } + + let sourceDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: globalDs.id, appVersionId: versionFrom.id }, + }); + if (!sourceDsv) { + sourceDsv = await manager.findOne(DataSourceVersion, { + where: { dataSourceId: globalDs.id, isDefault: true }, + }); + } + + const newDsv = await manager.save( + manager.create(DataSourceVersion, { + dataSourceId: globalDs.id, + name: dsvName, + isDefault: false, + isActive: true, + appVersionId: appVersion.id, + branchId: null, + versionFromId: sourceDsv?.id || null, + }) + ); + + if (sourceDsv) { + const sourceDsvos = await manager.find(DataSourceVersionOptions, { + where: { dataSourceVersionId: sourceDsv.id }, + }); + for (const dsvo of sourceDsvos) { + await manager.save( + manager.create(DataSourceVersionOptions, { + dataSourceVersionId: newDsv.id, + environmentId: dsvo.environmentId, + options: dsvo.options, + }) + ); + } } } } diff --git a/server/src/modules/versions/util.service.ts b/server/src/modules/versions/util.service.ts index f9df162d6e..32486c1472 100644 --- a/server/src/modules/versions/util.service.ts +++ b/server/src/modules/versions/util.service.ts @@ -4,7 +4,7 @@ import { AppVersionUpdateDto } from '@dto/app-version-update.dto'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { IVersionUtilService } from './interfaces/IUtilService'; import { dbTransactionWrap } from '@helpers/database.helper'; -import { EntityManager, Not } from 'typeorm'; +import { EntityManager, IsNull, Not } from 'typeorm'; import { App } from '@entities/app.entity'; import { User } from '@entities/user.entity'; import { VersionsCreateService } from './services/create.service'; @@ -145,7 +145,7 @@ export class VersionUtilService implements IVersionUtilService { } async createVersion(app: App, user: User, versionCreateDto: VersionCreateDto, manager?: EntityManager) { - const { versionName, versionFromId, versionDescription, versionType } = versionCreateDto; + const { versionName, versionFromId, versionDescription, versionType, branchId } = versionCreateDto; if (!versionName || versionName.trim().length === 0) { // need to add logic to get the version name -> from the version created at from throw new BadRequestException('Version name cannot be empty.'); @@ -156,9 +156,8 @@ export class VersionUtilService implements IVersionUtilService { manager ); if (organizationGit && organizationGit.isBranchingEnabled) { - // Only allow one draft version of type 'version' (not branch) - // Branch versions can have multiple drafts - // If versionType is not provided or is not BRANCH, check for existing draft + // Only allow one draft version of type 'version' (not branch) per branch. + // Scoping by branchId ensures drafts on different branches don't conflict. const isCreatingBranchVersion = versionType === AppVersionType.BRANCH; if (!isCreatingBranchVersion) { @@ -167,6 +166,7 @@ export class VersionUtilService implements IVersionUtilService { appId: app.id, status: AppVersionStatus.DRAFT, versionType: Not(AppVersionType.BRANCH), + branchId: branchId ?? IsNull(), }, }); if (existingDraftVersion) { @@ -178,7 +178,7 @@ export class VersionUtilService implements IVersionUtilService { const result = await dbTransactionWrap(async (manager: EntityManager) => { const versionFrom = await manager.findOneOrFail(AppVersion, { where: { id: versionFromId, appId: app.id }, - relations: ['dataSources', 'dataSources.dataQueries', 'dataSources.dataSourceOptions'], + relations: ['dataSources', 'dataSources.dataQueries'], }); const firstPriorityEnv = await this.appEnvironmentUtilService.get(organizationId, null, true, manager); @@ -197,7 +197,8 @@ export class VersionUtilService implements IVersionUtilService { description: versionDescription ? versionDescription : null, versionType: versionType ? versionType : AppVersionType.VERSION, createdBy: user.id, - co_relation_id: versionFrom.co_relation_id, + co_relation_id: app.co_relation_id, + ...(branchId && { branchId }), }) ); @@ -225,7 +226,16 @@ export class VersionUtilService implements IVersionUtilService { async deleteVersion(app: App, user: User, manager?: EntityManager): Promise { return await dbTransactionWrap(async (manager: EntityManager) => { - const numVersions = await this.versionRepository.getCount(app.id); + const versionToDelete = app.appVersions[0]; + const branchId = versionToDelete?.branchId ?? null; + + // For platform git sync apps, count only versions on the same branch so that + // versions on other branches don't inflate the count and bypass the guard. + // For all other apps (branchId=null), fall back to the original count across + // all versions — behaviour is unchanged. + const numVersions = branchId + ? await manager.count(AppVersion, { where: { appId: app.id, branchId } }) + : await this.versionRepository.getCount(app.id); if (numVersions <= 1) { throw new ForbiddenException('Cannot delete only version of app'); diff --git a/server/src/modules/workspace-branches/ability/guard.ts b/server/src/modules/workspace-branches/ability/guard.ts new file mode 100644 index 0000000000..c1cf1f02d6 --- /dev/null +++ b/server/src/modules/workspace-branches/ability/guard.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { FeatureAbilityFactory } from '.'; +import { AbilityGuard } from '@modules/app/guards/ability.guard'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; + +@Injectable() +export class FeatureAbilityGuard extends AbilityGuard { + protected getAbilityFactory() { + return FeatureAbilityFactory; + } + + protected getSubjectType() { + return WorkspaceBranch; + } +} diff --git a/server/src/modules/workspace-branches/ability/index.ts b/server/src/modules/workspace-branches/ability/index.ts new file mode 100644 index 0000000000..8a3d3ae9af --- /dev/null +++ b/server/src/modules/workspace-branches/ability/index.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { Ability, AbilityBuilder, InferSubjects } from '@casl/ability'; +import { AbilityFactory } from '@modules/app/ability-factory'; +import { UserAllPermissions } from '@modules/app/types'; +import { FEATURE_KEY } from '../constants'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; + +type Subjects = InferSubjects | 'all'; +export type FeatureAbility = Ability<[FEATURE_KEY, Subjects]>; + +@Injectable() +export class FeatureAbilityFactory extends AbilityFactory { + protected getSubjectType() { + return WorkspaceBranch; + } + + protected defineAbilityFor(can: AbilityBuilder['can'], userAllPermissions: UserAllPermissions): void { + const { superAdmin, isAdmin, isBuilder } = userAllPermissions; + // Super admins and workspace admins have full branch management access + if (superAdmin || isAdmin) { + can( + [ + FEATURE_KEY.LIST_BRANCHES, + FEATURE_KEY.CREATE_BRANCH, + FEATURE_KEY.SWITCH_BRANCH, + FEATURE_KEY.DELETE_BRANCH, + FEATURE_KEY.PUSH_WORKSPACE, + FEATURE_KEY.PULL_WORKSPACE, + FEATURE_KEY.CHECK_UPDATES, + FEATURE_KEY.LIST_REMOTE_BRANCHES, + FEATURE_KEY.FETCH_PULL_REQUESTS, + FEATURE_KEY.ENSURE_DRAFT, + ], + WorkspaceBranch + ); + } + // Builders can create branches, commit (push), pull, switch, and view — but not delete + if (isBuilder) { + can( + [ + FEATURE_KEY.LIST_BRANCHES, + FEATURE_KEY.CREATE_BRANCH, + FEATURE_KEY.SWITCH_BRANCH, + FEATURE_KEY.PUSH_WORKSPACE, + FEATURE_KEY.PULL_WORKSPACE, + FEATURE_KEY.CHECK_UPDATES, + FEATURE_KEY.LIST_REMOTE_BRANCHES, + FEATURE_KEY.FETCH_PULL_REQUESTS, + FEATURE_KEY.ENSURE_DRAFT, + ], + WorkspaceBranch + ); + } + } +} diff --git a/server/src/modules/workspace-branches/branch-context.service.ts b/server/src/modules/workspace-branches/branch-context.service.ts new file mode 100644 index 0000000000..9dbfb662a4 --- /dev/null +++ b/server/src/modules/workspace-branches/branch-context.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { IBranchContextService } from './interfaces/IBranchContextService'; + +@Injectable() +export class BranchContextService implements IBranchContextService { + // CE: no branching — always returns null (feature gated) + async getActiveBranchId(organizationId: string): Promise { + return null; + } + + async getDefaultBranchId(organizationId: string): Promise { + return null; + } +} diff --git a/server/src/modules/workspace-branches/constants/feature.ts b/server/src/modules/workspace-branches/constants/feature.ts new file mode 100644 index 0000000000..800ac8ef14 --- /dev/null +++ b/server/src/modules/workspace-branches/constants/feature.ts @@ -0,0 +1,19 @@ +import { FEATURE_KEY } from '.'; +import { MODULES } from '@modules/app/constants/modules'; +import { FeaturesConfig } from '../types'; +import { LICENSE_FIELD } from '@modules/licensing/constants'; + +export const FEATURES: FeaturesConfig = { + [MODULES.WORKSPACE_BRANCHES]: { + [FEATURE_KEY.LIST_BRANCHES]: { license: LICENSE_FIELD.GIT_SYNC }, + [FEATURE_KEY.CREATE_BRANCH]: { license: LICENSE_FIELD.GIT_SYNC, auditLogsKey: 'BRANCH_CREATE' }, + [FEATURE_KEY.SWITCH_BRANCH]: { license: LICENSE_FIELD.GIT_SYNC }, + [FEATURE_KEY.DELETE_BRANCH]: { license: LICENSE_FIELD.GIT_SYNC, auditLogsKey: 'BRANCH_DELETE' }, + [FEATURE_KEY.PUSH_WORKSPACE]: { license: LICENSE_FIELD.GIT_SYNC, auditLogsKey: 'WORKSPACE_PUSH_COMMIT' }, + [FEATURE_KEY.PULL_WORKSPACE]: { license: LICENSE_FIELD.GIT_SYNC, auditLogsKey: 'MASTER_PULL_COMMIT' }, + [FEATURE_KEY.CHECK_UPDATES]: { license: LICENSE_FIELD.GIT_SYNC }, + [FEATURE_KEY.LIST_REMOTE_BRANCHES]: { license: LICENSE_FIELD.GIT_SYNC }, + [FEATURE_KEY.FETCH_PULL_REQUESTS]: { license: LICENSE_FIELD.GIT_SYNC }, + [FEATURE_KEY.ENSURE_DRAFT]: { license: LICENSE_FIELD.GIT_SYNC }, + }, +}; diff --git a/server/src/modules/workspace-branches/constants/index.ts b/server/src/modules/workspace-branches/constants/index.ts new file mode 100644 index 0000000000..9976a1d619 --- /dev/null +++ b/server/src/modules/workspace-branches/constants/index.ts @@ -0,0 +1,12 @@ +export enum FEATURE_KEY { + LIST_BRANCHES = 'LIST_BRANCHES', + CREATE_BRANCH = 'CREATE_BRANCH', + SWITCH_BRANCH = 'SWITCH_BRANCH', + DELETE_BRANCH = 'DELETE_BRANCH', + PUSH_WORKSPACE = 'PUSH_WORKSPACE', + PULL_WORKSPACE = 'PULL_WORKSPACE', + CHECK_UPDATES = 'CHECK_UPDATES', + LIST_REMOTE_BRANCHES = 'LIST_REMOTE_BRANCHES', + FETCH_PULL_REQUESTS = 'FETCH_PULL_REQUESTS', + ENSURE_DRAFT = 'ENSURE_DRAFT', +} diff --git a/server/src/modules/workspace-branches/controller.ts b/server/src/modules/workspace-branches/controller.ts new file mode 100644 index 0000000000..d0614d50fa --- /dev/null +++ b/server/src/modules/workspace-branches/controller.ts @@ -0,0 +1,95 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../session/guards/jwt-auth.guard'; +import { User } from '@modules/app/decorators/user.decorator'; +import { WorkspaceBranchService } from './service'; +import { CreateBranchDto, WorkspacePushDto, WorkspacePullDto, EnsureDraftDto } from './dto'; +import { IWorkspaceBranchController } from './interfaces/IController'; +import { FEATURE_KEY } from './constants'; +import { InitModule } from '@modules/app/decorators/init-module'; +import { MODULES } from '@modules/app/constants/modules'; +import { InitFeature } from '@modules/app/decorators/init-feature.decorator'; +import { FeatureAbilityGuard } from './ability/guard'; + +@InitModule(MODULES.WORKSPACE_BRANCHES) +@Controller('workspace-branches') +export class WorkspaceBranchController implements IWorkspaceBranchController { + constructor(protected workspaceBranchService: WorkspaceBranchService) {} + + @InitFeature(FEATURE_KEY.LIST_BRANCHES) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Get() + async list(@User() user) { + return this.workspaceBranchService.list(user.organizationId); + } + + @InitFeature(FEATURE_KEY.CHECK_UPDATES) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Get('check-updates') + async checkForUpdates(@User() user, @Query('branch') branch?: string) { + return this.workspaceBranchService.checkForUpdates(user.organizationId, branch); + } + + @InitFeature(FEATURE_KEY.CREATE_BRANCH) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Post() + async create(@User() user, @Body() dto: CreateBranchDto) { + return this.workspaceBranchService.createBranch(user.organizationId, dto, user); + } + + @InitFeature(FEATURE_KEY.SWITCH_BRANCH) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Put(':id/activate') + async switchBranch(@User() user, @Param('id') branchId: string, @Body() body?: { appId?: string }) { + return this.workspaceBranchService.switchBranch(user.organizationId, branchId, body?.appId); + } + + @InitFeature(FEATURE_KEY.DELETE_BRANCH) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Delete(':id') + async deleteBranch(@User() user, @Param('id') branchId: string) { + await this.workspaceBranchService.deleteBranch(user.organizationId, branchId, user); + return { success: true }; + } + + @InitFeature(FEATURE_KEY.PUSH_WORKSPACE) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Post('push') + async pushWorkspace(@User() user, @Body() dto: WorkspacePushDto) { + return this.workspaceBranchService.pushWorkspace(user.organizationId, dto, user); + } + + @InitFeature(FEATURE_KEY.PULL_WORKSPACE) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Post('pull') + async pullWorkspace(@User() user, @Body() dto?: WorkspacePullDto) { + return this.workspaceBranchService.pullWorkspace(user.organizationId, user, dto?.sourceBranch, dto?.branchId); + } + + @InitFeature(FEATURE_KEY.ENSURE_DRAFT) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Post('ensure-draft') + async ensureAppDraft(@User() user: any, @Body() dto: EnsureDraftDto) { + return this.workspaceBranchService.ensureAppDraft( + user.organizationId, + dto.appId, + dto.branchId, + user, + dto.tagSha, + dto.tagName + ); + } + + @InitFeature(FEATURE_KEY.LIST_REMOTE_BRANCHES) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Get('remote') + async listRemoteBranches(@User() user) { + return this.workspaceBranchService.listRemoteBranches(user.organizationId); + } + + @InitFeature(FEATURE_KEY.FETCH_PULL_REQUESTS) + @UseGuards(JwtAuthGuard, FeatureAbilityGuard) + @Get('pull-requests') + async getPullRequests(@User() user) { + return this.workspaceBranchService.getPullRequests(user.organizationId); + } +} diff --git a/server/src/modules/workspace-branches/dto/index.ts b/server/src/modules/workspace-branches/dto/index.ts new file mode 100644 index 0000000000..81a1e8ee39 --- /dev/null +++ b/server/src/modules/workspace-branches/dto/index.ts @@ -0,0 +1,65 @@ +import { IsNotEmpty, IsString, IsOptional, IsUUID } from 'class-validator'; + +export class CreateBranchDto { + @IsNotEmpty() + @IsString() + name: string; + + @IsOptional() + @IsUUID() + sourceBranchId?: string; + + @IsOptional() + @IsString() + commitSha?: string; +} + +export class SwitchBranchDto { + @IsNotEmpty() + @IsUUID() + branchId: string; +} + +export class WorkspacePushDto { + @IsNotEmpty() + @IsString() + commitMessage: string; + + @IsOptional() + @IsString() + targetBranch?: string; + + @IsOptional() + @IsUUID() + branchId?: string; +} + +export class WorkspacePullDto { + @IsOptional() + @IsString() + sourceBranch?: string; + + @IsOptional() + @IsUUID() + branchId?: string; +} + +export class EnsureDraftDto { + @IsNotEmpty() + @IsUUID() + appId: string; + + @IsOptional() + @IsUUID() + branchId?: string; + + // Present when the user selected a git tag instead of "Latest commit" + @IsOptional() + @IsString() + tagSha?: string; + + // Full tag name (e.g. "my-app/v1") used to populate source_tag on the version + @IsOptional() + @IsString() + tagName?: string; +} diff --git a/server/src/modules/workspace-branches/interfaces/IBranchContextService.ts b/server/src/modules/workspace-branches/interfaces/IBranchContextService.ts new file mode 100644 index 0000000000..258c2835cf --- /dev/null +++ b/server/src/modules/workspace-branches/interfaces/IBranchContextService.ts @@ -0,0 +1,5 @@ +export interface IBranchContextService { + /** @deprecated Use branchId from request query params instead. Always returns null. */ + getActiveBranchId(organizationId: string): Promise; + getDefaultBranchId(organizationId: string): Promise; +} diff --git a/server/src/modules/workspace-branches/interfaces/IController.ts b/server/src/modules/workspace-branches/interfaces/IController.ts new file mode 100644 index 0000000000..62edb5ee1d --- /dev/null +++ b/server/src/modules/workspace-branches/interfaces/IController.ts @@ -0,0 +1,14 @@ +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; +import { WorkspaceBranchListResponse, CheckUpdatesResponse } from './IService'; + +export interface IWorkspaceBranchController { + list(user: any): Promise; + create(user: any, dto: any): Promise; + switchBranch(user: any, branchId: string): Promise<{ success: boolean }>; + deleteBranch(user: any, branchId: string): Promise<{ success: boolean }>; + pushWorkspace(user: any, dto: any): Promise<{ success: boolean }>; + pullWorkspace(user: any): Promise<{ success: boolean }>; + checkForUpdates(user: any, branch: string): Promise; + listRemoteBranches(user: any): Promise<{ name: string }[]>; + getPullRequests(user: any): Promise; +} diff --git a/server/src/modules/workspace-branches/interfaces/IService.ts b/server/src/modules/workspace-branches/interfaces/IService.ts new file mode 100644 index 0000000000..8510f9a31a --- /dev/null +++ b/server/src/modules/workspace-branches/interfaces/IService.ts @@ -0,0 +1,47 @@ +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; +import { User } from '@entities/user.entity'; +import { CreateBranchDto, WorkspacePushDto } from '../dto'; + +export interface WorkspaceBranchListResponse { + branches: WorkspaceBranch[]; + activeBranchId: string | null; +} + +export interface CheckUpdatesResponse { + hasUpdates: boolean; + latestCommit: { + message: string; + author: string; + date: string; + sha: string; + } | null; +} + +export interface IWorkspaceBranchService { + list(organizationId: string): Promise; + createBranch(organizationId: string, dto: CreateBranchDto, user?: User): Promise; + switchBranch( + organizationId: string, + branchId: string, + appId?: string + ): Promise<{ success: boolean; resolvedAppId?: string }>; + deleteBranch(organizationId: string, branchId: string, user?: User): Promise; + pushWorkspace(organizationId: string, dto: WorkspacePushDto, user?: User): Promise<{ success: boolean }>; + pullWorkspace( + organizationId: string, + user?: User, + sourceBranch?: string, + branchId?: string + ): Promise<{ success: boolean }>; + ensureAppDraft( + organizationId: string, + appId: string, + branchId: string | undefined, + user: User, + tagSha?: string, + tagName?: string + ): Promise<{ draftVersionId: string | null }>; + checkForUpdates(organizationId: string, branch?: string): Promise; + listRemoteBranches(organizationId: string): Promise<{ name: string }[]>; + getPullRequests(organizationId: string): Promise; +} diff --git a/server/src/modules/workspace-branches/module.ts b/server/src/modules/workspace-branches/module.ts new file mode 100644 index 0000000000..5ce9b529c2 --- /dev/null +++ b/server/src/modules/workspace-branches/module.ts @@ -0,0 +1,38 @@ +import { FeatureAbilityFactory } from './ability'; +import { SubModule } from '@modules/app/sub-module'; +import { DynamicModule } from '@nestjs/common'; +import { GitSyncModule } from '@modules/git-sync/module'; +import { AppGitModule } from '@modules/app-git/module'; +import { FolderAppsModule } from '@modules/folder-apps/module'; +import { FoldersModule } from '@modules/folders/module'; +import { ImportExportResourcesModule } from '@modules/import-export-resources/module'; + +export class WorkspaceBranchesModule extends SubModule { + static async register(configs?: { IS_GET_CONTEXT: boolean }, isMainImport?: boolean): Promise { + const { WorkspaceBranchController, WorkspaceBranchService } = await this.getProviders( + configs, + 'workspace-branches', + ['controller', 'service'] + ); + + const { PlatformGitPullService, PlatformGitPushService } = await this.getProviders( + configs, + 'platform-git-sync', + ['pull.service', 'push.service'] + ); + + return { + module: WorkspaceBranchesModule, + imports: [ + await GitSyncModule.register(configs), + await AppGitModule.register(configs), + await FolderAppsModule.register(configs), + await FoldersModule.register(configs), + await ImportExportResourcesModule.register(configs), + ], + controllers: isMainImport ? [WorkspaceBranchController] : [], + providers: [WorkspaceBranchService, FeatureAbilityFactory, PlatformGitPullService, PlatformGitPushService], + exports: [WorkspaceBranchService, PlatformGitPullService, PlatformGitPushService], + }; + } +} diff --git a/server/src/modules/workspace-branches/service.ts b/server/src/modules/workspace-branches/service.ts new file mode 100644 index 0000000000..ebb1631985 --- /dev/null +++ b/server/src/modules/workspace-branches/service.ts @@ -0,0 +1,64 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { WorkspaceBranch } from '@entities/workspace_branch.entity'; +import { User } from '@entities/user.entity'; +import { IWorkspaceBranchService, WorkspaceBranchListResponse, CheckUpdatesResponse } from './interfaces/IService'; +import { CreateBranchDto, WorkspacePushDto } from './dto'; + +@Injectable() +export class WorkspaceBranchService implements IWorkspaceBranchService { + async list(organizationId: string): Promise { + throw new NotFoundException(); + } + + async createBranch(organizationId: string, dto: CreateBranchDto, user?: User): Promise { + throw new NotFoundException(); + } + + async switchBranch( + organizationId: string, + branchId: string, + appId?: string + ): Promise<{ success: boolean; resolvedAppId?: string }> { + throw new NotFoundException(); + } + + async deleteBranch(organizationId: string, branchId: string, user?: User): Promise { + throw new NotFoundException(); + } + + async pushWorkspace(organizationId: string, dto: WorkspacePushDto, user?: User): Promise<{ success: boolean }> { + throw new NotFoundException(); + } + + async pullWorkspace( + organizationId: string, + user?: User, + sourceBranch?: string, + branchId?: string + ): Promise<{ success: boolean }> { + throw new NotFoundException(); + } + + async ensureAppDraft( + organizationId: string, + appId: string, + branchId: string | undefined, + user: User, + tagSha?: string, + tagName?: string + ): Promise<{ draftVersionId: string | null }> { + throw new NotFoundException(); + } + + async checkForUpdates(organizationId: string, branch?: string): Promise { + throw new NotFoundException(); + } + + async listRemoteBranches(organizationId: string): Promise<{ name: string }[]> { + throw new NotFoundException(); + } + + async getPullRequests(organizationId: string): Promise { + throw new NotFoundException(); + } +} diff --git a/server/src/modules/workspace-branches/types/index.ts b/server/src/modules/workspace-branches/types/index.ts new file mode 100644 index 0000000000..923d2a15cc --- /dev/null +++ b/server/src/modules/workspace-branches/types/index.ts @@ -0,0 +1,20 @@ +import { FEATURE_KEY } from '../constants'; +import { FeatureConfig } from '@modules/app/types'; +import { MODULES } from '@modules/app/constants/modules'; + +interface Features { + [FEATURE_KEY.LIST_BRANCHES]: FeatureConfig; + [FEATURE_KEY.CREATE_BRANCH]: FeatureConfig; + [FEATURE_KEY.SWITCH_BRANCH]: FeatureConfig; + [FEATURE_KEY.DELETE_BRANCH]: FeatureConfig; + [FEATURE_KEY.PUSH_WORKSPACE]: FeatureConfig; + [FEATURE_KEY.PULL_WORKSPACE]: FeatureConfig; + [FEATURE_KEY.CHECK_UPDATES]: FeatureConfig; + [FEATURE_KEY.LIST_REMOTE_BRANCHES]: FeatureConfig; + [FEATURE_KEY.FETCH_PULL_REQUESTS]: FeatureConfig; + [FEATURE_KEY.ENSURE_DRAFT]: FeatureConfig; +} + +export interface FeaturesConfig { + [MODULES.WORKSPACE_BRANCHES]: Features; +}