Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/sim/app/api/workspaces/[id]/changelog/[releaseId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { updateChangelogReleaseContract } from '@/lib/api/contracts/changelog'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { updateChangelogRelease } from '@/lib/changelog/application/changelog'
import { changelogOperations } from '@/lib/changelog/application/operations'

export const PATCH = defineInternalJsonRoute({
contract: updateChangelogReleaseContract,
auth: internalSessionAuth,
operation: changelogOperations.update,
rateLimit: internalRateLimits.user({ bucketName: 'changelog' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({
workspaceId: params.id,
releaseId: params.releaseId,
expectedRevision: body.expectedRevision,
title: body.title,
bumpReason: body.bumpReason,
version: body.version,
}),
useCase: updateChangelogRelease,
})
19 changes: 19 additions & 0 deletions apps/sim/app/api/workspaces/[id]/changelog/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { listChangelogReleasesContract } from '@/lib/api/contracts/changelog'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listChangelogReleases } from '@/lib/changelog/application/changelog'
import { changelogOperations } from '@/lib/changelog/application/operations'

export const GET = defineInternalJsonRoute({
contract: listChangelogReleasesContract,
auth: internalSessionAuth,
operation: changelogOperations.read,
rateLimit: internalRateLimits.user({ bucketName: 'changelog' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: params.id, cursor: query.cursor }),
useCase: listChangelogReleases,
})
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ async function render(
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
}}
Expand Down Expand Up @@ -311,6 +312,7 @@ it.each([
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': false,
'mothership-plan-mode': planEnabled,
}}
Expand Down Expand Up @@ -406,6 +408,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
}}
Expand Down
35 changes: 22 additions & 13 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation'
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isChangelogEnabled } from '@/lib/changelog/feature-flag'
import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
Expand Down Expand Up @@ -59,19 +60,26 @@ export default async function OrganizationLayout({
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
redirect(WORKSPACE_SETTINGS_PATH)

const [, modelSelectorEnabled, planModeEnabled, dashboardsEnabled, workflowTestsEnabled] =
await Promise.all([
prefetchOrganizationSidebar(
queryClient,
organizationId,
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
getActiveOrganizationId(session)
),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
isDashboardsEnabled(organizationId),
isWorkflowTestsEnabled(organizationId),
])
const [
,
modelSelectorEnabled,
planModeEnabled,
dashboardsEnabled,
workflowTestsEnabled,
changelogEnabled,
] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
organizationId,
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
getActiveOrganizationId(session)
),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
isDashboardsEnabled(organizationId),
isWorkflowTestsEnabled(organizationId),
isChangelogEnabled(organizationId),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

return (
Expand All @@ -80,6 +88,7 @@ export default async function OrganizationLayout({
flags={{
dashboards: dashboardsEnabled,
'workflow-tests': workflowTestsEnabled,
changelog: changelogEnabled,
'mothership-model-selector': modelSelectorEnabled,
'mothership-plan-mode': planModeEnabled,
}}
Expand Down
98 changes: 98 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import { useState } from 'react'
import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
import { Rss } from '@sim/emcn/icons'
import { EmptyState } from '@/components/empty-state/empty-state'
import type { ChangelogRelease } from '@/lib/api/contracts/changelog'
import {
ReleaseDetailsModal,
ReleaseEntry,
} from '@/app/workspace/[workspaceId]/changelog/components'
import ChangelogLoading from '@/app/workspace/[workspaceId]/changelog/loading'
import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
import { useChangelogReleases } from '@/hooks/queries/changelog'

interface ChangelogProps {
workspaceId: string
}

/** Every release Sim published in this workspace, newest first. */
export function Changelog({ workspaceId }: ChangelogProps) {
return (
<PermissionAccessBoundary configKey='hideFilesTab'>
<ChangelogContent workspaceId={workspaceId} />
</PermissionAccessBoundary>
)
}

function ChangelogContent({ workspaceId }: ChangelogProps) {
const canEdit = useUserPermissionsContext().canEdit === true
const query = useChangelogReleases(workspaceId)
const [editing, setEditing] = useState<ChangelogRelease | null>(null)
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const edges = useScrollEdges(scrollElement)

if (query.isPending) return <ChangelogLoading />
const releases = query.data?.pages.flatMap((page) => page.releases) ?? []

return (
<Resource>
<Resource.Header icon={Rss} title='Changelog' />
{query.error && !query.data ? (
<div role='alert' className='flex flex-col items-center gap-3 p-6'>
<p className='text-[var(--text-error)] text-small'>{query.error.message}</p>
<Chip onClick={() => query.refetch()}>Retry</Chip>
</div>
) : releases.length === 0 ? (
<EmptyState
title='Changelog'
description='Sim publishes a release here when it ships changes to this workspace.'
/>
) : (
<div
ref={setScrollElement}
className={cn(
'@container/changelog min-h-0 flex-1 overflow-y-auto py-10',
scrollFadeClass
)}
{...scrollFadeAttributes(edges)}
>
<div className='mx-auto flex max-w-[960px] flex-col gap-12 px-8'>
{releases.map((release) => (
<ReleaseEntry
key={release.id}
workspaceId={workspaceId}
release={release}
canEdit={canEdit}
onEditDetails={setEditing}
/>
))}
{query.hasNextPage && (
<div className='flex flex-col gap-2'>
<Chip onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
{query.isFetchingNextPage ? 'Loading...' : 'Show older releases'}
</Chip>
{query.isFetchNextPageError && (
<p role='alert' className='text-[var(--text-error)] text-small'>
{query.error?.message}
</p>
)}
</div>
)}
</div>
</div>
)}
{editing && (
<ReleaseDetailsModal
key={editing.id}
workspaceId={workspaceId}
release={editing}
onClose={() => setEditing(null)}
/>
)}
</Resource>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'

import type { ReactNode } from 'react'
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'

interface ChangelogFeatureGateProps {
children: ReactNode
}

export function ChangelogFeatureGate({ children }: ChangelogFeatureGateProps) {
const enabled = useFeatureFlag('changelog')
return enabled ? (
children
) : (
<div role='alert' className='p-6 text-[var(--text-muted)]'>
The changelog is not enabled
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { ChangelogFeatureGate } from './changelog-feature-gate/changelog-feature-gate'
export { ReleaseDetailsModal } from './release-details-modal/release-details-modal'
export { ReleaseEntry } from './release-entry/release-entry'
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client'

import { useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import type { ChangelogRelease } from '@/lib/api/contracts/changelog'
import { useUpdateChangelogRelease } from '@/hooks/queries/changelog'

interface ReleaseDetailsModalProps {
workspaceId: string
release: ChangelogRelease
onClose: () => void
}

/** Edits a release's title, version label, and bump reason under its revision. */
export function ReleaseDetailsModal({ workspaceId, release, onClose }: ReleaseDetailsModalProps) {
const [title, setTitle] = useState(release.title)
const [version, setVersion] = useState(release.version)
const [bumpReason, setBumpReason] = useState(release.bumpReason)
const update = useUpdateChangelogRelease(workspaceId)

const save = () =>
update.mutate(
{
releaseId: release.id,
expectedRevision: release.revision,
...(title !== release.title ? { title } : {}),
...(version !== release.version ? { version } : {}),
...(bumpReason !== release.bumpReason ? { bumpReason } : {}),
},
{ onSuccess: onClose }
)

const unchanged =
title === release.title && version === release.version && bumpReason === release.bumpReason
const empty = !title.trim() || !version.trim() || !bumpReason.trim()

return (
<ChipModal open onOpenChange={(open) => !open && onClose()} srTitle='Edit release'>
<ChipModalHeader onClose={onClose}>Edit release</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='input'
title='Title'
value={title}
onChange={setTitle}
maxLength={200}
required
disabled={update.isPending}
/>
<ChipModalField
type='input'
title='Version'
value={version}
onChange={setVersion}
placeholder='1.4.0'
maxLength={40}
autoComplete='off'
required
disabled={update.isPending}
/>
<ChipModalField
type='textarea'
title='Why this version'
value={bumpReason}
onChange={setBumpReason}
rows={3}
maxLength={500}
required
disabled={update.isPending}
/>
<ChipModalError>{update.error?.message}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
cancelDisabled={update.isPending}
primaryAction={{
label: update.isPending ? 'Saving...' : 'Save',
onClick: save,
disabled: update.isPending || unchanged || empty,
}}
/>
</ChipModal>
)
}
Loading
Loading