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
11 changes: 11 additions & 0 deletions .changeset/11687-empty-state-copy-matches-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/plugin-list': minor
'@object-ui/i18n': minor
---

An empty list's copy no longer contradicts the page it sits on (objectui#11687).

An identity list (an object managed by the authentication provider) said its records are "not added by hand here", even beside an "Invite User" or "Register OAuth Application" button on the same page. The console now asks whether the page offers a way to add a row: its New button, or a toolbar action the page draws, judged by the same placement, capability and `visible` gates the toolbar applies. When it does, the identity copy is not used and the list shows its own empty state instead. When the toolbar action is hidden (for example a multi-organization-only Invite User in a single-organization deployment), the identity copy stays. The Teams list keeps its own copy, which already names its Create Team button.

A list view emptied by its own declared filter said "No records match your current filters or search." when no filter or search had been applied. That message is now said only when the user applied a filter or a search. A view emptied by its own filter alone keeps the "No matching records" title and reads "No records match this view’s filter.", through a new `list.viewFilterNoMatchesMessage` key in all ten language packs and in `LIST_DEFAULT_TRANSLATIONS`. A list with no filter at all still gets the first-run copy.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { resolveManagedByEmptyState } from '../managedByEmptyState';
import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../managedByEmptyState';

// Mirror the real i18n fallback: return the English `defaultValue` baked into
// the helper. The en.ts bundle mirrors these strings verbatim, so asserting on
Expand Down Expand Up @@ -96,4 +96,81 @@ describe('resolveManagedByEmptyState', () => {
expect(es?.message).not.toMatch(/reset password/i);
}
});
/**
* objectui#11687 — the copy says identity rows are "not added by hand here".
* A page that offers a way to add one (its New button, or a toolbar action
* such as Invite User / Register OAuth Application) contradicts it, so the
* arm yields to the list's own empty state.
*/
describe('a page that offers a create action (objectui#11687)', () => {
it('yields the generic identity copy to the list when the page offers one', () => {
for (const name of ['sys_invitation', 'sys_oauth_application', 'sys_member', undefined]) {
expect(resolveManagedByEmptyState('better-auth', t, name, undefined, true)).toBeUndefined();
}
});

it('keeps the generic identity copy on a page that offers none (API Keys)', () => {
const es = resolveManagedByEmptyState('better-auth', t, 'sys_api_key', { edit: true }, false);
expect(es?.title).toBe('No identity records');
expect(es?.message).toMatch(/not added by hand here/i);
});

it('yields sys_user\'s "not created here" copy beside Invite User / Create User', () => {
expect(resolveManagedByEmptyState('better-auth', t, 'sys_user', { edit: true }, true)).toBeUndefined();
});

it('keeps sys_team\'s copy, which names its own Create Team button', () => {
expect(resolveManagedByEmptyState('better-auth', t, 'sys_team', undefined, true)?.title).toBe('No teams yet');
});

it('leaves the other buckets to their own rules', () => {
expect(resolveManagedByEmptyState('append-only', t, 'sys_audit_log', undefined, true)?.title).toBe('No events recorded');
expect(resolveManagedByEmptyState('engine-owned', t, 'sys_automation_run', undefined, true)?.title).toBe('Nothing here yet');
});
});

/**
* The toolbar half of "the page offers a create action": the same three
* questions `action:bar` and `action:button` ask of a `list_toolbar` action.
*/
describe('listToolbarDrawsAction (objectui#11687)', () => {
const allowAll = () => true;
const invite = {
name: 'invite_user',
label: 'Invite User',
locations: ['list_toolbar'],
// `requiresFeature: 'organization'` as the spec lowers it (default-on flag).
visible: { dialect: 'cel', source: 'features.organization != false' },
};

it('counts an action placed on the toolbar whose gate holds', () => {
expect(listToolbarDrawsAction([invite], allowAll, { features: { organization: true } })).toBe(true);
});

it('does not count an action its own `visible` hides (single-org Invite User)', () => {
expect(listToolbarDrawsAction([invite], allowAll, { features: { organization: false } })).toBe(false);
});

it('does not count an action placed only on rows or the record header', () => {
const rowOnly = { name: 'cancel_invitation', locations: ['list_item', 'record_header'] };
const unplaced = { name: 'orphan' };
expect(listToolbarDrawsAction([rowOnly, unplaced], allowAll, {})).toBe(false);
});

it('does not count an action the caller may not invoke', () => {
const gated = { name: 'create_x', locations: ['list_toolbar'], requiredPermissions: ['manage_x'] };
expect(listToolbarDrawsAction([gated], () => false, {})).toBe(false);
expect(listToolbarDrawsAction([gated], allowAll, {})).toBe(true);
});

it('fails closed on a `visible` that throws, as the button does', () => {
const broken = { name: 'x', locations: ['list_toolbar'], visible: { dialect: 'cel', source: 'nope.missing ==' } };
expect(listToolbarDrawsAction([broken], allowAll, {})).toBe(false);
});

it('answers false for an object with no actions', () => {
expect(listToolbarDrawsAction(undefined, allowAll, {})).toBe(false);
expect(listToolbarDrawsAction([], allowAll, {})).toBe(false);
});
});
});
108 changes: 91 additions & 17 deletions packages/app-shell/src/utils/managedByEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,89 @@
* (in the header) and the empty state (in the body) tell a consistent
* story without repeating themselves verbatim.
*/
import { resolveEffectiveCrudAffordances, type UserActionsOverride } from '@object-ui/core';
import {
ExpressionEvaluator,
hasDeclaredPredicate,
resolveEffectiveCrudAffordances,
toPredicateInput,
type UserActionsOverride,
} from '@object-ui/core';
// The one authority for the narrowed `t` (objectui#8261): a key and optional
// options (including a `defaultValue` used as the English fallback when a
// locale lacks the key) — imported, not re-declared.
import type { TranslateFn } from '@object-ui/i18n';
import { actionRendersAt } from '@object-ui/types';

