diff --git a/.changeset/khaki-cats-pretend.md b/.changeset/khaki-cats-pretend.md new file mode 100644 index 0000000000..59cccbc9eb --- /dev/null +++ b/.changeset/khaki-cats-pretend.md @@ -0,0 +1,5 @@ +--- +"tinacms": patch +--- + +fix: display editorial workflow modal on deleted branches diff --git a/packages/tinacms/src/internalClient/index.ts b/packages/tinacms/src/internalClient/index.ts index 82c0ff5585..3e1e041221 100644 --- a/packages/tinacms/src/internalClient/index.ts +++ b/packages/tinacms/src/internalClient/index.ts @@ -567,6 +567,13 @@ mutation addPendingDocumentMutation( this.protectedBranches?.includes(decodeURIComponent(this.branch)) ); } + + async branchExists(branchName: string): Promise { + if (this.isLocalMode) return true; + const branches = await this.listBranches({ includeIndexStatus: false }); + return branches.some((b) => b.name === branchName); + } + async createBranch({ baseBranch, branchName }: BranchData) { const url = `${this.contentApiBase}/github/${this.clientId}/create_branch`; diff --git a/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx b/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx new file mode 100644 index 0000000000..aa1dc1549b --- /dev/null +++ b/packages/tinacms/src/toolkit/form-builder/branch-deleted-modal.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { BiError } from 'react-icons/bi'; +import { GitBranchIcon } from 'lucide-react'; +import { useCMS } from '../react-core'; +import { + Modal, + ModalActions, + ModalBody, + ModalHeader, + PopupModal, +} from '../react-modals'; +import { PrefixedTextField } from './create-branch-modal'; +import { Form } from '@toolkit/forms'; +import { formatBranchName } from '@toolkit/plugin-branch-switcher'; +import { Button } from '@toolkit/styles'; +import { useEditorialWorkflow } from './use-editorial-workflow'; +import { WorkflowProgressIndicator } from './workflow-progress-indicator'; + +export const BranchDeletedModal = ({ + branchName, + close, + path, + values, + crudType, + tinaForm, +}: { + branchName: string; + close: () => void; + path: string; + values: Record; + crudType: string; + tinaForm?: Form; +}) => { + const cms = useCMS(); + const tinaApi = cms.api.tina; + const [newBranchName, setNewBranchName] = React.useState(''); + + const baseBranch = + tinaApi.protectedBranches[0] || + cms.api.tina.schema.config.config.repoProvider.defaultBranchName || + 'main'; + + const { + isExecuting, + errorMessage, + currentStep, + elapsedTime, + executeWorkflow, + reset, + } = useEditorialWorkflow(); + + const handleCreate = async () => { + const success = await executeWorkflow({ + branchName: `tina/${newBranchName}`, + baseBranch, + path, + values, + crudType, + tinaForm, + }); + + if (success) close(); + }; + + return ( + + + + Branch no longer exists + + + {isExecuting ? ( + + ) : ( +
+
+ + + The branch{' '} + {branchName}{' '} + no longer exists. It may have been merged or deleted. Your + changes cannot be pushed to it. + +
+ +

+ Create a new branch from{' '} + {baseBranch} to + continue editing, or cancel and switch to an existing branch + from the branch menu. +

+ + {errorMessage && ( +
+ + + Error: {errorMessage} + +
+ )} + + ) => { + reset(); + setNewBranchName(formatBranchName(e.target.value)); + }} + /> +
+ )} +
+ {!isExecuting && ( + + + + + )} +
+
+ ); +}; diff --git a/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx b/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx index 0ba4a57ee3..ec541d0690 100644 --- a/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx +++ b/packages/tinacms/src/toolkit/form-builder/create-branch-modal.tsx @@ -1,8 +1,6 @@ import * as React from 'react'; -import { AiOutlineLoading } from 'react-icons/ai'; import { BiError } from 'react-icons/bi'; import { GitBranchIcon, TriangleAlert } from 'lucide-react'; -import { useBranchData } from '@toolkit/plugin-branch-switcher'; import { Button, DropdownButton } from '@toolkit/styles'; import { useCMS } from '../react-core'; import { @@ -13,39 +11,9 @@ import { PopupModal, } from '../react-modals'; import { FieldLabel } from '@toolkit/fields'; -import { - EDITORIAL_WORKFLOW_STATUS, - EDITORIAL_WORKFLOW_ERROR, - EditorialWorkflowErrorDetails, -} from './editorial-workflow-constants'; -import { - CREATE_DOCUMENT_GQL, - DELETE_DOCUMENT_GQL, - UPDATE_DOCUMENT_GQL, -} from '../../admin/api'; import { Form } from '@toolkit/forms'; - -const pathRelativeToCollection = ( - collectionPath: string, - fullPath: string -): string => { - // Normalize paths with forward slashes - const normalizedCollectionPath = collectionPath.replace(/\\/g, '/'); - const normalizedFullPath = fullPath.replace(/\\/g, '/'); - - // Ensure collection path ends with a slash - const collectionPathWithSlash = normalizedCollectionPath.endsWith('/') - ? normalizedCollectionPath - : normalizedCollectionPath + '/'; - - if (normalizedFullPath.startsWith(collectionPathWithSlash)) { - return normalizedFullPath.substring(collectionPathWithSlash.length); - } - - throw new Error( - `Path ${fullPath} not within collection path ${collectionPath}` - ); -}; +import { useEditorialWorkflow } from './use-editorial-workflow'; +import { WorkflowProgressIndicator } from './workflow-progress-indicator'; // Format the default branch name by removing content/ prefix and file extension const formatDefaultBranchName = ( @@ -82,6 +50,7 @@ export const CreateBranchModal = ({ values, crudType, tinaForm, + onBaseBranchDeleted, }: { safeSubmit: () => Promise; close: () => void; @@ -89,306 +58,81 @@ export const CreateBranchModal = ({ values: Record; crudType: string; tinaForm?: Form; + onBaseBranchDeleted?: () => void; }) => { const cms = useCMS(); const tinaApi = cms.api.tina; - const { setCurrentBranch } = useBranchData(); - const [disabled, setDisabled] = React.useState(false); const [newBranchName, setNewBranchName] = React.useState( formatDefaultBranchName(path, crudType) ); - const [isExecuting, setIsExecuting] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(''); - const [currentStep, setCurrentStep] = React.useState(0); - const [elapsedTime, setElapsedTime] = React.useState(0); - - React.useEffect(() => { - let interval: ReturnType; - if (isExecuting && currentStep > 0) { - interval = setInterval(() => { - setElapsedTime((prev) => prev + 1); - }, 1000); - } else { - setElapsedTime(0); - } - return () => { - if (interval) clearInterval(interval); - }; - }, [isExecuting, currentStep]); - - const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - const steps = [ - { - id: 1, - name: 'Creating branch', - description: 'Setting up workspace', - }, - { - id: 2, - name: 'Updating branch', - description: 'Syncing content to branch', - }, - { - id: 3, - name: 'Creating pull request', - description: 'Preparing for review', - }, - ]; + const [isBranchGuardChecking, setIsBranchGuardChecking] = + React.useState(false); + + const { + isExecuting, + errorMessage, + currentStep, + elapsedTime, + executeWorkflow, + reset, + } = useEditorialWorkflow(); const executeEditorialWorkflow = async () => { - try { - const branchName = `tina/${newBranchName}`; - setDisabled(true); - setIsExecuting(true); - setCurrentStep(1); - - let graphql = ''; - if (crudType === 'create') { - graphql = CREATE_DOCUMENT_GQL; - } else if (crudType === 'delete') { - graphql = DELETE_DOCUMENT_GQL; - } else if (crudType !== 'view') { - graphql = UPDATE_DOCUMENT_GQL; - } + setIsBranchGuardChecking(true); - const collection = tinaApi.schema.getCollectionByFullPath(path); + const baseBranch = decodeURIComponent(tinaApi.branch); - // Run beforeSubmit hook if defined on the collection - let submittedValues = values; - if (collection?.ui?.beforeSubmit) { - const valOverride = await collection.ui.beforeSubmit({ - cms, - values, - form: tinaForm, - }); - if (valOverride) { - submittedValues = valOverride; - } - } - - const params = tinaApi.schema.transformPayload( - collection.name, - submittedValues + let baseBranchExists = true; + try { + console.debug( + '[tina:branch-guard] executeEditorialWorkflow: checking base branch:', + baseBranch ); - const relativePath = pathRelativeToCollection(collection.path, path); - - const result = await tinaApi.executeEditorialWorkflow({ - branchName: branchName, - baseBranch: tinaApi.branch, - prTitle: `${branchName.replace('tina/', '').replace('-', ' ')} (PR from TinaCMS)`, - graphQLContentOp: { - query: graphql, - variables: { - collection: collection.name, - relativePath: relativePath, - params, - }, - }, - onStatusUpdate: (status) => { - switch (status.status) { - case EDITORIAL_WORKFLOW_STATUS.SETTING_UP: - case EDITORIAL_WORKFLOW_STATUS.CREATING_BRANCH: - setCurrentStep(1); - break; - case EDITORIAL_WORKFLOW_STATUS.INDEXING: - setCurrentStep(2); - break; - case EDITORIAL_WORKFLOW_STATUS.CONTENT_GENERATION: - case EDITORIAL_WORKFLOW_STATUS.CREATING_PR: - setCurrentStep(3); - break; - case EDITORIAL_WORKFLOW_STATUS.COMPLETE: - setCurrentStep(4); - break; - } - }, - }); - - if (!result.branchName) { - throw new Error('Branch creation failed.'); - } - - setCurrentBranch(result.branchName); - - if (result.warning) { - cms.alerts.warn( - `${result.warning} Please reconnect GitHub authoring here: ${tinaApi.gitSettingsLink}`, - 0 - ); - } - - cms.alerts.success( - `Branch created successfully - Pull Request at ${result.pullRequestUrl}`, - 0 + baseBranchExists = await tinaApi.branchExists(baseBranch); + } catch (err) { + console.error( + '[tina:branch-guard] executeEditorialWorkflow: branchExists threw, failing open:', + err ); - - // For new content creation, redirect to the collection page with folder - if (crudType === 'create') { - // Extract folder path from relativePath (e.g., "folder/file.md" -> "folder") - const folderPath = relativePath.includes('/') - ? relativePath.substring(0, relativePath.lastIndexOf('/')) - : ''; - window.location.hash = `#/collections/${collection.name}${ - folderPath ? `/${folderPath}` : '' - }`; - } - - close(); - } catch (e: unknown) { - console.error(e); - let errorMessage = - 'Branch operation failed. Talking to GitHub was unsuccessful, please try again. If the problem persists please contact support at https://tina.io/support 🦙'; - - const err = e as EditorialWorkflowErrorDetails; - - // Check for structured error codes from the API - if (err.errorCode) { - switch (err.errorCode) { - case EDITORIAL_WORKFLOW_ERROR.BRANCH_EXISTS: - errorMessage = 'A branch with this name already exists'; - break; - case EDITORIAL_WORKFLOW_ERROR.BRANCH_HIERARCHY_CONFLICT: - errorMessage = - err.message || 'Branch name conflicts with an existing branch'; - break; - case EDITORIAL_WORKFLOW_ERROR.VALIDATION_FAILED: - errorMessage = err.message || 'Invalid branch name'; - break; - default: - errorMessage = err.message || errorMessage; - break; - } - } else if (err.message) { - // Fallback to message parsing for backwards compatibility - if (err.message.toLowerCase().includes('already exists')) { - errorMessage = 'A branch with this name already exists'; - } else if (err.message.toLowerCase().includes('conflict')) { - errorMessage = err.message; - } - } - - setErrorMessage(errorMessage); - setDisabled(false); - setIsExecuting(false); - setCurrentStep(0); } - }; + console.debug( + '[tina:branch-guard] executeEditorialWorkflow: base branch exists?', + baseBranchExists + ); - const renderProgressIndicator = () => { - return ( - <> - {/* Horizontal step indicators */} -
- {/* Connecting line - only between steps */} -
- {currentStep > 1 && currentStep <= steps.length && ( -
- )} - {/* Green progress bar for completed sections */} - {currentStep > 2 && ( -
- )} + if (!baseBranchExists) { + console.debug( + '[tina:branch-guard] executeEditorialWorkflow: base branch deleted — handing off' + ); + onBaseBranchDeleted?.(); + return; + } - {steps.map((step, index) => { - const stepNumber = index + 1; - const isActive = stepNumber === currentStep; - const isCompleted = stepNumber < currentStep; + setIsBranchGuardChecking(false); - return ( -
-
- {isCompleted ? ( - - - - ) : isActive ? ( - - ) : ( - stepNumber - )} -
-
-
- {step.name} -
-
- {step.description} -
-
-
- ); - })} -
+ const success = await executeWorkflow({ + branchName: `tina/${newBranchName}`, + baseBranch, + path, + values, + crudType, + tinaForm, + }); - {/* Timer and estimated time - inline */} -
-
Estimated time: 1-2 min
- {isExecuting && currentStep > 0 && ( -
- - - - {formatTime(elapsedTime)} -
- )} -
- - Learn more about Editorial Workflow - - - ); + if (success) { + close(); + } }; const renderStateContent = () => { if (isExecuting) { - return renderProgressIndicator(); + return ( + + ); } else { return (
@@ -425,7 +169,7 @@ export const CreateBranchModal = ({ value={newBranchName} onChange={(e) => { // reset error state on change - setErrorMessage(''); + reset(); setNewBranchName(e.target.value); }} /> @@ -456,7 +200,7 @@ export const CreateBranchModal = ({ variant='primary' align='start' className='w-full sm:w-auto' - disabled={newBranchName === '' || disabled} + disabled={newBranchName === '' || isBranchGuardChecking} onMainAction={executeEditorialWorkflow} items={[ { diff --git a/packages/tinacms/src/toolkit/form-builder/form-builder.tsx b/packages/tinacms/src/toolkit/form-builder/form-builder.tsx index 13c48bd05d..dc84c9cf37 100644 --- a/packages/tinacms/src/toolkit/form-builder/form-builder.tsx +++ b/packages/tinacms/src/toolkit/form-builder/form-builder.tsx @@ -18,6 +18,7 @@ import { FormPortalProvider } from './form-portal'; import { LoadingDots } from './loading-dots'; import { ResetForm } from './reset-form'; import { CreateBranchModal } from './create-branch-modal'; +import { BranchDeletedModal } from './branch-deleted-modal'; import { SavedContentEvent, SaveContentErrorEvent, @@ -98,6 +99,9 @@ export const FormBuilder: FC = ({ const hideFooter = !!rest.hideFooter; const [createBranchModalOpen, setCreateBranchModalOpen] = React.useState(false); + const [deletedBranchModalOpen, setDeletedBranchModalOpen] = + React.useState(false); + const [isGuardChecking, setIsGuardChecking] = React.useState(false); const tinaForm = form.tinaForm; const finalForm = form.tinaForm.finalForm; @@ -182,27 +186,93 @@ export const FormBuilder: FC = ({ const safeSubmit = async () => { if (canSubmit) { + const alertsBefore = new Set(cms.alerts.all.map((a) => a.id)); + console.debug( + '[tina:branch-guard] safeSubmit: calling handleSubmit' + ); + const result = await handleSubmit(); if (result && result[FORM_ERROR]) { const error = result[FORM_ERROR]; + const errorMsg = + error instanceof Error ? error.message : String(error); + + console.debug( + '[tina:branch-guard] safeSubmit: FORM_ERROR detected:', + errorMsg + ); + + // If the save failed because the branch no longer exists, + // intercept the generic error alert and replace it with the + // branch-deleted modal. + if (/branch.*not found/i.test(errorMsg)) { + console.debug( + '[tina:branch-guard] safeSubmit: branch-not-found — dismissing alert and opening modal' + ); + for (const alert of cms.alerts.all) { + if (!alertsBefore.has(alert.id) && alert.level === 'error') { + cms.alerts.dismiss(alert); + } + } + setDeletedBranchModalOpen(true); + return; + } + captureEvent(SaveContentErrorEvent, { documentPath: tinaForm.path, - error: error instanceof Error ? error.message : String(error), + error: errorMsg, }); } else { captureEvent(SavedContentEvent, { documentPath: tinaForm.path, }); } + } else { + console.debug( + '[tina:branch-guard] safeSubmit: skipped — canSubmit is false' + ); } }; const safeHandleSubmit = async () => { + setIsGuardChecking(true); + + const currentBranch = decodeURIComponent(cms.api.tina.getBranch()); + + let exists = true; + try { + console.debug( + '[tina:branch-guard] safeHandleSubmit: checking branch:', + currentBranch + ); + exists = await cms.api.tina.branchExists(currentBranch); + } catch (err) { + console.error( + '[tina:branch-guard] safeHandleSubmit: branchExists threw, failing open:', + err + ); + } + + console.debug( + '[tina:branch-guard] safeHandleSubmit: branchExists returned:', + exists + ); + if (!exists) { + console.debug( + '[tina:branch-guard] safeHandleSubmit: branch missing — opening modal' + ); + setIsGuardChecking(false); + setDeletedBranchModalOpen(true); + return; + } + if (usingProtectedBranch) { setCreateBranchModalOpen(true); } else { - safeSubmit(); + await safeSubmit(); } + + setIsGuardChecking(false); }; return ( @@ -215,6 +285,20 @@ export const FormBuilder: FC = ({ values={tinaForm.values} tinaForm={tinaForm} close={() => setCreateBranchModalOpen(false)} + onBaseBranchDeleted={() => { + setCreateBranchModalOpen(false); + setDeletedBranchModalOpen(true); + }} + /> + )} + {deletedBranchModalOpen && ( + setDeletedBranchModalOpen(false)} + path={tinaForm.path} + values={tinaForm.values} + crudType={tinaForm.crudType} + tinaForm={tinaForm} /> )} @@ -251,8 +335,8 @@ export const FormBuilder: FC = ({ )}