mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(settings): unify save/discard across editable surfaces (#6173)
Save is now always rendered (primary, disabled until there is something to save) and Discard only appears once there is something to discard. Previously sandboxes showed no action at all until dirty — so a new sandbox had no visible Create button — while skills always showed a disabled Save and no Discard. - saveDiscardActions moves to @/components/settings and owns the one rule - adds SettingsActionChip / SettingsActionChips so the settings shell and the ReactNode-slot headers render actions through the same chip path - skills, secrets, connected credentials, custom tools and the secrets manager drop their hand-rolled Save chips for the shared helper - credential-detail drafts seed once per credential instead of re-seeding from every refetch, which was acting as an implicit discard
This commit is contained in:
@@ -167,11 +167,32 @@ Any settings surface with editable state uses **one** shared stack — never
|
||||
hand-roll a Save button, a Discard button, a `beforeunload`, or an "Unsaved
|
||||
changes" modal:
|
||||
|
||||
- **`saveDiscardActions(config)`** (`…/components/save-discard-actions/save-discard-actions`)
|
||||
— returns the canonical dirty-gated **Discard + Save** `SettingsAction[]` (empty
|
||||
when not dirty). Spread it into a `SettingsPanel` `actions` array, beside any
|
||||
- **`saveDiscardActions(config)`** (`@/components/settings/save-discard-actions`)
|
||||
— returns the canonical **Discard + Save** `SettingsAction[]`. **Save is always
|
||||
rendered** (primary), disabled until there is something to save, so every
|
||||
editable surface announces its primary action in the same place and a create
|
||||
form is never a page with no visible way to commit it; **Discard appears only
|
||||
when dirty**. Spread it into a `SettingsPanel` `actions` array, beside any
|
||||
sibling actions (a detail view's Delete / Remove override). Config: `dirty`,
|
||||
`saving`, `onSave`, `onDiscard`, `saveDisabled?`, `saveLabel?`, `savingLabel?`.
|
||||
`saving`, `onSave`, `onDiscard`, `saveDisabled?`, `saveTooltip?`, `creating?`,
|
||||
`saveLabel?`, `savingLabel?`. Create flows pass `creating` — the
|
||||
Create / Creating... labels come as a pair and can never drift apart.
|
||||
`saveLabel`/`savingLabel` are only for genuinely bespoke wording (SSO's
|
||||
`Update`); never hand-roll the pair to get a create label.
|
||||
- **`<SaveDiscardChips {...config} />`** (same module) — the identical rule
|
||||
rendered as chips, for surfaces whose header takes a `ReactNode` instead of
|
||||
action data (`CredentialDetailLayout`: skills, secrets, connected credentials).
|
||||
Both stacks derive from the one function; never hand-roll a Save chip.
|
||||
|
||||
`CredentialDetailLayout` stays slot-driven for exactly two reasons: its back
|
||||
control is a real `<ChipLink href>` (deep-linkable / middle-clickable, which
|
||||
`SettingsBackAction`'s `onSelect` cannot express), and actions like
|
||||
`SkillImportButton` own a hidden file input and their own pending state.
|
||||
**Everything else in one of those headers should be `SettingsAction` data**
|
||||
rendered through `<SettingsActionChips actions={…} />` from
|
||||
`@/components/settings/settings-header` — that is the shared chip path, and it
|
||||
is what keeps tone/icon/variant/tooltip handling from drifting between the two
|
||||
shells. Reach for it before hand-rolling a `Chip`.
|
||||
- **`useSettingsUnsavedGuard({ isDirty })`** (`…/settings/hooks/use-settings-unsaved-guard`)
|
||||
— syncs the page's local `isDirty` into the shared `useSettingsDirtyStore` (so
|
||||
the sidebar's **section-switch** confirm + the centralized `beforeunload` both
|
||||
|
||||
+22
-6
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
@@ -30,11 +30,21 @@ export function useCredentialDetailForm({
|
||||
|
||||
const [displayNameDraft, setDisplayNameDraft] = useState('')
|
||||
const [descriptionDraft, setDescriptionDraft] = useState('')
|
||||
const [seededCredentialId, setSeededCredentialId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayNameDraft(credential?.displayName ?? '')
|
||||
setDescriptionDraft(credential?.description ?? '')
|
||||
}, [credential?.id, credential?.displayName, credential?.description])
|
||||
// Seed drafts when the credential first resolves (or the route id changes); a
|
||||
// background refetch of the same credential must not clobber an in-progress
|
||||
// edit — Discard is the one way to reset.
|
||||
/** Applies a credential to both drafts — the one definition of "reset to server state". */
|
||||
const seedDrafts = useCallback((source: WorkspaceCredential) => {
|
||||
setDisplayNameDraft(source.displayName)
|
||||
setDescriptionDraft(source.description ?? '')
|
||||
}, [])
|
||||
|
||||
if (credential && credential.id !== seededCredentialId) {
|
||||
setSeededCredentialId(credential.id)
|
||||
seedDrafts(credential)
|
||||
}
|
||||
|
||||
const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
|
||||
const isDescriptionDirty = credential
|
||||
@@ -68,9 +78,14 @@ export function useCredentialDetailForm({
|
||||
isDescriptionDirty,
|
||||
displayNameDraft,
|
||||
descriptionDraft,
|
||||
updateCredential,
|
||||
updateCredential.mutateAsync,
|
||||
updateCredential.isPending,
|
||||
])
|
||||
|
||||
const discard = useCallback(() => {
|
||||
if (credential) seedDrafts(credential)
|
||||
}, [credential, seedDrafts])
|
||||
|
||||
return {
|
||||
displayNameDraft,
|
||||
setDisplayNameDraft,
|
||||
@@ -78,6 +93,7 @@ export function useCredentialDetailForm({
|
||||
setDescriptionDraft,
|
||||
isDirty,
|
||||
save,
|
||||
discard,
|
||||
isSaving: updateCredential.isPending,
|
||||
handleBackClick: guard.handleBackClick,
|
||||
showUnsavedAlert: guard.showUnsavedAlert,
|
||||
|
||||
@@ -40,8 +40,7 @@ const MEMBERSHIP_OPTIONS = [
|
||||
|
||||
type Membership = (typeof MEMBERSHIP_OPTIONS)[number]['value']
|
||||
|
||||
const MEMBERSHIP_HINTS: Record<Membership, string> = {
|
||||
member: 'Joins your organization. Adds a seat.',
|
||||
const MEMBERSHIP_HINTS: Partial<Record<Membership, string>> = {
|
||||
admin: 'Joins your organization and can manage it. Adds a seat.',
|
||||
external:
|
||||
'Access to the selected workspaces only — no seat. Only available for people already on a paid Sim plan.',
|
||||
|
||||
+7
-3
@@ -15,6 +15,7 @@ import { ArrowLeft } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
|
||||
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { resolveCredentialDisplay } from '@/lib/integrations'
|
||||
import {
|
||||
@@ -204,9 +205,12 @@ export function ConnectedCredentialDetail({
|
||||
>
|
||||
Disconnect
|
||||
</Chip>
|
||||
<Chip onClick={form.save} disabled={!form.isDirty || form.isSaving}>
|
||||
{form.isSaving ? 'Saving...' : 'Save'}
|
||||
</Chip>
|
||||
<SaveDiscardChips
|
||||
dirty={form.isDirty}
|
||||
saving={form.isSaving}
|
||||
onSave={form.save}
|
||||
onDiscard={form.discard}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
|
||||
|
||||
+12
-30
@@ -5,6 +5,7 @@ import { ChipConfirmModal, toast } from '@sim/emcn'
|
||||
import { ArrowLeft, Wrench } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import { ResourceTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
CredentialDetailHeading,
|
||||
@@ -23,8 +24,6 @@ import {
|
||||
useSchemaGeneration,
|
||||
validateCustomToolSchema,
|
||||
} from '@/app/workspace/[workspaceId]/components/custom-tool-editor'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
@@ -50,9 +49,9 @@ interface CustomToolDetailProps {
|
||||
|
||||
/**
|
||||
* Full-page custom tool editor rendered as a settings detail sub-view: a back
|
||||
* chip, dirty-gated Discard/Save, Delete, and the Schema and Code editors
|
||||
* stacked (no tabs — the page has room for both). Uses the same fields as the
|
||||
* canvas modal so the two surfaces never drift.
|
||||
* chip, Save/Discard, Delete, and the Schema and Code editors stacked (no tabs —
|
||||
* the page has room for both). Uses the same fields as the canvas modal so the
|
||||
* two surfaces never drift.
|
||||
*/
|
||||
export function CustomToolDetail({
|
||||
workspaceId,
|
||||
@@ -200,22 +199,6 @@ export function CustomToolDetail({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On create, the primary action is always visible so the page announces what
|
||||
* it is for — disabled until the schema is a valid function definition.
|
||||
* (`saveDiscardActions` is dirty-gated and would render nothing on an empty
|
||||
* draft.) Discard still only appears once there is something to discard.
|
||||
*/
|
||||
const createToolActions: SettingsAction[] = [
|
||||
...(dirty ? [{ text: 'Discard', onSelect: handleDiscard, disabled: saving }] : []),
|
||||
{
|
||||
text: saving ? 'Creating...' : 'Create',
|
||||
variant: 'primary' as const,
|
||||
onSelect: handleSave,
|
||||
disabled: saving || streaming || !isSchemaValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
@@ -224,15 +207,14 @@ export function CustomToolDetail({
|
||||
actions={[
|
||||
...(readOnly
|
||||
? []
|
||||
: isEditing
|
||||
? saveDiscardActions({
|
||||
dirty,
|
||||
saving,
|
||||
onSave: handleSave,
|
||||
onDiscard: handleDiscard,
|
||||
saveDisabled: !isSchemaValid || streaming,
|
||||
})
|
||||
: createToolActions),
|
||||
: saveDiscardActions({
|
||||
dirty,
|
||||
saving,
|
||||
onSave: handleSave,
|
||||
onDiscard: handleDiscard,
|
||||
saveDisabled: !isSchemaValid || streaming,
|
||||
creating: !isEditing,
|
||||
})),
|
||||
...(tool && !readOnly
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useParams } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { CodeIcon } from '@/components/icons'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import type { SandboxDependencyIssue } from '@/lib/api/contracts/sandboxes'
|
||||
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
type SandboxDraft,
|
||||
toSubmittedLines,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
|
||||
@@ -202,6 +202,7 @@ export function Sandboxes() {
|
||||
setIssues([])
|
||||
},
|
||||
saveDisabled: !canAdmin || current.name.trim().length === 0,
|
||||
creating: isCreating,
|
||||
}),
|
||||
...(selected && canAdmin
|
||||
? [
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
|
||||
interface SaveDiscardActionsConfig {
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
onSave: () => void
|
||||
onDiscard: () => void
|
||||
saveDisabled?: boolean
|
||||
savingLabel?: string
|
||||
saveLabel?: string
|
||||
}
|
||||
|
||||
/** The dirty-gated Discard + Save action pair for settings surfaces — empty when not dirty. */
|
||||
export function saveDiscardActions({
|
||||
dirty,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveDisabled = false,
|
||||
savingLabel = 'Saving...',
|
||||
saveLabel = 'Save',
|
||||
}: SaveDiscardActionsConfig): SettingsAction[] {
|
||||
if (!dirty) return []
|
||||
return [
|
||||
{ text: 'Discard', onSelect: onDiscard, disabled: saving },
|
||||
{
|
||||
text: saving ? savingLabel : saveLabel,
|
||||
variant: 'primary',
|
||||
onSelect: onSave,
|
||||
disabled: saving || saveDisabled,
|
||||
},
|
||||
]
|
||||
}
|
||||
+13
-22
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
|
||||
@@ -17,7 +18,6 @@ import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/cr
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { isValidEnvVarName } from '@/executor/constants'
|
||||
@@ -981,27 +981,18 @@ export function SecretsManager() {
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search secrets...',
|
||||
}}
|
||||
actions={[
|
||||
...(hasChanges
|
||||
? [
|
||||
{
|
||||
text: 'Discard',
|
||||
onSelect: handleCancel,
|
||||
disabled: isListSaving,
|
||||
} satisfies SettingsAction,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: isListSaving ? 'Saving...' : 'Save',
|
||||
onSelect: handleSave,
|
||||
disabled: hasConflicts || hasInvalidKeys || isLoading || !hasChanges || isListSaving,
|
||||
tooltip: hasConflicts
|
||||
? 'Resolve all conflicts before saving'
|
||||
: hasInvalidKeys
|
||||
? 'Fix invalid variable names before saving'
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
actions={saveDiscardActions({
|
||||
dirty: hasChanges,
|
||||
saving: isListSaving,
|
||||
onSave: handleSave,
|
||||
onDiscard: handleCancel,
|
||||
saveDisabled: hasConflicts || hasInvalidKeys || isLoading,
|
||||
saveTooltip: hasConflicts
|
||||
? 'Resolve all conflicts before saving'
|
||||
: hasInvalidKeys
|
||||
? 'Fix invalid variable names before saving'
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className='flex flex-col gap-7'>
|
||||
|
||||
+12
-1
@@ -97,5 +97,16 @@ export function useSecretValue({ workspaceId, credential }: UseSecretValueParams
|
||||
}
|
||||
}
|
||||
|
||||
return { value: draft, setValue: setDraft, canEdit, isConflicted, isDirty, save, isSaving }
|
||||
const discard = () => setDraft(currentValue)
|
||||
|
||||
return {
|
||||
value: draft,
|
||||
setValue: setDraft,
|
||||
canEdit,
|
||||
isConflicted,
|
||||
isDirty,
|
||||
save,
|
||||
discard,
|
||||
isSaving,
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn'
|
||||
import { ArrowLeft, Key } from '@sim/emcn/icons'
|
||||
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
|
||||
import {
|
||||
AddPeopleModal,
|
||||
CredentialDetailHeading,
|
||||
@@ -51,9 +52,12 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
|
||||
</Chip>
|
||||
)}
|
||||
{canEditValue && (
|
||||
<Chip onClick={valueField.save} disabled={!valueField.isDirty || valueField.isSaving}>
|
||||
{valueField.isSaving ? 'Saving...' : 'Save'}
|
||||
</Chip>
|
||||
<SaveDiscardChips
|
||||
dirty={valueField.isDirty}
|
||||
saving={valueField.isSaving}
|
||||
onSave={valueField.save}
|
||||
onDiscard={valueField.discard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AddPeopleModal } from '@/components/permissions'
|
||||
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
|
||||
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
CredentialDetailHeading,
|
||||
@@ -36,7 +37,7 @@ interface SkillDetailProps {
|
||||
|
||||
/**
|
||||
* Full-page skill detail, mirroring the integration credential detail surface:
|
||||
* a fixed action bar (Share / Delete / Save), a heading, editable Name /
|
||||
* a fixed action bar (Share / Delete / Discard / Save), a heading, editable Name /
|
||||
* Description / Content sections, and the Skill Editors roster. Non-editors
|
||||
* and built-in template skills render read-only.
|
||||
*/
|
||||
@@ -71,15 +72,20 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [prevSkillId, setPrevSkillId] = useState<string | null>(null)
|
||||
|
||||
/** Applies a full skill shape to all three drafts and remounts the Content editor. */
|
||||
const seedDrafts = (source: { name: string; description: string; content: string }) => {
|
||||
setNameDraft(source.name)
|
||||
setDescriptionDraft(source.description)
|
||||
setContentDraft(source.content)
|
||||
setErrors({})
|
||||
setContentSeed((seed) => seed + 1)
|
||||
}
|
||||
|
||||
// Seed drafts when the skill first resolves (or the route id changes); a
|
||||
// background refetch of the same skill must not clobber an in-progress edit.
|
||||
if (skill && skill.id !== prevSkillId) {
|
||||
setPrevSkillId(skill.id)
|
||||
setNameDraft(skill.name)
|
||||
setDescriptionDraft(skill.description)
|
||||
setContentDraft(skill.content)
|
||||
setErrors({})
|
||||
setContentSeed((seed) => seed + 1)
|
||||
seedDrafts(skill)
|
||||
}
|
||||
|
||||
const isDirty =
|
||||
@@ -155,11 +161,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
|
||||
const handleContentPaste = (text: string): boolean => {
|
||||
const parsed = parseSkillMarkdown(text)
|
||||
if (!parsed.nameFromFrontmatter) return false
|
||||
setNameDraft(parsed.name)
|
||||
setDescriptionDraft(parsed.description)
|
||||
setContentDraft(parsed.content)
|
||||
setErrors({})
|
||||
setContentSeed((seed) => seed + 1)
|
||||
seedDrafts(parsed)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -169,6 +171,10 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
|
||||
</ChipLink>
|
||||
)
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (skill) seedDrafts(skill)
|
||||
}
|
||||
|
||||
const actions =
|
||||
skill && canEdit ? (
|
||||
<>
|
||||
@@ -178,9 +184,12 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
|
||||
<Chip onClick={() => setShowDeleteConfirm(true)} disabled={deleteSkill.isPending}>
|
||||
Delete
|
||||
</Chip>
|
||||
<Chip onClick={handleSave} disabled={!isDirty || updateSkill.isPending}>
|
||||
{updateSkill.isPending ? 'Saving...' : 'Save'}
|
||||
</Chip>
|
||||
<SaveDiscardChips
|
||||
dirty={isDirty}
|
||||
saving={updateSkill.isPending}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Chip, ChipLink, toast } from '@sim/emcn'
|
||||
import { ChipLink, toast } from '@sim/emcn'
|
||||
import { ArrowLeft } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
|
||||
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
CredentialDetailHeading,
|
||||
@@ -34,7 +35,7 @@ interface SkillCreateProps {
|
||||
|
||||
/**
|
||||
* Full-page skill creation, mirroring the skill detail surface: a fixed action
|
||||
* bar (Import / Create skill), a heading, and the editable Name / Description /
|
||||
* bar (Import / Discard / Create), a heading, and the editable Name / Description /
|
||||
* Content sections. Importing a SKILL.md prefills all three fields in place.
|
||||
*/
|
||||
export function SkillCreate({ workspaceId }: SkillCreateProps) {
|
||||
@@ -90,10 +91,11 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const applyImportedSkill = (data: ParsedSkill) => {
|
||||
setNameDraft(data.name)
|
||||
setDescriptionDraft(data.description)
|
||||
setContentDraft(data.content)
|
||||
/** Applies a full skill shape to all three drafts and remounts the Content editor. */
|
||||
const seedDrafts = (source: Pick<ParsedSkill, 'name' | 'description' | 'content'>) => {
|
||||
setNameDraft(source.name)
|
||||
setDescriptionDraft(source.description)
|
||||
setContentDraft(source.content)
|
||||
setErrors({})
|
||||
setContentSeed((seed) => seed + 1)
|
||||
}
|
||||
@@ -106,7 +108,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
|
||||
const handleContentPaste = (text: string): boolean => {
|
||||
const parsed = parseSkillMarkdown(text)
|
||||
if (!parsed.nameFromFrontmatter) return false
|
||||
applyImportedSkill(parsed)
|
||||
seedDrafts(parsed)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -116,12 +118,18 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
|
||||
</ChipLink>
|
||||
)
|
||||
|
||||
const handleDiscard = () => seedDrafts({ name: '', description: '', content: '' })
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<SkillImportButton onImport={applyImportedSkill} disabled={createSkill.isPending} />
|
||||
<Chip variant='primary' onClick={handleCreate} disabled={createSkill.isPending}>
|
||||
{createSkill.isPending ? 'Creating...' : 'Create'}
|
||||
</Chip>
|
||||
<SkillImportButton onImport={seedDrafts} disabled={createSkill.isPending} />
|
||||
<SaveDiscardChips
|
||||
dirty={isDirty}
|
||||
saving={createSkill.isPending}
|
||||
onSave={handleCreate}
|
||||
onDiscard={handleDiscard}
|
||||
creating
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type SettingsAction, SettingsActionChips } from '@/components/settings/settings-header'
|
||||
|
||||
export interface SaveDiscardActionsConfig {
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
onSave: () => void
|
||||
onDiscard: () => void
|
||||
saveDisabled?: boolean
|
||||
/** Labels the primary action `Create` / `Creating...` instead of `Save` / `Saving...`. */
|
||||
creating?: boolean
|
||||
/** Bespoke wording (e.g. `Update`). Prefer `creating` for the standard create flow. */
|
||||
saveLabel?: string
|
||||
/** Bespoke in-flight wording. Pair it with `saveLabel` so the two can't drift. */
|
||||
savingLabel?: string
|
||||
/** Explains a `saveDisabled` Save on hover — Save is always visible, so a blocked one should say why. */
|
||||
saveTooltip?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical Save/Discard header actions for any editable surface.
|
||||
*
|
||||
* Save is always rendered — every editable surface announces its primary action
|
||||
* in the same place, and a create form is never a page with no visible way to
|
||||
* commit it — and is disabled until there is something to save. Discard only
|
||||
* appears once there is something to discard.
|
||||
*/
|
||||
export function saveDiscardActions({
|
||||
dirty,
|
||||
saving,
|
||||
onSave,
|
||||
onDiscard,
|
||||
saveDisabled = false,
|
||||
creating = false,
|
||||
saveLabel = creating ? 'Create' : 'Save',
|
||||
savingLabel = creating ? 'Creating...' : 'Saving...',
|
||||
saveTooltip,
|
||||
}: SaveDiscardActionsConfig): SettingsAction[] {
|
||||
return [
|
||||
...(dirty ? [{ id: 'discard', text: 'Discard', onSelect: onDiscard, disabled: saving }] : []),
|
||||
{
|
||||
id: 'save',
|
||||
text: saving ? savingLabel : saveLabel,
|
||||
variant: 'primary',
|
||||
onSelect: onSave,
|
||||
disabled: saving || saveDisabled || !dirty,
|
||||
tooltip: saveTooltip,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link saveDiscardActions} rendered as chips, for surfaces whose header takes
|
||||
* a `ReactNode` (`CredentialDetailLayout`) instead of the settings shell's
|
||||
* action data. Both stacks derive their chips from the one rule above.
|
||||
*/
|
||||
export function SaveDiscardChips(config: SaveDiscardActionsConfig) {
|
||||
return <SettingsActionChips actions={saveDiscardActions(config)} />
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
|
||||
|
||||
export interface SettingsAction {
|
||||
/** Stable render identity. Falls back to `text`, which remounts the chip whenever the label flips (Save → Saving...). */
|
||||
id?: string
|
||||
text: string
|
||||
textTone?: 'error'
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
@@ -116,6 +118,75 @@ export function useSettingsHeader(config: SettingsHeaderConfig) {
|
||||
}, [register])
|
||||
}
|
||||
|
||||
interface SettingsActionChipProps {
|
||||
/** Presentation fields plus the default `onSelect` / `onPrefetch` handlers. */
|
||||
action: SettingsAction
|
||||
/** Overrides `action.onSelect` — the header shell passes a ref-reading indirection to avoid stale closures. */
|
||||
onSelect?: () => void
|
||||
/** Overrides `action.onPrefetch`, same reason. */
|
||||
onPrefetch?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The one chip rendering of a {@link SettingsAction}. Both header stacks render
|
||||
* through this, so an action's chrome — tone, icon, variant, disabled, tooltip —
|
||||
* is identical wherever it is mounted. Container spacing still belongs to the
|
||||
* enclosing shell.
|
||||
*/
|
||||
export function SettingsActionChip({
|
||||
action,
|
||||
onSelect = action.onSelect,
|
||||
onPrefetch = action.onPrefetch,
|
||||
}: SettingsActionChipProps) {
|
||||
const chip = (
|
||||
<Chip
|
||||
variant={action.variant}
|
||||
active={action.active}
|
||||
leftIcon={action.icon}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onPrefetch}
|
||||
onFocus={onPrefetch}
|
||||
disabled={action.disabled}
|
||||
// A disabled <button> is not a hit-test target, so the tooltip's wrapping
|
||||
// span would never see pointerenter and the explanation would never show.
|
||||
className={cn(action.tooltip && action.disabled && 'pointer-events-none')}
|
||||
>
|
||||
{action.textTone === 'error' ? (
|
||||
<span className='text-[var(--text-error)]'>{action.text}</span>
|
||||
) : (
|
||||
action.text
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
if (!action.tooltip) return chip
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>{chip}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{action.tooltip}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a {@link SettingsAction} list as chips using each action's own
|
||||
* handlers. This is the data path for headers that take a `ReactNode`
|
||||
* (`CredentialDetailLayout`) — reach for it instead of hand-rolling `Chip`s, so
|
||||
* those surfaces keep the same chrome as the settings shell. The shell itself
|
||||
* maps through {@link SettingsActionChip} directly, since it must route every
|
||||
* handler through a ref to avoid stale closures.
|
||||
*/
|
||||
export function SettingsActionChips({ actions }: { actions: SettingsAction[] }) {
|
||||
return (
|
||||
<>
|
||||
{actions.map((action) => (
|
||||
<SettingsActionChip key={action.id ?? action.text} action={action} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsHeaderShell({ children }: { children: ReactNode }) {
|
||||
const read = useContext(ReadContext)
|
||||
const configRef = read?.configRef
|
||||
@@ -138,44 +209,18 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) {
|
||||
Docs
|
||||
</ChipLink>
|
||||
)}
|
||||
{actions?.map((action, index) => {
|
||||
const chip = (
|
||||
<Chip
|
||||
key={action.text}
|
||||
variant={action.variant}
|
||||
active={action.active}
|
||||
leftIcon={action.icon}
|
||||
onClick={() => configRef?.current.actions?.[index]?.onSelect()}
|
||||
onMouseEnter={
|
||||
action.onPrefetch
|
||||
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
|
||||
: undefined
|
||||
}
|
||||
onFocus={
|
||||
action.onPrefetch
|
||||
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
|
||||
: undefined
|
||||
}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.textTone === 'error' ? (
|
||||
<span className='text-[var(--text-error)]'>{action.text}</span>
|
||||
) : (
|
||||
action.text
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
return action.tooltip ? (
|
||||
<Tooltip.Root key={action.text}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex'>{chip}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{action.tooltip}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
chip
|
||||
)
|
||||
})}
|
||||
{actions?.map((action, index) => (
|
||||
<SettingsActionChip
|
||||
key={action.id ?? action.text}
|
||||
action={action}
|
||||
onSelect={() => configRef?.current.actions?.[index]?.onSelect()}
|
||||
onPrefetch={
|
||||
action.onPrefetch
|
||||
? () => configRef?.current.actions?.[index]?.onPrefetch?.()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
|
||||
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
|
||||
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
MemberRow,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/member-list'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ArrowLeft, ChevronDown, Image as ImageIcon, X } from 'lucide-react'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import {
|
||||
type FlattenOutputsBlockInput,
|
||||
type FlattenOutputsEdgeInput,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
|
||||
@@ -20,6 +20,8 @@ import { toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
import { CustomPatternsEditor } from '@/components/pii/custom-patterns-editor'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import type { SettingsAction } from '@/components/settings/settings-header'
|
||||
import type { UpdateOrganizationDataRetentionBody } from '@/lib/api/contracts/organization'
|
||||
import type { RetentionOverride } from '@/lib/api/contracts/primitives'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
@@ -41,9 +43,7 @@ import {
|
||||
stripNerEntities,
|
||||
} from '@/lib/guardrails/pii-entities'
|
||||
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChipConfirmModal, ChipInput, Label, toast } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import {
|
||||
MAX_SESSION_POLICY_HOURS,
|
||||
MIN_IDLE_TIMEOUT_HOURS,
|
||||
MIN_SESSION_LIFETIME_HOURS,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
|
||||
@@ -58,12 +58,9 @@ vi.mock('@/ee/sso/components/verified-domains-section', () => ({
|
||||
VerifiedDomainsSection: () => <div />,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions',
|
||||
() => ({
|
||||
saveDiscardActions: () => [],
|
||||
})
|
||||
)
|
||||
vi.mock('@/components/settings/save-discard-actions', () => ({
|
||||
saveDiscardActions: () => [],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({
|
||||
SettingsEmptyState: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ChevronDown, Eye, EyeOff } from 'lucide-react'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import type { SettingsAction } from '@/components/settings/settings-header'
|
||||
import type { SsoRegistrationBody } from '@/lib/api/contracts/auth'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { Image as ImageIcon, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
||||
import { HEX_COLOR_REGEX } from '@/lib/branding'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
CHIP_FIELD_INPUT,
|
||||
CHIP_FIELD_SHELL,
|
||||
} from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { AlertTriangle, Plus } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { saveDiscardActions } from '@/components/settings/save-discard-actions'
|
||||
import type { SettingsAction } from '@/components/settings/settings-header'
|
||||
import type { ForkLineageChildApi, ForkLineageNodeApi } from '@/lib/api/contracts/workspace-fork'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
|
||||
@@ -24,9 +26,7 @@ import {
|
||||
type RowAction,
|
||||
RowActionsMenu,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
|
||||
Reference in New Issue
Block a user