export interface ManagedByEmptyState {
title: string;
message: string;
icon: string;
}

/**
* Does the list page's toolbar draw at least one of the object's declared
* actions? (objectui#11687)
*
* The answer is the toolbar's own verdict, asked with the same predicates the
* `action:bar` → `action:button` pair applies to `list_toolbar`: placement
* through `actionRendersAt`, the capability gate the caller passes in (the
* `useCapabilityGate` closure), and the action's `visible` gate, asked through
* `hasDeclaredPredicate` and evaluated fail-closed with no row bound — a list
* toolbar has no record. `requiresFeature` / `requiresMembershipReach` arrive
* already lowered into that `visible` (spec `lowerRequiresFeature`), so a
* multi-org-only "Invite User" counts in a multi-org deployment and not in a
* single-org one, exactly as the button does.
*
* Why "the toolbar draws an action" may stand for "the page offers a way to
* add a row" in the `better-auth` arm, without asking an action what it does:
* read off objectstack's platform objects when objectui#11687 landed, every
* `list_toolbar` action on a `better-auth` object either adds a row (Invite
* User, Register OAuth Application, Create Team, Enable 2FA, …) or sits on a
* list whose every view is filtered ("Sign out other devices" on sessions), so
* an empty one gets the list's view-filter copy rather than first-run copy.
* ⚠️ Nothing re-derives that reading (AGENTS.md #9); a `better-auth` object
* that gains a toolbar action which adds nothing should revisit it.
*/
export function listToolbarDrawsAction(
actions: readonly unknown[] | null | undefined,
mayInvoke: (requiredPermissions: unknown) => boolean,
predicateScope: Record<string, unknown>,
): boolean {
if (!Array.isArray(actions)) return false;
return actions.some((candidate) => {
if (candidate == null || typeof candidate !== 'object') return false;
const action = candidate as {
locations?: readonly string[];
requiredPermissions?: unknown;
visible?: unknown;
};
if (!actionRendersAt(action, 'list_toolbar')) return false;
if (!mayInvoke(action.requiredPermissions)) return false;
if (!hasDeclaredPredicate(action.visible)) return true;
try {
return new ExpressionEvaluator({ ...predicateScope }).evaluateCondition(
toPredicateInput(action.visible),
{ throwOnError: true },
);
} catch {
// Fail-closed, as `action:button` hides a button whose `visible` throws.
return false;
}
});
}

/**
* @param pageOffersCreate The list page offers a way to add a row: its generic
* New button, or a `list_toolbar` action it draws
* ({@link listToolbarDrawsAction}). The `better-auth` arm's copy says rows
* are "not added by hand here", which that page contradicts (objectui#11687).
*/
export function resolveManagedByEmptyState(
managedBy: string | undefined | null,
t: TranslateFn,
objectName?: string | null,
userActions?: UserActionsOverride | null,
pageOffersCreate = false,
): ManagedByEmptyState | undefined {
switch (managedBy) {
// ADR-0103 — rows a platform service owns end to end: "entries appear
Expand Down Expand Up @@ -70,6 +136,30 @@ export function resolveManagedByEmptyState(
}),
};
case 'better-auth':
// `sys_team` is an identity table that CAN be created by hand —
// the `create_team` toolbar action (multi-org gated) hits better-auth's
// `organization/create-team`. The generic "not added by hand here" copy
// below would flatly contradict that visible Create Team button
// (objectui review — the reported empty-state/CTA mismatch). Give teams
// their own accurate copy instead of the identity-record disclaimer.
if (objectName === 'sys_team') {
return {
icon: 'Users',
title: t('list.managedBy.betterAuthTeam.title', { defaultValue: 'No teams yet' }),
message: t('list.managedBy.betterAuthTeam.message', {
defaultValue:
'Teams group members within an organization. Create one with “Create Team”, or they arrive through your auth provider’s organization and SSO provisioning flows.',
}),
};
}
// objectui#11687 — a page that offers a way to add a row (its New
// button, or a toolbar action such as Invite User or Register OAuth
// Application) is contradicted by every arm below except `sys_team`'s,
// which names its own Create Team button. Yield to the list's own copy:
// first-run, or the view-filter / user-filter wording, which the list
// chooses from its query. The `engine-owned` arm above does the same for
// an opened `create` affordance.
if (pageOffersCreate) return undefined;
// `sys_user` is the one identity table with a concrete onboarding
// answer, so give it actionable guidance: teammates arrive via an
// org-level invite + SSO just-in-time provisioning (ADR-0024 D9), and
Expand All @@ -89,22 +179,6 @@ export function resolveManagedByEmptyState(
}),
};
}
// `sys_team` is the other identity table that CAN be created by hand —
// the `create_team` toolbar action (multi-org gated) hits better-auth's
// `organization/create-team`. The generic "not added by hand here" copy
// below would flatly contradict that visible Create Team button
// (objectui review — the reported empty-state/CTA mismatch). Give teams
// their own accurate copy instead of the identity-record disclaimer.
if (objectName === 'sys_team') {
return {
icon: 'Users',
title: t('list.managedBy.betterAuthTeam.title', { defaultValue: 'No teams yet' }),
message: t('list.managedBy.betterAuthTeam.message', {
defaultValue:
'Teams group members within an organization. Create one with “Create Team”, or they arrive through your auth provider’s organization and SSO provisioning flows.',
}),
};
}
return {
icon: 'ShieldAlert',
title: t('list.managedBy.betterAuth.title', { defaultValue: 'No identity records' }),
Expand Down
Loading
Loading