updates on the logic on the skills and settings tab
This commit is contained in:
@@ -10,6 +10,8 @@ import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
|
|||||||
import { wallClockNow } from '@/lib/assistant/agent/signals';
|
import { wallClockNow } from '@/lib/assistant/agent/signals';
|
||||||
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
|
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
|
import { canOnboardClients } from '@/lib/clientOnboarding';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AgentOperationsBanner
|
* AgentOperationsBanner
|
||||||
@@ -40,6 +42,10 @@ export default function AgentOperationsBanner({
|
|||||||
className = ''
|
className = ''
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// The Skills & Tools tab is the owner login's only; nobody else gets a
|
||||||
|
// button into it.
|
||||||
|
const { user, isClient } = useAuth();
|
||||||
|
const canConfigure = canOnboardClients(user, isClient);
|
||||||
// Which settings the rules are running on — the registry's, or the code
|
// Which settings the rules are running on — the registry's, or the code
|
||||||
// defaults while it is unreachable. Said on screen, so a tuned threshold is
|
// defaults while it is unreachable. Said on screen, so a tuned threshold is
|
||||||
// never assumed when it is not in force.
|
// never assumed when it is not in force.
|
||||||
@@ -141,7 +147,7 @@ export default function AgentOperationsBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{canConfigure && <Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate('/doormile/settings?tab=skills')}
|
onClick={() => navigate('/doormile/settings?tab=skills')}
|
||||||
@@ -150,7 +156,7 @@ export default function AgentOperationsBanner({
|
|||||||
>
|
>
|
||||||
<SlidersHorizontal className="h-3.5 w-3.5 text-accent-ink" />
|
<SlidersHorizontal className="h-3.5 w-3.5 text-accent-ink" />
|
||||||
<span>Configure skills</span>
|
<span>Configure skills</span>
|
||||||
</Button>
|
</Button>}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Input } from '@/components/ds';
|
|||||||
import Customers from '@/pages/doormile/customers/Customers';
|
import Customers from '@/pages/doormile/customers/Customers';
|
||||||
import Profitability from '@/pages/doormile/reports/Profitability';
|
import Profitability from '@/pages/doormile/reports/Profitability';
|
||||||
import AgentStudio from './agentStudio/AgentStudio';
|
import AgentStudio from './agentStudio/AgentStudio';
|
||||||
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
|
import { canOnboardClients } from '@/lib/clientOnboarding';
|
||||||
|
|
||||||
// Skills & Tools (Agent Studio) reads and writes the backend agent registry
|
// Skills & Tools (Agent Studio) reads and writes the backend agent registry
|
||||||
// (/admin/ai/*, docs/agent-platform-plan.md Phase 2). Changes are shared and
|
// (/admin/ai/*, docs/agent-platform-plan.md Phase 2). Changes are shared and
|
||||||
@@ -18,13 +20,24 @@ import AgentStudio from './agentStudio/AgentStudio';
|
|||||||
//
|
//
|
||||||
// It is NOT the default tab. Landing on a section that does nothing is how an
|
// It is NOT the default tab. Landing on a section that does nothing is how an
|
||||||
// operator concludes the AI is broken; Profitability leads instead.
|
// operator concludes the AI is broken; Profitability leads instead.
|
||||||
const TABS = [
|
const ALL_TABS = [
|
||||||
{ id: 'profitability', label: 'Profitability', icon: TrendingUp },
|
{ id: 'profitability', label: 'Profitability', icon: TrendingUp },
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'skills', label: 'Skills & Tools', icon: Sparkles },
|
{ id: 'skills', label: 'Skills & Tools', icon: Sparkles, ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skills & Tools is shown to the console owner login only (admin@doormile.com,
|
||||||
|
* the same owner list as Client Onboarding). Everyone else, other staff and
|
||||||
|
* every client, gets the other tabs; a ?tab=skills link falls to the default.
|
||||||
|
*/
|
||||||
|
export function settingsTabsFor(isOwner) {
|
||||||
|
return ALL_TABS.filter((tab) => !tab.ownerOnly || isOwner);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const { user, isClient } = useAuth();
|
||||||
|
const TABS = settingsTabsFor(canOnboardClients(user, isClient));
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tabParam = searchParams.get('tab');
|
const tabParam = searchParams.get('tab');
|
||||||
|
|
||||||
@@ -42,6 +55,8 @@ export default function Settings() {
|
|||||||
if (tabParam && TABS.some((t) => t.id === tabParam)) {
|
if (tabParam && TABS.some((t) => t.id === tabParam)) {
|
||||||
setActiveTab(tabParam);
|
setActiveTab(tabParam);
|
||||||
}
|
}
|
||||||
|
// TABS is derived from the login, which does not change on this page.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tabParam]);
|
}, [tabParam]);
|
||||||
|
|
||||||
const handleTabChange = (id) => {
|
const handleTabChange = (id) => {
|
||||||
@@ -121,7 +136,7 @@ export default function Settings() {
|
|||||||
exit={{ opacity: 0, y: -4 }}
|
exit={{ opacity: 0, y: -4 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
>
|
||||||
{activeTab === 'skills' && <AgentStudio />}
|
{activeTab === 'skills' && TABS.some((t) => t.id === 'skills') && <AgentStudio />}
|
||||||
{activeTab === 'profitability' && (
|
{activeTab === 'profitability' && (
|
||||||
<Profitability
|
<Profitability
|
||||||
embedded
|
embedded
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ jest.mock('lucide-react', () =>
|
|||||||
);
|
);
|
||||||
jest.mock('@/lib/assistant/scan');
|
jest.mock('@/lib/assistant/scan');
|
||||||
jest.mock('@/lib/assistant/agent/AgentFactory');
|
jest.mock('@/lib/assistant/agent/AgentFactory');
|
||||||
|
// The "Configure skills" button is the owner login's only.
|
||||||
|
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin' }, isClient: false };
|
||||||
|
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
|
||||||
|
|
||||||
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
@@ -112,6 +115,26 @@ describe('AgentOperationsBanner', () => {
|
|||||||
expect(screen.getByTestId('where')).toHaveTextContent('/doormile/settings?tab=skills');
|
expect(screen.getByTestId('where')).toHaveTextContent('/doormile/settings?tab=skills');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers "Configure skills" to nobody but the owner login', async () => {
|
||||||
|
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
||||||
|
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
|
||||||
|
const owner = mockAuth;
|
||||||
|
try {
|
||||||
|
for (const who of [
|
||||||
|
{ user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false },
|
||||||
|
{ user: { email: 'ops@client.test', role: 'manager', tenantid: 4 }, isClient: true },
|
||||||
|
]) {
|
||||||
|
mockAuth = who;
|
||||||
|
const { unmount } = renderBanner();
|
||||||
|
await screen.findAllByText(/./);
|
||||||
|
expect(screen.queryByRole('button', { name: /Configure skills/i })).toBeNull();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
mockAuth = owner;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('says when it is running on default thresholds, and stops once the registry answers', async () => {
|
it('says when it is running on default thresholds, and stops once the registry answers', async () => {
|
||||||
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
|
||||||
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
|
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ jest.mock('@/api/doormile', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('@/api/doormile/notify', () => ({ OpenToast: jest.fn() }));
|
jest.mock('@/api/doormile/notify', () => ({ OpenToast: jest.fn() }));
|
||||||
|
const mockAuth = { user: { email: 'admin@doormile.com', role: 'admin' }, isClient: false };
|
||||||
|
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
|
||||||
|
|
||||||
const api = require('@/api/doormile');
|
const api = require('@/api/doormile');
|
||||||
const { OpenToast } = require('@/api/doormile/notify');
|
const { OpenToast } = require('@/api/doormile/notify');
|
||||||
|
|||||||
23
tests/lib/settingsTabs.test.js
Normal file
23
tests/lib/settingsTabs.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
jest.mock('lucide-react', () => new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : () => null) }));
|
||||||
|
jest.mock('@/pages/doormile/customers/Customers', () => () => null);
|
||||||
|
jest.mock('@/pages/doormile/reports/Profitability', () => () => null);
|
||||||
|
jest.mock('@/pages/doormile/settings/agentStudio/AgentStudio', () => () => null);
|
||||||
|
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => ({}) }));
|
||||||
|
|
||||||
|
import { settingsTabsFor } from '@/pages/doormile/settings/Settings';
|
||||||
|
import { canOnboardClients } from '@/lib/clientOnboarding';
|
||||||
|
|
||||||
|
const ids = (isOwner) => settingsTabsFor(isOwner).map((t) => t.id);
|
||||||
|
|
||||||
|
describe('Settings tabs per login', () => {
|
||||||
|
test('admin@doormile.com sees Skills & Tools', () => {
|
||||||
|
expect(ids(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, false))).toEqual([
|
||||||
|
'profitability', 'customers', 'skills',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other Doormile staff and clients do not', () => {
|
||||||
|
expect(ids(canOnboardClients({ email: 'ops@doormile.com', role: 'admin' }, false))).toEqual(['profitability', 'customers']);
|
||||||
|
expect(ids(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, true))).toEqual(['profitability', 'customers']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user