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
4 changes: 4 additions & 0 deletions src/app/api/installed-apps/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { getActionableInstalls } from '@installed-apps/lib/installed-apps.controller'
import { withErrorHandler } from '@/lib/with-error-handler'

export const GET = withErrorHandler(getActionableInstalls)
3 changes: 3 additions & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const ROUTES = Object.freeze({
segment: '/api/segments/:segmentId',
segmentConfig: '/api/segments/config',
segmentStats: '/api/segments/stats',
installedApps: '/api/installed-apps',
},
})

Expand Down Expand Up @@ -53,6 +54,7 @@ export const authorizedRoutes: Record<string, RouteRule[]> = {
ROUTES.api.segment,
ROUTES.api.segmentConfig,
ROUTES.api.segmentStats,
ROUTES.api.installedApps,
],
clientUsers: [
ROUTES.api.workspace,
Expand All @@ -69,5 +71,6 @@ export const authorizedRoutes: Record<string, RouteRule[]> = {
ROUTES.api.notificationCounts,
ROUTES.api.bannerImages,
ROUTES.api.image,
ROUTES.api.installedApps,
],
}
26 changes: 22 additions & 4 deletions src/features/action-items/components/action-item.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Icon } from '@assembly-js/design-system'
import { useAuthStore } from '@auth/providers/auth.provider'
import type { ActionDefinition } from '@editor/components/Sidebar/Actions/constant'
import { useViewStore, ViewMode } from '@editor/stores/viewStore'
import { isDynamicAction, type RenderableAction } from '@installed-apps/lib/dynamic-action'
import { HandleBarTemplate } from '@/features/handlebar-template/components/handle-bar-template'
import { cn } from '@/utils/tailwind'

