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
10 changes: 10 additions & 0 deletions .changeset/25915-app-no-access-shared-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the "nothing in this app is available to you" screen is drawn by the shared `DataEmptyState`

Clause-②: no (one block swaps its own markup for the shared components; no exported type change. If a shared component needs a new prop, the thread reports first and the seat amends this line)
Metadata-protocol: none

The screen an app's root shows when the app serves the caller no navigation, or names only objects the caller may not read (`AppNoAccessEmptyState`), now uses `DataEmptyState` from `@object-ui/components` instead of the `Empty` primitive with a hand-copied icon square. It is announced to assistive technology as `role="status"`. The text, the Open Setup link, the test ids and the `data-reason` attribute (now on the status node) are unchanged; the title takes the shared component's smaller size.
82 changes: 42 additions & 40 deletions ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@

import { Link } from 'react-router-dom';
import { Lock } from 'lucide-react';
// The three primitives `RouteNotFound` and the app-verdict screens in
// `AppContent.tsx` already compose, so this screen looks like its siblings and
// adds nothing to the eager `ui-components` chunk.
import { Button, Empty, EmptyDescription, EmptyTitle } from '@object-ui/components';
// The shared empty state (objectstack#25915): it draws the muted icon square
// this screen used to hand-copy, and it is `role="status"`, so "nothing here
// for you" is announced instead of being an unlabelled block. `AppContent.tsx`
// already reaches it through `PreviewDraftEmptyState`, so the eager graph
// gains no new module.
import { Button, DataEmptyState } from '@object-ui/components';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import { resolveKeyedI18nLabel } from '../utils/index.js';

Expand All @@ -56,44 +58,44 @@ export function AppNoAccessEmptyState({ app, setupPath, reason = 'no-navigation'
// The same label the header's app switcher shows for this app.
const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) });

const description =
reason === 'no-readable-object'
? setupPath
? t('empty.appNoReadableObjectGrantDescription', {
defaultValue: 'Your account cannot read any of the records in this app yet. You can grant access in Setup.',
})
: t('empty.appNoReadableObjectDescription', {
defaultValue:
'Your account cannot read any of the records in this app yet. Ask an administrator to assign you a permission set.',
})
: setupPath
? t('empty.appNothingAvailableGrantDescription', {
defaultValue: 'This app has no pages your account can open. You can grant access in Setup.',
})
: t('empty.appNothingAvailableDescription', {
defaultValue: 'This app has no pages your account can open. Ask an administrator to grant you access.',
});

return (
<div className="flex h-full w-full items-center justify-center p-8" data-testid="app-no-access-empty-state">
<Empty>
<div className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground" aria-hidden="true">
<Lock className="size-5" />
</div>
<EmptyTitle>
{t('empty.appNothingAvailable', {
app: label,
defaultValue: 'Nothing in {{app}} is available to you yet',
})}
</EmptyTitle>
<EmptyDescription data-reason={reason}>
{reason === 'no-readable-object'
? setupPath
? t('empty.appNoReadableObjectGrantDescription', {
defaultValue: 'Your account cannot read any of the records in this app yet. You can grant access in Setup.',
})
: t('empty.appNoReadableObjectDescription', {
defaultValue:
'Your account cannot read any of the records in this app yet. Ask an administrator to assign you a permission set.',
})
: setupPath
? t('empty.appNothingAvailableGrantDescription', {
defaultValue: 'This app has no pages your account can open. You can grant access in Setup.',
})
: t('empty.appNothingAvailableDescription', {
defaultValue: 'This app has no pages your account can open. Ask an administrator to grant you access.',
})}
</EmptyDescription>
{setupPath && (
<Button asChild variant="outline">
<Link to={setupPath} data-testid="app-no-access-open-setup">
{t('empty.openSetup', { defaultValue: 'Open Setup' })}
</Link>
</Button>
)}
</Empty>
<DataEmptyState
data-reason={reason}
icon={<Lock className="size-5 text-muted-foreground" aria-hidden="true" />}
title={t('empty.appNothingAvailable', {
app: label,
defaultValue: 'Nothing in {{app}} is available to you yet',
})}
description={description}
action={
setupPath ? (
<Button asChild variant="outline">
<Link to={setupPath} data-testid="app-no-access-open-setup">
{t('empty.openSetup', { defaultValue: 'Open Setup' })}
</Link>
</Button>
) : undefined
}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* 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.
*/

/**
* The app root's "nothing here for you" screen is drawn by the shared
* `DataEmptyState` (objectstack#25915), so it is announced as
* `role="status"`. Before, it composed the bare `Empty` primitive around a
* hand-copied icon square, an unlabelled block to assistive technology.
*
* The routing that decides WHEN this screen shows is pinned by the
* AppContent landing suites; this file pins only what the screen is.
*
* The `t` mock returns each call site's `defaultValue`, which the i18n
* call-site gate holds byte-equal to the `en` pack value.
*/

import '@testing-library/jest-dom/vitest';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/i18n')>()),
useObjectTranslation: () => ({
t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
}),
useObjectLabel: () => ({
appLabel: (app: { name: string; label?: string }) => app.label ?? app.name,
}),
}));

import { AppNoAccessEmptyState } from '../AppNoAccessEmptyState';

function renderState(props: Partial<ComponentProps<typeof AppNoAccessEmptyState>> = {}) {
return render(
<MemoryRouter>
<AppNoAccessEmptyState app={{ name: 'crm', label: 'CRM' }} setupPath={null} {...props} />
</MemoryRouter>,
);
}

describe('AppNoAccessEmptyState draws the shared empty state (objectstack#25915)', () => {
it('is a role="status" DataEmptyState inside the test-id wrapper', () => {
renderState();

const wrapper = screen.getByTestId('app-no-access-empty-state');
const status = screen.getByRole('status');
expect(wrapper).toContainElement(status);
expect(status).toHaveAttribute('data-slot', 'data-empty-state');
expect(status).toHaveTextContent('Nothing in {{app}} is available to you yet');
expect(status).toHaveTextContent('Ask an administrator to grant you access.');
});

it.each([
['no-navigation', undefined],
['no-readable-object', 'no-readable-object' as const],
])('carries data-reason=%s on the status node', (expected, reason) => {
renderState(reason ? { reason } : {});

expect(screen.getByRole('status')).toHaveAttribute('data-reason', expected);
});

it('offers Open Setup as the action only when a setup path is given', () => {
renderState({ setupPath: '/setup/permissions' });

const link = screen.getByTestId('app-no-access-open-setup');
expect(screen.getByRole('status')).toContainElement(link);
expect(link).toHaveAttribute('href', '/setup/permissions');
});
});
Loading