mirror of
https://github.com/ToolJet/ToolJet
synced 2026-04-21 13:37:28 +00:00
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:
parent
b7ea2a1338
commit
58ba6b8563
146 changed files with 7110 additions and 1087 deletions
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
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 |
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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%' }}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -80,8 +80,6 @@ export const AppMenu = function AppMenu({
|
|||
);
|
||||
};
|
||||
|
||||
console.log('AppMenu render', { appId, canCreateApp, canDeleteApp, canUpdateApp });
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
60
frontend/src/_helpers/active-branch.js
Normal file
60
frontend/src/_helpers/active-branch.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
105
frontend/src/_services/workspace_branches.service.js
Normal file
105
frontend/src/_services/workspace_branches.service.js
Normal 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);
|
||||
}
|
||||
195
frontend/src/_stores/workspaceBranchesStore.js
Normal file
195
frontend/src/_stores/workspaceBranchesStore.js
Normal 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' }
|
||||
)
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
Add
|
||||
</ButtonSolid>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
239
frontend/src/_ui/WorkspaceBranchDropdown/CreateBranchModal.jsx
Normal file
239
frontend/src/_ui/WorkspaceBranchDropdown/CreateBranchModal.jsx
Normal 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;
|
||||
227
frontend/src/_ui/WorkspaceBranchDropdown/SwitchBranchModal.jsx
Normal file
227
frontend/src/_ui/WorkspaceBranchDropdown/SwitchBranchModal.jsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
589
frontend/src/_ui/WorkspaceBranchDropdown/index.jsx
Normal file
589
frontend/src/_ui/WorkspaceBranchDropdown/index.jsx
Normal 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;
|
||||
61
frontend/src/_ui/WorkspaceGitCTA/index.jsx
Normal file
61
frontend/src/_ui/WorkspaceGitCTA/index.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
595
frontend/src/_ui/WorkspaceGitSyncModal/index.jsx
Normal file
595
frontend/src/_ui/WorkspaceGitSyncModal/index.jsx
Normal 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;
|
||||
28
frontend/src/_ui/WorkspaceLockedBanner/index.jsx
Normal file
28
frontend/src/_ui/WorkspaceLockedBanner/index.jsx
Normal 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;
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
167
server/data-migrations/1773229179000-SeedWorkspaceBranchData.ts
Normal file
167
server/data-migrations/1773229179000-SeedWorkspaceBranchData.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
63
server/migrations/1773100000000-AddPlatformGitSyncSupport.ts
Normal file
63
server/migrations/1773100000000-AddPlatformGitSyncSupport.ts
Normal 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;`);
|
||||
}
|
||||
}
|
||||
72
server/migrations/1773200000000-RemoveAppBranchStateTable.ts
Normal file
72
server/migrations/1773200000000-RemoveAppBranchStateTable.ts
Normal 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;`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
64
server/scripts/migration/generate_app_meta.sh
Executable file
64
server/scripts/migration/generate_app_meta.sh
Executable 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"
|
||||
|
|
@ -32,6 +32,10 @@ export class ImportResourcesDto {
|
|||
@IsOptional()
|
||||
@IsBoolean()
|
||||
skip_permissions_group_check?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
branchId?: string;
|
||||
}
|
||||
|
||||
export class ImportAppDto {
|
||||
|
|
|
|||
|
|
@ -167,4 +167,5 @@ export class App extends BaseEntity {
|
|||
aiConversations: AiConversation[];
|
||||
|
||||
public editingVersion;
|
||||
public isStub: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
74
server/src/entities/data_source_version.entity.ts
Normal file
74
server/src/entities/data_source_version.entity.ts
Normal 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[];
|
||||
}
|
||||
42
server/src/entities/data_source_version_options.entity.ts
Normal file
42
server/src/entities/data_source_version_options.entity.ts
Normal 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;
|
||||
}
|
||||
54
server/src/entities/workspace_branch.entity.ts
Normal file
54
server/src/entities/workspace_branch.entity.ts
Normal 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;
|
||||
}
|
||||
20
server/src/helpers/platform-git-pull-registry.ts
Normal file
20
server/src/helpers/platform-git-pull-registry.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ export class AppEnvironmentService implements IAppEnvironmentService {
|
|||
'parentVersionId',
|
||||
'promotedFrom',
|
||||
'versionType',
|
||||
'branchId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'publishedAt',
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ export class AppGitPullDto {
|
|||
@IsString()
|
||||
@IsOptional()
|
||||
commitHash?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
gitBranchName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
workspaceBranchId?: string;
|
||||
}
|
||||
|
||||
export class AppGitPullUpdateDto {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class AppGitModule extends SubModule {
|
|||
FeatureAbilityFactory,
|
||||
...(isMainImport ? [AppVersionRenameListener] : []),
|
||||
],
|
||||
exports: [SSHAppGitUtilityService, HTTPSAppGitUtilityService, GitLabAppGitUtilityService],
|
||||
exports: [SourceControlProviderService, SSHAppGitUtilityService, HTTPSAppGitUtilityService, GitLabAppGitUtilityService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
35
server/src/modules/branch-context/module.ts
Normal file
35
server/src/modules/branch-context/module.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue