import { getExistingManifestContext, type ExistingManifestContextOptions } from './existing-manifest-context'; import type { ManifestComponentDeletion, ManifestScope } from './manifest-builder'; import { SduiPage } from './sdui-page'; import { Group, type GroupConfig } from './group'; import type { SavedSuperGroup } from './saved-super-group'; import { optionalArrayComponentField, optionalStringComponentField, requireObjectComponentField, requireStringComponentField, } from './_existing-component-fields'; import type { AppVisibilityPlatform } from './_helpers'; /** * 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; /** * Platforms this app is visible on. * * Optional. @default `['WEB']` */ visibilityPlatforms?: AppVisibilityPlatform[]; /** * SDUI pages shown as tabs in the app. Tab order matches the array order. * * Required. Must include at least one page. */ pages: AppPage[]; /** Embedded app audiences; use defineGroup, not a standalone saved-group wrapper. */ sharedGroup?: Group | null; adminGroup?: Group | null; } interface AppConfig extends Record { type: 'CUSTOM_APP'; shared_group_config?: GroupConfig | null; admin_group_config?: GroupConfig | null; } function inlineGroup(group: Group | null | undefined): Group | null | undefined { if (group != null && typeof group._toDict !== 'function') { throw new TypeError('Inline app groups must be created with defineGroup or Group.'); } return group; } 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(); const visibilityPlatforms = entry.getVisibilityPlatforms(); return { api_name: sduiPageId, page_type: 'sdui_page', sdui_page_id: sduiPageId, ...(visibilityPlatforms != null && { visibility_platforms: [...visibilityPlatforms] }), }; } 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; private readonly _visibilityPlatforms: AppVisibilityPlatform[] | undefined; /** Wire-format pages, resolved eagerly at construction. */ private readonly _pages: Record[]; private readonly _sharedGroup: Group | null | undefined; private readonly _adminGroup: Group | null | undefined; /** * @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._visibilityPlatforms = props.visibilityPlatforms ? [...props.visibilityPlatforms] : undefined; this._pages = resolvePages(props.pages); this._sharedGroup = inlineGroup(props.sharedGroup); this._adminGroup = inlineGroup(props.adminGroup); 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'], _visibilityPlatforms: optionalArrayComponentField( component, 'visibility_platforms', App.componentType, ), _pages: clonePages(component['pages']), _sharedGroup: App.groupFromExistingComponent(component, 'shared_group_config'), _adminGroup: App.groupFromExistingComponent(component, 'admin_group_config'), }); scope._register(app); return app; } private static groupFromExistingComponent( component: Record, key: 'shared_group_config' | 'admin_group_config', ): Group | null | undefined { const value = component[key]; if (value == null) return value; const apiName = requireStringComponentField(component, 'api_name', App.componentType); const slot = key === 'shared_group_config' ? 'shared' : 'admin'; return Group._fromExistingConfig(requireObjectComponentField(component, key, App.componentType), { meObjId: `CUSTOM_APP:${apiName}:${slot}`, spokeOwnerHandle: 'DUMMY_UNITY_ADMIN', }); } 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; } /** Embedded groups remain owned by this app; edit their rules without replacing the slot. */ getSharedGroup(): Group | null | undefined { return this._sharedGroup; } getAdminGroup(): Group | null | undefined { return this._adminGroup; } /** @internal Only saved groups referenced by embedded rules become manifest dependencies. */ getReferencedGroups(): SavedSuperGroup[] { return [ ...(this._sharedGroup?.getReferencedGroups() ?? []), ...(this._adminGroup?.getReferencedGroups() ?? []), ]; } /** * 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(): AppConfig { const component: AppConfig = { 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; if (this._visibilityPlatforms != null) component['visibility_platforms'] = [...this._visibilityPlatforms]; component['pages'] = this._pages.map((p) => ({ ...p })); if (this._sharedGroup !== undefined) { component['shared_group_config'] = this._sharedGroup?._toDict({ meObjId: `CUSTOM_APP:${this._apiName}:shared`, spokeOwnerHandle: 'DUMMY_UNITY_ADMIN', }) ?? null; } if (this._adminGroup !== undefined) { component['admin_group_config'] = this._adminGroup?._toDict({ meObjId: `CUSTOM_APP:${this._apiName}:admin`, spokeOwnerHandle: 'DUMMY_UNITY_ADMIN', }) ?? null; } return component; } }