/* * Custom SDUI todo page backed by `todo__c` records. * * This function handles both the initial page render and follow-up UI actions: * - `init` lists todos for the current `context.function.role_id` and returns * a JSON-stringified SDUI spec in `body.spec`. * - `addTodo`, `toggleTodo`, and `clearCompleted` mutate Custom Object records, * then return the fresh `TodoListState` written into `/todoList` by * `responsePath`. * * In `triggerFunction`, server-bound action params live under `inputs` so * repeated item values like `todo.$('id')` resolve before reaching * `event['parameters']`. Client-side controls such as `functionApiName`, * `responsePath`, `onSuccess`, and `onError` stay top-level. * * `/isMutating` is a shared loading flag: it disables every action while an API * call is in flight and shows a small loading message so disabled controls have * visible context. */ import { createSpec, equals, neq, or_, type SpecContext } from '@rippling/pebble-sdui-web'; import { Button, Card, Checkbox, Field, HStack, PageContent, Text, TextInput, VStack, render, setState, triggerFunction, } from '@rippling/pebble-sdui-web/catalogs/custom-apps'; import RipplingSDK, { FunctionContext, FunctionResponse, SduiPageFunctionEvent, } from '@rippling/rippling-sdk'; import type { CustomObjectDataRow } from '@rippling/rippling-sdk/resources/custom-objects/records'; const OBJECT_API_NAME = 'todo__c'; type TodoPageAction = 'init' | 'addTodo' | 'toggleTodo' | 'clearCompleted'; type TodoPageFunctionParameters = { action: TodoPageAction; text?: string; todoId?: string; }; type Todo = { id: string; text: string; completed: boolean; }; type TodoListState = { todos: Todo[]; isEmpty: boolean; }; type TodoAppState = { newTodoText: string; todoList: TodoListState; isMutating: boolean; errorMessage: string; }; type TodoSpec = SpecContext; function createClient(context: FunctionContext): RipplingSDK { return new RipplingSDK({ bearerToken: context.env.rippling_user_bearer_token, }); } function toTodo(record: CustomObjectDataRow): Todo { return { id: record.id, text: record.name, completed: record['completed__c'] === true, }; } async function listTodos(client: RipplingSDK, roleId: string): Promise { const response = await client.customObjects.records.query(OBJECT_API_NAME, { limit: 100, query: `created_by = "${roleId}"`, // RQL query to filter todos by the current role }); return (response.results ?? []).map(toTodo); } function todoListState(todos: Todo[]): TodoListState { return { todos, isEmpty: todos.length === 0, }; } function todoState(todos: Todo[]): TodoAppState { return { newTodoText: '', todoList: todoListState(todos), isMutating: false, errorMessage: '', }; } function ok(body: Record | unknown[] = {}): FunctionResponse { return new FunctionResponse({ body: body as Record, statusCode: 200, headers: {}, message: '', }); } function badRequest(message: string): FunctionResponse { return new FunctionResponse({ body: { error: message }, statusCode: 400, headers: {}, message, }); } function serverError(error: unknown): FunctionResponse { const message = error instanceof Error ? error.message : 'Unknown error'; return new FunctionResponse({ body: { error: message }, statusCode: 500, headers: {}, message, }); } function stringParam(params: TodoPageFunctionParameters, key: 'text' | 'todoId'): string { const value = params[key]; return typeof value === 'string' ? value.trim() : ''; } // A single mutation flag disables all actions and drives the visible loading text. function setMutationState(isMutating: boolean) { return setState({ statePath: '/isMutating', value: isMutating, }); } function buildAddTodoSection(spec: TodoSpec, functionApiName: string) { return VStack({ id: 'addTodoSection', props: {}, children: [ Field({ id: 'todoInputField', props: { label: 'New Todo', }, children: [ TextInput({ id: 'todoInput', props: { name: 'newTodo', placeholder: 'Enter a new todo item', value: spec.$bindState('/newTodoText'), isDisabled: spec.$state('/isMutating'), }, }), ], }), Button({ id: 'addButton', props: { label: 'Add Todo', appearance: 'PRIMARY', isFluid: true, isDisabled: spec.$cond( or_(equals(spec.$state('/newTodoText'), ''), equals(spec.$state('/isMutating'), true)), true, false, ), }, on: { press: [ setMutationState(true), triggerFunction( { functionApiName, inputs: { action: 'addTodo', text: spec.$state('/newTodoText'), }, responsePath: '/todoList', }, { onSuccess: { set: { '/newTodoText': '', '/errorMessage': '', '/isMutating': false } }, onError: { set: { '/errorMessage': 'Could not add todo. Try again.', '/isMutating': false } }, }, ), ], }, }), ], }); } function buildTodoItems(spec: TodoSpec, functionApiName: string) { const [todoRepeat, todo] = spec.repeat('/todoList/todos', 'id'); return VStack({ id: 'todoItems', repeat: todoRepeat, props: { gap: 'space200', }, children: [ HStack({ id: 'todoItem', props: { gap: 'space300', align: 'center', padding: 'space300', backgroundColor: 'colorSurfaceContainer', radius: 'shapeCornerMd', }, children: [ Checkbox({ id: 'todoCheckbox', props: { name: 'completed', value: todo.$bind('completed'), isDisabled: spec.$state('/isMutating'), }, on: { change: [ setMutationState(true), triggerFunction( { functionApiName, inputs: { action: 'toggleTodo', todoId: todo.$('id'), }, responsePath: '/todoList', }, { onSuccess: { set: { '/errorMessage': '', '/isMutating': false } }, onError: { set: { '/errorMessage': 'Could not update todo. Refresh and try again.', '/isMutating': false, }, }, }, ), ], }, }), Text({ id: 'todoText', props: { text: todo.$('text'), decoration: spec.$cond(equals(todo.$('completed'), true), 'line-through', 'none'), color: spec.$cond(equals(todo.$('completed'), true), 'colorOnSurfaceVariant', 'colorOnSurface'), }, }), ], }), ], }); } function buildTodoList(spec: TodoSpec, functionApiName: string) { return VStack({ id: 'todoList', props: { gap: 'space300', }, children: [ HStack({ id: 'todoListHeader', props: { align: 'center', justify: 'space-between', gap: 'space300', }, children: [ Text({ id: 'todoListTitle', props: { text: 'Your Todos', typestyle: 'typestyleTitleSmall600', }, }), Button({ id: 'clearCompletedButton', props: { label: 'Clear completed', appearance: 'OUTLINE', size: 'S', isDisabled: spec.$cond( or_(equals(spec.$state('/isMutating'), true), equals(spec.$state('/todoList/isEmpty'), true)), true, false, ), }, on: { press: [ setMutationState(true), triggerFunction( { functionApiName, inputs: { action: 'clearCompleted', }, responsePath: '/todoList', }, { onSuccess: { set: { '/errorMessage': '', '/isMutating': false } }, onError: { set: { '/errorMessage': 'Could not clear completed todos. Try again.', '/isMutating': false, }, }, }, ), ], }, }), ], }), Text({ id: 'emptyTodos', visible: equals(spec.$state('/todoList/isEmpty'), true), props: { text: 'No todos yet.', color: 'colorOnSurfaceVariant', }, }), buildTodoItems(spec, functionApiName), ], }); } function buildError(spec: TodoSpec) { return Text({ id: 'todoError', visible: neq(spec.$state('/errorMessage'), ''), props: { text: spec.$state('/errorMessage'), color: 'colorError', }, }); } function buildLoading(spec: TodoSpec) { return Text({ id: 'todoLoading', // Keep disabled controls explainable while a triggerFunction call is in flight. visible: equals(spec.$state('/isMutating'), true), props: { text: 'Updating todos...', color: 'colorOnSurfaceVariant', }, }); } function buildTodoCard(spec: TodoSpec, functionApiName: string) { return Card({ id: 'todoCard', props: {}, children: [ VStack({ id: 'todoContent', props: { gap: 'space400', padding: 'space400', }, children: [ buildAddTodoSection(spec, functionApiName), buildError(spec), buildLoading(spec), buildTodoList(spec, functionApiName), ], }), ], }); } function renderSpec(state: TodoAppState, functionApiName: string) { const spec = createSpec({ state }); const root = PageContent({ id: 'todoApp', props: { size: '776', alignment: 'CENTER', }, children: [buildTodoCard(spec, functionApiName)], }); return render({ root, state }); } async function addTodo( client: RipplingSDK, params: TodoPageFunctionParameters, roleId: string, ): Promise { const text = stringParam(params, 'text'); if (!text) { throw new Error('Todo text is required'); } await client.customObjects.records.create(OBJECT_API_NAME, { name: text, completed__c: false, }); return todoListState(await listTodos(client, roleId)); } async function toggleTodo( client: RipplingSDK, params: TodoPageFunctionParameters, roleId: string, ): Promise { const todoId = stringParam(params, 'todoId'); if (!todoId) { throw new Error('todoId must be a todo record ID'); } const todo = toTodo(await client.customObjects.records.retrieve(OBJECT_API_NAME, todoId)); await client.customObjects.records.update(OBJECT_API_NAME, todoId, { completed__c: !todo.completed, }); return todoListState(await listTodos(client, roleId)); } async function clearCompleted(client: RipplingSDK, roleId: string): Promise { const todos = await listTodos(client, roleId); const completedTodoIds = todos.filter((todo) => todo.completed).map((todo) => todo.id); if (completedTodoIds.length === 0) { return todoListState(todos); } await client.customObjects.records.bulkDelete(OBJECT_API_NAME, { all_or_nothing: false, rows_to_delete: completedTodoIds, }); return todoListState(await listTodos(client, roleId)); } export async function onRipplingEvent( event: SduiPageFunctionEvent, context: FunctionContext, ): Promise { const params = event.parameters; const action = params.action; const roleId = context.function.role_id; if (!roleId) { return badRequest('Role ID is required'); } try { const client = createClient(context); if (action === 'init') { const functionApiName = context.function.function_api_name; const todos = await listTodos(client, roleId); return ok({ spec: JSON.stringify(renderSpec(todoState(todos), functionApiName)) }); } if (action === 'addTodo') { return ok(await addTodo(client, params, roleId)); } if (action === 'toggleTodo') { return ok(await toggleTodo(client, params, roleId)); } if (action === 'clearCompleted') { return ok(await clearCompleted(client, roleId)); } return badRequest(`Unknown action: ${action}`); } catch (error) { return serverError(error); } }