mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix: explain in the fork dropdowns why a fork can't be created
The create-fork entry disappeared whenever forking was unavailable, leaving no trace of why. Keep it visible, disabled, and carrying the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014cq84eSViYocWFKfqvzdpR
This commit is contained in:
co-authored by
Claude Opus 5
parent
337154b830
commit
80164a9177
@@ -17,7 +17,7 @@
|
||||
buildWorkspaceHierarchy
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { forkBlockedReason, type ForkBlockedReason } from '$lib/utils/editInFork'
|
||||
import { forkAccentStyle } from '$lib/utils/forkColor'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
@@ -27,7 +27,6 @@
|
||||
canUserBypassRuleKindInRulesets
|
||||
} from '$lib/workspaceProtectionRules.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import InputError from '$lib/components/InputError.svelte'
|
||||
@@ -169,19 +168,20 @@
|
||||
rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot
|
||||
)
|
||||
|
||||
// Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking
|
||||
// is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace
|
||||
// (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and
|
||||
// devOfRoot only resolves when the user is a member of it.
|
||||
const forksGateOpen = $derived(
|
||||
(!isCloudHosted() || $maybePremium) &&
|
||||
$workspaceStore !== 'admins' &&
|
||||
(canCreateFork($userStore) || !!devOfRoot)
|
||||
// Why forking is unavailable here (admins workspace, non-premium cloud, or a protection rule), or
|
||||
// undefined when it is available. DisableWorkspaceForking on the active workspace (a locked prod)
|
||||
// doesn't apply when there's a dev to fork from instead — the dev isn't locked, and devOfRoot only
|
||||
// resolves when the user is a member of it.
|
||||
const forkGateReason = $derived(
|
||||
forkBlockedReason($userStore, $workspaceStore, {
|
||||
premium: $maybePremium,
|
||||
hasDevWorkspace: !!devOfRoot
|
||||
})
|
||||
)
|
||||
// A fork is a new workspace, so it's subject to the community-edition cap on
|
||||
// the number of non-'admins' workspaces (backend _check_nb_of_workspaces,
|
||||
// run only on community builds). An enterprise license lifts the cap. We
|
||||
// mirror the backend count with the client-side workspace list to hide the
|
||||
// mirror the backend count with the client-side workspace list to disable the
|
||||
// affordance once the cap is reached; the server still enforces the real
|
||||
// (instance-wide) check on commit, so this is purely UX.
|
||||
const CE_MAX_NON_ADMIN_WORKSPACES = 2
|
||||
@@ -189,20 +189,24 @@
|
||||
const ceWorkspaceCapReached = $derived(
|
||||
!$enterpriseLicense && nonAdminWorkspaceCount >= CE_MAX_NON_ADMIN_WORKSPACES
|
||||
)
|
||||
// The interactive create-fork row is shown unless the cap is reached;
|
||||
// otherwise (structural gate open but cap hit) we surface a disabled row
|
||||
// explaining the limit — never stage a fork the backend would reject.
|
||||
// Structural: the consumer wired up a create path at all, and there's a family to fork from. The
|
||||
// row is absent only in that case — every other blocker keeps it visible and names itself.
|
||||
const forkAffordanceOpen = $derived(
|
||||
allowCreateFork && forksGateOpen && (!!onCreateFork || !!onRequestCreateFork) && !!root
|
||||
allowCreateFork && (!!onCreateFork || !!onRequestCreateFork) && !!root
|
||||
)
|
||||
// The upsell (CE workspace cap) only applies to in-place inline creation;
|
||||
// onRequestCreateFork delegates to a flow that enforces its own limits.
|
||||
const showCreateFork = $derived(
|
||||
forkAffordanceOpen && (!ceWorkspaceCapReached || !!onRequestCreateFork)
|
||||
)
|
||||
const showForkUpsell = $derived(
|
||||
forkAffordanceOpen && ceWorkspaceCapReached && !onRequestCreateFork
|
||||
// Blocker for the create-fork row: the gate above, else the CE workspace cap. The cap only applies
|
||||
// to in-place inline creation; onRequestCreateFork delegates to a flow that enforces its own
|
||||
// limits. Never stage a fork the backend would reject.
|
||||
const createForkBlocked = $derived<ForkBlockedReason | undefined>(
|
||||
forkGateReason ??
|
||||
(ceWorkspaceCapReached && !onRequestCreateFork
|
||||
? {
|
||||
note: 'Workspace limit reached',
|
||||
title: `Community edition is limited to ${CE_MAX_NON_ADMIN_WORKSPACES + 1} workspaces. Archive a workspace or upgrade to an enterprise license to create more forks.`
|
||||
}
|
||||
: undefined)
|
||||
)
|
||||
const showCreateFork = $derived(forkAffordanceOpen && !createForkBlocked)
|
||||
|
||||
let dropdownOpen = $state(false)
|
||||
let creatingFork = $state(false)
|
||||
@@ -559,17 +563,16 @@
|
||||
<span>{createForkLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{:else if showForkUpsell}
|
||||
{:else if forkAffordanceOpen && createForkBlocked}
|
||||
<div class="my-1 border-t border-border-light shrink-0"></div>
|
||||
<div
|
||||
class={`${rowBase} opacity-60 cursor-not-allowed`}
|
||||
aria-disabled="true"
|
||||
title="Community edition is limited to {CE_MAX_NON_ADMIN_WORKSPACES +
|
||||
1} workspaces. Archive a workspace or upgrade to an enterprise license to create more forks."
|
||||
title={createForkBlocked.title}
|
||||
>
|
||||
<Plus size={14} class="shrink-0 text-tertiary" />
|
||||
<span>{createForkLabel}</span>
|
||||
<span class="ml-auto shrink-0 text-2xs text-tertiary"> Workspace limit reached </span>
|
||||
<span class="ml-auto shrink-0 text-2xs text-tertiary">{createForkBlocked.note}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if settingsHref}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
findWorkspaceRoot,
|
||||
isForkOwner
|
||||
} from '$lib/utils/workspaceHierarchy'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { forkBlockedReason } from '$lib/utils/editInFork'
|
||||
import { getContrastTextColor } from '$lib/utils'
|
||||
import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
|
||||
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
|
||||
@@ -113,10 +113,10 @@
|
||||
return withForks
|
||||
})
|
||||
// Gate for the "Workspace fork" entry pinned below the list (the global fork
|
||||
// modal carries its own base-workspace picker). Hidden on non-premium cloud,
|
||||
// in the admins workspace, or when forking is disabled.
|
||||
const canForkHere = $derived(
|
||||
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore)
|
||||
// modal carries its own base-workspace picker). When forking is unavailable the
|
||||
// entry stays, disabled and carrying the reason, rather than disappearing.
|
||||
const forkBlocked = $derived(
|
||||
forkBlockedReason($userStore, $workspaceStore, { premium: $maybePremium })
|
||||
)
|
||||
const familyWorkspaces = $derived.by(() => {
|
||||
if (strictWorkspaceSelect) return hierarchy
|
||||
@@ -375,7 +375,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
|
||||
{#if !strictWorkspaceSelect}
|
||||
<div class="py-1" role="none">
|
||||
{#if isCloudHosted() || $superadmin}
|
||||
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
|
||||
@@ -383,7 +383,21 @@
|
||||
Workspace
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{#if canForkHere}
|
||||
{#if forkBlocked}
|
||||
<!-- Kept visible so the reason forking is unavailable is readable here, rather than
|
||||
leaving the entry to silently vanish. -->
|
||||
<div
|
||||
class="text-primary font-normal w-full flex flex-row gap-2 items-center px-4 py-2 text-xs opacity-60 cursor-not-allowed"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
aria-disabled="true"
|
||||
title={forkBlocked.title}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Workspace fork
|
||||
<span class="ml-auto shrink-0 text-2xs text-tertiary">{forkBlocked.note}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
onClick={() => (globalForkModal.val = { opened: true })}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { checkItemExists } from '$lib/utils_workspace_deploy'
|
||||
import { updateDevWorkspaceModal } from '$lib/utils/editInForkModal.svelte'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
export type ItemType = 'script' | 'flow' | 'app' | 'raw_app'
|
||||
|
||||
@@ -55,6 +56,41 @@ export function canCreateFork(user: UserExt | undefined): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/** Why a create-fork entry is disabled: a short right-hand note, and the tooltip that explains it. */
|
||||
export type ForkBlockedReason = { note: string; title: string }
|
||||
|
||||
/**
|
||||
* The blocker on creating a fork of the given workspace, or undefined when there is none. Callers
|
||||
* render the create-fork entry disabled and carrying this reason rather than dropping it, so a user
|
||||
* who can't fork reads why instead of hunting for a missing entry. `premium` comes from
|
||||
* `maybePremium` (forking is metered per paid seat on cloud); `hasDevWorkspace` relaxes the forking
|
||||
* rule, since a locked prod's dev workspace is itself forkable.
|
||||
*/
|
||||
export function forkBlockedReason(
|
||||
user: UserExt | undefined,
|
||||
workspaceId: string | undefined,
|
||||
{ premium, hasDevWorkspace = false }: { premium: boolean; hasDevWorkspace?: boolean }
|
||||
): ForkBlockedReason | undefined {
|
||||
if (workspaceId === 'admins')
|
||||
return {
|
||||
note: 'Not forkable',
|
||||
title: 'The admins workspace cannot be forked. Switch to another workspace to create a fork.'
|
||||
}
|
||||
if (isCloudHosted() && !premium)
|
||||
return {
|
||||
note: 'Paid plans only',
|
||||
title:
|
||||
'Forking a workspace is available on paid plans. Upgrade this workspace to create forks.'
|
||||
}
|
||||
if (!canCreateFork(user) && !hasDevWorkspace)
|
||||
return {
|
||||
note: 'Disabled',
|
||||
title:
|
||||
'A protection rule disables forking of this workspace. A workspace admin can change it in the workspace settings.'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function editPathFor(itemType: ItemType, itemPath: string): string {
|
||||
switch (itemType) {
|
||||
case 'script':
|
||||
|
||||
Reference in New Issue
Block a user