diff --git a/.changeset/11687-empty-state-copy-matches-page.md b/.changeset/11687-empty-state-copy-matches-page.md new file mode 100644 index 0000000000..caaa42978c --- /dev/null +++ b/.changeset/11687-empty-state-copy-matches-page.md @@ -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. diff --git a/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts b/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts index f82c376903..243753e957 100644 --- a/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts +++ b/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts @@ -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 @@ -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); + }); + }); }); diff --git a/packages/app-shell/src/utils/managedByEmptyState.ts b/packages/app-shell/src/utils/managedByEmptyState.ts index 1127d9c7d3..af9b5720b0 100644 --- a/packages/app-shell/src/utils/managedByEmptyState.ts +++ b/packages/app-shell/src/utils/managedByEmptyState.ts @@ -21,11 +21,18 @@ * (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; @@ -33,11 +40,70 @@ export interface ManagedByEmptyState { 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, +): 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 @@ -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 @@ -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' }), diff --git a/packages/app-shell/src/views/ObjectView.emptyStateMatchesPage-11687.test.tsx b/packages/app-shell/src/views/ObjectView.emptyStateMatchesPage-11687.test.tsx new file mode 100644 index 0000000000..d4e58f7c73 --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.emptyStateMatchesPage-11687.test.tsx @@ -0,0 +1,321 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11687 — an empty list's copy must not contradict the page it sits on. + * + * Two defects, measured on the console (objectui#11672, item 15): + * + * 1. Invitations and OAuth Applications said "These records are created by + * the authentication provider … not added by hand here." beside an + * "Invite User" / "Register OAuth Application" button. The `better-auth` + * empty state did not ask whether the page offers a way to add a row. + * 2. Webhooks' Active view, zero rows, said "No records match your current + * filters or search." with no filter or search applied: the view's own + * declared filter was read as the user's. + * + * Each case below is one of the four measured pages, mounted through the real + * `ObjectView` and the real `ListView`, in a real i18next instance booted in + * `en` — the strings asserted are the pack's, not a mock's. The fixtures carry + * each object as the server serves it: `requiresFeature` / + * `requiresMembershipReach` already lowered into the action's `visible`, the + * spec's `lowerRequiresFeature` / `lowerRequiresMembershipReach` order. + * + * API Keys is the page that offers NO create action — `sys_api_key` declares + * no `list_toolbar` action and leaves `create` at the bucket default (off); + * keys are minted elsewhere. So it keeps the identity copy, and it is the + * negative control for the first defect. The single-org Invitations case is + * the other control: the same object, its Invite User hidden by its own gate, + * keeps the identity copy too — the copy follows the button, not the object. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }), +})); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +// ── The served objects (abridged to the keys this page reads) ────────────── + +/** `requiresMembershipReach: 'invite_member'` then `requiresFeature: 'organization'`, lowered. */ +const INVITE_MEMBER_GATE = + "('org_owner' in current_user.positions || 'org_admin' in current_user.positions || " + + "'delegated_admin' in current_user.positions) && features.organization != false"; + +const SYS_INVITATION = { + name: 'sys_invitation', + label: 'Invitation', + pluralLabel: 'Invitations', + managedBy: 'better-auth', + fields: { + id: { type: 'text', label: 'Id' }, + email: { type: 'email', label: 'Email' }, + status: { type: 'text', label: 'Status' }, + }, + actions: [ + { + name: 'invite_user', + label: 'Invite User', + variant: 'primary', + locations: ['list_toolbar'], + type: 'api', + target: '/api/v1/auth/organization/invite-member', + visible: { dialect: 'cel', source: INVITE_MEMBER_GATE }, + }, + { + name: 'cancel_invitation', + label: 'Cancel Invitation', + mode: 'delete', + locations: ['list_item'], + type: 'api', + target: '/api/v1/auth/organization/cancel-invitation', + }, + ], + listViews: { + pending: { + name: 'pending', + label: 'Pending', + type: 'grid', + columns: ['email', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'pending' }], + }, + }, +}; + +const SYS_API_KEY = { + name: 'sys_api_key', + label: 'API Key', + pluralLabel: 'API Keys', + managedBy: 'better-auth', + userActions: { edit: true }, + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + revoked: { type: 'boolean', label: 'Revoked' }, + }, + actions: [ + { + name: 'revoke_api_key', + label: 'Revoke API Key', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'PATCH', + target: '/api/v1/data/sys_api_key/{id}', + }, + ], + listViews: { + all_keys: { name: 'all_keys', label: 'All', type: 'grid', columns: ['name', 'revoked'] }, + }, +}; + +const SYS_OAUTH_APPLICATION = { + name: 'sys_oauth_application', + label: 'OAuth Application', + pluralLabel: 'OAuth Applications', + managedBy: 'better-auth', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + disabled: { type: 'boolean', label: 'Disabled' }, + }, + actions: [ + { + name: 'create_oauth_application', + label: 'Register OAuth Application', + mode: 'create', + locations: ['list_toolbar'], + type: 'api', + target: '/api/v1/auth/oauth2/create-client', + // `requiresFeature: 'oidcProvider'`, lowered (a default-on flag). + visible: { dialect: 'cel', source: 'features.oidcProvider != false' }, + }, + ], + listViews: { + all_apps: { name: 'all_apps', label: 'All', type: 'grid', columns: ['name', 'disabled'] }, + }, +}; + +const SYS_WEBHOOK = { + name: 'sys_webhook', + label: 'Webhook', + pluralLabel: 'Webhooks', + managedBy: 'config', + userActions: { create: true, edit: true, delete: true, import: false }, + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + active: { type: 'boolean', label: 'Active' }, + }, + listViews: { + active: { + name: 'active', + label: 'Active', + type: 'grid', + columns: ['name', 'active'], + filter: [{ field: 'active', operator: 'equals', value: true }], + }, + }, +}; + +const OBJECTS = [SYS_INVITATION, SYS_API_KEY, SYS_OAUTH_APPLICATION, SYS_WEBHOOK]; + +/** The seeded admin: an org admin, so the membership-reach half of Invite User holds. */ +const ADMIN = { id: 'u1', name: 'Dev Admin', positions: ['org_admin'] }; + +/** A multi-org deployment with the OIDC provider on — the dogfooded showcase. */ +const MULTI_ORG = { organization: true, oidcProvider: true }; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** Mount one page with zero rows and read its empty state's text. */ +async function emptyPage( + objectName: string, + viewId: string, + features: Record = MULTI_ORG, +): Promise { + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + const dataSource = { + find: vi.fn(async () => ({ data: [], total: 0 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as never; + render( + + + + + {}} />} + /> + + + + , + ); + const node = await screen.findByTestId('empty-state', undefined, { timeout: 5000 }); + return node.textContent ?? ''; +} + +const IDENTITY_COPY = 'not added by hand here'; +const USER_QUERY_COPY = 'your current filters or search'; +const VIEW_FILTER_COPY = 'No records match this view’s filter.'; + +describe('objectui#11687 — list empty states match the page they sit on', () => { + it('Invitations: beside Invite User, the default Pending view names its own filter', async () => { + const text = await emptyPage('sys_invitation', 'pending'); + + expect(screen.getByRole('button', { name: /Invite User/ })).toBeTruthy(); + expect(text).not.toContain(IDENTITY_COPY); + expect(text).toContain(VIEW_FILTER_COPY); + expect(text).not.toContain(USER_QUERY_COPY); + }); + + it('Invitations, single-org (control): Invite User is hidden by its gate, so the identity copy stays', async () => { + const text = await emptyPage('sys_invitation', 'pending', { organization: false, oidcProvider: true }); + + expect(screen.queryByRole('button', { name: /Invite User/ })).toBeNull(); + expect(text).toContain('No identity records'); + expect(text).toContain(IDENTITY_COPY); + }); + + it('API Keys: the page offers no create action, so the identity copy stays', async () => { + const text = await emptyPage('sys_api_key', 'all_keys'); + + expect(screen.queryByRole('button', { name: /New|Create|Register|Invite/ })).toBeNull(); + expect(text).toContain('No identity records'); + expect(text).toContain(IDENTITY_COPY); + }); + + it('OAuth Applications: beside Register OAuth Application, the unfiltered list gets first-run copy', async () => { + const text = await emptyPage('sys_oauth_application', 'all_apps'); + + expect(screen.getByRole('button', { name: /Register OAuth Application/ })).toBeTruthy(); + expect(text).not.toContain(IDENTITY_COPY); + expect(text).toContain('Nothing here yet'); + }); + + it('Webhooks, Active view: zero rows and nothing applied names the view\'s filter, not the user\'s', async () => { + const text = await emptyPage('sys_webhook', 'active'); + + expect(text).toContain('No matching records'); + expect(text).toContain(VIEW_FILTER_COPY); + expect(text).not.toContain(USER_QUERY_COPY); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 1fb96051d8..aff68811e2 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -67,7 +67,7 @@ import { createIdentityImportDataSource, IDENTITY_IMPORT_OBJECT, type IdentityPa import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields } from './IdentityImportPanels.js'; import { importTargetFields } from './importTargetFields.js'; import { useExpressionContext } from '../providers/ExpressionProvider.js'; -import { resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; +import { listToolbarDrawsAction, resolveManagedByEmptyState } from '../utils/managedByEmptyState.js'; import { resolveViewId } from '../utils/resolveViewId.js'; import { defaultListViewId, viewRowId, isSavedViewId, viewEntry } from '../utils/viewIdentity.js'; import { warnSuppressedListNav } from '../utils/warnSuppressedListNav.js'; @@ -76,7 +76,7 @@ import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration'; -import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged } from '@object-ui/react'; +import { ActionProvider, useNavigationOverlay, SchemaRenderer, useActionTextLocalizer, useRowPredicate, RelatedRecordActionsProvider, notifyDataChanged, useCapabilityGate, usePredicateScope } from '@object-ui/react'; import type { RelatedRecordActionsValue, RelatedRecordHandlers } from '@object-ui/react'; import { toast } from 'sonner'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime.js'; @@ -2049,6 +2049,22 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co }); const createDisabled = createPredicates?.disabledWhen != null && createDisabledPred; + /** + * objectui#11687 — does this page offer a way to add a row? The New button + * above, or a `list_toolbar` action the schema-driven toolbar draws (Invite + * User, Register OAuth Application, …), each by the verdict its own control + * renders on. The managed-by empty state reads it: a `better-auth` list + * must not say rows are "not added by hand here" beside such a button. + * A boolean, computed each render from the payload (`objectDef.actions`), + * so `renderListView` depends on a primitive, not a memoised identity + * (AGENTS.md #10). + */ + const mayInvokeToolbarAction = useCapabilityGate(); + const toolbarPredicateScope = usePredicateScope(); + const pageOffersCreate = + (objectCanCreate && createVisible) || + listToolbarDrawsAction(objectDef.actions, mayInvokeToolbarAction, toolbarPredicateScope); + /** * [#5142] The object-list toolbar's IMPORT predicates — the `import` half * of the toolbar-scope pair the spec resolver emits, mirroring what @@ -3346,7 +3362,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co viewDef.name || viewDef.id || '', viewDef.emptyState ?? listSchema.emptyState - ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name, (objectDef as any)?.userActions), + ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name, (objectDef as any)?.userActions, pageOffersCreate), ), aria: viewDef.aria ?? listSchema.aria, // (the legacy `filters` twin of the `filter` above lived here until @@ -3552,7 +3568,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co dataSource={ds} /> ); - }, [activeView, activeViewDeclaresColumns, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, user?.id]); + }, [activeView, activeViewDeclaresColumns, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, user?.id, pageOffersCreate]); // Memoize the merged views array so PluginObjectView doesn't get a new // reference on every render (which would trigger unnecessary data refetches). diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 02a764627f..00351a6a93 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -812,6 +812,7 @@ const ar = { firstRunMessage: "أنشئ سجلك الأول للبدء.", noMatches: "لا توجد سجلات مطابقة", noMatchesMessage: "لا توجد سجلات تطابق عوامل التصفية أو البحث الحالية. حاول تعديلها أو مسحها.", + viewFilterNoMatchesMessage: "لا توجد سجلات تطابق عامل التصفية لطريقة العرض هذه.", loadErrorTitle: "تعذّر تحميل السجلات", loadErrorMessage: "حدث خطأ أثناء تحميل هذه البيانات. تحقق من اتصالك وحاول مرة أخرى.", loadErrorForbiddenTitle: "ليس لديك صلاحية الوصول", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index dad896ce81..cc3cd60229 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -708,6 +708,7 @@ const de = { firstRunMessage: "Legen Sie Ihren ersten Datensatz an, um loszulegen.", noMatches: "Keine passenden Datensätze", noMatchesMessage: "Keine Datensätze entsprechen Ihren aktuellen Filtern oder Ihrer Suche. Passen Sie sie an oder setzen Sie sie zurück.", + viewFilterNoMatchesMessage: "Keine Datensätze entsprechen dem Filter dieser Ansicht.", loadErrorTitle: "Datensätze konnten nicht geladen werden", loadErrorMessage: "Beim Laden dieser Daten ist etwas schiefgelaufen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", loadErrorForbiddenTitle: "Kein Zugriff", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 032a6e5a8c..3a7cf020ac 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -961,6 +961,7 @@ const en = { firstRunMessage: 'Create your first record to get started.', noMatches: 'No matching records', noMatchesMessage: 'No records match your current filters or search. Try adjusting or clearing them.', + viewFilterNoMatchesMessage: 'No records match this view’s filter.', loadErrorTitle: 'Couldn\u2019t load records', loadErrorMessage: 'Something went wrong while loading this data. Check your connection and try again.', loadErrorForbiddenTitle: 'You don\u2019t have access', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 15de671462..faa29d424c 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -735,6 +735,7 @@ const es = { firstRunMessage: "Cree su primer registro para empezar.", noMatches: "No hay registros coincidentes", noMatchesMessage: "Ningún registro coincide con sus filtros o su búsqueda actuales. Pruebe a ajustarlos o borrarlos.", + viewFilterNoMatchesMessage: "Ningún registro coincide con el filtro de esta vista.", loadErrorTitle: "No se pudieron cargar los registros", loadErrorMessage: "Algo salió mal al cargar estos datos. Compruebe su conexión y vuelva a intentarlo.", loadErrorForbiddenTitle: "No tiene acceso", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 805edd3f12..d52ad37cd8 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -731,6 +731,7 @@ const fr = { firstRunMessage: "Créez votre premier enregistrement pour commencer.", noMatches: "Aucun enregistrement correspondant", noMatchesMessage: "Aucun enregistrement ne correspond à vos filtres ou à votre recherche. Essayez de les ajuster ou de les effacer.", + viewFilterNoMatchesMessage: "Aucun enregistrement ne correspond au filtre de cette vue.", loadErrorTitle: "Impossible de charger les enregistrements", loadErrorMessage: "Une erreur s'est produite lors du chargement de ces données. Vérifiez votre connexion et réessayez.", loadErrorForbiddenTitle: "Accès refusé", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7d8f5a8e4b..71cb9fee08 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -708,6 +708,7 @@ const ja = { firstRunMessage: "最初のレコードを作成して始めましょう。", noMatches: "該当するレコードがありません", noMatchesMessage: "現在のフィルターや検索条件に一致するレコードはありません。条件を調整するか解除してください。", + viewFilterNoMatchesMessage: "このビューのフィルターに一致するレコードはありません。", loadErrorTitle: "レコードを読み込めませんでした", loadErrorMessage: "データの読み込み中に問題が発生しました。接続を確認して再試行してください。", loadErrorForbiddenTitle: "アクセス権がありません", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 0f99c3e2e6..859c6a657e 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -708,6 +708,7 @@ const ko = { firstRunMessage: "첫 레코드를 만들어 시작하세요.", noMatches: "일치하는 레코드가 없습니다", noMatchesMessage: "현재 필터나 검색어와 일치하는 레코드가 없습니다. 조건을 조정하거나 지워 보세요.", + viewFilterNoMatchesMessage: "이 뷰의 필터와 일치하는 레코드가 없습니다.", loadErrorTitle: "레코드를 불러오지 못했습니다", loadErrorMessage: "이 데이터를 불러오는 중 문제가 발생했습니다. 연결을 확인하고 다시 시도하세요.", loadErrorForbiddenTitle: "접근 권한이 없습니다", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 88f919aca1..70012a4e74 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -730,6 +730,7 @@ const pt = { firstRunMessage: "Crie seu primeiro registro para começar.", noMatches: "Nenhum registro correspondente", noMatchesMessage: "Nenhum registro corresponde aos seus filtros ou à sua busca atuais. Tente ajustá-los ou limpá-los.", + viewFilterNoMatchesMessage: "Nenhum registro corresponde ao filtro desta visualização.", loadErrorTitle: "Não foi possível carregar os registros", loadErrorMessage: "Algo deu errado ao carregar estes dados. Verifique sua conexão e tente novamente.", loadErrorForbiddenTitle: "Você não tem acesso", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index a4bce464cc..ff2225e8f7 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -762,6 +762,7 @@ const ru = { firstRunMessage: "Создайте первую запись, чтобы начать.", noMatches: "Подходящих записей нет", noMatchesMessage: "Ни одна запись не соответствует текущим фильтрам или поиску. Измените или сбросьте их.", + viewFilterNoMatchesMessage: "Ни одна запись не соответствует фильтру этого представления.", loadErrorTitle: "Не удалось загрузить записи", loadErrorMessage: "При загрузке данных произошла ошибка. Проверьте соединение и попробуйте снова.", loadErrorForbiddenTitle: "Нет доступа", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index c683b85f40..b4ad27b941 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -756,6 +756,7 @@ const zh = { firstRunMessage: '创建第一条记录即可开始。', noMatches: '没有匹配的记录', noMatchesMessage: '没有符合当前筛选或搜索条件的记录,试试调整或清除它们。', + viewFilterNoMatchesMessage: '没有符合该视图筛选条件的记录。', loadErrorTitle: '无法加载记录', loadErrorMessage: '加载数据时出错。请检查网络连接后重试。', loadErrorForbiddenTitle: '无权访问', diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 5295667cef..7ae9512282 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -830,6 +830,10 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.firstRunMessage': 'Create your first record to get started.', 'list.noMatches': 'No matching records', 'list.noMatchesMessage': 'No records match your current filters or search. Try adjusting or clearing them.', + // Emptied by the VIEW's own declared filter, with no user filter or search + // applied (objectui#11687): the "your filters" wording above would name + // filters the user never set. + 'list.viewFilterNoMatchesMessage': 'No records match this view’s filter.', 'list.loading': 'Loading records…', // Load FAILED (network / server error) — distinct from empty. Offer retry. 'list.loadErrorTitle': 'Couldn\u2019t load records', @@ -5200,17 +5204,24 @@ export const ListView = React.forwardRef(({ // full of records. That reads as data loss or a permission problem // and sends triage away from the view layer, which is exactly what // this issue reported. + // + // …but it is not the USER's query (objectui#11687). "Your current + // filters or search" is said only when the user applied one — the + // search box, the user-filter chips, the filter panel. A view + // emptied by its own declared filter alone gets its own message, + // which names the view's filter instead of telling the user to + // clear filters they never set. const hasBaseFilter = Array.isArray(authoredFilter) ? authoredFilter.length > 0 : !!authoredFilter && typeof authoredFilter === 'object' ? Object.keys(authoredFilter).length > 0 : false; - const hasActiveQuery = + const hasUserQuery = !!(searchTerm && searchTerm.trim()) || - hasBaseFilter || (Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) || (Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0); + const hasActiveQuery = hasUserQuery || hasBaseFilter; // objectui#11227 — `title` and `message` are the spec's `I18nLabel` // (`EmptyStateSchema`): a plain string or an inline locale map. They // are RESOLVED against the display locale, as the view's `label` @@ -5222,7 +5233,11 @@ export const ListView = React.forwardRef(({ const title = resolveInlineI18nLabel(schema.emptyState?.title, displayLocale) ?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle')); const description = resolveInlineI18nLabel(schema.emptyState?.message, displayLocale) - ?? (hasActiveQuery ? t('list.noMatchesMessage') : t('list.firstRunMessage')); + ?? (hasUserQuery + ? t('list.noMatchesMessage') + : hasBaseFilter + ? t('list.viewFilterNoMatchesMessage') + : t('list.firstRunMessage')); return ( { +async function emptyState( + schema: ListViewSchema, + session: Pick, 'initialFilters' | 'initialSearchTerm'> = {}, +): Promise { const ds = emptyDataSource(); const { container } = render( - + , ); await waitFor(() => { @@ -102,6 +112,41 @@ describe('ListView empty state — a filtered view says it is filtered (#4155)', expect(panel.textContent).not.toMatch(/Create your first record/i); }); + // objectui#11687 — the Webhooks "Active" view: its own filter, zero rows, + // and no filter or search from the user. + it('a view filtered to empty names the VIEW\'s filter, not "your current filters or search"', async () => { + const panel = await emptyState( + authored({ ...BASE, filter: [{ field: 'active', operator: 'equals', value: true }] }), + ); + + expect(panel.textContent).toContain('No records match this view’s filter.'); + expect(panel.textContent).not.toMatch(/your current filters or search/i); + expect(panel.textContent).not.toMatch(/Create your first record/i); + }); + + it('a search the user typed still gets the "your current filters or search" copy', async () => { + const panel = await emptyState(BASE, { initialSearchTerm: 'zzz' }); + + expect(panel.textContent).toMatch(/No matching records/i); + expect(panel.textContent).toMatch(/your current filters or search/i); + }); + + it('a filter the user applied over a filtered view is the user\'s, and says so', async () => { + const panel = await emptyState( + authored({ ...BASE, filter: [['status', 'not_in', ['archived']]] }), + { + initialFilters: { + id: 'root', + logic: 'and', + conditions: [{ id: 'c1', field: 'name', operator: 'contains', value: 'zzz' }], + } as never, + }, + ); + + expect(panel.textContent).toMatch(/your current filters or search/i); + expect(panel.textContent).not.toContain('No records match this view’s filter.'); + }); + it('a genuinely unfiltered empty list still invites the user to create', async () => { const panel = await emptyState(BASE);