import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { ManifestComponentDeletion, ManifestScope } from './manifest-builder'; import { SduiPage } from './sdui-page'; import { optionalStringComponentField, requireStringComponentField } from './_existing-component-fields'; /** * A single page entry in an {@link App}. * * Apps host SDUI page tabs. To show custom-object records, create an * {@link SduiPage} that renders an SDUI `ObjectListView` for that object. */ export type AppPage = SduiPage; /** * S3 icon reference for an {@link App}. */ export interface AppIcon { /** S3 bucket holding the icon asset. */ s3Bucket: string; /** S3 key (path) within `s3Bucket`. */ s3Key: string; } /** * Initialization properties for {@link App}. */ export interface AppProps { /** * Unique identifier for this app (e.g. `'gym_membership_management_app'`). * * Required. */ apiName: string; /** * Display name shown as the app title in the Rippling UI. * * Required. */ name: string; /** * Human-readable description of what this app does. * * Required. */ description: string; /** * App type discriminator. * * Optional. @default `'custom'` */ appType?: string; /** * S3 icon for this app. * * Optional. @default null */ icon?: AppIcon; /** * SDUI pages shown as tabs in the app. Tab order matches the array order. * * Required. Must include at least one page. */ pages: AppPage[]; } function resolvePage(entry: AppPage): Record { if (!(entry instanceof SduiPage)) { throw new Error( 'App pages must be SduiPage instances. To show a custom object, create an SDUI page that renders ObjectListView.', ); } const sduiPageId = entry.getSduiPageId(); return { api_name: sduiPageId, page_type: 'sdui_page', sdui_page_id: sduiPageId, }; } function resolvePages(pages: AppPage[] | undefined): Record[] { if (pages == null || pages.length === 0) { throw new Error('App pages must include at least one page.'); } return pages.map(resolvePage); } function clonePages(value: unknown): Record[] { if (!Array.isArray(value) || value.length === 0) { throw new Error('App pages must include at least one page.'); } return cloneJson(value) as Record[]; } function cloneJson(value: T): T { if (value == null) return value; return JSON.parse(JSON.stringify(value)) as T; } /** * Defines a Rippling app and registers it with the manifest. * * An app is the top-level navigation container. It groups one or more SDUI pages * as tabs visible to users. You would normally define the app last in your * manifest file, after all objects, functions, and SDUI pages have been declared. * * @example * ```ts * new App(manifest, { * apiName: 'gym_membership_management_app', * name: 'Gym Membership Management', * description: 'Members, trainers, schedules, and check-in', * appType: 'custom', * pages: [memberListPage, trainerListPage, todaySchedulePage, checkInDeskPage], * }); * ``` * * @see {@link SduiPage} — SDUI surface tab source. */ export class App { static readonly componentType = 'CUSTOM_APP' as const; static toDeletionIdentifier(apiName: string): ManifestComponentDeletion { return { type: App.componentType, api_name: apiName, }; } private readonly _apiName: string; private readonly _name: string; private readonly _description: string; private readonly _appType: string | undefined; private readonly _icon: AppIcon | undefined; /** Wire-format pages, resolved eagerly at construction. */ private readonly _pages: Record[]; /** * @param scope - The manifest to register this app with. * @param props - Initialization properties. */ constructor(scope: ManifestScope, props: AppProps) { this._apiName = props.apiName; this._name = props.name; this._description = props.description; this._appType = props.appType; this._icon = props.icon; this._pages = resolvePages(props.pages); scope._register(this); } /** * Loads this app from the active existing-manifest JSON context. */ static loadFromExisting(apiName: string, options: ExistingManifestContextOptions = {}): App { return getExistingManifestContext(options).loadApp(apiName); } /** @internal Hydrates an app from existing manifest wire JSON. */ static _fromExistingComponent(scope: ManifestScope, component: Record): App { const app = Object.create(App.prototype) as App; Object.assign(app as Record, { _apiName: requireStringComponentField(component, 'api_name', App.componentType), _name: requireStringComponentField(component, 'name', App.componentType), _description: optionalStringComponentField(component, 'description'), _appType: component['app_type'], _icon: App.appIconFromExistingComponent(component['icon']), _pages: clonePages(component['pages']), }); scope._register(app); return app; } private static appIconFromExistingComponent(value: unknown): AppIcon | undefined { if (value == null || typeof value !== 'object') return undefined; const wire = value as Record; if (typeof wire['s3_bucket'] !== 'string' || typeof wire['s3_key'] !== 'string') return undefined; return { s3Bucket: wire['s3_bucket'], s3Key: wire['s3_key'] }; } /** * Returns the api_name of this app (e.g. `'gym_membership_management_app'`). */ getApiName(): string { return this._apiName; } /** * Returns the display name of this app. */ getName(): string { return this._name; } /** * Serializes this app to the wire format consumed by the manifest install endpoint. * * @returns A plain object with `type: 'CUSTOM_APP'`. Optional fields are omitted when unset. */ toDict(): Record { const component: Record = { type: App.componentType, api_name: this._apiName, name: this._name, description: this._description, }; if (this._icon != null) { component['icon'] = { s3_bucket: this._icon.s3Bucket, s3_key: this._icon.s3Key }; } if (this._appType != null) component['app_type'] = this._appType; component['pages'] = this._pages.map((p) => ({ ...p })); return component; } }