Enable Git Sync for Datasources, constants and dashboard (#15434)

* feat: Folder permission system

* fix(group-permissions): resolve custom group validation, folder edit check, and UI inconsistencie

* edit folder container && no folder in custom resource

* fix the ui for custom in empty state

* fix: coercion logic for folder permissions

* feat: enhance folder permissions handling in app components

* feat: add folder granular permissions handling in user apps permissions

* feat: implement granular folder permissions in ability guard and service

* feat: improve error handling for folder permissions with specific messages

* feat: enhance EnvironmentSelect component to handle disabled state and improve display logic

* chore: bump ee submodules

* add basic framework to support platform git

* feat: Update permission prop to isEditable in BaseManageGranularAccess component

* chore: bump ee server submodule

* fix: refine folder visibility logic based on user permissions

* feat: enhance MultiValue rendering and styling for "All environments" option

* fix:Uniqueness-of-data-source

* revert folder changes

* fix folder imports

* feat: allow app lazy loading

feat: import all apps of branches

* feat: implement folder ownership checks and enhance app permissions handling

* fix:ui changes

* feat: update WorkspaceGitSyncModal UI

* feat: enhance folder permissions handling for app ownership and actions

* chore: clarify folder creation and deletion permissions in workspace context

* fix: pull commit button & swtich branch visibility

* feat: import app from git repo

* fix: freezed state

* remove reference of activebranchId

* fix linting

* fix: update folder permission labels

* fixed folder permission cases

* fixed css class issue

* fix: datasource UI

* minor fix

* feat: streamline folder permissions handling by removing redundant checks and simplifying access logic

* refactor: made error message consistent

* fix:ui changes and PR fetching on master

* fix: datasource and snapshot creation

* fix: app rendering and stub loading

* fix: add missing permission message for folder deletion action

* refactor: consolidate forbidden messages for folder actions and maintain consistency

* fix: allow pull into current branch

* fix renaming of tags and reload on branch switch

* fix: allow branches import from git

* fix:push or tab removed

* feat: streamline permission handling and improve app visibility logic

* fix: remove default access denial message in AbilityGuard

* fixed all user page functionality falky case

* feat: add workspace-level PR fetch endpoint (returns all repo PRs without app filtering)

* fix: remove app_branch_table

* Fixed profile flaky case

* fixed granular access flaky case

* fix: allow branch creation from tags

* fix: update default branch creation logic to use provider config

* fix: dso and dsv operations on codebase

* fix: constants reloading and refetch org git details on data

* uniquness per branch

* removed comment

* fix: update app version handling and add is_stub column for branch-level tracking

* fix workspace branch backfilling for scoped branches

* added unique constraint - migration

* fix: update app version unique constraint to include branchId for branch-aware handling

* fix: update subproject commit reference in server/ee

* chore: revert package-lock.json

* chore: revert frontend/package-lock.json to main

* removed banner and changed migration

* minor fix

* fix: remove unused import and handle UUID parse error gracefully in AppsUtilService

* fix: update app stub checks to safely access app_versions

* refactor: revert folder operations

* fix: removed branch id logic

* fix: ds migration

* fix encrypted diff logic

* fix: update openCreateAppModal to handle workspace branch lock

* fix: subscriber filtering, freeze priority, meta hash optimization, and co_relation_id backfill

* feat: add script to generate app metadata from app.json files

* fix: meta script

fix: backfilling of co-realtion-ids

* refactor: streamline parameter formatting in workspace git sync adapter methods

* Improves data source handling for workspace git sync

Fixes workspace git sync to properly recognize data sources across branches by improving correlation ID handling and branch-aware data source version creation.

Uses strict equality comparison in deep equal utility to prevent type coercion issues.

Excludes credential_id from data source comparison to prevent unnecessary save button states.

Removes is_active filter from branch data source queries to include all versions for proper synchronization.

* refactor: update branch switching logic and improve error handling for data source creation

* fix: migration order

* 🚀 chore: update submodules to latest main after auto-merge (#15628)

Co-authored-by: gsmithun4 <3417097+gsmithun4@users.noreply.github.com>

* chore: update version to 3.21.8-beta across all components

* fix:import app from device

* fix:ui Edit&launch,folderCopy,branching dropdown in apps and ds

* fix:encrypted helper text on master

* fix: import from git flow

* logs cleanup

* fix:migration-datasource-uniqueness

* fix: app on pull

* chore: update server submodule hash

* fix: corelation-id generation and version naming

* fix: last versions deletion error

fix: no multiple version creation

* fix:ui and toast

* chore: update server submodule hash

* feat: add branch handling for app availability and improve error handling

* fix: update encrypted value handling in DynamicForm and improve workspace constant validation logic

* fix: improve formatting of help text in DynamicForm and enhance error message for adding constants on master branch

* fix: correct version creation and pull in default branch

* chore: update server submodule hash

fix: remove logs from other PR

* fix:data source uniquness at workspace level

* fix: update header component logic for path validation and improve version import handling

* chore: update server submodule to latest commit

* fixed folder modal changes

* fix:failed to create a query error inside apps

* feat: add branchId support for data source versioning in app import/export service

* fix: push & pull of tags and versions

* fix: update subproject commit reference in server/ee

* fix:removed gitSync logic from module rename

* fix:removed switchbranch modal & allowed renaming from masted module&workflow creation

* chore: Update server submodule hash

* fix: change stub button to edit

* refactor/git-sync-remove-modules-workflows

* fix:version name for module and workflo
w

* fix:templet app creation

* fix: add author details for branch

---------

Co-authored-by: gsmithun4 <gsmithun4@gmail.com>
Co-authored-by: Pratush <pratush@Pratushs-MBP.lan>
Co-authored-by: Shantanu Mane <maneshantanu.20@gmail.com>
Co-authored-by: parthy007 <parthadhikari1812@gmail.com>
Co-authored-by: Yukti Goyal <yuktigoyal02@gmail.com>
Co-authored-by: Muhsin Shah <muhsinshah21@gmail.com>
Co-authored-by: Adish M <44204658+adishM98@users.noreply.github.com>
Co-authored-by: gsmithun4 <3417097+gsmithun4@users.noreply.github.com>
Co-authored-by: Parth <108089718+parthy007@users.noreply.github.com>
This commit is contained in:
vjaris42 2026-03-27 23:23:23 +05:30 committed by GitHub
parent b7ea2a1338
commit 58ba6b8563
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
146 changed files with 7110 additions and 1087 deletions

View file

@ -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);
});

View file

@ -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]",

View file