interface ActionItemProps {
action: ActionDefinition
action: RenderableAction
isLoading?: boolean
mode: ViewMode
className?: string
Expand All @@ -31,7 +31,9 @@ export const ActionItem = ({ action, isLoading, mode, className, count }: Action
const handleClick = () => {
if (!clientId) return

if (action.key === 'tasks' && tasksAppId) {
if (isDynamicAction(action)) {
window.parent.postMessage({ type: 'history.push', id: action.installId, route: 'apps' }, '*')
} else if (action.key === 'tasks' && tasksAppId) {
window.parent.postMessage({ type: 'history.push', id: tasksAppId, route: 'apps' }, '*')
} else {
window.parent.postMessage({ type: 'history.push', route: action.key }, '*')
Expand Down Expand Up @@ -62,7 +64,23 @@ export const ActionItem = ({ action, isLoading, mode, className, count }: Action
<span className="flex min-w-0 items-center gap-1 overflow-hidden font-medium text-sm">
<span className="shrink-0">{action.verb}</span>
<span className="flex min-w-0 shrink">
<HandleBarTemplate mode={mode} template={action.template} displayContent="{{N}}" fallbackValue={count ?? 0} />
{isDynamicAction(action) ? (
// Dynamic rows have no handlebar template. In the editor we show the same
// `{{N}}` placeholder chip as built-ins (the count is not yet resolved); in
// preview the resolved unread count is the whole value.
mode === ViewMode.EDITOR ? (
<HandleBarTemplate mode={mode} template="{{count}}" displayContent="{{N}}" />
) : (
<span>{count ?? 0}</span>
)
) : (
<HandleBarTemplate
mode={mode}
template={action.template}
displayContent="{{N}}"
fallbackValue={count ?? 0}
/>
)}
</span>
<span className="min-w-0 truncate [flex-shrink:9999]">{noun}</span>
</span>
Expand Down
19 changes: 11 additions & 8 deletions src/features/action-items/components/actions-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useViewStore, ViewMode } from '@editor/stores/viewStore'
import { isDynamicAction, type RenderableAction } from '@installed-apps/lib/dynamic-action'
import { useNotificationCounts } from '@notification-counts/hooks/useNotificationCounts'
import type { NotificationCountKey } from '@notification-counts/notification-counts.dto'
import { useEnabledActions } from '@settings/hooks/useEnabledActions'
Expand All @@ -17,12 +18,14 @@ export const ActionsCard = ({ readonly }: ActionCardProps) => {

const isPreviewMode = readonly || viewMode === ViewMode.PREVIEW

// Client/preview only surfaces actions that actually have pending items. In the
// editor, counts are placeholders, so we keep every enabled action visible so the
// admin can still configure them.
const visibleActions = isPreviewMode
? enabledActions.filter((action) => (counts?.[action.key as NotificationCountKey] ?? 0) > 0)
: enabledActions
// Dynamic app rows read their count from the per-app map; built-ins from the named fields.
const getCount = (action: RenderableAction): number | undefined =>
isDynamicAction(action) ? counts?.apps[action.appId] : counts?.[action.key as NotificationCountKey]

// Client/preview surfaces only rows with pending items; the editor keeps every enabled
// action visible (counts are placeholders) so the admin can see and configure them.
// This holds for both built-in and dynamic Studio-app rows.
const visibleActions = enabledActions.filter((action) => (isPreviewMode ? (getCount(action) ?? 0) > 0 : true))

const visibleCount = visibleActions.length

Expand Down Expand Up @@ -52,12 +55,12 @@ export const ActionsCard = ({ readonly }: ActionCardProps) => {
>
{visibleActions.map((action) => (
<ActionItem
key={action.key}
key={isDynamicAction(action) ? action.installId : action.key}
isLoading={isLoading}
action={action}
mode={isPreviewMode ? ViewMode.PREVIEW : ViewMode.EDITOR}
portalUrl={workspace?.portalUrl}
count={counts?.[action.key as NotificationCountKey]}
count={getCount(action)}
/>
))}
</div>
Expand Down
5 changes: 2 additions & 3 deletions src/features/editor/components/Sidebar/Actions/ActionItem.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Icon, Toggle } from '@assembly-js/design-system'
import { Icon, type IconType, Toggle } from '@assembly-js/design-system'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { ActionItemIcon } from '@/features/editor/components/Sidebar/Actions/type'

type ActionItemProps = {
id: string
icon: ActionItemIcon
icon: IconType
label: string
checked: boolean
onChange: () => void
Expand Down
5 changes: 3 additions & 2 deletions src/features/editor/components/Sidebar/Actions/constant.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ActionItemIcon, ActionItemLabelType } from '@editor/components/Sidebar/Actions/type'
import type { IconType } from '@assembly-js/design-system'
import type { ActionItemLabelType } from '@editor/components/Sidebar/Actions/type'
import type { SettingsUpdateDto } from '@settings/lib/settings-actions.dto'
import type { TemplateString } from '@/features/handlebar-template/types/hande-bar-template.type'

Expand All @@ -16,7 +17,7 @@ export type ActionDefinition = {
label: string
/** Imperative verb that prefixes the action sentence, e.g. "Pay 2 invoices". */
verb: string
icon: ActionItemIcon
icon: IconType
template: TemplateString
key: ActionKey
singularLabel?: string
Expand Down
3 changes: 0 additions & 3 deletions src/features/editor/components/Sidebar/Actions/type.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import type { IconType } from '@assembly-js/design-system'
import type { ActionItemLabel } from '@/features/editor/components/Sidebar/Actions/constant'

export type ActionItemIcon = Extract<IconType, 'Billing' | 'Contract' | 'Tasks' | 'Form'>

export type ActionItemLabelType = (typeof ActionItemLabel)[keyof typeof ActionItemLabel]
68 changes: 51 additions & 17 deletions src/features/editor/components/Sidebar/Actions/useActions.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,63 @@
import type { IconType } from '@assembly-js/design-system'
import { ActionDefinitions } from '@editor/components/Sidebar/Actions/constant'
import { useInstalledApps } from '@installed-apps/hooks/useInstalledApps'
import { isIconType } from '@installed-apps/lib/icon-names'
import { useSettingsStore } from '@settings/providers/settings.provider'

// Stands in when an install's `icon` is absent or not a design-system IconType.
const FALLBACK_ICON: IconType = 'CustomApps'

type ActionToggleItem = {
key: string
label: string
icon: IconType
checked: boolean
onChange: () => void
}

export const useActions = () => {
const actions = useSettingsStore((s) => s.actions)
const setActions = useSettingsStore((s) => s.setActions)
const { installedApps } = useInstalledApps()

const order = actions?.order ?? []
const hiddenAppIds = actions?.hiddenAppIds ?? []

// Built-in actions are keyed by their ActionKey and toggled via named boolean fields.
const builtInItems: ActionToggleItem[] = Object.values(ActionDefinitions).map((item) => ({
key: item.key,
label: item.label,
icon: item.icon,
checked: actions?.[item.key] ?? false,
onChange: () => {
setActions({ [item.key]: !actions[item.key] })
},
}))

// Dynamic Studio-app actions are keyed by appId and toggled via the hiddenAppIds
// deny-list (checked = not hidden = shown; default on). Tasks is filtered out
// server-side so it never collides with the built-in Tasks row.
const appItems: ActionToggleItem[] = installedApps.map((install) => ({
key: install.appId,
label: install.displayName,
icon: isIconType(install.icon) ? install.icon : FALLBACK_ICON,
checked: !hiddenAppIds.includes(install.appId),
onChange: () => {
const nextHidden = hiddenAppIds.includes(install.appId)
? hiddenAppIds.filter((id) => id !== install.appId)
: [...hiddenAppIds, install.appId]
setActions({ hiddenAppIds: nextHidden })
},
}))

const actionItems = Object.values(ActionDefinitions)
.sort((a, b) => {
const aIndex = order.indexOf(a.key)
const bIndex = order.indexOf(b.key)
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
})
.map((item) => {
return {
key: item.key,
label: item.label,
icon: item.icon,
checked: actions?.[item.key] ?? false,
onChange: () => {
setActions({ [item.key]: !actions[item.key] })
},
}
})
// Built-ins and Studio apps share a single drag-orderable list. `order` holds a mix
// of ActionKeys and appIds; items absent from it (e.g. a freshly installed app) fall
// to the end until the user positions them.
const actionItems = [...builtInItems, ...appItems].sort((a, b) => {
const aIndex = order.indexOf(a.key)
const bIndex = order.indexOf(b.key)
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
})

const onReorder = (newOrder: string[]) => {
setActions({ order: newOrder })
Expand Down
27 changes: 27 additions & 0 deletions src/features/installed-apps/hooks/useInstalledApps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client'

import { useAuthStore } from '@auth/providers/auth.provider'
import { type ActionableInstallsDto, ActionableInstallsDtoSchema } from '@installed-apps/installed-apps.dto'
import { useQuery } from '@tanstack/react-query'
import { ROUTES } from '@/app/routes'
import { api } from '@/lib/core/axios.instance'

export const INSTALLED_APPS_QUERY_KEY = 'installed-apps'

export const useInstalledApps = () => {
const workspaceId = useAuthStore((s) => s.workspaceId)

const { data, isLoading } = useQuery({
queryKey: [INSTALLED_APPS_QUERY_KEY, workspaceId],
queryFn: async (): Promise<ActionableInstallsDto> => {
const res = await api.get<{ data: ActionableInstallsDto }>(ROUTES.api.installedApps)
return ActionableInstallsDtoSchema.parse(res.data.data)
},
enabled: Boolean(workspaceId),
})

return {
installedApps: data ?? [],
isLoading,
}
}
15 changes: 15 additions & 0 deletions src/features/installed-apps/installed-apps.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { RegisteredActionLabelSchema } from '@assembly/types'
import z from 'zod'

// A Studio app install eligible for "Your Actions": active and with a complete registered action label.
export const ActionableInstallDtoSchema = z.object({
installId: z.string(),
appId: z.string(),
displayName: z.string(),
icon: z.string().nullable(),
actionLabel: RegisteredActionLabelSchema,
})
export type ActionableInstallDto = z.infer<typeof ActionableInstallDtoSchema>

export const ActionableInstallsDtoSchema = z.array(ActionableInstallDtoSchema)
export type ActionableInstallsDto = z.infer<typeof ActionableInstallsDtoSchema>
35 changes: 35 additions & 0 deletions src/features/installed-apps/lib/dynamic-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { IconType } from '@assembly-js/design-system'
import type { ActionDefinition } from '@editor/components/Sidebar/Actions/constant'
import type { ActionableInstallDto } from '@installed-apps/installed-apps.dto'
import { isIconType } from '@installed-apps/lib/icon-names'

// Stands in when an install's `icon` is absent or not a design-system IconType.
const FALLBACK_ICON: IconType = 'CustomApps'

// A "Your Actions" row synthesized at runtime from an actionable Studio app install.
// Mirrors ActionDefinition's presentational fields so ActionItem renders it identically,
// but is identified by its install/app rather than a built-in ActionKey.
export type DynamicActionDefinition = {
verb: string
/** Plural noun, e.g. "documents". */
label: string
/** Singular noun, e.g. "document". */
singularLabel: string
icon: IconType
installId: string
appId: string
}

// The union ActionItem / ActionsCard render: built-in rows and dynamic Studio-app rows.
export type RenderableAction = ActionDefinition | DynamicActionDefinition

export const isDynamicAction = (action: RenderableAction): action is DynamicActionDefinition => 'installId' in action

export const toDynamicActionDefinition = (install: ActionableInstallDto): DynamicActionDefinition => ({
verb: install.actionLabel.verb,
label: install.actionLabel.pluralNoun,
singularLabel: install.actionLabel.singularNoun,
icon: isIconType(install.icon) ? install.icon : FALLBACK_ICON,
installId: install.installId,
appId: install.appId,
})
Loading
Loading