updates on the design and the onboarding things
This commit is contained in:
@@ -179,6 +179,25 @@ export const onboardClient = async (data) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Edit an onboarded client and its login (owner login only). `authid` is the
|
||||
* login's id from GET /admin/clients/onboarded. Only fields sent change;
|
||||
* `password` resets the login's password when non-empty.
|
||||
*/
|
||||
export const updateOnboardedClient = async (authid, data) => {
|
||||
const response = await doormileAxios.put(`/admin/clients/${encodeURIComponent(authid)}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a client's console login (owner login only). The company and its
|
||||
* order history are kept; the client is marked Inactive if no login remains.
|
||||
*/
|
||||
export const deleteOnboardedClient = async (authid) => {
|
||||
const response = await doormileAxios.delete(`/admin/clients/${encodeURIComponent(authid)}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** Operating cities a new client can be placed in (applocations; owner login only). */
|
||||
export const getOnboardingCities = async () => {
|
||||
const response = await doormileAxios.get('/admin/clients/cities');
|
||||
|
||||
@@ -193,6 +193,20 @@ export const useOnboardedClients = (options) =>
|
||||
export const useOnboardingCities = (options) =>
|
||||
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
|
||||
|
||||
export const useUpdateOnboardedClient = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ authid, data }) => api.updateOnboardedClient(authid, data),
|
||||
invalidates: [KEYS.tenants],
|
||||
successMessage: 'Client updated',
|
||||
});
|
||||
|
||||
export const useDeleteOnboardedClient = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: (authid) => api.deleteOnboardedClient(authid),
|
||||
invalidates: [KEYS.tenants],
|
||||
successMessage: 'Client login removed',
|
||||
});
|
||||
|
||||
// No toasts: the page shows its own success panel and inline errors.
|
||||
export const useOnboardClient = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
242
src/pages/doormile/clients/ClientLoginDialogs.jsx
Normal file
242
src/pages/doormile/clients/ClientLoginDialogs.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds';
|
||||
import { inputVariants } from '@/components/ui/input';
|
||||
import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks';
|
||||
import { generateClientPassword } from '@/lib/clientOnboarding';
|
||||
|
||||
/**
|
||||
* Edit and Remove for one onboarded client login (a row of "Clients with a
|
||||
* console login"). Both call owner-only endpoints: PUT and DELETE
|
||||
* /admin/clients/:authid.
|
||||
*/
|
||||
|
||||
const STATUSES = ['Active', 'Pending', 'Inactive'];
|
||||
|
||||
/** Only the fields that actually changed, so an edit never rewrites the rest. */
|
||||
export function changedFields(row, form) {
|
||||
const out = {};
|
||||
const trim = (v) => String(v ?? '').trim();
|
||||
if (trim(form.companyname) !== trim(row.tenantname)) out.companyname = trim(form.companyname);
|
||||
if (trim(form.contactname) !== trim(row.contactname)) out.contactname = trim(form.contactname);
|
||||
if (trim(form.email).toLowerCase() !== trim(row.loginemail).toLowerCase()) out.email = trim(form.email).toLowerCase();
|
||||
if (form.phone.replace(/\D/g, '') !== String(row.primarycontact || '').replace(/\D/g, '')) out.phone = form.phone.replace(/\D/g, '');
|
||||
if (form.status !== row.status) out.status = form.status;
|
||||
if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp);
|
||||
if (form.password) out.password = form.password;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateEdit(form, changes) {
|
||||
const errors = {};
|
||||
if ('companyname' in changes && changes.companyname.length < 2) errors.companyname = 'Enter the company name';
|
||||
if ('contactname' in changes && changes.contactname.length < 2) errors.contactname = "Enter the contact person's name";
|
||||
if ('email' in changes && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(changes.email)) errors.email = 'Enter a valid email address';
|
||||
if ('phone' in changes && !/^[6-9]\d{9}$/.test(changes.phone)) errors.phone = 'Enter a 10-digit mobile number';
|
||||
if (form.password && form.password.length < 8) errors.password = 'At least 8 characters';
|
||||
if (form.password && form.password.length > 72) errors.password = 'At most 72 characters';
|
||||
return errors;
|
||||
}
|
||||
|
||||
function formFrom(row) {
|
||||
return {
|
||||
companyname: row?.tenantname || '',
|
||||
contactname: row?.contactname || '',
|
||||
email: row?.loginemail || '',
|
||||
phone: String(row?.primarycontact || '').replace(/\D/g, ''),
|
||||
status: STATUSES.includes(row?.status) ? row.status : 'Active',
|
||||
requiredeliveryotp: Boolean(row?.requiredeliveryotp),
|
||||
password: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function EditClientModal({ row, onClose }) {
|
||||
const update = useUpdateOnboardedClient();
|
||||
const [form, setForm] = useState(formFrom(row));
|
||||
const [errors, setErrors] = useState({});
|
||||
const [serverError, setServerError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(formFrom(row));
|
||||
setErrors({});
|
||||
setServerError('');
|
||||
setShowPassword(false);
|
||||
}, [row]);
|
||||
|
||||
const set = (key) => (value) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
setErrors((e) => ({ ...e, [key]: undefined }));
|
||||
};
|
||||
|
||||
const changes = row ? changedFields(row, form) : {};
|
||||
const nothingChanged = Object.keys(changes).length === 0;
|
||||
|
||||
const save = () => {
|
||||
const nextErrors = validateEdit(form, changes);
|
||||
setErrors(nextErrors);
|
||||
setServerError('');
|
||||
if (Object.keys(nextErrors).length || nothingChanged) return;
|
||||
update.mutate(
|
||||
{ authid: row.authid, data: changes },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res?.success !== false) onClose();
|
||||
},
|
||||
onError: (err) => setServerError(err?.response?.data?.message || 'The change was not saved.'),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(row)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={`Edit ${row?.tenantname || 'client'}`}
|
||||
description="Changes the client and its console login together."
|
||||
icon={Pencil}
|
||||
busy={update.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={update.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={save} loading={update.isPending} disabled={nothingChanged}>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<Alert tone="destructive" className="mb-4" role="alert">
|
||||
{serverError}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="Company name" error={errors.companyname} className="sm:col-span-2">
|
||||
<Input value={form.companyname} maxLength={120} onChange={(e) => set('companyname')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Contact person" error={errors.contactname}>
|
||||
<Input value={form.contactname} maxLength={80} onChange={(e) => set('contactname')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Mobile number" error={errors.phone}>
|
||||
<Input
|
||||
value={form.phone}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Login email" error={errors.email} hint="Changing it changes what they sign in with">
|
||||
<Input type="email" value={form.email} autoComplete="off" onChange={(e) => set('email')(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select value={form.status} onChange={(e) => set('status')(e.target.value)} className={inputVariants()}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label="New password"
|
||||
error={errors.password}
|
||||
hint="Leave empty to keep the current password"
|
||||
htmlFor="edit-client-password"
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input
|
||||
id="edit-client-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => set('password')(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
set('password')(generateClientPassword());
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
<KeyRound className="mr-1.5 h-4 w-4" /> Generate new password
|
||||
</Button>
|
||||
{form.password && (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
|
||||
{showPassword ? 'Hide' : 'Show'} password
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Field label="Require delivery OTP" inline className="sm:col-span-2">
|
||||
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemoveClientModal({ row, onClose }) {
|
||||
const remove = useDeleteOnboardedClient();
|
||||
const [typed, setTyped] = useState('');
|
||||
const [serverError, setServerError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setTyped('');
|
||||
setServerError('');
|
||||
}, [row]);
|
||||
|
||||
const confirmed = row && typed.trim().toLowerCase() === String(row.tenantname).trim().toLowerCase();
|
||||
|
||||
const submit = () => {
|
||||
if (!confirmed) return;
|
||||
remove.mutate(row.authid, {
|
||||
onSuccess: (res) => {
|
||||
if (res?.success !== false) onClose();
|
||||
},
|
||||
onError: (err) => setServerError(err?.response?.data?.message || 'The login was not removed.'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(row)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={`Remove ${row?.tenantname || 'client'}'s login`}
|
||||
icon={Trash2}
|
||||
busy={remove.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={remove.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={submit} loading={remove.isPending} disabled={!confirmed}>
|
||||
Remove login
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<Alert tone="destructive" className="mb-4" role="alert">
|
||||
{serverError}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3 text-body-sm text-ink-2">
|
||||
<p>
|
||||
<span className="font-semibold">{row?.loginemail}</span> will no longer be able to sign in to the console.
|
||||
</p>
|
||||
<p>
|
||||
The client's orders, customers and pricing are kept. If this is its last login, the client is marked{' '}
|
||||
<span className="font-semibold">Inactive</span>. A session already signed in ends when its token expires.
|
||||
</p>
|
||||
<Field label={`Type "${row?.tenantname || ''}" to confirm`}>
|
||||
<Input value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react';
|
||||
import {
|
||||
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
||||
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { inputVariants } from '@/components/ui/input';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
|
||||
import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs';
|
||||
import {
|
||||
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding,
|
||||
} from '@/lib/clientOnboarding';
|
||||
@@ -54,6 +55,12 @@ function serverFieldOf(message) {
|
||||
|
||||
function OnboardedList() {
|
||||
const { data = [], isLoading } = useOnboardedClients();
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [removing, setRemoving] = useState(null);
|
||||
// Rows without an authid come from a backend older than Edit/Delete (it has
|
||||
// neither the field nor the PUT/DELETE routes). Never send /clients/undefined:
|
||||
// disable the actions and say what to do instead.
|
||||
const backendTooOld = data.length > 0 && data.some((r) => r.authid == null);
|
||||
const columns = [
|
||||
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
|
||||
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
|
||||
@@ -64,17 +71,53 @@ function OnboardedList() {
|
||||
header: 'Login created',
|
||||
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (r) => (
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={r.authid == null}
|
||||
onClick={() => setEditing(r)}
|
||||
aria-label={`Edit ${r.tenantname}`}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={r.authid == null}
|
||||
onClick={() => setRemoving(r)}
|
||||
aria-label={`Delete ${r.tenantname}`}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Surface radius="xl" padding="lg">
|
||||
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
|
||||
{backendTooOld && (
|
||||
<Alert tone="warning" className="mb-3" role="alert" title="Edit and Delete are not available on this server yet">
|
||||
The API server this console talks to is running an older backend build without the edit and delete
|
||||
endpoints. Rebuild and redeploy doormile_backend from the current code, then reload this page.
|
||||
</Alert>
|
||||
)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data}
|
||||
getRowId={(row, i) => `${row.tenantid}-${row.loginemail}-${i}`}
|
||||
getRowId={(row, i) => row.authid ?? `${row.tenantid}-${row.loginemail}-${i}`}
|
||||
loading={isLoading}
|
||||
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
|
||||
/>
|
||||
<EditClientModal row={editing} onClose={() => setEditing(null)} />
|
||||
<RemoveClientModal row={removing} onClose={() => setRemoving(null)} />
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useRunAiPlayground } from '../../../../lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* Agent Studio's Test tab. Each prompt is one real run on the backend
|
||||
* (POST /admin/ai/playground/run): Claude with the chosen skill's tools, where
|
||||
* (POST /admin/ai/playground/run): the configured model with the chosen skill's tools, where
|
||||
* read tools execute with personal data redacted and write/notify tools come
|
||||
* back as proposals that are never executed. The trace shown is the server's,
|
||||
* step for step — nothing here is simulated.
|
||||
@@ -34,7 +34,7 @@ export function playgroundErrorMessage(err) {
|
||||
const status = err?.response?.status;
|
||||
const data = err?.response?.data || {};
|
||||
if (data.code === 'PLAYGROUND_NOT_CONFIGURED') {
|
||||
return 'The Test playground is not switched on for this server yet (no Claude client is configured). Nothing was run.';
|
||||
return 'The Test playground is not switched on for this server yet (no model API key is configured). Nothing was run.';
|
||||
}
|
||||
if (status === 429) return data.message || 'Playground limit reached. Try again in a few minutes.';
|
||||
if (status === 403) return 'Only an admin can run the Test playground.';
|
||||
@@ -128,7 +128,7 @@ export default function AgentPlayground({
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900 font-heading">Agent Test Playground</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
Run a prompt through Claude as {activeAgent?.name || 'this agent'} and see every tool call it makes.
|
||||
Run a prompt through the AI model as {activeAgent?.name || 'this agent'} and see every tool call it makes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,8 +153,8 @@ export default function AgentPlayground({
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-emerald-600" />
|
||||
<p>
|
||||
Runs are real Claude calls on live data. Read tools execute with names, phones, addresses and notes removed
|
||||
before Claude sees them. Write and notify tools are <span className="font-bold">never executed</span> — they
|
||||
Runs are real AI model calls on live data. Read tools execute with names, phones, addresses and notes removed
|
||||
before the model sees them. Write and notify tools are <span className="font-bold">never executed</span> — they
|
||||
come back as proposals. Admin only, 10 runs per 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@ export default function AgentPlayground({
|
||||
<div className="font-semibold text-sm">Playground session ready</div>
|
||||
<p className="text-xs text-slate-400 max-w-sm">
|
||||
{canRun
|
||||
? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools Claude calls and what they return.`
|
||||
? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools the model calls and what they return.`
|
||||
: 'Only an admin can run the Test playground.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -249,7 +249,7 @@ export default function AgentPlayground({
|
||||
{run.isPending && (
|
||||
<div className="flex items-center gap-2 text-red-400 animate-pulse font-sans text-xs py-2">
|
||||
<div className="w-3 h-3 border-2 border-red-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span>Running on Claude…</span>
|
||||
<span>Running on the model…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -125,17 +125,17 @@ export default function AgentStudio() {
|
||||
{/* Said on screen: what this page changes today, and what it does not. */}
|
||||
<div
|
||||
role="note"
|
||||
className="flex items-start gap-2.5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900"
|
||||
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
|
||||
<p>
|
||||
<span className="font-bold">Half wired.</span> Changes here are saved to the Doormile agent registry and
|
||||
every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
|
||||
<p className="leading-relaxed">
|
||||
<span className="font-bold text-amber-800">Half wired.</span>{' '}
|
||||
Changes here are saved to the Doormile agent registry and every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the
|
||||
chat's “what needs attention” — run on these settings now. AI_engine's Dispatch,
|
||||
Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its
|
||||
registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on
|
||||
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real Claude
|
||||
calls (reads redacted, writes only proposed) once the server has a Claude client; until then it says so.
|
||||
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real AI model
|
||||
calls (reads redacted, writes only proposed) once the server has a model API key; until then it says so.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -174,9 +174,10 @@ export default function AgentStudio() {
|
||||
) : (
|
||||
<>
|
||||
{/* 1. Top Level Mode Tabs */}
|
||||
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
|
||||
<div className="flex items-center gap-1 p-1 bg-slate-100/80 rounded-2xl border border-slate-200/80">
|
||||
{TOP_NAV_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
const badgeCount =
|
||||
tab.id === 'skills' ? agentSkills.length : tab.id === 'tools' ? model.tools.length : null;
|
||||
|
||||
@@ -186,18 +187,19 @@ export default function AgentStudio() {
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
|
||||
'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer select-none whitespace-nowrap',
|
||||
isActive
|
||||
? 'border-brand text-brand font-bold'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
|
||||
? 'bg-white text-slate-900 shadow-sm border border-slate-200/80'
|
||||
: 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-3.5 h-3.5', isActive ? 'text-brand' : 'text-slate-400')} />
|
||||
<span>{tab.label}</span>
|
||||
{badgeCount !== null && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
|
||||
isActive ? 'bg-brand text-white' : 'bg-slate-100 text-slate-500'
|
||||
'px-1.5 py-0.5 rounded-md text-[10px] font-bold transition-colors',
|
||||
isActive ? 'bg-brand/10 text-brand' : 'bg-slate-200 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{badgeCount}
|
||||
|
||||
@@ -9,67 +9,78 @@ import {
|
||||
Route,
|
||||
Check,
|
||||
Layers,
|
||||
CircleDot,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CATEGORY_META = {
|
||||
AUTHORING: {
|
||||
icon: Pencil,
|
||||
gradient: 'from-violet-500 to-purple-600',
|
||||
bgGlow: 'bg-violet-50',
|
||||
badgeBg: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
accent: 'text-violet-600',
|
||||
glow: 'hover:shadow-violet-100',
|
||||
},
|
||||
WORKFORCE: {
|
||||
icon: Users,
|
||||
gradient: 'from-blue-500 to-indigo-600',
|
||||
bgGlow: 'bg-blue-50',
|
||||
badgeBg: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
accent: 'text-blue-600',
|
||||
glow: 'hover:shadow-blue-100',
|
||||
},
|
||||
LOGISTICS: {
|
||||
icon: Package,
|
||||
gradient: 'from-emerald-500 to-teal-600',
|
||||
bgGlow: 'bg-emerald-50',
|
||||
badgeBg: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
accent: 'text-emerald-600',
|
||||
glow: 'hover:shadow-emerald-100',
|
||||
},
|
||||
DISPATCH: {
|
||||
icon: Route,
|
||||
gradient: 'from-orange-500 to-rose-500',
|
||||
bgGlow: 'bg-orange-50',
|
||||
badgeBg: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
accent: 'text-orange-600',
|
||||
glow: 'hover:shadow-orange-100',
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_META = {
|
||||
icon: Layers,
|
||||
gradient: 'from-slate-500 to-slate-600',
|
||||
bgGlow: 'bg-slate-50',
|
||||
badgeBg: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
accent: 'text-slate-600',
|
||||
glow: 'hover:shadow-slate-100',
|
||||
};
|
||||
|
||||
export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
const isEnabled = skill.enabled !== false;
|
||||
|
||||
// Icon and color mappings based on category
|
||||
const getCategoryMeta = (cat) => {
|
||||
switch ((cat || '').toUpperCase()) {
|
||||
case 'AUTHORING':
|
||||
return {
|
||||
icon: Pencil,
|
||||
textColor: 'text-brand',
|
||||
bgColor: 'bg-brand-tint',
|
||||
borderColor: 'border-brand/20',
|
||||
};
|
||||
case 'WORKFORCE':
|
||||
return {
|
||||
icon: Users,
|
||||
textColor: 'text-slate-700',
|
||||
bgColor: 'bg-slate-100',
|
||||
borderColor: 'border-slate-200',
|
||||
};
|
||||
case 'LOGISTICS':
|
||||
return {
|
||||
icon: Package,
|
||||
textColor: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
borderColor: 'border-emerald-200',
|
||||
};
|
||||
case 'DISPATCH':
|
||||
return {
|
||||
icon: Route,
|
||||
textColor: 'text-brand',
|
||||
bgColor: 'bg-brand-tint',
|
||||
borderColor: 'border-brand/20',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Layers,
|
||||
textColor: 'text-slate-600',
|
||||
bgColor: 'bg-slate-50',
|
||||
borderColor: 'border-slate-200',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const meta = getCategoryMeta(skill.category);
|
||||
const meta = CATEGORY_META[(skill.category || '').toUpperCase()] || DEFAULT_META;
|
||||
const CategoryIcon = meta.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xs hover:shadow-md hover:border-slate-300 transition-all p-5 flex flex-col justify-between group">
|
||||
<div>
|
||||
{/* Top Header: Category Tag (Left) & Status Badge (Right) */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'relative bg-white rounded-2xl border border-slate-200/80 shadow-sm transition-all duration-300 overflow-hidden cursor-pointer group',
|
||||
'hover:shadow-xl hover:shadow-slate-200/60 hover:-translate-y-0.5 hover:border-slate-300',
|
||||
meta.glow
|
||||
)}
|
||||
>
|
||||
{/* Top gradient accent bar */}
|
||||
<div className={cn('h-1 w-full bg-gradient-to-r', meta.gradient)} />
|
||||
|
||||
<div className="p-5">
|
||||
{/* Header row: category badge + status */}
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider border',
|
||||
meta.bgColor,
|
||||
meta.textColor,
|
||||
meta.borderColor
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-bold tracking-wider border',
|
||||
meta.badgeBg
|
||||
)}
|
||||
>
|
||||
<CategoryIcon className="w-3 h-3" />
|
||||
@@ -78,71 +89,92 @@ export default function SkillCard({ skill, onTest, onDetails }) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10.5px] font-semibold border',
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[10px] font-bold border transition-colors',
|
||||
isEnabled
|
||||
? 'bg-brand-tint/70 text-brand border-brand/20'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: 'bg-slate-100 text-slate-500 border-slate-200'
|
||||
)}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 stroke-[2.5]" />
|
||||
<span>Added</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>Active</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Disabled</span>
|
||||
<>
|
||||
<CircleDot className="w-3 h-3" />
|
||||
<span>Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Title */}
|
||||
<h3 className="text-base font-bold text-slate-900 font-heading mt-3 tracking-tight">
|
||||
{skill.title}
|
||||
</h3>
|
||||
{/* Icon + Title */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-gradient-to-br shadow-sm',
|
||||
meta.gradient
|
||||
)}
|
||||
>
|
||||
<CategoryIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-slate-900 font-heading leading-tight tracking-tight">
|
||||
{skill.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Description */}
|
||||
<p className="text-xs text-slate-500 mt-1.5 leading-relaxed line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Prompt Trigger Pill (if present) */}
|
||||
{/* Prompt pill */}
|
||||
{skill.prompt && (
|
||||
<div className="mt-3 p-2 rounded-xl bg-brand-tint/30 border border-brand/15 text-xs text-brand flex items-center gap-2">
|
||||
<Sparkles className="w-3.5 h-3.5 text-brand shrink-0" />
|
||||
<span className="italic truncate font-medium">"{skill.prompt}"</span>
|
||||
<div className="mb-3 px-3 py-2 rounded-xl bg-slate-50 border border-slate-200/80 flex items-start gap-2">
|
||||
<Sparkles className={cn('w-3 h-3 shrink-0 mt-0.5', meta.accent)} />
|
||||
<span className={cn('text-[10.5px] italic truncate font-medium', meta.accent)}>
|
||||
“{skill.prompt}”
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope / Integration Tag */}
|
||||
{/* Integration tag */}
|
||||
{skill.integration && (
|
||||
<div className="mt-3">
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-slate-50 border border-slate-200/80 text-[10.5px] font-medium text-slate-600">
|
||||
<Layers className="w-3 h-3 text-slate-400" />
|
||||
<span>{skill.integration}</span>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-slate-50 border border-slate-200 text-[10px] font-semibold text-slate-500">
|
||||
<Layers className="w-3 h-3" />
|
||||
{skill.integration}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Action Bar */}
|
||||
<div className="flex items-center justify-between pt-3.5 mt-3.5 border-t border-slate-100 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTest(skill)}
|
||||
className="inline-flex items-center gap-1.5 text-brand hover:text-brand-dark font-bold transition-colors cursor-pointer"
|
||||
>
|
||||
<Play className="w-3 h-3 fill-current" />
|
||||
<span>Test in Playground</span>
|
||||
</button>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3.5 mt-1 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTest(skill)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-[11px] font-bold transition-all cursor-pointer',
|
||||
'px-3 py-1.5 rounded-lg bg-slate-50 hover:bg-slate-100 text-slate-600 hover:text-slate-900 border border-slate-200'
|
||||
)}
|
||||
>
|
||||
<Play className="w-3 h-3 fill-current" />
|
||||
<span>Test</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDetails(skill)}
|
||||
className="inline-flex items-center gap-1 text-slate-500 hover:text-slate-900 font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
<span>Details</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDetails(skill)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-[11px] font-semibold transition-all cursor-pointer group/btn',
|
||||
meta.accent, 'hover:opacity-70'
|
||||
)}
|
||||
>
|
||||
<span>Details</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 group-hover/btn:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -117,21 +117,21 @@ export default function SkillCardsGrid({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Global Search Input (Sleek pill style matching reference) */}
|
||||
{/* 2. Global Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search skills, capabilities or pages..."
|
||||
className="w-full h-10 bg-white border border-slate-200 rounded-full pl-10 pr-9 text-xs text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-1 focus:ring-brand/20 shadow-2xs transition-all"
|
||||
placeholder="Search skills, capabilities or integrations..."
|
||||
className="w-full h-11 bg-white border border-slate-200 rounded-2xl pl-11 pr-10 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm hover:border-slate-300 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 rounded-full cursor-pointer"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-700 rounded-full hover:bg-slate-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -150,17 +150,17 @@ export default function SkillCardsGrid({
|
||||
type="button"
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={cn(
|
||||
'px-3.5 py-1 rounded-full text-xs font-medium transition-all whitespace-nowrap cursor-pointer flex items-center gap-1.5',
|
||||
'px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all duration-200 whitespace-nowrap cursor-pointer flex items-center gap-1.5 border',
|
||||
isSelected
|
||||
? 'bg-brand text-white shadow-2xs font-semibold'
|
||||
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
? 'bg-slate-900 text-white border-slate-900 shadow-sm'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 hover:text-slate-900'
|
||||
)}
|
||||
>
|
||||
<span>{displayLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10.5px]',
|
||||
isSelected ? 'text-white/90 font-bold' : 'text-slate-400'
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded-md',
|
||||
isSelected ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
@@ -170,14 +170,17 @@ export default function SkillCardsGrid({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 4. Capabilities Count & "+ New skill" Action Row */}
|
||||
<div className="flex items-center justify-between pt-1 text-xs text-slate-500">
|
||||
<div>
|
||||
<span className="font-semibold text-slate-800">
|
||||
{filteredSkills.length} of {scopedSkills.length} capabilities
|
||||
</span>{' '}
|
||||
for this scope ·{' '}
|
||||
<span className="font-semibold text-brand">{enabledCount} enabled</span>
|
||||
{/* 4. Stats + New Skill Row */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-50 border border-slate-200 text-xs">
|
||||
<span className="text-slate-500">Total</span>
|
||||
<span className="font-bold text-slate-900">{filteredSkills.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-50 border border-emerald-200 text-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-emerald-700 font-semibold">{enabledCount} active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -185,10 +188,10 @@ export default function SkillCardsGrid({
|
||||
onClick={onNewSkill}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : createDisabledReason}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-white border border-brand text-brand hover:bg-brand-tint font-semibold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 disabled:hover:bg-white"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-white font-semibold text-xs shadow-sm transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
|
||||
<span>New skill</span>
|
||||
<span>New Skill</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +223,7 @@ export default function SkillCardsGrid({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
|
||||
@@ -1,213 +1,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Wrench,
|
||||
Search,
|
||||
Code,
|
||||
Code2,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Edit3,
|
||||
Bell,
|
||||
Database,
|
||||
X,
|
||||
Hash,
|
||||
MapPin,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function ToolRegistryView({ tools = [] }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedTool, setExpandedTool] = useState(null);
|
||||
/* ─── Kind config ─────────────────────────────────────────────────────────── */
|
||||
const KIND = {
|
||||
read: {
|
||||
icon: Eye,
|
||||
label: 'Read',
|
||||
card: 'border-sky-200 bg-sky-50/40',
|
||||
iconWrap: 'bg-sky-100 text-sky-600',
|
||||
badge: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||
dot: 'bg-sky-500',
|
||||
},
|
||||
write: {
|
||||
icon: Edit3,
|
||||
label: 'Write',
|
||||
card: 'border-violet-200 bg-violet-50/30',
|
||||
iconWrap: 'bg-violet-100 text-violet-600',
|
||||
badge: 'bg-violet-100 text-violet-700 border-violet-200',
|
||||
dot: 'bg-violet-500',
|
||||
},
|
||||
notify: {
|
||||
icon: Bell,
|
||||
label: 'Notify',
|
||||
card: 'border-amber-200 bg-amber-50/30',
|
||||
iconWrap: 'bg-amber-100 text-amber-600',
|
||||
badge: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
dot: 'bg-amber-500',
|
||||
},
|
||||
};
|
||||
const KIND_DEFAULT = {
|
||||
icon: Database,
|
||||
label: 'General',
|
||||
card: 'border-slate-200 bg-white',
|
||||
iconWrap: 'bg-slate-100 text-slate-500',
|
||||
badge: 'bg-slate-100 text-slate-600 border-slate-200',
|
||||
dot: 'bg-slate-400',
|
||||
};
|
||||
|
||||
const filtered = tools.filter((t) => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return (
|
||||
!q ||
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
(t.parentSkill || '').toLowerCase().includes(q) ||
|
||||
(t.category || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
function kindOf(tool) {
|
||||
return KIND[(tool.kind || '').toLowerCase()] || KIND_DEFAULT;
|
||||
}
|
||||
|
||||
/* ─── Stat chip ───────────────────────────────────────────────────────────── */
|
||||
function StatChip({ label, value, tone = 'slate' }) {
|
||||
const tones = {
|
||||
slate: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
amber: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
sky: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
};
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header & Stats Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white p-5 rounded-3xl border border-slate-200/90 shadow-2xs">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900 font-heading flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4 text-brand" />
|
||||
<span>Agent Tool Registry (Schema Engine)</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Every capability a skill may call, from the Doormile agent registry. Write and messaging tools need an
|
||||
operator's confirmation unless their agent is autonomous.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs font-semibold">
|
||||
<div className="px-3 py-1.5 rounded-xl bg-slate-50 border border-slate-200 text-slate-700">
|
||||
<span className="text-slate-400 font-normal">Total Tools: </span>
|
||||
<span className="font-bold text-slate-900">{tools.length}</span>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
|
||||
<span className="text-amber-600/80 font-normal">Gated Actions: </span>
|
||||
<span className="font-bold">
|
||||
{tools.filter((t) => t.requiresConfirmation).length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter tools by function name, category or description..."
|
||||
className="w-full bg-white border border-slate-200/90 rounded-2xl pl-11 pr-4 py-2.5 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/15 shadow-2xs transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tools Table Container */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="py-3 px-4">Tool Function</th>
|
||||
<th className="py-3 px-4">Kind</th>
|
||||
<th className="py-3 px-4">Used by</th>
|
||||
<th className="py-3 px-4">Safety Gate</th>
|
||||
<th className="py-3 px-4 text-right">Arguments</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map((tool) => {
|
||||
const isExpanded = expandedTool === tool.name;
|
||||
|
||||
return (
|
||||
<React.Fragment key={tool.name}>
|
||||
<tr
|
||||
onClick={() =>
|
||||
setExpandedTool(isExpanded ? null : tool.name)
|
||||
}
|
||||
className={cn(
|
||||
'hover:bg-slate-50/80 transition-colors cursor-pointer select-none',
|
||||
isExpanded && 'bg-brand-tint/30'
|
||||
)}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-brand" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
<span className="font-mono font-bold text-slate-900 text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500 mt-0.5 pl-5 line-clamp-1">
|
||||
{tool.description}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4">
|
||||
<span className="inline-flex px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-700">
|
||||
{tool.category || 'General'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 font-medium text-slate-700">
|
||||
{tool.parentSkill || '—'}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4">
|
||||
{/* It read "Autonomous" for every ungated tool, which
|
||||
described a read-only lookup as an agent acting on
|
||||
its own. Ungated tools are reads or internal events. */}
|
||||
{tool.requiresConfirmation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-800 border border-amber-200">
|
||||
<ShieldAlert className="w-3 h-3" />
|
||||
Confirmation Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
No confirmation needed
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="px-2.5 py-1 rounded-xl bg-slate-100 text-slate-700 font-bold text-[11px]">
|
||||
{tool.parameters?.length || 0} params
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expandable JSONSchema Parameters View */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-50/90">
|
||||
<td colSpan={5} className="p-4 pl-9 space-y-3">
|
||||
{(tool.target || tool.implementedAt) && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
|
||||
<dt className="font-semibold text-slate-500">Touches</dt>
|
||||
<dd className="font-mono text-slate-800 break-all">{tool.target || '—'}</dd>
|
||||
<dt className="font-semibold text-slate-500">Implemented at</dt>
|
||||
<dd className="font-mono text-slate-800 break-all">{tool.implementedAt || '—'}</dd>
|
||||
</dl>
|
||||
)}
|
||||
{(!tool.parameters || tool.parameters.length === 0) && (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
No parameters are pinned down for this tool yet — its input is open.
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs font-bold text-slate-800 flex items-center gap-2">
|
||||
<Code className="w-3.5 h-3.5 text-brand" />
|
||||
<span>Tool Parameters Schema (OpenAI / Claude JSONSchema Specification)</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{tool.parameters?.map((param) => (
|
||||
<div
|
||||
key={param.name}
|
||||
className="p-3 bg-white rounded-xl border border-slate-200 text-xs space-y-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono font-bold text-brand">
|
||||
{param.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-600">
|
||||
{param.type}
|
||||
</span>
|
||||
{param.required && (
|
||||
<span className="text-[9px] font-bold text-rose-600 bg-rose-50 px-1 rounded">
|
||||
required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-600">
|
||||
{param.description}
|
||||
</p>
|
||||
{param.default !== undefined && (
|
||||
<div className="text-[10px] text-slate-400 font-mono">
|
||||
default: {String(param.default)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('flex flex-col items-center px-5 py-3 rounded-2xl border', tones[tone])}>
|
||||
<span className="text-2xl font-black leading-none">{value}</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest mt-1 opacity-70">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Parameter pill ──────────────────────────────────────────────────────── */
|
||||
function ParamPill({ param }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-3 rounded-xl bg-white border border-slate-200 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono font-bold text-[11px] text-brand">{param.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-600 border border-slate-200">
|
||||
{param.type}
|
||||
</span>
|
||||
{param.required && (
|
||||
<span className="text-[9px] font-bold text-rose-600 bg-rose-50 px-1.5 py-0.5 rounded-md border border-rose-200">
|
||||
req
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{param.description && (
|
||||
<p className="text-[10.5px] text-slate-500 leading-relaxed">{param.description}</p>
|
||||
)}
|
||||
{param.default !== undefined && (
|
||||
<div className="text-[9.5px] font-mono text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md mt-0.5">
|
||||
default: {String(param.default)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Tool Card ───────────────────────────────────────────────────────────── */
|
||||
function ToolCard({ tool }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const k = kindOf(tool);
|
||||
const KindIcon = k.icon;
|
||||
const hasParams = tool.parameters && tool.parameters.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border transition-all duration-200 overflow-hidden group',
|
||||
k.card,
|
||||
open ? 'shadow-md' : 'shadow-sm hover:shadow-md hover:-translate-y-px'
|
||||
)}
|
||||
>
|
||||
{/* Card header — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left p-4 flex items-start gap-3 cursor-pointer"
|
||||
>
|
||||
{/* Kind icon */}
|
||||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm', k.iconWrap)}>
|
||||
<KindIcon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-mono font-black text-xs text-slate-900 tracking-tight">{tool.name}</span>
|
||||
|
||||
{/* Kind badge */}
|
||||
<span className={cn('px-2 py-0.5 rounded-lg text-[9.5px] font-bold border capitalize', k.badge)}>
|
||||
{k.label}
|
||||
</span>
|
||||
|
||||
{/* Safety */}
|
||||
{tool.requiresConfirmation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-rose-50 text-rose-600 border border-rose-200">
|
||||
<ShieldAlert className="w-2.5 h-2.5" />
|
||||
Gated
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
<ShieldCheck className="w-2.5 h-2.5" />
|
||||
Open
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Params count */}
|
||||
{hasParams && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[9.5px] font-bold bg-slate-100 text-slate-600 border border-slate-200">
|
||||
<Hash className="w-2.5 h-2.5" />
|
||||
{tool.parameters.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed line-clamp-1">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Expand chevron */}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-slate-400 shrink-0 mt-1 transition-transform duration-200',
|
||||
open && 'rotate-180 text-brand'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expandable detail panel */}
|
||||
{open && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-black/5 space-y-4">
|
||||
{/* Meta grid */}
|
||||
{(tool.target || tool.implementedAt || tool.parentSkill) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.target && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-xl bg-white/70 border border-slate-200">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider">Touches</div>
|
||||
<div className="font-mono text-[11px] text-slate-800 mt-0.5">{tool.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tool.implementedAt && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-xl bg-white/70 border border-slate-200">
|
||||
<Layers className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-wider">Implemented at</div>
|
||||
<div className="font-mono text-[10px] text-slate-700 mt-0.5 break-all">{tool.implementedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{hasParams ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600 uppercase tracking-wider">
|
||||
<Code2 className="w-3.5 h-3.5 text-brand" />
|
||||
Parameters
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.parameters.map((p) => <ParamPill key={p.name} param={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-slate-400 italic">No parameters — input is open-ended.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main view ───────────────────────────────────────────────────────────── */
|
||||
export default function ToolRegistryView({ tools = [] }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [kindFilter, setKindFilter] = useState('all');
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: tools.length };
|
||||
tools.forEach((t) => {
|
||||
const k = (t.kind || 'read').toLowerCase();
|
||||
c[k] = (c[k] || 0) + 1;
|
||||
});
|
||||
return c;
|
||||
}, [tools]);
|
||||
|
||||
const kindTabs = ['all', ...Object.keys(KIND).filter((k) => counts[k])];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return tools.filter((t) => {
|
||||
const matchesKind = kindFilter === 'all' || (t.kind || 'read').toLowerCase() === kindFilter;
|
||||
const matchesSearch =
|
||||
!q ||
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
(t.parentSkill || '').toLowerCase().includes(q) ||
|
||||
(t.target || '').toLowerCase().includes(q);
|
||||
return matchesKind && matchesSearch;
|
||||
});
|
||||
}, [tools, kindFilter, search]);
|
||||
|
||||
const gatedCount = tools.filter((t) => t.requiresConfirmation).length;
|
||||
const openCount = tools.length - gatedCount;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Stats row ─────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
<StatChip label="Total Tools" value={tools.length} tone="slate" />
|
||||
<StatChip label="Read" value={counts.read || 0} tone="sky" />
|
||||
<StatChip label="Write / Notify" value={(counts.write || 0) + (counts.notify || 0)} tone="amber" />
|
||||
<StatChip label="Gated" value={gatedCount} tone="amber" />
|
||||
<StatChip label="Open" value={openCount} tone="emerald" />
|
||||
|
||||
{/* Right-side header text */}
|
||||
<div className="flex-1 min-w-[200px] flex flex-col justify-center pl-2">
|
||||
<h3 className="text-sm font-bold text-slate-900 font-heading flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4 text-brand" />
|
||||
Agent Tool Registry
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 leading-relaxed">
|
||||
Every capability a skill may call. Write & notify tools need operator confirmation unless the agent is autonomous.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + Kind filter ───────────────────────────────────────── */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, target system, or description…"
|
||||
className="w-full h-10 bg-white border border-slate-200 rounded-xl pl-10 pr-9 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-slate-400 hover:text-slate-700 hover:bg-slate-100 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kind filter tabs */}
|
||||
<div className="flex items-center gap-1 bg-slate-100 rounded-xl p-1 shrink-0">
|
||||
{kindTabs.map((k) => {
|
||||
const meta = KIND[k];
|
||||
const isActive = kindFilter === k;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setKindFilter(k)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-xs font-semibold transition-all cursor-pointer capitalize flex items-center gap-1.5',
|
||||
isActive ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||
)}
|
||||
>
|
||||
{meta && <span className={cn('w-1.5 h-1.5 rounded-full', meta.dot)} />}
|
||||
{k === 'all' ? 'All' : meta?.label || k}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-bold',
|
||||
isActive ? 'text-slate-600' : 'text-slate-400'
|
||||
)}
|
||||
>
|
||||
{counts[k] || 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tool Cards ────────────────────────────────────────────────── */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-16 rounded-2xl border border-dashed border-slate-200 bg-slate-50 text-center">
|
||||
<Wrench className="w-8 h-8 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-semibold text-slate-500">No tools match your filter</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearch(''); setKindFilter('all'); }}
|
||||
className="mt-3 text-xs text-brand font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{filtered.map((tool) => <ToolCard key={tool.name} tool={tool} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('Agent Studio page', () => {
|
||||
// The engine reads it only once its Phase 5 build is deployed; say so, not "live".
|
||||
expect(note).toHaveTextContent(/once its registry-reading build is deployed/);
|
||||
expect(note).toHaveTextContent(/environment defaults/);
|
||||
expect(note).toHaveTextContent(/The Test tab runs real Claude calls/);
|
||||
expect(note).toHaveTextContent(/The Test tab runs real AI model calls/);
|
||||
expect(note).not.toHaveTextContent(/The Test tab is a simulation/);
|
||||
// Since Phase 3 the console skills DO run on these settings; the note must not say otherwise.
|
||||
expect(note).toHaveTextContent(/run on these settings now/);
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('@/api/doormile', () => ({
|
||||
onboardClient: jest.fn(),
|
||||
getOnboardedClients: jest.fn(),
|
||||
getOnboardingCities: jest.fn(),
|
||||
updateOnboardedClient: jest.fn(),
|
||||
deleteOnboardedClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/api/doormile/notify', () => ({
|
||||
OpenToast: jest.fn(),
|
||||
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
||||
}));
|
||||
|
||||
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
|
||||
@@ -67,8 +74,79 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
api.getOnboardingCities.mockResolvedValue(CITIES);
|
||||
api.getOnboardedClients.mockResolvedValue([
|
||||
{ tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
|
||||
{ authid: 31, tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', requiredeliveryotp: false, contactname: 'Old Contact', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
|
||||
]);
|
||||
api.updateOnboardedClient.mockResolvedValue({ success: true, data: {} });
|
||||
api.deleteOnboardedClient.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('Edit and Delete on a client login', () => {
|
||||
it('shows Edit and Delete on each row', async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByRole('button', { name: 'Edit Older Client' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends only the fields that changed', async () => {
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const save = screen.getByRole('button', { name: /Save changes/ });
|
||||
expect(save).toBeDisabled(); // nothing changed yet
|
||||
fireEvent.change(dialog.querySelector('select'), { target: { value: 'Inactive' } });
|
||||
fireEvent.change(screen.getAllByLabelText(/Mobile number/)[1], { target: { value: '9123456780' } });
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' }));
|
||||
});
|
||||
|
||||
it('resets the password only when one is entered, and checks its length', async () => {
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'short' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
await waitFor(() => expect(document.getElementById('edit-client-password-error')).toHaveTextContent('At least 8 characters'));
|
||||
expect(api.updateOnboardedClient).not.toHaveBeenCalled();
|
||||
fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'new-pass-123' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { password: 'new-pass-123' }));
|
||||
});
|
||||
|
||||
it('shows a server conflict in the dialog', async () => {
|
||||
api.updateOnboardedClient.mockRejectedValue({ response: { status: 409, data: { message: 'this email already has a console login' } } });
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' }));
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.change(screen.getAllByLabelText(/Login email/)[1], { target: { value: 'taken@x.example' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
expect(await screen.findByText('this email already has a console login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('never calls /clients/undefined when the backend is too old to send ids', async () => {
|
||||
api.getOnboardedClients.mockResolvedValue([
|
||||
{ tenantid: 12, tenantname: 'Older Client', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager' },
|
||||
]);
|
||||
renderPage();
|
||||
const edit = await screen.findByRole('button', { name: 'Edit Older Client' });
|
||||
expect(edit).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeDisabled();
|
||||
expect(screen.getByText('Edit and Delete are not available on this server yet')).toBeInTheDocument();
|
||||
fireEvent.click(edit);
|
||||
expect(api.updateOnboardedClient).not.toHaveBeenCalled();
|
||||
expect(api.deleteOnboardedClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes only after the company name is typed', async () => {
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete Older Client' }));
|
||||
await screen.findByRole('dialog');
|
||||
const remove = screen.getByRole('button', { name: /Remove login/ });
|
||||
expect(remove).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/to confirm/), { target: { value: 'older client' } });
|
||||
expect(remove).not.toBeDisabled();
|
||||
fireEvent.click(remove);
|
||||
await waitFor(() => expect(api.deleteOnboardedClient).toHaveBeenCalledWith(31));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client onboarding page', () => {
|
||||
|
||||
Reference in New Issue
Block a user