@ -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) => {

View file

@ -224,7 +224,7 @@ describe("dashboard", () => {
commonSelectors.appCardOptions(commonText.addToFolderOption)
).click();
verifyModal(
dashboardText.addToFolderTitle,
dashboardText.updateFolderTitle,
dashboardText.addToFolderButton,
dashboardSelector.selectFolder
);

4
db-git-sync.drawio.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -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) => {

View file

@ -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 }) {
<div className="branch-name-title">{displayBranchName || 'No branch selected'}</div>
<div className="branch-metadata-feature">
<span className="metadata-text">
Created by {currentBranch?.created_by || currentBranch?.author || 'Unknown'}
Created by {activeBranchInfo?.created_by || activeBranchInfo?.author || 'Unknown'}
</span>
<span></span>
<span className="metadata-text">
{getRelativeTime(
selectedVersion?.createdAt ||
selectedVersion?.created_at ||
currentBranch?.createdAt ||
currentBranch?.created_at
activeBranchInfo?.created_at ||
activeBranchInfo?.updated_at ||
selectedVersion?.createdAt ||
selectedVersion?.created_at
)}
</span>
</div>
@ -566,20 +665,19 @@ export function BranchDropdown({ appId, organizationId }) {
<SolidIcon name="plus" width="14" fill="var(--indigo9)" />
<span>Create new branch</span>
</button>
{console.log('BranchDropdown - allBranches:', allBranches, 'length:', allBranches.length) ||
(allBranches.length > 0 && (
<button
className="switch-branch-btn"
onClick={() => {
setShowDropdown(false);
setShowSwitchModal(true);
}}
data-cy="switch-branch-btn"
>
<SolidIcon name="refresh" width="14" />
<span>Switch branch</span>
</button>
))}
{allBranches.length > 0 && (
<button
className="switch-branch-btn"
onClick={() => {
setShowDropdown(false);
setShowSwitchModal(true);
}}
data-cy="switch-branch-btn"
>
<SolidIcon name="refresh" width="14" />
<span>Switch branch</span>
</button>
)}
</>
) : (
<>

View file

@ -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 (
<>
<AlertDialog
show={true}
closeModal={onClose}
title="Create branch"
checkForBackground={true}
customClassName="create-branch-modal"
>
<div className="create-branch-modal-body">
{/* Draft warning message */}
{isDraftVersionActive && (
<div className="draft-warning-message">
<SolidIcon name="information" width="16" />
<span>A draft version exists. Commit or discard it before creating a new branch.</span>
</div>
)}
{/* Create from dropdown */}
<div className="form-group">
<label htmlFor="create-from-select" className="form-label">
Create from version
</label>
<div className="custom-dropdown" ref={dropdownRef}>
<button
type="button"
className={cx('custom-dropdown-trigger', { 'is-open': isDropdownOpen })}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
disabled={isCreating}
>
<div className="custom-dropdown-value">
{selectedVersion ? (
<>
<span className="version-name">{selectedVersion.name}</span>
{selectedBadge && (
<span className={cx('status-badge', selectedBadge.className)}>{selectedBadge.label}</span>
)}
</>
) : (
<span className="version-name">Select version...</span>
)}
</div>
<SolidIcon name="cheverondown" width="16" />
</button>
{isDropdownOpen && (
<div className="custom-dropdown-menu">
{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 (
<div
key={version.id}
className={cx('dropdown-item', { 'is-selected': isSelected })}
onClick={() => {
setCreateFrom(version.id);
setIsDropdownOpen(false);
}}
>
{isSelected && (
<div className="check-icon">
<SolidIcon name="tick" width="16" />
</div>
)}
{!isSelected && <div className="check-icon-placeholder" />}
<div className="item-content">
<div className="item-header">
<span className="item-name">{version.name}</span>
{badge && <span className={cx('status-badge', badge.className)}>{badge.label}</span>}
</div>
{createdFrom && <div className="item-description">{createdFrom}</div>}
</div>
</div>
);
})}
</div>
)}
</div>
<AlertDialog
show={true}
closeModal={onClose}
title="Create branch"
checkForBackground={true}
customClassName="create-branch-modal"
>
<div className="create-branch-modal-body">
{/* Draft warning message */}
{isDraftVersionActive && (
<div className="draft-warning-message">
<SolidIcon name="information" width="16" />
<span>A draft version exists. Commit or discard it before creating a new branch.</span>
</div>
)}
{/* Branch name input */}
<div className="form-group">
<label htmlFor="branch-name-input" className="form-label">
Branch name
</label>
<input
id="branch-name-input"
type="text"
className={`branch-modal-form-input ${validationError ? 'form-input-error' : ''}`}
placeholder="Enter branch name"
value={branchName}
onChange={handleBranchNameChange}
onKeyDown={handleKeyDown}
disabled={isCreating}
autoFocus
/>
{validationError && <div className="form-error-message">{validationError}</div>}
<div className="form-helper-text">Branch name must be unique and max 50 characters</div>
</div>
{/* Auto-commit checkbox */}
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
className="form-checkbox"
checked={autoCommit}
onChange={(e) => setAutoCommit(e.target.checked)}
disabled={true}
/>
<span className="checkbox-text">
Commit changes
<span className="checkbox-helper">
Branch will always be created in git to ensure sync with ToolJet
</span>
</span>
</label>
</div>
{/* Info message about branch creation */}
<Alert placeSvgTop={true} svg="warning-icon" cls="create-branch-info">
Branch can only be created from master
</Alert>
{/* Footer buttons */}
<div className="col d-flex justify-content-end gap-2 mt-3">
<ButtonSolid variant="tertiary" onClick={onClose} disabled={isCreating} size="md">
Cancel
</ButtonSolid>
<ButtonSolid
variant="primary"
onClick={handleCreateBranch}
disabled={isCreating || isDraftVersionActive || !branchName.trim()}
isLoading={isCreating}
size="md"
{/* Create from dropdown — shows "Latest (main)" + app-specific git tags */}
<div className="form-group">
<label htmlFor="create-from-select" className="form-label">
Create from
</label>
<div className="custom-dropdown" ref={dropdownRef}>
<button
type="button"
className={cx('custom-dropdown-trigger', { 'is-open': isDropdownOpen })}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
disabled={isCreating || isLoadingTags}
>
{'Create branch'}
</ButtonSolid>
<div className="custom-dropdown-value">
<span className="version-name">{isLoadingTags ? 'Loading...' : selectedOption.label}</span>
{!selectedOption.commitSha && !isLoadingTags && (
<span className={cx('status-badge', 'status-badge-released')}>Default</span>
)}
</div>
<SolidIcon name="cheverondown" width="16" />
</button>
{isDropdownOpen && (
<div className="custom-dropdown-menu">
{dropdownOptions.map((option, idx) => {
const isSelected = option.label === selectedOption.label;
return (
<div
key={idx}
className={cx('dropdown-item', { 'is-selected': isSelected })}
onClick={() => {
setSelectedOption(option);
setIsDropdownOpen(false);
}}
>
{isSelected && (
<div className="check-icon">
<SolidIcon name="tick" width="16" />
</div>
)}
{!isSelected && <div className="check-icon-placeholder" />}
<div className="item-content">
<div className="item-header">
<span className="item-name">{option.label}</span>
{!option.commitSha && (
<span className={cx('status-badge', 'status-badge-released')}>Default</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</AlertDialog>
{/* Draft Version Warning Modal */}
{showDraftWarning && <DraftVersionWarningModal onClose={() => setShowDraftWarning(false)} />}
</>
{/* Branch name input */}
<div className="form-group">
<label htmlFor="branch-name-input" className="form-label">
Branch name
</label>
<input
id="branch-name-input"
type="text"
className={`branch-modal-form-input ${validationError ? 'form-input-error' : ''}`}
placeholder="Enter branch name"
value={branchName}
onChange={handleBranchNameChange}
onKeyDown={handleKeyDown}
disabled={isCreating}
autoFocus
/>
{validationError && <div className="form-error-message">{validationError}</div>}
<div className="form-helper-text">Branch name must be unique and max 50 characters</div>
</div>
{/* Auto-commit checkbox */}
<div className="form-group">
<label className="checkbox-label">
<input type="checkbox" className="form-checkbox" checked={true} disabled={true} />
<span className="checkbox-text">
Commit changes
<span className="checkbox-helper">Branch will always be created in git to ensure sync with ToolJet</span>
</span>
</label>
</div>
{/* Info message */}
<Alert placeSvgTop={true} svg="warning-icon" cls="create-branch-info">
Branch can only be created from master
</Alert>
{/* Footer buttons */}
<div className="col d-flex justify-content-end gap-2 mt-3">
<ButtonSolid variant="tertiary" onClick={onClose} disabled={isCreating} size="md">
Cancel
</ButtonSolid>
<ButtonSolid
variant="primary"
onClick={handleCreateBranch}
disabled={isCreating || isDraftVersionActive || !branchName.trim() || isLoadingTags}
isLoading={isCreating}
size="md"
>
Create branch
</ButtonSolid>
</div>
</div>
</AlertDialog>
);
}

View file

@ -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');
});
}

View file

@ -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

View file

@ -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 (
<div className={cx('header', { 'dark-theme theme-dark': darkMode })} style={{ width: '100%' }}>
<header className="navbar navbar-expand-md d-print-none tw-h-12" style={{ zIndex: 12 }}>
@ -83,8 +88,8 @@ export const EditorHeader = ({ darkMode }) => {
<>
<PreviewAndShareIcons />
{<BranchDropdown appId={appId} organizationId={organizationId} />}
{/* 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 && (
<VersionManagerErrorBoundary>
<VersionManagerDropdown darkMode={darkMode} />
</VersionManagerErrorBoundary>

View file

@ -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 = () => {

View file

@ -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 (
<div className="locked-branch-banner">
<div className="locked-branch-banner-content">
<svg
{/* <svg
className="locked-branch-banner-icon"
width="16"
height="16"
@ -34,16 +36,19 @@ const LockedBranchBanner = ({ isVisible = false, branchName = '', reason = 'merg
>
<path
d="M12.6667 7.33333H12V5.33333C12 3.49238 10.5076 2 8.66667 2C6.82572 2 5.33333 3.49238 5.33333 5.33333V7.33333H4.66667C3.93029 7.33333 3.33333 7.93029 3.33333 8.66667V12.6667C3.33333 13.403 3.93029 14 4.66667 14H12.6667C13.403 14 14 13.403 14 12.6667V8.66667C14 7.93029 13.403 7.33333 12.6667 7.33333ZM6.66667 5.33333C6.66667 4.22876 7.56209 3.33333 8.66667 3.33333C9.77124 3.33333 10.6667 4.22876 10.6667 5.33333V7.33333H6.66667V5.33333Z"
fill="currentColor"
/>
</svg>
// fill="currentColor"
stroke='currentColor'
strokeWidth="1.2"
/>
</svg> */}
<SolidIcon name="lock" width="16" />
<div className="locked-branch-banner-text">
<span className="locked-branch-banner-message">{reasonText}</span>
{branchName && (
{/* {branchName && (
<span className="locked-branch-banner-branch">
Branch: <strong>{branchName}</strong>
</span>
)}
)} */}
</div>
</div>
</div>

View file

@ -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 (
<div className="d-flex justify-content-end navbar-right-section">
@ -25,7 +28,7 @@ const RightTopHeaderButtons = ({ isModuleEditor }) => {
<GitSyncManager />
<div className="tw-hidden navbar-seperator" />
{/* <PreviewAndShareIcons /> */}
{isNotPromotedOrReleased && <LifecycleCTAButton />}
{(isNotPromotedOrReleased || isWorkspaceGitApp) && <LifecycleCTAButton />}
{/* need to review if we need this or not */}
{/* {!isModuleEditor && <PromoteReleaseButton />} */}
</div>

View file

@ -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 */}
<div className="branch-list-section">
{isLoading ? (
{isLoading || switchingBranchName ? (
<div className="loading-state">
<div className="spinner"></div>
<span>Loading branches...</span>
<span>{switchingBranchName ? `Switching to ${switchingBranchName}...` : 'Loading branches...'}</span>
</div>
) : filteredBranches.length === 0 ? (
<div className="empty-state">

View file

@ -108,17 +108,19 @@ const VersionDropdownItem = ({
<Popover.Body className={cx('d-flex flex-column p-0', { 'dark-theme theme-dark': darkMode })}>
<div
className={cx('dropdown-item tj-text-xsm', {
'cursor-pointer': isDraft,
disabled: !isDraft,
// 'cursor-pointer': isDraft,
'cursor-pointer': true,
// disabled: !isDraft,
'dark-theme theme-dark': darkMode,
})}
onClick={(e) => {
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

View file

@ -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();
}
);

View file

@ -21,7 +21,6 @@ export const ModalHeader = React.memo(
isFullScreen,
}) => {
const canvasHeaderHeight = getCanvasHeight(headerHeight);
// console.log(headerMaxHeight, 'headerMaxHeight');
return (
<BootstrapModal.Header style={{ ...customStyles.modalHeader }} data-cy={`modal-header`} onClick={onClick}>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>

View file

@ -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);

View file

@ -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) => {

View file

@ -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
*/

View file

@ -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');

View file

@ -257,7 +257,7 @@ export default function AppCard({
AppName
);
}
const isStub = app?.app_versions?.[0]?.is_stub;
return (
<ToolTip
message="Modules are not available on your current plan."
@ -318,7 +318,7 @@ export default function AppCard({
)}
</div>
<div className="appcard-buttons-wrap">
{(canUpdate || appType === 'module') && (
{(canUpdate || appType === 'module' || isStub) && (
<div>
<ToolTip message={`Open in ${appType !== 'workflow' ? 'app builder' : 'workflow editor'}`}>
<Link
@ -334,7 +334,6 @@ export default function AppCard({
folder_id: currentFolder?.id,
});
}}
reloadDocument
>
<button
type="button"
@ -350,7 +349,7 @@ export default function AppCard({
</div>
)}
{!canUpdate && canView && appType !== 'module' && hasNonReleasedPreviewAccess && ViewButton}
{appType !== 'module' && LaunchButton}
{!isStub && appType !== 'module' && LaunchButton}
</div>
</div>
</div>

View file

@ -80,8 +80,6 @@ export const AppMenu = function AppMenu({
);
};
console.log('AppMenu render', { appId, canCreateApp, canDeleteApp, canUpdateApp });
return (
<OverlayTrigger
trigger="click"

View file

@ -57,6 +57,9 @@ import posthogHelper from '@/modules/common/helpers/posthogHelper';
const { iconList, defaultIcon } = configs;
import { PermissionDeniedModal } from './PermissionDeniedModal/PermissionDeniedModal';
import { updateCurrentSession } from '@/_helpers/authorizeWorkspace';
import { WorkspaceLockedBanner } from '@/_ui/WorkspaceLockedBanner';
import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore';
import { WorkspaceSwitchBranchModal } from '@/_ui/WorkspaceBranchDropdown/SwitchBranchModal';
const MAX_APPS_PER_PAGE = 9;
class HomePageComponent extends React.Component {
@ -109,11 +112,15 @@ class HomePageComponent extends React.Component {
importingApp: false,
importingGitAppOperations: {},
latestCommitData: null,
selectedImportBranch: null,
remoteBranches: [],
fetchingRemoteBranches: false,
tags: [],
fetchingLatestCommitData: false,
selectedVersionOption: null,
featuresLoaded: false,
showCreateAppModal: false,
showSwitchBranchForCreate: false,
showCreateAppFromTemplateModal: false,
showImportAppModal: false,
showCloneAppModal: false,
@ -191,6 +198,11 @@ class HomePageComponent extends React.Component {
};
componentDidMount() {
const gitSyncToast = sessionStorage.getItem('git_sync_toast');
if (gitSyncToast) {
sessionStorage.removeItem('git_sync_toast');
setTimeout(() => 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 (
<Layout switchDarkMode={this.props.switchDarkMode} darkMode={this.props.darkMode}>
<div className="wrapper home-page">
{/* <WorkspaceLockedBanner /> */}
{/* this needs more revamp and conditions---> currently added this for testing*/}
{showInsufficentPermissionModal && (
<PermissionDeniedModal
@ -1512,6 +1594,14 @@ class HomePageComponent extends React.Component {
darkMode={this.props.darkMode}
/>
)}
<WorkspaceSwitchBranchModal
show={this.state.showSwitchBranchForCreate}
onClose={() => this.setState({ showSwitchBranchForCreate: false })}
onBranchSwitch={() => {
this.fetchApps(1, this.state.currentFolder.id);
this.setState({ showSwitchBranchForCreate: false, showCreateAppModal: true });
}}
/>
<AppActionModal
modalStates={{
showCreateAppModal,
@ -1659,7 +1749,7 @@ class HomePageComponent extends React.Component {
darkMode={this.props.darkMode}
/>
<ModalBase
title={selectedAppRepo ? 'Import app' : 'Import app from git repository'}
title={'Import app from git repository'}
show={showGitRepositoryImportModal}
handleClose={this.toggleGitRepositoryImportModal}
handleConfirm={this.importGitApp}
@ -1671,129 +1761,165 @@ class HomePageComponent extends React.Component {
}}
darkMode={this.props.darkMode}
>
{fetchingAppsFromRepos ? (
{fetchingRemoteBranches ? (
<div className="loader-container">
<div className="primary-spin-loader"></div>
</div>
) : (
<>
{/* BRANCH SELECT */}
<div className="form-group">
<label className="mb-1 tj-text-sm tj-text font-weight-500" data-cy="create-app-from-label">
Create app from
<label className="mb-1 tj-text-sm tj-text font-weight-500" data-cy="select-branch-label">
Select branch
</label>
<div className="tj-app-input" data-cy="app-select">
<div className="tj-app-input" data-cy="branch-select">
<Select
options={this.generateOptionsForRepository()}
options={(remoteBranches || []).map((b) => ({
name: b.name || b,
value: b.name || b,
}))}
disabled={importingApp}
onChange={this.handleAppRepoChange}
onChange={this.handleImportBranchChange}
width={'100%'}
value={selectedAppRepo}
placeholder={'Select app from git repository...'}
value={selectedImportBranch}
placeholder={'Select branch...'}
closeMenuOnSelect={true}
customWrap={true}
/>
</div>
</div>
{selectedAppRepo && (
<div className="commit-info">
{/* APP NAME */}
<div className="form-group">
<label className="mb-1 info-label tj-text-xsm font-weight-500" data-cy="app-name-label">
App name
</label>
<div className="tj-app-input">
<input
type="text"
value={this.state.importedAppName}
className={cx('form-control font-weight-400', {
'tj-input-error-state': importingGitAppOperations?.message,
})}
onChange={this.handleAppNameChange}
/>
</div>
<div>
<div
className={cx(
{ 'tj-input-error': importingGitAppOperations?.message },
'tj-text-xxsm info-text'
)}
data-cy="app-name-helper-text"
>
{importingGitAppOperations?.message}
</div>
</div>
</div>
{/* EDITABLE CHECKBOX */}
<div className="application-editable-checkbox-container">
<input
className="form-check-input"
checked={this.state.isAppImportEditable}
type="checkbox"
onChange={() =>
this.setState((prevState) => ({ isAppImportEditable: !prevState.isAppImportEditable }))
}
/>
Make application editable
<div className="helper-text">
<div className="tj-text tj-text-xsm"></div>
<div className="tj-text-xxsm">
Enabling this allows editing and git sync push/pull access in development.
</div>
</div>
</div>
{/* VERSION/TAG SELECT */}
{/* APP SELECT - shown after branch is selected */}
{selectedImportBranch && (
<>
<div className="form-group">
<label className="mb-1 info-label tj-text-xsm font-weight-500" data-cy="version-select-label">
Select version to pull from
<label className="mb-1 tj-text-sm tj-text font-weight-500" data-cy="create-app-from-label">
Create app from
</label>
<div className="tj-app-input" data-cy="version-select">
<Select
options={this.generateVersionOptions()}
disabled={importingApp || fetchingLatestCommitData}
onChange={this.handleVersionOptionChange}
width={'100%'}
value={this.state.selectedVersionOption}
placeholder={fetchingLatestCommitData ? 'Loading versions...' : 'Select version or tag...'}
closeMenuOnSelect={true}
customWrap={true}
customOption={this.renderVersionOption}
/>
</div>
</div>
{/* LAST COMMIT */}
<div className="form-group">
<label className="mb-1 tj-text-xsm font-weight-500" data-cy="last-commit-label">
Last commit
</label>
<div className="last-commit-info form-control">
{fetchingLatestCommitData ? (
// need to add UI for loading state here -> Pending
<div className="message-info">Loading...</div>
<div className="tj-app-input" data-cy="app-select">
{fetchingAppsFromRepos ? (
<div style={{ padding: '8px 0' }}>
<div className="primary-spin-loader" style={{ width: '20px', height: '20px' }}></div>
</div>
) : (
<>
<div className="message-info">
<div data-cy="last-commit-message">
{this.getSelectedVersionCommitInfo().message || 'No commits yet'}
</div>
<div data-cy="last-commit-version">
{this.getSelectedVersionCommitInfo().gitVersionName}
</div>
</div>
{this.getSelectedVersionCommitInfo().author && this.getSelectedVersionCommitInfo().date && (
<div className="author-info" data-cy="auther-info">
{`Done by ${this.getSelectedVersionCommitInfo().author} at ${moment(
new Date(this.getSelectedVersionCommitInfo().date)
).format('DD MMM YYYY, h:mm a')}`}
</div>
)}
</>
<Select
options={this.generateOptionsForRepository()}
disabled={importingApp}
onChange={this.handleAppRepoChange}
width={'100%'}
value={selectedAppRepo}
placeholder={'Select app...'}
closeMenuOnSelect={true}
customWrap={true}
/>
)}
</div>
</div>
</div>
{selectedAppRepo && (
<div className="commit-info">
{/* APP NAME */}
<div className="form-group">
<label className="mb-1 info-label tj-text-xsm font-weight-500" data-cy="app-name-label">
App name
</label>
<div className="tj-app-input">
<input
type="text"
value={this.state.importedAppName}
className={cx('form-control font-weight-400', {
'tj-input-error-state': importingGitAppOperations?.message,
})}
onChange={this.handleAppNameChange}
/>
</div>
<div>
<div
className={cx(
{ 'tj-input-error': importingGitAppOperations?.message },
'tj-text-xxsm info-text'
)}
data-cy="app-name-helper-text"
>
{importingGitAppOperations?.message}
</div>
</div>
</div>
{/* EDITABLE CHECKBOX */}
<div className="application-editable-checkbox-container">
<input
className="form-check-input"
checked={this.state.isAppImportEditable}
type="checkbox"
onChange={() =>
this.setState((prevState) => ({ isAppImportEditable: !prevState.isAppImportEditable }))
}
/>
Make application editable
<div className="helper-text">
<div className="tj-text tj-text-xsm"></div>
<div className="tj-text-xxsm">
Enabling this allows editing and git sync push/pull access in development.
</div>
</div>
</div>
{/* VERSION/TAG SELECT */}
<div className="form-group">
<label className="mb-1 info-label tj-text-xsm font-weight-500" data-cy="version-select-label">
Select version to pull from
</label>
<div className="tj-app-input" data-cy="version-select">
<Select
options={this.generateVersionOptions()}
disabled={importingApp || fetchingLatestCommitData}
onChange={this.handleVersionOptionChange}
width={'100%'}
value={this.state.selectedVersionOption}
placeholder={
fetchingLatestCommitData ? 'Loading versions...' : 'Select version or tag...'
}
closeMenuOnSelect={true}
customWrap={true}
customOption={this.renderVersionOption}
/>
</div>
</div>
{/* LAST COMMIT */}
<div className="form-group">
<label className="mb-1 tj-text-xsm font-weight-500" data-cy="last-commit-label">
Last commit
</label>
<div className="last-commit-info form-control">
{fetchingLatestCommitData ? (
<div className="message-info">Loading...</div>
) : (
<>
<div className="message-info">
<div data-cy="last-commit-message">
{this.getSelectedVersionCommitInfo().message || 'No commits yet'}
</div>
<div data-cy="last-commit-version">
{this.getSelectedVersionCommitInfo().gitVersionName}
</div>
</div>
{this.getSelectedVersionCommitInfo().author &&
this.getSelectedVersionCommitInfo().date && (
<div className="author-info" data-cy="auther-info">
{`Done by ${this.getSelectedVersionCommitInfo().author} at ${moment(
new Date(this.getSelectedVersionCommitInfo().date)
).format('DD MMM YYYY, h:mm a')}`}
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</>
)}
</>
)}
@ -1801,14 +1927,15 @@ class HomePageComponent extends React.Component {
<Modal
show={showAddToFolderModal && !!appOperations.selectedApp}
closeModal={() => this.setState({ showAddToFolderModal: false, appOperations: {} })}
title={this.props.t('homePage.appCard.addToFolder', 'Add to folder')}
title={this.props.t('homePage.appCard.updateFolder', 'Update folder')}
>
<div className="row">
<div className="col modal-main">
<div className="mb-3 move-selected-app-to-text " data-cy="move-selected-app-to-text">
<p>
{this.props.t('homePage.appCard.move', 'Move')}
<span>{` "${appOperations?.selectedApp?.name}" `}</span>
{this.props.t('homePage.appCard.update', 'Update')}{' '}
<span>{`${appOperations?.selectedApp?.name}'s`}</span>{' '}
{this.props.t('homePage.appCard.folder', 'folder')}
</p>
<span>{this.props.t('homePage.appCard.to', 'to')}</span>
@ -1924,11 +2051,17 @@ class HomePageComponent extends React.Component {
<Button
disabled={getDisabledState()}
className={`create-new-app-button col-11 ${creatingApp ? 'btn-loading' : ''}`}
onClick={() =>
this.setState({
showCreateAppModal: true,
})
}
onClick={() => {
if (
this.isWorkspaceBranchLocked() &&
this.props.appType !== 'module' &&
this.props.appType !== 'workflow'
) {
this.setState({ showSwitchBranchForCreate: true });
} else {
this.setState({ showCreateAppModal: true });
}
}}
data-cy={`create-new-${
this.props.appType === 'workflow'
? 'workflows'
@ -1986,9 +2119,9 @@ class HomePageComponent extends React.Component {
currentFolder={currentFolder}
folderChanged={this.folderChanged}
foldersChanged={this.foldersChanged}
canCreateFolder={this.canCreateFolder()}
canDeleteFolder={this.canDeleteFolder()}
canUpdateFolder={this.canUpdateFolder()}
canCreateFolder={this.canCreateFolder() && !this.isWorkspaceBranchLocked()}
canDeleteFolder={this.canDeleteFolder() && !this.isWorkspaceBranchLocked()}
canUpdateFolder={this.canUpdateFolder() && !this.isWorkspaceBranchLocked()}
darkMode={this.props.darkMode}
canCreateApp={this.canCreateApp()}
appType={this.props.appType}
@ -2030,6 +2163,10 @@ class HomePageComponent extends React.Component {
</div>
<div className={cx('col home-page-content')} data-cy="home-page-content">
{/* <WorkspaceLockedBanner pageContext={this.props.appType === 'workflow' ? 'workflows' : this.props.appType === 'module' ? 'modules' : 'apps'} /> */}
{this.props.appType !== 'workflow' && this.props.appType !== 'module' && (
<WorkspaceLockedBanner pageContext="apps" />
)}
<div className="w-100 mb-5 container home-page-content-container">
{featuresLoaded && !isLoading ? (
<>
@ -2044,6 +2181,7 @@ class HomePageComponent extends React.Component {
!appSearchKey && <HeaderSkeleton />
)}
{/* <WorkspaceLockedBanner pageContext={this.props.appType === 'workflow' ? 'workflows' : this.props.appType === 'module' ? 'modules' : 'apps'} /> */}
{this.props.appType !== 'workflow' && this.props.appType !== 'module' && this.canCreateApp() && (
<CreateAppWithPrompt createApp={this.createApp} />
)}
@ -2094,8 +2232,24 @@ class HomePageComponent extends React.Component {
readAndImport={this.readAndImport}
isImportingApp={isImportingApp}
fileInput={this.fileInput}
openCreateAppModal={this.openCreateAppModal}
openCreateAppFromTemplateModal={this.openCreateAppFromTemplateModal}
openCreateAppModal={() => {
if (
this.isWorkspaceBranchLocked() &&
this.props.appType !== 'module' &&
this.props.appType !== 'workflow'
) {
this.setState({ showSwitchBranchForCreate: true });
} else {
this.openCreateAppModal();
}
}}
openCreateAppFromTemplateModal={(template) => {
if (this.isWorkspaceBranchLocked()) {
toast.error('Master is locked. Create a branch to create an app from template.');
return;
}
this.openCreateAppFromTemplateModal(template);
}}
creatingApp={creatingApp}
darkMode={this.props.darkMode}
showTemplateLibraryModal={this.state.showTemplateLibraryModal}

View file

@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ButtonSolid } from '@/_ui/AppButton/AppButton';
import posthogHelper from '@/modules/common/helpers/posthogHelper';
import { useWorkspaceBranchesStore } from '@/_stores/workspaceBranchesStore';
const identifyUniqueCategories = (templates) =>
['all', ...new Set(_.map(templates, 'category'))].map((categoryId) => ({
id: categoryId,
@ -62,6 +63,15 @@ export default function TemplateLibraryModal(props) {
const [deploying, setDeploying] = useState(false);
const { currentBranch, orgGitConfig } = useWorkspaceBranchesStore((state) => ({
currentBranch: state.currentBranch,
orgGitConfig: state.orgGitConfig,
}));
const isOnDefaultBranch =
(orgGitConfig?.is_branching_enabled || orgGitConfig?.isBranchingEnabled) &&
(currentBranch?.is_default || currentBranch?.isDefault);
return (
<Modal
show={props.show}
@ -107,6 +117,12 @@ export default function TemplateLibraryModal(props) {
</ButtonSolid>
<ButtonSolid
onClick={() => {
if (isOnDefaultBranch) {
toast.error('Master is locked. Create a branch to create an app from template.', {
position: 'top-center',
});
return;
}
props.openCreateAppFromTemplateModal(selectedApp);
setShowCreateAppFromTemplateModal(false);
props.onCloseButtonClick();

View file

@ -55,6 +55,7 @@ const DynamicForm = ({
layout = 'vertical',
renderCopilot,
elementsProps = null,
isWorkspaceBranchLocked = false,
}) => {
const [computedProps, setComputedProps] = React.useState({});
const isHorizontalLayout = layout === 'horizontal';
@ -77,7 +78,7 @@ const DynamicForm = ({
}, []);
React.useEffect(() => {
if (isGDS) {
if (isGDS && currentAppEnvironmentId) {
orgEnvironmentConstantService.getConstantsFromEnvironment(currentAppEnvironmentId).then((data) => {
const constants = {
globals: {},
@ -321,6 +322,11 @@ const DynamicForm = ({
const buttonText = buttonTextProp || button_text;
const editorType = editorTypeProp || editor_type;
const helpText = helpTextProp || help_text;
const isEncrypted = type === 'password' || encrypted === true;
const showEncryptedLockedHelpText = isWorkspaceBranchLocked && isEncrypted;
const finalHelpText = showEncryptedLockedHelpText
? 'Encrypted values are not pushed to git and are updated directly in Tooljet'
: helpText;
switch (type) {
case 'password':
@ -335,7 +341,8 @@ const DynamicForm = ({
style: { marginBottom: '0px !important' },
value: options?.[key]?.value || '',
...(type === 'textarea' && { rows: rows }),
...(helpText && { helpText }),
// ...(helpText && { finalHelpText }),
...(finalHelpText && { helpText: finalHelpText }),
onChange: (e) => optionchanged(key, e.target.value, true), //shouldNotAutoSave is true because autosave should occur during onBlur, not after each character change (in optionchanged).
onblur: () => onBlur(),
isGDS,
@ -343,6 +350,8 @@ const DynamicForm = ({
workspaceConstants: currentOrgEnvironmentConstants,
encrypted: useEncrypted,
isWorkspaceConstant: isWorkspaceConstant,
disabled: isWorkspaceBranchLocked && !useEncrypted,
isDisabled: isWorkspaceBranchLocked && !useEncrypted,
};
}
case 'toggle':
@ -363,7 +372,8 @@ const DynamicForm = ({
useMenuPortal: disableMenuPortal ? false : queryName ? true : false,
styles: computeSelectStyles ? computeSelectStyles('100%') : {},
useCustomStyles: computeSelectStyles ? true : false,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isWorkspaceBranchLocked || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
encrypted: options?.[key]?.encrypted,
};
case 'checkbox-group':
@ -390,7 +400,8 @@ const DynamicForm = ({
optionchanged,
isRenderedAsQueryEditor,
workspaceConstants: currentOrgEnvironmentConstants,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isWorkspaceBranchLocked || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
encrypted: options?.[key]?.encrypted,
buttonText,
width: width,
@ -420,7 +431,8 @@ const DynamicForm = ({
optionchanged,
isRenderedAsQueryEditor,
workspaceConstants: currentOrgEnvironmentConstants,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isWorkspaceBranchLocked || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
encrypted: options?.[key]?.encrypted,
buttonText,
width: width,
@ -452,7 +464,8 @@ const DynamicForm = ({
multiple_auth_enabled: options?.multiple_auth_enabled?.value,
optionchanged,
workspaceConstants: currentOrgEnvironmentConstants,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isWorkspaceBranchLocked || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
options,
optionsChanged,
selectedDataSource,
@ -471,7 +484,8 @@ const DynamicForm = ({
selectedDataSource,
currentAppEnvironmentId,
workspaceConstants: currentOrgEnvironmentConstants,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isWorkspaceBranchLocked || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
optionsChanged,
multiple_auth_enabled: options?.multiple_auth_enabled?.value,
scopes: options?.scopes?.value,
@ -554,7 +568,8 @@ const DynamicForm = ({
spec: options.spec?.value,
audience: options?.audience?.value,
workspaceConstants: currentOrgEnvironmentConstants,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isWorkspaceBranchLocked || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
optionsChanged,
};
case 'filters':

View file

@ -42,6 +42,7 @@ const DynamicFormV2 = ({
showValidationErrors,
clearValidationErrorBanner,
elementsProps = null,
isWorkspaceBranchLocked = false,
}) => {
const uiProperties = schema['tj:ui:properties'] || {};
const dsm = React.useMemo(() => new DataSourceSchemaManager(schema), [schema]);
@ -109,7 +110,7 @@ const DynamicFormV2 = ({
.map((item) => item.key);
};
React.useEffect(() => {
if (isGDS) {
if (isGDS && currentAppEnvironmentId) {
orgEnvironmentConstantService.getConstantsFromEnvironment(currentAppEnvironmentId).then((data) => {
const constants = {
globals: {},
@ -408,12 +409,23 @@ const DynamicFormV2 = ({
const { label, description, widget, required, width, key, help_text: helpText, list, buttonText } = uiProperties;
const isRequired = required || conditionallyRequiredProperties.includes(key);
const isEncrypted = widget === 'password-v3' || encryptedProperties.includes(key);
const isEncrypted =
widget === 'password-v3' ||
widget === 'password-v3-textarea' ||
widget === 'password' ||
encryptedProperties.includes(key);
const currentValue = options?.[key]?.value;
const skipValidation =
(!hasUserInteracted && !showValidationErrors) || (!interactedFields.has(key) && !showValidationErrors);
// On locked master branch, disable non-encrypted fields (encrypted fields remain editable)
const isFieldDisabledByBranchLock = isWorkspaceBranchLocked && !isEncrypted;
const workspaceConstant = options?.[key]?.workspace_constant;
const isEditing = computedProps[key] && computedProps[key].disabled === false;
const showEncryptedLockedHelpText = isWorkspaceBranchLocked && isEncrypted;
const finalHelpText = showEncryptedLockedHelpText
? 'Encrypted values are not pushed to git and are updated directly in Tooljet'
: helpText;
const handleOptionChange = (key, value, flag = true) => {
if (!hasUserInteracted) {
@ -447,7 +459,7 @@ const DynamicFormV2 = ({
'dynamic-form-encrypted-field': isEncrypted,
}),
style: { marginBottom: '0px !important' },
helpText: helpText,
helpText: finalHelpText,
value: currentValue || '',
onChange: (e) => handleOptionChange(key, e.target.value, true),
isGDS: true,
@ -455,6 +467,8 @@ const DynamicFormV2 = ({
onBlur,
workspaceVariables,
workspaceConstants: currentOrgEnvironmentConstants,
disabled: isFieldDisabledByBranchLock,
isDisabled: isFieldDisabledByBranchLock,
};
}
case 'password-v3':
@ -491,7 +505,7 @@ const DynamicFormV2 = ({
'dynamic-form-encrypted-field': isEncrypted,
}),
style: { marginBottom: '0px !important' },
helpText: helpText,
helpText: finalHelpText,
value: currentValue || '',
onChange: (e) => handleOptionChange(key, e.target.value, true),
isGDS: true,
@ -499,7 +513,10 @@ const DynamicFormV2 = ({
onBlur,
isRequired: isRequired,
isValidatedMessages: validationStatus,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
disabled:
isFieldDisabledByBranchLock || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
isDisabled:
isFieldDisabledByBranchLock || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
workspaceVariables,
workspaceConstants: currentOrgEnvironmentConstants,
isEditing: isEditing,
@ -521,7 +538,8 @@ const DynamicFormV2 = ({
handleOptionChange,
isRenderedAsQueryEditor,
workspaceConstants: currentOrgEnvironmentConstants,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
isDisabled:
isFieldDisabledByBranchLock || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
encrypted: isEncrypted,
buttonText,
width: width,
@ -578,7 +596,8 @@ const DynamicFormV2 = ({
checked: currentValue,
label: label,
helpText: helpText,
disabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
disabled:
isFieldDisabledByBranchLock || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
onChange: (e) => handleOptionChange(key, e.target.checked, true),
};
case 'dropdown':
@ -589,6 +608,7 @@ const DynamicFormV2 = ({
onChange: (value) => handleOptionChange(key, value, true),
width: width || '100%',
encrypted: options?.[key]?.encrypted,
isDisabled: isFieldDisabledByBranchLock,
};
case 'checkbox':
return {
@ -599,7 +619,10 @@ const DynamicFormV2 = ({
onChange: (e) => handleOptionChange(key, e.target.checked, true),
helpText: helpText,
isRequired: isRequired,
isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
disabled:
isFieldDisabledByBranchLock || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
isDisabled:
isFieldDisabledByBranchLock || (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()),
};
case 'checkbox-group':
return {
@ -815,7 +838,10 @@ const DynamicFormV2 = ({
className={cx({ 'flex-grow-1': isHorizontalLayout })}
>
{flipComponentDropdown.widget === 'toggle-flip' ? (
<ToggleV2 {...getElementProps(flipComponentDropdown)} dataCy={generateCypressDataCy(flipComponentDropdown.label)} />
<ToggleV2
{...getElementProps(flipComponentDropdown)}
dataCy={generateCypressDataCy(flipComponentDropdown.label)}
/>
) : (
<Select
{...getElementProps(flipComponentDropdown)}

View file

@ -0,0 +1,60 @@
import { authenticationService } from '@/_services';
const BRANCH_KEY_PREFIX = 'tj_active_branch_';
function getOrgId() {
return authenticationService.currentSessionValue?.current_organization_id || '';
}
export function getActiveBranch(orgId) {
const id = orgId || getOrgId();
if (!id) return null;
try {
const stored = localStorage.getItem(`${BRANCH_KEY_PREFIX}${id}`);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
export function setActiveBranch(branch, orgId) {
const id = orgId || getOrgId();
if (!id) return;
try {
if (branch) {
localStorage.setItem(`${BRANCH_KEY_PREFIX}${id}`, JSON.stringify({ id: branch.id, name: branch.name }));
} else {
localStorage.removeItem(`${BRANCH_KEY_PREFIX}${id}`);
}
} catch {
// ignore localStorage errors
}
}
export function getActiveBranchId(orgId) {
const branch = getActiveBranch(orgId);
return branch?.id || null;
}
/**
* Remove all tj_active_branch_* keys except the one for the current org.
* Call once on app load to prevent stale keys from accumulating
* across migration dumps or org switches.
*/
export function cleanupStaleBranchKeys(orgId) {
const id = orgId || getOrgId();
if (!id) return;
try {
const currentKey = `${BRANCH_KEY_PREFIX}${id}`;
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(BRANCH_KEY_PREFIX) && key !== currentKey) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch {
// ignore localStorage errors
}
}

View file

@ -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

View file

@ -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.',

View file

@ -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 });

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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',

View file

@ -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(),

View file

@ -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);
}

View file

@ -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) {

View file

@ -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
);
}

View file

@ -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';

View file

@ -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) };

View file

@ -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);
}

View file

@ -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' }
)
);

View file

@ -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 {

View file

@ -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 (
<header className="layout-header">
<div className="row w-100 gx-0">
@ -157,6 +166,15 @@ function Header({
'color-disabled': !darkMode,
})}
>
{featureAccess?.gitSync &&
isBranchStoreInitialized &&
pathname !== 'Workspace constants' &&
isGitSupportedPage && (
<>
<WorkspaceBranchDropdown />
<WorkspaceGitCTA />
</>
)}
{Object.keys(featureAccess).length > 0 && (
<LicenseBanner limits={featureAccess} showNavBarActions={true} />
)}

View file

@ -95,7 +95,13 @@ export default ({
style={{ gap: '0px', fontSize: '12px', fontWeight: '500', padding: '0px 9px' }}
disabled={isDisabled}
>
<AddRectangle width="15" fill="#3E63DD" opacity="1" secondaryFill="#ffffff" />
{/* <AddRectangle width="15" fill="#3E63DD" opacity="1" secondaryFill="#ffffff" /> */}
<AddRectangle
width="15"
fill={isDisabled ? '#C1C8CD' : '#3E63DD'}
opacity={isDisabled ? '0.3' : '1'}
secondaryFill={isDisabled ? '#ffffff' : '#ffffff'}
/>
&nbsp;&nbsp;Add
</ButtonSolid>
</div>

View file

@ -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);

View file

@ -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 (
<AlertDialog
show={true}
closeModal={onClose}
title="Create branch"
checkForBackground={true}
customClassName="create-branch-modal"
>
<div className="create-branch-modal-body">
{/* Create from dropdown */}
{/* <div className="form-group">
<label htmlFor="create-from-select" className="form-label">
Create from branch
</label>
<div className="custom-dropdown" ref={dropdownRef}>
<button
type="button"
className={cx('custom-dropdown-trigger', { 'is-open': isDropdownOpen })}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
disabled={isCreating}
>
<div className="custom-dropdown-value">
{selectedSourceBranch ? (
<>
<span className="version-name">{selectedSourceBranch.name}</span>
{(selectedSourceBranch.is_default || selectedSourceBranch.isDefault) && (
<span className={cx('status-badge', 'status-badge-released')}>Default</span>
)}
</>
) : (
<span className="version-name">Select branch...</span>
)}
</div>
<SolidIcon name="cheverondown" width="16" />
</button>
{isDropdownOpen && (
<div className="custom-dropdown-menu">
{branches.map((branch) => {
const isSelected = branch.id === selectedSourceBranchId;
return (
<div
key={branch.id}
className={cx('dropdown-item', { 'is-selected': isSelected })}
onClick={() => {
setSourceBranchId(branch.id);
setIsDropdownOpen(false);
}}
>
{isSelected && (
<div className="check-icon">
<SolidIcon name="tick" width="16" />
</div>
)}
{!isSelected && <div className="check-icon-placeholder" />}
<div className="item-content">
<div className="item-header">
<span className="item-name">{branch.name}</span>
{(branch.is_default || branch.isDefault) && (
<span className={cx('status-badge', 'status-badge-released')}>Default</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div> */}
{/* Branch name input */}
<div className="form-group">
<label htmlFor="branch-name-input" className="form-label">
Branch name
</label>
<input
id="branch-name-input"
type="text"
className={`branch-modal-form-input ${validationError ? 'form-input-error' : ''}`}
placeholder="Enter branch name"
value={branchName}
onChange={handleBranchNameChange}
onKeyDown={handleKeyDown}
disabled={isCreating}
autoFocus
/>
{validationError && <div className="form-error-message">{validationError}</div>}
<div className="form-helper-text">
{/* Branch name must be unique and contain only letters, numbers, hyphens, and underscores */}
Branch name must be unique and max 50 characters
</div>
</div>
{/* Auto-commit checkbox */}
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
className="form-checkbox"
checked={autoCommit}
onChange={(e) => setAutoCommit(e.target.checked)}
disabled={true}
/>
<span className="checkbox-text">
Commit changes
<span className="checkbox-helper">Branch will always be created in git to ensure sync with ToolJet</span>
</span>
</label>
</div>
{/* Info message */}
<Alert placeSvgTop={true} svg="warning-icon" cls="create-branch-info">
{/* Branch can only be created from the default branch */}
Branch can only be created from the master
</Alert>
{/* Footer buttons */}
<div className="col d-flex justify-content-end gap-2 mt-3">
<ButtonSolid variant="tertiary" onClick={onClose} disabled={isCreating} size="md">
Cancel
</ButtonSolid>
<ButtonSolid
variant="primary"
onClick={handleCreate}
disabled={isCreating || !branchName.trim()}
isLoading={isCreating}
size="md"
>
Create branch
</ButtonSolid>
</div>
</div>
</AlertDialog>
);
}
// Keep backward compatibility
export { WorkspaceCreateBranchModal as CreateBranchModal };
export default WorkspaceCreateBranchModal;

View file

@ -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 (
<AlertDialog
show={show}
closeModal={onClose}
title="Switch branch"
checkForBackground={true}
customClassName="switch-branch-modal"
>
<div className="switch-branch-modal-content">
{/* Info message - only shown on default branch */}
{isOnDefaultBranch && (
<Alert placeSvgTop={true} svg="warning-icon" cls="create-branch-info">
Default branch is locked. Switch branches to make changes.
</Alert>
)}
{/* Search Section */}
<div className="search-section">
<label className="section-label">ALL OPEN BRANCHES</label>
<div className="search-input-wrapper">
<SolidIcon name="search" width="16" fill="var(--slate11)" />
<input
type="text"
className="search-input"
placeholder="Search.."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
data-cy="workspace-branch-search-input"
/>
</div>
</div>
{/* Branch List */}
<div className="branch-list-section">
{isLoading ? (
<div className="loading-state">
<div className="spinner"></div>
<span>Loading branches...</span>
</div>
) : filteredBranches.length === 0 ? (
<div className="empty-state">
<p>No branches found</p>
</div>
) : (
filteredBranches.map((branch) => {
const isCurrentBranch = branch.id === activeBranchId;
return (
<div
key={branch.id || branch.name}
className={`branch-list-item ${isCurrentBranch ? 'active' : ''}`}
onClick={() => handleBranchClick(branch)}
data-cy={`workspace-branch-list-item-${branch.name}`}
>
<div className="branch-checkbox">
{isCurrentBranch && <SolidIcon name="check2" width="16" fill="var(--indigo9)" />}
</div>
<div className="branch-list-content">
<div className="branch-list-name">
{branch.name}
{(branch.is_default || branch.isDefault) && (
<span style={{ fontSize: 10, opacity: 0.6, marginLeft: 4 }}>(default)</span>
)}
</div>
<div className="branch-list-meta">
Created by {branch.author || branch.created_by || 'default'},{' '}
{getRelativeTime(branch.createdAt || branch.created_at)}
</div>
</div>
</div>
);
})
)}
</div>
{/* Footer Actions */}
<div className="modal-footer-actions">
<button
className="footer-btn secondary"
onClick={handleViewInGitRepo}
data-cy="workspace-view-in-git-repo-btn"
>
<span>View in git repo</span>
<SolidIcon name="newtab" width="14" fill="var(--icon-default)" />
</button>
<button
className="footer-btn accent"
onClick={() => {
setShowCreateModal(true);
}}
data-cy="workspace-create-branch-from-modal-btn"
>
<SolidIcon name="plusicon" width="14" fill="var(--indigo9)" />
<span>Create new branch</span>
</button>
</div>
</div>
{/* Create Branch Modal */}
{showCreateModal && (
<WorkspaceCreateBranchModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
onClose();
}}
/>
)}
</AlertDialog>
);
}
export default WorkspaceSwitchBranchModal;

View file

@ -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);
}
}
}
}

View file

@ -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) => (
<Popover
id="branch-dropdown-popover"
className={cx('branch-dropdown-popover', { 'dark-theme theme-dark': darkMode })}
ref={popoverRef}
{...overlayProps}
style={{
...overlayProps?.style,
minWidth: '320px',
borderRadius: '8px',
border: '1px solid var(--border-weak)',
boxShadow: '0px 0px 1px rgba(48, 50, 51, 0.05), 0px 1px 1px rgba(48, 50, 51, 0.1)',
padding: 0,
}}
>
<Popover.Body style={{ padding: 0 }}>
<div className={`${darkMode ? 'theme-dark' : ''}`} data-cy="workspace-branch-dropdown-popover">
{/* Current Branch Header */}
<div className={`branch-dropdown-current-branch ${!isOnDefaultBranch ? 'with-border' : ''}`}>
{isOnDefaultBranch ? (
<>
<div className="branch-icon-container">
<SolidIcon name="lockclosed" width="16" fill="var(--indigo9)" />
</div>
<div className="branch-info">
<div className="branch-name-title">{displayBranchName}</div>
<div className="branch-metadata">
<span className="metadata-text">Default branch</span>
{(currentBranch?.updatedAt || currentBranch?.updated_at) && (
<>
<span></span>
<span className="metadata-text">
{getRelativeTime(currentBranch.updatedAt || currentBranch.updated_at)}
</span>
</>
)}
</div>
</div>
</>
) : (
<>
<div className="branch-icon-container-feature">
<SolidIcon name="gitbranch" width="16" fill="var(--indigo9)" />
</div>
<div className="branch-info">
<div className="branch-name-title">{displayBranchName}</div>
<div className="branch-metadata-feature">
<span className="metadata-text">
Created by{' '}
{currentBranch?.createdBy || currentBranch?.created_by || currentBranch?.author || 'Unknown'}
</span>
{(currentBranch?.createdAt || currentBranch?.created_at) && (
<>
<span></span>
<span className="metadata-text">
{getRelativeTime(currentBranch?.createdAt || currentBranch?.created_at)}
</span>
</>
)}
</div>
</div>
</>
)}
</div>
{/* Main Content Area */}
{isOnDefaultBranch ? (
<>
{/* Old: static info state for default branch */}
{/* <div className="fetch-prs-section">
<div className="empty-pr-state-box" style={{ margin: '0' }}>
<AlertTriangle width="18" height="18" />
<div className="empty-pr-content">
<div className="empty-pr-title">Platform-level git sync</div>
<div className="empty-pr-description">
Pull changes from the default branch to sync workspace resources
</div>
</div>
</div>
</div> */}
{/* Fetch PRs Button - Shown at top for default branch, hides after fetching */}
{!hasFetchedPRs && (
<div className="fetch-prs-section">
<button
className={`fetch-prs-btn ${isLoadingPRs ? 'loading' : ''}`}
onClick={handleFetchPRs}
disabled={isLoadingPRs}
data-cy="workspace-fetch-prs-btn"
>
{isLoadingPRs ? (
<>
<div className="spinner-small"></div>
<span>Loading...</span>
</>
) : (
<>
<SolidIcon name="refresh" width="14" />
<span>Fetch PRs</span>
</>
)}
</button>
</div>
)}
{/* PR Tabs and List - Only shown after fetching */}
{hasFetchedPRs && (
<>
{/* PR Tabs */}
<div className="pr-tabs">
<button
className={`pr-tab ${activeTab === 'open' ? 'active' : ''}`}
onClick={() => setActiveTab('open')}
>
Open PR
</button>
<button
className={`pr-tab ${activeTab === 'closed' ? 'active' : ''}`}
onClick={() => setActiveTab('closed')}
>
Closed PR
</button>
</div>
{/* PR List */}
<div className="pr-list-container">
{displayPRs.length === 0 ? (
<div className="empty-pr-state-box">
<AlertTriangle width="18" height="18" />
<div className="empty-pr-content">
<div className="empty-pr-title">
{activeTab === 'open' ? 'There are no open PRs' : 'There are no closed PRs'}
</div>
<div className="empty-pr-description">
{activeTab === 'open'
? 'Create a pull request to contribute your changes'
: 'Merge a pull request to contribute your changes'}
</div>
</div>
</div>
) : (
displayPRs.map((pr) => (
<div key={pr.id} className="pr-item" data-cy={`workspace-pr-item-${pr.id}`}>
<div className="pr-icon">
<SolidIcon name="gitmerge" width="20" fill="var(--slate11)" />
</div>
<div className="pr-content">
<OverflowTooltip
className="pr-title"
childrenClassName="pr-title"
placement="top"
whiteSpace="nowrap"
>
{pr.title || 'Untitled PR'}
</OverflowTooltip>
<div className="pr-metadata">
from {pr.source_branch || pr.sourceBranch} | {formatPRDate(pr.created_at || pr.createdAt)}
</div>
</div>
</div>
))
)}
</div>
</>
)}
</>
) : (
<>
{/* Old: static "Push your changes" state for feature branch */}
{/* <div className="no-commits-empty-state">
<AlertTriangle width="18" height="18" />
<div className="empty-state-content">
<div className="empty-state-title">Push your changes</div>
<div className="empty-state-description">
Commit and push workspace changes, then create a pull request
</div>
</div>
</div> */}
{/* Fetch Branch Info Button - Only show when not fetched yet */}
{!hasFetchedBranchInfo && (
<div className="fetch-branch-info-section">
<button
className="fetch-branch-info-btn"
onClick={fetchLastCommit}
disabled={isLoadingCommit}
data-cy="workspace-fetch-branch-info-btn"
>
<SolidIcon name="refresh" width="14" />
<span>{isLoadingCommit ? 'Fetching...' : 'Fetch branch info'}</span>
</button>
</div>
)}
{/* Latest Commit Section & Empty State - Only show after fetching */}
{hasFetchedBranchInfo && (
<>
{/* Latest Commit Section - for non-default branches with commits */}
{lastCommit && !isLoadingCommit && (
<div className="latest-commit-section">
<div className="latest-commit-header">
<span className="section-label">LATEST COMMIT</span>
</div>
<div className="commit-info">
<div className="commit-icon">
<SolidIcon name="commit" width="20" />
</div>
<div className="commit-content">
<div className="commit-title">{lastCommit.message || 'No message'}</div>
<div className="commit-metadata">
By {lastCommit.author || 'Unknown'} | {formatCommitDate(lastCommit.date)}
</div>
</div>
</div>
</div>
)}
{/* Empty state - no commits yet */}
{!lastCommit && !isLoadingCommit && (
<div className="no-commits-empty-state">
<AlertTriangle width="18" height="18" />
<div className="empty-state-content">
<div className="empty-state-title">There are no commits yet</div>
<div className="empty-state-description">
Commit your changes to create a pull request to contribute them
</div>
</div>
</div>
)}
{/* Loading state for commit */}
{isLoadingCommit && (
<div className="loading-commit-state">
<div className="spinner"></div>
<span>Loading commit info...</span>
</div>
)}
</>
)}
</>
)}
{/* Footer actions */}
<div className="branch-dropdown-footer">
{isOnDefaultBranch ? (
<>
{isBranchingEnabled && (
<button
className="create-branch-btn"
onClick={() => {
setShowDropdown(false);
setShowCreateModal(true);
}}
data-cy="workspace-create-branch-btn"
>
<SolidIcon name="plus" width="14" fill="var(--indigo9)" />
<span>Create new branch</span>
</button>
)}
<button
className="switch-branch-btn"
onClick={() => {
setShowDropdown(false);
setShowSwitchModal(true);
}}
data-cy="workspace-switch-branch-btn"
>
<SolidIcon name="refresh" width="14" />
<span>Switch branch</span>
</button>
</>
) : (
<>
{/* Feature branch footer: Create PR + Switch branch */}
<button className="create-pr-btn" onClick={handleCreatePR} data-cy="workspace-create-pr-btn">
<SolidIcon name="gitmerge" width="14" fill="var(--indigo9)" />
<span>Create pull request</span>
</button>
<button
className="switch-branch-btn"
onClick={() => {
setShowDropdown(false);
setShowSwitchModal(true);
}}
data-cy="workspace-switch-branch-btn"
>
<SolidIcon name="refresh" width="14" />
<span>Switch branch</span>
</button>
</>
)}
</div>
</div>
</Popover.Body>
</Popover>
);
return (
<>
<div
className={`branch-dropdown-container ${showDropdown ? 'selected' : ''} ${darkMode ? 'dark-theme' : ''}`}
ref={buttonRef}
data-cy="workspace-branch-dropdown-container"
>
<button
className="branch-dropdown-button"
onClick={() => setShowDropdown(!showDropdown)}
data-cy="workspace-branch-dropdown-header"
>
<SolidIcon name="gitbranch" width="16" fill="var(--slate12)" />
<span className="branch-name" data-cy="workspace-current-branch-name">
{displayBranchName}
</span>
</button>
</div>
<Overlay
show={showDropdown}
target={buttonRef.current}
placement="bottom-end"
rootClose
onHide={() => 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 }) => (
<div style={{ position: 'absolute', zIndex: 1050 }}>{renderPopover(props)}</div>
)}
</Overlay>
{/* Create Branch Modal */}
{showCreateModal && (
<WorkspaceCreateBranchModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => setShowCreateModal(false)}
/>
)}
{/* Switch Branch Modal */}
{showSwitchModal && (
<WorkspaceSwitchBranchModal show={showSwitchModal} onClose={() => setShowSwitchModal(false)} />
)}
</>
);
}
export default WorkspaceBranchDropdown;

View file

@ -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 (
<>
<div className="lifecycle-cta-button">
{/* <Button variant="secondary" onClick={() => setShowModal(true)}>
<SolidIcon fill="var(--icon-accent)" viewBox="0 0 16 16" name="commit" width="16" />
<span>{isOnDefaultBranch ? 'Pull commit ' : 'Commit'}</span>
</Button> */}
<Button variant="secondary" onClick={() => openModal('pull')}>
<SolidIcon fill="var(--icon-accent)" viewBox="0 0 16 16" name="pull-changes" width="16" />
<span>Pull</span>
</Button>
</div>
{/* {showModal && <WorkspaceGitSyncModal isOnDefaultBranch={isOnDefaultBranch} onClose={() => setShowModal(false)} />} */}
{!isOnDefaultBranch && (
<div className="lifecycle-cta-button">
<Button variant="secondary" onClick={() => openModal('push')}>
<SolidIcon fill="var(--icon-accent)" viewBox="0 0 16 16" name="commit" width="16" />
<span>Commit</span>
</Button>
</div>
)}
{showModal && (
<WorkspaceGitSyncModal
isOnDefaultBranch={isOnDefaultBranch}
initialTab={initialTab}
onClose={() => setShowModal(false)}
/>
)}
</>
);
}
export default WorkspaceGitCTA;

View file

@ -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;
}
}

View file

@ -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 = () => (
<div className="import-confirmation-section">
<p>
<strong>{selectedBranch}</strong> branch does not exist in ToolJet, pulling this will import it as a new branch
with the latest commit. Do you want to proceed?
</p>
</div>
);
// ---- Pull section content ----
const renderPullSection = () => (
// <div className="pull-section">
<div
className={cx('pull-section', {
'pull-section--centered': checkingForUpdate?.status !== UPDATE_STATUS.AVAILABLE,
})}
>
<form
noValidate
className={`d-flex w-100 ${
checkingForUpdate?.status === UPDATE_STATUS.AVAILABLE
? 'align-items-start justify-content-start'
: 'align-items-center justify-content-center'
}`}
>
{/* Check for updates button */}
{checkingForUpdate?.visible && (
<div className="form-group mb-3">
<div onClick={() => checkForUpdates()} className="check-for-updates cursor-pointer">
{checkingForUpdate?.status === UPDATE_STATUS.FETCHING ? (
<div className="loader-container">
<div className="primary-spin-loader"></div>
</div>
) : (
<SolidIcon name={checkingForUpdate?.status === UPDATE_STATUS.UNAVAILABLE ? 'tick' : 'gitsync'} />
)}
<div className="font-weight-500 tj-text-xsm" data-cy="check-for-updates-label">
{checkingForUpdate?.message}
</div>
</div>
</div>
)}
{/* Updates available: branch dropdown + commit info */}
{checkingForUpdate?.status === UPDATE_STATUS.AVAILABLE && (
<div className="d-flex flex-column align-items-center justify-content-center w-100">
<div className="form-group mb-3 w-100">
<Dropdown
label="Select branch to pull from"
options={dropdownBranches.reduce((acc, branch) => {
acc[branch.name] = {
value: branch.name,
label: branch.name,
};
return acc;
}, {})}
value={selectedBranch}
onChange={handleBranchChange}
width="100%"
theme={darkMode ? 'dark' : 'light'}
/>
</div>
{/* Latest commit info or up-to-date message */}
{latestCommitData ? (
<div className="w-100">
<div className="selected-commit-header">LATEST COMMIT</div>
<div className="d-flex w-100">
<div className="selected-commit-info">
<div className="commit-icon">
<SolidIcon name="commit" width="20" />
</div>
<div className="commit-content">
<OverflowTooltip
className="commit-title"
whiteSpace="normal"
style={{
maxWidth: '100%',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{latestCommitData.message || 'No message'}
</OverflowTooltip>
<div className="commit-metadata">
By {latestCommitData.author || 'Unknown'} | {formatCommitDate(latestCommitData.date)}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="no-commits-empty-state w-100">
<SolidIcon name="tick" />
<div className="empty-state-content">
<div className="empty-state-title">Up to date</div>
<div className="empty-state-description">
Workspace is up to date with the latest changes on this branch
</div>
</div>
</div>
)}
</div>
)}
</form>
</div>
);
// ---- Push section content ----
const renderPushSection = () => (
<form noValidate>
<div className="push-section mb-4">
<div className="d-flex flex-column w-100 align-items-start">
<div className="form-group mb-2 w-100">
<label className="mb-1 tj-text-xsm font-weight-500" data-cy="commit-message-label">
Commit message
</label>
<div className="tj-app-input">
<input
onChange={handleCommitChange}
type="text"
value={commitMessage}
placeholder="Briefly describe the changes you've made"
className="form-control font-weight-400"
data-cy="commit-message-input"
autoFocus
/>
</div>
</div>
{pushLatestCommitLoading && (
<div className="d-flex justify-content-center w-100 mt-2">
<div className="loader-container">
<div className="primary-spin-loader"></div>
</div>
</div>
)}
{!pushLatestCommitLoading && pushLatestCommitData && (
<div className="w-100 mt-2">
<div className="selected-commit-header">LATEST COMMIT</div>
<div className="d-flex w-100">
<div className="selected-commit-info">
<div className="commit-icon">
<SolidIcon name="commit" width="20" />
</div>
<div className="commit-content">
<OverflowTooltip
className="commit-title"
whiteSpace="normal"
style={{
maxWidth: '100%',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{pushLatestCommitData.message || 'No message'}
</OverflowTooltip>
<div className="commit-metadata">
By {pushLatestCommitData.author || 'Unknown'} | {formatCommitDate(pushLatestCommitData.date)}
</div>
</div>
</div>
</div>
</div>
)}
{!pushLatestCommitLoading && !pushLatestCommitData && (
<div className="no-commits-empty-state w-100 mt-2">
<div className="empty-state-content">
<div className="empty-state-title">No commits yet</div>
<div className="empty-state-description">This will be your first commit to the repository.</div>
</div>
</div>
)}
</div>
</div>
</form>
);
// ---- Push/Pull tab header (feature branches only) ----
const renderPushPullTabs = () => (
<div className="push-pull-tabs row mt-2" style={{ width: '350px' }}>
<div className={`tab-push ${activeTab === 'push' ? 'active' : ''} text-center w-50`}>
<button
className={`btn w-100 py-2 ${activeTab === 'push' ? 'text-primary' : 'text-secondary'} border-0`}
style={{
color: activeTab === 'push' ? '#4368E3' : '',
backgroundColor: darkMode ? '#1E2226' : '#FCFCFD',
}}
onClick={() => setActiveTab('push')}
>
<span className="push-icon" style={{ marginRight: '5px', fontWeight: '500' }}>
<SolidIcon name="push-changes" fill={activeTab === 'push' ? '#4368E3' : '#ACB2B9'} />
</span>
<span style={{ fontWeight: activeTab === 'push' ? '500' : 'normal' }}>Push</span>
</button>
</div>
<div className={`tab-pull ${activeTab === 'pull' ? 'active' : ''} text-center w-50`}>
<button
className={`btn w-100 py-2 ${activeTab === 'pull' ? 'text-primary' : 'text-secondary'} border-0`}
style={{
color: activeTab === 'pull' ? '#4368E3' : '',
backgroundColor: darkMode ? '#1E2226' : '#FCFCFD',
}}
onClick={() => setActiveTab('pull')}
>
<span className="push-icon" style={{ marginRight: '5px', fontWeight: '500' }}>
<SolidIcon name="pull-changes" fill={activeTab === 'pull' ? '#4368E3' : '#ACB2B9'} />
</span>
<span style={{ fontWeight: activeTab === 'pull' ? '500' : 'normal' }}>Pull</span>
</button>
</div>
</div>
);
// --- Modal body ---
const renderModalBody = () => {
// Default branch: pull-only
if (isOnDefaultBranch) {
if (actionChoiceMode) {
return <div className="pull-container">{renderImportConfirmation()}</div>;
}
return <div className="pull-container">{renderPullSection()}</div>;
}
// Feature branch: push/pull tabs
if (activeTab === 'pull') {
if (actionChoiceMode) {
return <div className="pushpull-container">{renderImportConfirmation()}</div>;
}
return <div className="pushpull-container">{renderPullSection()}</div>;
}
return <div className="pushpull-container">{renderPushSection()}</div>;
};
const renderModalFooter = () => {
// Pull tab active (default branch or feature branch pull tab)
if (activeTab === 'pull' || isOnDefaultBranch) {
if (actionChoiceMode) {
return (
<Modal.Footer>
<ButtonSolid
variant="tertiary"
onClick={() => {
setActionChoiceMode(false);
setSelectedBranch(currentBranchName);
}}
disabled={isPulling}
>
Cancel
</ButtonSolid>
<ButtonSolid variant="primary" onClick={handleContinue} disabled={isPulling} isLoading={isPulling}>
Continue
</ButtonSolid>
</Modal.Footer>
);
}
return (
<Modal.Footer>
<ButtonSolid variant="tertiary" onClick={onClose} disabled={isPulling}>
Cancel
</ButtonSolid>
<ButtonSolid
variant="primary"
onClick={handlePull}
disabled={checkingForUpdate?.status !== UPDATE_STATUS.AVAILABLE || isPulling}
isLoading={isPulling}
>
Pull changes
</ButtonSolid>
</Modal.Footer>
);
}
// Push tab active
return (
<Modal.Footer>
<ButtonSolid variant="tertiary" onClick={onClose} disabled={isPushing}>
Cancel
</ButtonSolid>
<ButtonSolid
variant="primary"
onClick={handlePush}
disabled={isPushing || !commitMessage.trim()}
isLoading={isPushing}
// leftIcon="commit"
// fill="var(--indigo1)"
// iconWidth="20"
>
Commit changes
</ButtonSolid>
</Modal.Footer>
);
};
// 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 (
<Modal
backdrop="static"
show={true}
onHide={onClose}
size="sm"
centered={true}
contentClassName={cx('git-sync-modal', {
'theme-dark dark-theme': darkMode,
})}
>
<Modal.Header>
<Modal.Title
className={cx('font-weight-500', { 'mt-3': !isOnDefaultBranch && !actionChoiceMode })}
data-cy="modal-title"
>
<div className="git-sync-title row align-items-center" style={{ width: '350px' }}>
<div className="col-9">{modalTitle}</div>
<div onClick={onClose} className="col-3 text-end cursor-pointer" data-cy="modal-close-button">
<SolidIcon name="remove" width="20" />
</div>
{gitSyncUrl && !actionChoiceMode && (
<div
className="col-12 d-flex align-items-center"
style={{
color: 'var(--text-placeholder, #6A727C)',
fontSize: 'var(--size-default, 12px)',
fontWeight: 'var(--weight-regular, 400)',
}}
>
<span className="me-1" style={{ textDecoration: 'none' }}>
in
</span>
<OverflowTooltip placement="bottom" style={{ maxWidth: '300px' }}>
<span className="helper-text">{gitSyncUrl}</span>
</OverflowTooltip>
</div>
)}
</div>
{/* {!isOnDefaultBranch && !actionChoiceMode && renderPushPullTabs()} */}
</Modal.Title>
</Modal.Header>
<Modal.Body>{renderModalBody()}</Modal.Body>
{renderModalFooter()}
</Modal>
);
}
export default WorkspaceGitSyncModal;

View file

@ -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 (
<LockedBranchBanner
isVisible={isVisible}
branchName={currentBranch?.name || ''}
reason="main_config_branch"
pageContext={pageContext}
/>
);
}
export default WorkspaceLockedBanner;

View file

@ -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 = ({
</div>
<OrganizationList />
</div>
<div className="page-wrapper mt-4">
<div className="container-xl" style={{ width: '880px' }}>
<div className="page-wrapper" style={{ marginTop: 0 }}>
<div className="container-xl mt-4" style={{ width: '880px' }}>
<div className="align-items-center d-flex justify-content-between">
<div className="tj-text-sm font-weight-500" data-cy="env-name">
{capitalize(activeTabEnvironment?.name)} ({globalCount + secretCount})

View file

@ -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 && (
<span className="input-icon-addon">
@ -1225,7 +1225,7 @@ class DataSourceManagerComponent extends React.Component {
appId={this.state.appId}
/>
</div>
{!isSampleDb && (
{!isSampleDb && this.props.showSaveBtn !== false && (
<div className="col-auto" data-cy="db-connection-save-button">
<ButtonSolid
className={`m-2 datasource-save-btn-white-icon ${isSaving ? 'btn-loading' : ''}`}
@ -1269,22 +1269,24 @@ class DataSourceManagerComponent extends React.Component {
{this.props.t('globals.readDocumentation', 'Read documentation')}
</a>
</div>
<div className="col-auto" data-cy="db-connection-save-button">
<ButtonSolid
leftIcon="floppydisk"
fill={'#FDFDFE'}
className="m-2 datasource-save-btn-white-icon"
disabled={
isSaving || this.props.isVersionReleased || isSaveDisabled || this.props.isSaveDisabled
}
variant="primary"
onClick={this.createDataSource}
>
{isSaving
? this.props.t('editor.queryManager.dataSourceManager.saving' + '...', 'Saving...')
: this.props.t('globals.save', 'Save')}
</ButtonSolid>
</div>
{this.props.showSaveBtn !== false && (
<div className="col-auto" data-cy="db-connection-save-button">
<ButtonSolid
leftIcon="floppydisk"
fill={'#FDFDFE'}
className="m-2"
disabled={
isSaving || this.props.isVersionReleased || isSaveDisabled || this.props.isSaveDisabled
}
variant="primary"
onClick={this.createDataSource}
>
{isSaving
? this.props.t('editor.queryManager.dataSourceManager.saving' + '...', 'Saving...')
: this.props.t('globals.save', 'Save')}
</ButtonSolid>
</div>
)}
</Modal.Footer>
)}
</>

View file

@ -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) => (
<ToolTip message="You do not have permission to add a data source" show={!canAddDataSource} placement="bottom">
<ToolTip
message={!hasCreatePermission ? 'You do not have permission to add a data source' : ''}
show={!hasCreatePermission}
placement="bottom"
>
<div>
<ButtonSolid
disabled={addingDataSource || !canAddDataSource}
disabled={addingDataSource || !hasCreatePermission}
isLoading={addingDataSource}
variant="secondary"
onClick={() => createDataSource(item)}
onClick={() => {
if (isWorkspaceBranchLocked) {
setPendingAddDataSource(item);
setShowSwitchBranchModal(true);
} else {
createDataSource(item);
}
}}
data-cy={`${item.title.toLowerCase().replace(/\s+/g, '-')}-add-button`}
>
<SolidIcon name="plus" fill={darkMode ? '#3E63DD' : '#3E63DD'} width={18} viewBox="0 0 25 25" />
@ -482,6 +523,7 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource }
<div className="row gx-0">
<Sidebar renderSidebarList={renderSidebarList} updateSelectedDatasource={updateSelectedDatasource} />
<div ref={containerRef} className={cx('col animation-fade datasource-modal-container', {})}>
<WorkspaceLockedBanner pageContext="data sources" />
{containerRef && containerRef?.current && selectedDataSource && (
<DataSourceManager
showBackButton={selectedDataSource ? false : true}
@ -500,6 +542,7 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource }
isEditing={isEditing}
updateSelectedDatasource={updateSelectedDatasource}
showSaveBtn={canCreateDataSource() || canUpdateDataSource(selectedDataSource?.id) || canDeleteDataSource()}
isWorkspaceBranchLocked={isWorkspaceBranchLocked}
environmentLoading={environmentLoading}
tags={tags}
/>
@ -507,6 +550,23 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource }
{isLoading && loadingState()}
{!selectedDataSource && activeDatasourceList && !isLoading && segregateDataSources()}
</div>
{showSwitchBranchModal && (
<WorkspaceSwitchBranchModal
show={showSwitchBranchModal}
onClose={() => {
setShowSwitchBranchModal(false);
setPendingAddDataSource(null);
}}
onBranchSwitch={() => {
if (pendingAddDataSource) {
loadingSeenRef.current = false;
setPendingCreateDS(pendingAddDataSource);
setPendingAddDataSource(null);
}
setShowSwitchBranchModal(false);
}}
/>
)}
</div>
);
};

View file

@ -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 }) => {
</Modal>
<ConfirmDialog
show={isDeleteModalVisible}
message={'Do you want to delete?'}
title={isBranchingEnabled ? 'Delete datasource' : undefined}
message={
isBranchingEnabled
? "Deleting this data source will only apply changes to the selected branch. To reflect these changes on master, you'll need to push and commit your changes, then merge them."
: 'Do you want to delete?'
}
confirmButtonText={isBranchingEnabled ? 'Delete' : undefined}
confirmButtonLoading={isDeletingDatasource}
onConfirm={() => executeDataSourceDeletion()}
onCancel={() => cancelDeleteDataSource()}
darkMode={darkMode}
backdropClassName="delete-modal"
/>
{showSwitchBranchModal && (
<WorkspaceSwitchBranchModal
show={showSwitchBranchModal}
onClose={() => {
setShowSwitchBranchModal(false);
setPendingDeleteSource(null);
}}
onBranchSwitch={() => {
if (pendingDeleteSource) {
pendingDeleteAfterSwitchRef.current = pendingDeleteSource;
setPendingDeleteSource(null);
}
setShowSwitchBranchModal(false);
}}
/>
)}
</>
);
};

View file

@ -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();

View file

@ -0,0 +1,167 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedWorkspaceBranchData1773229179000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
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`);
}
}

View file

@ -0,0 +1,95 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeedDefaultDataSourceVersionsForAll1773229180000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// 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;
`);
}
}

View file

@ -0,0 +1,86 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class EnforceUniqueDataSourceNamesPerBranch1773229181000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS idx_unique_active_name_branch
`);
}
}

View file

@ -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<void> {
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<void> {
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);
`);
}
}

View file

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DropDataSourceOptionsTable1773300000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
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
)
`);
}
}

View file

@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BackfillAppCoRelationId1773400000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// No-op: we can't distinguish which apps originally had null co_relation_id
}
}

View file

@ -0,0 +1,66 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateWorkspaceBranchTables1772568626000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
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`);
}
}

View file

@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlatformGitSyncSupport1773100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
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;`);
}
}

View file

@ -0,0 +1,72 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveAppBranchStateTable1773200000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// 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;`);
}
}

View file

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCreatedByToWorkspaceBranch1773300000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE organization_git_sync_branches
DROP COLUMN IF EXISTS created_by;
`);
}
}

View file

@ -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"

View file

@ -32,6 +32,10 @@ export class ImportResourcesDto {
@IsOptional()
@IsBoolean()
skip_permissions_group_check?: boolean;
@IsOptional()
@IsUUID()
branchId?: string;
}
export class ImportAppDto {

View file

@ -167,4 +167,5 @@ export class App extends BaseEntity {
aiConversations: AiConversation[];
public editingVersion;
public isStub: boolean;
}

View file

@ -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;

View file

@ -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[];

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<any>;
}
let _pullService: IPlatformGitPullService | null = null;
export function registerPlatformGitPullService(service: IPlatformGitPullService): void {
_pullService = service;
}
export function getPlatformGitPullService(): IPlatformGitPullService | null {
return _pullService;
}

View file

@ -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<AppEnvironment>;
getAll(organizationId: string, appId?: string, manager?: EntityManager): Promise<AppEnvironment[]>;
getOptions(dataSourceId: string, organizationId: string, environmentId?: string): Promise<DataSourceOptions>;
getOptions(
dataSourceId: string,
organizationId: string,
environmentId?: string,
branchId?: string,
appVersionId?: string
): Promise<DataSourceVersionOptions>;
init(
editorVersion: Partial<AppVersion>,
organizationId: string,

View file

@ -222,6 +222,7 @@ export class AppEnvironmentService implements IAppEnvironmentService {
'parentVersionId',
'promotedFrom',
'versionType',
'branchId',
'createdAt',
'updatedAt',
'publishedAt',

View file

@ -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<DataSourceOptions> {
async getOptions(
dataSourceId: string,
organizationId: string,
environmentId?: string,
branchId?: string,
appVersionId?: string
): Promise<DataSourceVersionOptions> {
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}`
);
});
}

View file

@ -86,6 +86,14 @@ export class AppGitPullDto {
@IsString()
@IsOptional()
commitHash?: string;
@IsString()
@IsOptional()
gitBranchName?: string;
@IsString()
@IsOptional()
workspaceBranchId?: string;
}
export class AppGitPullUpdateDto {

View file

@ -65,7 +65,7 @@ export class AppGitModule extends SubModule {
FeatureAbilityFactory,
...(isMainImport ? [AppVersionRenameListener] : []),
],
exports: [SSHAppGitUtilityService, HTTPSAppGitUtilityService, GitLabAppGitUtilityService],
exports: [SourceControlProviderService, SSHAppGitUtilityService, HTTPSAppGitUtilityService, GitLabAppGitUtilityService],
};
}
}

View file

@ -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,
};

View file

@ -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',

View file

@ -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),

View file

@ -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)

View file

@ -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 {

View file

@ -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) {

View file

@ -39,7 +39,7 @@ export interface IAppsController {
tables(user: UserEntity, app: AppEntity): Promise<{ tables: any[] }>;
show(user: UserEntity, app: AppEntity): Promise<any>;
show(user: UserEntity, app: AppEntity, branchId?: string): Promise<any>;
appFromSlug(user: UserEntity, app: AppEntity): Promise<any>;

View file

@ -17,6 +17,6 @@ export interface IAppsService {
delete(app: App, user: User): Promise<void>;
getAllApps(user: User, appListDto: AppListDto, isGetAll: boolean): Promise<any>;
findTooljetDbTables(appId: string): Promise<{ table_id: string }[]>;
getOne(app: App, user: User): Promise<any>;
getOne(app: App, user: User, branchId?: string): Promise<any>;
getBySlug(app: App, user: User): Promise<any>;
}

View file

@ -16,7 +16,7 @@ export interface IAppsUtilService {
): Promise<AppEnvironment>;
getAppOrganizationDetails(app: App): Promise<Organization>;
update(app: App, appUpdateDto: AppUpdateDto, organizationId?: string, manager?: EntityManager): Promise<void>;
all(user: User, page: number, searchKey: string, type: string, isGetAll: boolean): Promise<AppBase[]>;
count(user: User, searchKey: string, type: string): Promise<number>;
all(user: User, page: number, searchKey: string, type: string, isGetAll: boolean, branchId?: string): Promise<AppBase[]>;
count(user: User, searchKey: string, type: string, branchId?: string): Promise<number>;
mergeDefaultComponentData(pages: any[]): any[];
}

View file

@ -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<any> {
async getOne(app: App, user: User, branchId?: string): Promise<any> {
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;

View file

@ -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<string, string>;
dataQueryMapping: Record<string, string>;
@ -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<string, unknown>,
createNewVersion?: boolean
createNewVersion?: boolean,
branchId?: string
): Promise<AppResourceMappings> {
// 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<any> = this.replaceDataQueryIdWithinDefinitions(
importingAppVersion.definition,
appResourceMappings.dataQueryMapping
@ -1221,14 +1244,15 @@ export class AppImportExportService {
externalResourceMappings: Record<string, unknown>,
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 = { ...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<void> {
// 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<string, unknown>): Array<object> {
@ -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,
})
);
}
})
);
}

View file

@ -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<App> {
export class AppsSubscriber implements EntitySubscriberInterface {
constructor(
private readonly appVersionRepository: VersionRepository,
private readonly appRepository: AppsRepository,
@ -13,25 +15,38 @@ export class AppsSubscriber implements EntitySubscriberInterface<App> {
datasourceRepository.subscribers.push(this);
}
listenTo() {
return App;
}
async afterInsert(event: InsertEvent<App>): Promise<void> {
const app = event.entity;
if (!app.slug) {
await this.appRepository.update(app.id, { slug: app.id });
async afterInsert(event: InsertEvent<any>): Promise<void> {
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<void> {
async afterLoad(app: any): Promise<void> {
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;
}
}

View file

@ -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<App> {
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<App> {
async findAppWithIdOrSlug(slug: string, organizationId: string, branchId?: string): Promise<App> {
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<AppBase[]> {
async all(
user: User,
page: number,
searchKey: string,
type: string,
isGetAll: boolean,
branchId?: string
): Promise<AppBase[]> {
//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<string>,
type?: string
type?: string,
branchId?: string
): SelectQueryBuilder<AppBase> {
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<number> {
async count(user: User, searchKey, type: APP_TYPES, branchId?: string): Promise<number> {
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;
}
}

View file

@ -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<DynamicModule> {
const { BranchContextService: BranchContextServiceImpl } = await this.getProviders(
configs,
'workspace-branches',
['branch-context.service']
);
return {
module: BranchContextModule,
global: true,
providers: [{ provide: BranchContextService, useClass: BranchContextServiceImpl }],
exports: [BranchContextService],
};
}
}

View file

@ -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)

View file

@ -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',

Some files were not shown because too many files have changed in this diff Show more