mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Canonicalize settings data grid frames
This commit is contained in:
@@ -158,6 +158,11 @@ credential, capability, status, and action cells, but table scroll framing must
|
||||
stay on the shared `Table` primitive. If a configured-node table needs bounded
|
||||
vertical height, apply it through `Table` `wrapperClass` instead of an outer
|
||||
lifecycle-local scroll container.
|
||||
Agent profile management tables follow that same presentation boundary:
|
||||
`frontend-modern/src/components/Settings/AgentProfilesPanel.tsx` may own agent
|
||||
profile and assignment columns, but embedded table framing must route through
|
||||
`PulseDataGrid`'s shared frame variants instead of lifecycle-local
|
||||
`overflow-x-auto` or side-border wrappers.
|
||||
|
||||
## Extension Points
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ Own canonical runtime payload shapes between backend and frontend.
|
||||
8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
10. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
The API token inventory table may own credential and usage cells, but it
|
||||
must inherit embedded table framing from `frontend-primitives`
|
||||
`PulseDataGrid` rather than carrying API-token-local scroll or border
|
||||
wrappers around the grid.
|
||||
11. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `security-privacy`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
12. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` shared with `agent-lifecycle`: the inline node credential slot is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
|
||||
13. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `agent-lifecycle`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
|
||||
|
||||
@@ -195,7 +195,11 @@ work extends shared components instead of creating new local variants.
|
||||
tables, Ceph tables, PMG resource panels, and `PulseDataGrid`, must follow
|
||||
the same rule: feature owners may pass `wrapperClass` for bounded height,
|
||||
border, radius, or scrollbar hiding, but they must not add raw table markup
|
||||
or local scroll wrappers around the shared table primitive.
|
||||
or local scroll wrappers around the shared table primitive. `PulseDataGrid`
|
||||
also owns its root frame variants: feature surfaces embedded directly inside
|
||||
an existing panel/card frame must use the shared `frame="flush"` mode rather
|
||||
than caller-local border overrides, horizontal-scroll wrappers, or negative
|
||||
margin compensation.
|
||||
Product-table subgroup/header rows must likewise consume the shared
|
||||
`frontend-modern/src/components/shared/groupedTableRowPresentation.ts`
|
||||
helper and `.grouped-table-row` CSS token contract instead of local
|
||||
|
||||
@@ -71,6 +71,11 @@ create, review, and approve cross-organization shares.
|
||||
|
||||
1. Add or change organization role and share semantics through `internal/models/organization.go`
|
||||
2. Add or change organization access, overview, sharing, RBAC feature-gating, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx`, `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx`, `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`, `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts`, `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`, `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`, `frontend-modern/src/components/Settings/useRolesPanelState.ts`, `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`, `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`, and `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts`
|
||||
Organization and RBAC tables may own their rows, columns, action cells, and
|
||||
domain copy, but their table scroll shell and embedded-frame treatment must
|
||||
come from `frontend-primitives` through `PulseDataGrid`/`Table`; do not add
|
||||
organization-local `overflow-x-auto`, negative-margin, or side-border
|
||||
compensation around grids.
|
||||
3. Route organization and RBAC frontend transport changes through `frontend-modern/src/api/orgs.ts` and `frontend-modern/src/api/rbac.ts`
|
||||
4. Keep backend organization management and lifecycle handlers aligned through `internal/api/org_handlers.go` and `internal/api/org_lifecycle_handlers.go`
|
||||
5. Keep RBAC role, assignment, and admin recovery transport aligned through `internal/api/access_control_handlers.go` and `internal/api/enterprise_extension_rbac_admin.go`
|
||||
|
||||
@@ -880,6 +880,7 @@
|
||||
"exact_files": [
|
||||
"frontend-modern/src/api/__tests__/agentProfiles.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx",
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/agentProfilesPresentation.test.ts"
|
||||
]
|
||||
},
|
||||
@@ -1656,7 +1657,8 @@
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts"
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3949,6 +3951,7 @@
|
||||
"frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx",
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/organizationRolePresentation.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/organizationSettingsPresentation.test.ts",
|
||||
@@ -4624,6 +4627,7 @@
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/SecurityPostureSummary.test.tsx",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"frontend-modern/src/stores/__tests__/systemSettings.test.ts"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -64,6 +64,10 @@ visibility, and privacy controls to operators.
|
||||
|
||||
1. `frontend-modern/src/api/security.ts` shared with `api-contracts`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
2. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `api-contracts`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
Token-management table rows are security-facing content, but the visual
|
||||
table frame and scroll shell belong to `frontend-primitives`
|
||||
`PulseDataGrid`; do not add token-surface-local overflow, side-border, or
|
||||
negative-margin wrappers around the inventory grid.
|
||||
3. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `api-contracts`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
4. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `frontend-primitives`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary.
|
||||
5. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `frontend-primitives`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary.
|
||||
|
||||
@@ -270,152 +270,149 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-x-auto">
|
||||
<PulseDataGrid
|
||||
data={sortedTokens()}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (token) => (
|
||||
<span class="font-medium text-base-content">{token.name || 'Untitled'}</span>
|
||||
),
|
||||
<PulseDataGrid
|
||||
data={sortedTokens()}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (token) => (
|
||||
<span class="font-medium text-base-content">{token.name || 'Untitled'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hint',
|
||||
label: 'Hint',
|
||||
render: (token) => (
|
||||
<span class="font-mono text-xs text-muted">{tokenHint(token)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'scopes',
|
||||
label: 'Scopes',
|
||||
render: (token) => {
|
||||
const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
|
||||
const scopeBadges = rawScopes.includes('*')
|
||||
? [{ value: '*', label: 'Full' }]
|
||||
: rawScopes.map((scope) => ({
|
||||
value: scope,
|
||||
label: API_SCOPE_LABELS[scope] ?? scope,
|
||||
}));
|
||||
return (
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={scopeBadges}>
|
||||
{(scope) => {
|
||||
const isWildcard = scope.value === '*';
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
isWildcard
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
: 'bg-surface-alt text-base-content'
|
||||
}`}
|
||||
title={scope.value}
|
||||
>
|
||||
{scope.label}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
key: 'hint',
|
||||
label: 'Hint',
|
||||
render: (token) => (
|
||||
<span class="font-mono text-xs text-muted">{tokenHint(token)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'scopes',
|
||||
label: 'Scopes',
|
||||
render: (token) => {
|
||||
const rawScopes =
|
||||
token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
|
||||
const scopeBadges = rawScopes.includes('*')
|
||||
? [{ value: '*', label: 'Full' }]
|
||||
: rawScopes.map((scope) => ({
|
||||
value: scope,
|
||||
label: API_SCOPE_LABELS[scope] ?? scope,
|
||||
}));
|
||||
return (
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={scopeBadges}>
|
||||
{(scope) => {
|
||||
const isWildcard = scope.value === '*';
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
isWildcard
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
||||
: 'bg-surface-alt text-base-content'
|
||||
}`}
|
||||
title={scope.value}
|
||||
>
|
||||
{scope.label}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: 'Usage',
|
||||
render: (token) => {
|
||||
const dockerUsageEntry = dockerTokenUsage().get(token.id);
|
||||
const agentUsageEntry = agentTokenUsage().get(token.id);
|
||||
const usageSegments: string[] = [];
|
||||
const usageTitleSegments: string[] = [];
|
||||
if (dockerUsageEntry) {
|
||||
usageSegments.push(
|
||||
dockerUsageEntry.count === 1
|
||||
? (dockerUsageEntry.items[0]?.label ?? 'Container runtime')
|
||||
: `${dockerUsageEntry.count} container runtimes`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: 'Usage',
|
||||
render: (token) => {
|
||||
const dockerUsageEntry = dockerTokenUsage().get(token.id);
|
||||
const agentUsageEntry = agentTokenUsage().get(token.id);
|
||||
const usageSegments: string[] = [];
|
||||
const usageTitleSegments: string[] = [];
|
||||
if (dockerUsageEntry) {
|
||||
usageSegments.push(
|
||||
dockerUsageEntry.count === 1
|
||||
? (dockerUsageEntry.items[0]?.label ?? 'Container runtime')
|
||||
: `${dockerUsageEntry.count} container runtimes`,
|
||||
);
|
||||
usageTitleSegments.push(
|
||||
`Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (agentUsageEntry) {
|
||||
usageSegments.push(
|
||||
agentUsageEntry.count === 1
|
||||
? `${agentUsageEntry.items[0]?.label ?? 'Agent'}`
|
||||
: `${agentUsageEntry.count} agents`,
|
||||
);
|
||||
usageTitleSegments.push(
|
||||
`Agents: ${agentUsageEntry.items.map((agent) => agent.label).join(', ')}`,
|
||||
);
|
||||
}
|
||||
const usageSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—';
|
||||
return (
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
title={
|
||||
usageTitleSegments.length > 0 ? usageTitleSegments.join('\n') : undefined
|
||||
}
|
||||
>
|
||||
<span class="text-muted">{usageSummary}</span>
|
||||
<Show when={agentUsageEntry && agentUsageEntry.count > 1}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Agents sharing this token ({agentUsageEntry!.count})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
usageTitleSegments.push(
|
||||
`Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
render: (token) => (
|
||||
<span class="text-muted">
|
||||
{formatRelativeTime(new Date(token.createdAt).getTime())}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUsedAt',
|
||||
label: 'Last used',
|
||||
render: (token) => (
|
||||
<span class="text-muted">
|
||||
{token.lastUsedAt
|
||||
? formatRelativeTime(new Date(token.lastUsedAt).getTime())
|
||||
: 'Never'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
align: 'right',
|
||||
render: (token) => (
|
||||
<button
|
||||
onClick={() => handleDelete(token)}
|
||||
disabled={!canManage()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
|
||||
}
|
||||
if (agentUsageEntry) {
|
||||
usageSegments.push(
|
||||
agentUsageEntry.count === 1
|
||||
? `${agentUsageEntry.items[0]?.label ?? 'Agent'}`
|
||||
: `${agentUsageEntry.count} agents`,
|
||||
);
|
||||
usageTitleSegments.push(
|
||||
`Agents: ${agentUsageEntry.items.map((agent) => agent.label).join(', ')}`,
|
||||
);
|
||||
}
|
||||
const usageSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—';
|
||||
return (
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
title={
|
||||
usageTitleSegments.length > 0 ? usageTitleSegments.join('\n') : undefined
|
||||
}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
),
|
||||
<span class="text-muted">{usageSummary}</span>
|
||||
<Show when={agentUsageEntry && agentUsageEntry.count > 1}>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Agents sharing this token ({agentUsageEntry!.count})
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
]}
|
||||
keyExtractor={(token) => token.id}
|
||||
desktopMinWidth="1000px"
|
||||
class="border-x-0 sm:border-x border-t-0 rounded-t-none"
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
render: (token) => (
|
||||
<span class="text-muted">
|
||||
{formatRelativeTime(new Date(token.createdAt).getTime())}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUsedAt',
|
||||
label: 'Last used',
|
||||
render: (token) => (
|
||||
<span class="text-muted">
|
||||
{token.lastUsedAt
|
||||
? formatRelativeTime(new Date(token.lastUsedAt).getTime())
|
||||
: 'Never'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
align: 'right',
|
||||
render: (token) => (
|
||||
<button
|
||||
onClick={() => handleDelete(token)}
|
||||
disabled={!canManage()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(token) => token.id}
|
||||
desktopMinWidth="1000px"
|
||||
frame="flush"
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -157,66 +157,64 @@ export const AgentProfilesPanel: Component = () => {
|
||||
</Show>
|
||||
|
||||
<Show when={!loading() && profiles().length > 0}>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<PulseDataGrid
|
||||
data={profiles()}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (profile) => (
|
||||
<span class="font-medium text-base-content">{profile.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
render: (profile) => (
|
||||
<span class="text-muted">{getSettingsCount(profile)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agents',
|
||||
label: 'Agents',
|
||||
render: (profile) => (
|
||||
<span class="inline-flex items-center gap-1 text-muted">
|
||||
<Users class="w-4 h-4" />
|
||||
{getAssignmentCount(profile.id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (profile) => (
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(profile)}
|
||||
class="p-1.5 rounded-md hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900"
|
||||
title="Edit profile"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(profile)}
|
||||
class="p-1.5 rounded-md hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
|
||||
title="Delete profile"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(profile) => profile.id}
|
||||
emptyState={getAgentProfilesEmptyState()}
|
||||
desktopMinWidth="600px"
|
||||
class="border-x-0 sm:border-x border-border"
|
||||
/>
|
||||
</div>
|
||||
<PulseDataGrid
|
||||
data={profiles()}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (profile) => (
|
||||
<span class="font-medium text-base-content">{profile.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
render: (profile) => (
|
||||
<span class="text-muted">{getSettingsCount(profile)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agents',
|
||||
label: 'Agents',
|
||||
render: (profile) => (
|
||||
<span class="inline-flex items-center gap-1 text-muted">
|
||||
<Users class="w-4 h-4" />
|
||||
{getAssignmentCount(profile.id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (profile) => (
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(profile)}
|
||||
class="p-1.5 rounded-md hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900"
|
||||
title="Edit profile"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(profile)}
|
||||
class="p-1.5 rounded-md hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
|
||||
title="Delete profile"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(profile) => profile.id}
|
||||
emptyState={getAgentProfilesEmptyState()}
|
||||
desktopMinWidth="600px"
|
||||
frame="flush"
|
||||
/>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
|
||||
@@ -235,93 +233,88 @@ export const AgentProfilesPanel: Component = () => {
|
||||
</Show>
|
||||
|
||||
<Show when={connectedAgents().length > 0}>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<PulseDataGrid
|
||||
data={connectedAgents()}
|
||||
columns={[
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
render: (agent) => (
|
||||
<div>
|
||||
<span class="font-medium text-base-content">
|
||||
{agent.displayName || agent.hostname}
|
||||
</span>
|
||||
<Show when={agent.displayName && agent.hostname !== agent.displayName}>
|
||||
<span class="ml-2 text-xs text-muted">({agent.hostname})</span>
|
||||
</Show>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'Profile',
|
||||
render: (agent) => {
|
||||
const assignment = () => getAgentAssignment(agent.assignmentId);
|
||||
return (
|
||||
<FormSelect
|
||||
label={`Profile for ${agent.displayName || agent.hostname}`}
|
||||
labelClass="sr-only"
|
||||
fieldBaseClass="contents"
|
||||
value={assignment()?.profile_id || ''}
|
||||
onChange={(e) =>
|
||||
handleAssign(agent.assignmentId, e.currentTarget.value)
|
||||
}
|
||||
selectBaseClass="min-h-10 sm:min-h-9 w-full sm:max-w-xs rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">No profile</option>
|
||||
<Show
|
||||
when={
|
||||
assignment()?.profile_id &&
|
||||
!getProfileById(assignment()!.profile_id)
|
||||
}
|
||||
>
|
||||
<option value={assignment()!.profile_id}>
|
||||
{getProfileOptionLabel(assignment()!.profile_id)}
|
||||
</option>
|
||||
</Show>
|
||||
<For each={profiles()}>
|
||||
{(profile) => (
|
||||
<option value={profile.id}>
|
||||
{getProfileOptionLabel(profile.id)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</FormSelect>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (agent) => {
|
||||
const indicator = () => getAgentStatusIndicator({ status: agent.status });
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(indicator().variant)}`}
|
||||
>
|
||||
{indicator().label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lastSeen',
|
||||
label: 'Last Seen',
|
||||
hiddenOnMobile: true,
|
||||
render: (agent) => (
|
||||
<span class="text-muted">
|
||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'}
|
||||
<PulseDataGrid
|
||||
data={connectedAgents()}
|
||||
columns={[
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
render: (agent) => (
|
||||
<div>
|
||||
<span class="font-medium text-base-content">
|
||||
{agent.displayName || agent.hostname}
|
||||
</span>
|
||||
),
|
||||
<Show when={agent.displayName && agent.hostname !== agent.displayName}>
|
||||
<span class="ml-2 text-xs text-muted">({agent.hostname})</span>
|
||||
</Show>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profile',
|
||||
label: 'Profile',
|
||||
render: (agent) => {
|
||||
const assignment = () => getAgentAssignment(agent.assignmentId);
|
||||
return (
|
||||
<FormSelect
|
||||
label={`Profile for ${agent.displayName || agent.hostname}`}
|
||||
labelClass="sr-only"
|
||||
fieldBaseClass="contents"
|
||||
value={assignment()?.profile_id || ''}
|
||||
onChange={(e) => handleAssign(agent.assignmentId, e.currentTarget.value)}
|
||||
selectBaseClass="min-h-10 sm:min-h-9 w-full sm:max-w-xs rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">No profile</option>
|
||||
<Show
|
||||
when={
|
||||
assignment()?.profile_id && !getProfileById(assignment()!.profile_id)
|
||||
}
|
||||
>
|
||||
<option value={assignment()!.profile_id} selected>
|
||||
{getProfileOptionLabel(assignment()!.profile_id)}
|
||||
</option>
|
||||
</Show>
|
||||
<For each={profiles()}>
|
||||
{(profile) => (
|
||||
<option value={profile.id}>
|
||||
{getProfileOptionLabel(profile.id)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</FormSelect>
|
||||
);
|
||||
},
|
||||
]}
|
||||
keyExtractor={(agent) => agent.id}
|
||||
emptyState={getAgentProfileAssignmentsEmptyState()}
|
||||
desktopMinWidth="800px"
|
||||
class="border-x-0 sm:border-x"
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (agent) => {
|
||||
const indicator = () => getAgentStatusIndicator({ status: agent.status });
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(indicator().variant)}`}
|
||||
>
|
||||
{indicator().label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lastSeen',
|
||||
label: 'Last Seen',
|
||||
hiddenOnMobile: true,
|
||||
render: (agent) => (
|
||||
<span class="text-muted">
|
||||
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(agent) => agent.id}
|
||||
emptyState={getAgentProfileAssignmentsEmptyState()}
|
||||
desktopMinWidth="800px"
|
||||
frame="flush"
|
||||
/>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
|
||||
|
||||
@@ -18,97 +18,96 @@ export const OrganizationAccessMembersSection: Component<OrganizationAccessMembe
|
||||
) => (
|
||||
<Show when={props.state.org()}>
|
||||
{(currentOrg) => (
|
||||
<div class="mt-4">
|
||||
<PulseDataGrid
|
||||
data={props.state.members()}
|
||||
columns={[
|
||||
{
|
||||
key: 'userId',
|
||||
label: 'User',
|
||||
render: (member) => <span class="text-base-content">{member.userId}</span>,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (member) => {
|
||||
const role = normalizeRole(member.role);
|
||||
const isOwner = () => member.userId === currentOrg().ownerUserId;
|
||||
return (
|
||||
<Show
|
||||
when={props.state.canManageCurrentOrg()}
|
||||
fallback={
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
<PulseDataGrid
|
||||
class="mt-4"
|
||||
data={props.state.members()}
|
||||
columns={[
|
||||
{
|
||||
key: 'userId',
|
||||
label: 'User',
|
||||
render: (member) => <span class="text-base-content">{member.userId}</span>,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (member) => {
|
||||
const role = normalizeRole(member.role);
|
||||
const isOwner = () => member.userId === currentOrg().ownerUserId;
|
||||
return (
|
||||
<Show
|
||||
when={props.state.canManageCurrentOrg()}
|
||||
fallback={
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FormSelect
|
||||
label={`Role for ${member.userId}`}
|
||||
labelClass="sr-only"
|
||||
fieldBaseClass="contents"
|
||||
value={role}
|
||||
onChange={(event) => {
|
||||
void props.state.updateRole(
|
||||
member,
|
||||
event.currentTarget.value as OrganizationRole,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
props.state.saving() ||
|
||||
(isOwner() && props.currentUser !== currentOrg().ownerUserId)
|
||||
}
|
||||
selectBaseClass="rounded-md border border-border bg-surface px-2 py-1 text-xs text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<FormSelect
|
||||
label={`Role for ${member.userId}`}
|
||||
labelClass="sr-only"
|
||||
fieldBaseClass="contents"
|
||||
value={role}
|
||||
onChange={(event) => {
|
||||
void props.state.updateRole(
|
||||
member,
|
||||
event.currentTarget.value as OrganizationRole,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
props.state.saving() ||
|
||||
(isOwner() && props.currentUser !== currentOrg().ownerUserId)
|
||||
}
|
||||
selectBaseClass="rounded-md border border-border bg-surface px-2 py-1 text-xs text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
<For
|
||||
each={ORGANIZATION_MEMBER_ROLE_OPTIONS.filter(
|
||||
(option) =>
|
||||
option.value !== 'owner' ||
|
||||
props.currentUser === currentOrg().ownerUserId,
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={ORGANIZATION_MEMBER_ROLE_OPTIONS.filter(
|
||||
(option) =>
|
||||
option.value !== 'owner' ||
|
||||
props.currentUser === currentOrg().ownerUserId,
|
||||
)}
|
||||
>
|
||||
{(option) => <option value={option.value}>{option.label}</option>}
|
||||
</For>
|
||||
</FormSelect>
|
||||
</Show>
|
||||
);
|
||||
},
|
||||
{(option) => <option value={option.value}>{option.label}</option>}
|
||||
</For>
|
||||
</FormSelect>
|
||||
</Show>
|
||||
);
|
||||
},
|
||||
{
|
||||
key: 'addedAt',
|
||||
label: 'Added',
|
||||
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'addedAt',
|
||||
label: 'Added',
|
||||
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (member: OrganizationMember) => {
|
||||
const isOwner = () => member.userId === currentOrg().ownerUserId;
|
||||
return (
|
||||
<Show when={props.state.canManageCurrentOrg() && !isOwner()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.removeMember(member);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Remove
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (member: OrganizationMember) => {
|
||||
const isOwner = () => member.userId === currentOrg().ownerUserId;
|
||||
return (
|
||||
<Show when={props.state.canManageCurrentOrg() && !isOwner()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.removeMember(member);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Remove
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
keyExtractor={(member) => member.userId}
|
||||
emptyState={getOrganizationAccessEmptyState()}
|
||||
desktopMinWidth="700px"
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
]}
|
||||
keyExtractor={(member) => member.userId}
|
||||
emptyState={getOrganizationAccessEmptyState()}
|
||||
desktopMinWidth="700px"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
|
||||
@@ -24,108 +24,106 @@ export const OrganizationIncomingSharesSection: Component<
|
||||
return (
|
||||
<div class="space-y-2 p-4 sm:p-6">
|
||||
<h4 class="text-sm font-semibold text-base-content">Incoming Shares</h4>
|
||||
<div class="mt-4 -mx-4 sm:mx-0 overflow-x-auto w-full">
|
||||
<PulseDataGrid
|
||||
data={props.state.incomingShares()}
|
||||
columns={[
|
||||
{
|
||||
key: 'sourceOrg',
|
||||
label: 'Source Org',
|
||||
render: (share) => (
|
||||
<span class="text-base-content">{share.sourceOrgName || share.sourceOrgId}</span>
|
||||
),
|
||||
<PulseDataGrid
|
||||
class="mt-4"
|
||||
data={props.state.incomingShares()}
|
||||
columns={[
|
||||
{
|
||||
key: 'sourceOrg',
|
||||
label: 'Source Org',
|
||||
render: (share) => (
|
||||
<span class="text-base-content">{share.sourceOrgName || share.sourceOrgId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource',
|
||||
label: 'Resource',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
|
||||
<span class="text-xs text-muted">
|
||||
{share.resourceType}:{share.resourceId}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accessRole',
|
||||
label: 'Access',
|
||||
render: (share) => {
|
||||
const role = normalizeOrganizationShareRole(share.accessRole);
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
{
|
||||
key: 'resource',
|
||||
label: 'Resource',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
|
||||
<span class="text-xs text-muted">
|
||||
{share.resourceType}:{share.resourceId}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accessRole',
|
||||
label: 'Access',
|
||||
render: (share) => {
|
||||
const role = normalizeOrganizationShareRole(share.accessRole);
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
|
||||
>
|
||||
{getOrganizationShareStatusLabel(share.status)}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{getOrganizationShareStatusDescription(
|
||||
share.status,
|
||||
share.acceptedAt,
|
||||
share.acceptedBy,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Requested',
|
||||
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (share) => (
|
||||
<Show when={props.state.canManageCurrentOrg()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Show when={share.status === 'pending'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.acceptIncomingShare(share);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</Show>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
|
||||
>
|
||||
{getOrganizationShareStatusLabel(share.status)}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{getOrganizationShareStatusDescription(
|
||||
share.status,
|
||||
share.acceptedAt,
|
||||
share.acceptedBy,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Requested',
|
||||
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (share) => (
|
||||
<Show when={props.state.canManageCurrentOrg()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Show when={share.status === 'pending'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.declineIncomingShare(share);
|
||||
void props.state.acceptIncomingShare(share);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{share.status === 'pending' ? 'Decline' : 'Remove'}
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(share) => share.id}
|
||||
emptyState={getOrganizationIncomingSharesEmptyState()}
|
||||
desktopMinWidth="980px"
|
||||
class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.declineIncomingShare(share);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{share.status === 'pending' ? 'Decline' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(share) => share.id}
|
||||
emptyState={getOrganizationIncomingSharesEmptyState()}
|
||||
desktopMinWidth="980px"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,97 +25,95 @@ export const OrganizationOutgoingSharesSection: Component<
|
||||
return (
|
||||
<div class="space-y-2 p-4 sm:p-6">
|
||||
<h4 class="text-sm font-semibold text-base-content">Outgoing Shares</h4>
|
||||
<div class="mt-4 -mx-4 sm:mx-0 overflow-x-auto w-full">
|
||||
<PulseDataGrid
|
||||
data={props.state.outgoingShares()}
|
||||
columns={[
|
||||
{
|
||||
key: 'resourceName',
|
||||
label: 'Resource',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
|
||||
<span class="text-xs text-muted">
|
||||
{share.resourceType}:{share.resourceId}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'targetOrgId',
|
||||
label: 'Target Org',
|
||||
render: (share) => (
|
||||
<span class="text-base-content">
|
||||
{props.state.orgNameById().get(share.targetOrgId) || share.targetOrgId}
|
||||
<PulseDataGrid
|
||||
class="mt-4"
|
||||
data={props.state.outgoingShares()}
|
||||
columns={[
|
||||
{
|
||||
key: 'resourceName',
|
||||
label: 'Resource',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
|
||||
<span class="text-xs text-muted">
|
||||
{share.resourceType}:{share.resourceId}
|
||||
</span>
|
||||
),
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'targetOrgId',
|
||||
label: 'Target Org',
|
||||
render: (share) => (
|
||||
<span class="text-base-content">
|
||||
{props.state.orgNameById().get(share.targetOrgId) || share.targetOrgId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'accessRole',
|
||||
label: 'Access',
|
||||
render: (share) => {
|
||||
const role = normalizeOrganizationShareRole(share.accessRole);
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
{
|
||||
key: 'accessRole',
|
||||
label: 'Access',
|
||||
render: (share) => {
|
||||
const role = normalizeOrganizationShareRole(share.accessRole);
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
|
||||
>
|
||||
{getOrganizationShareStatusLabel(share.status)}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{getOrganizationShareStatusDescription(
|
||||
share.status,
|
||||
share.acceptedAt,
|
||||
share.acceptedBy,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Requested',
|
||||
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (share) => (
|
||||
<Show when={props.state.canManageCurrentOrg()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.deleteShare(share);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Remove
|
||||
</button>
|
||||
</Show>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(share) => share.id}
|
||||
emptyState={getOrganizationOutgoingSharesEmptyState()}
|
||||
desktopMinWidth="980px"
|
||||
class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border"
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (share) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
|
||||
>
|
||||
{getOrganizationShareStatusLabel(share.status)}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{getOrganizationShareStatusDescription(
|
||||
share.status,
|
||||
share.acceptedAt,
|
||||
share.acceptedBy,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Requested',
|
||||
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (share) => (
|
||||
<Show when={props.state.canManageCurrentOrg()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.state.deleteShare(share);
|
||||
}}
|
||||
disabled={props.state.saving()}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
Remove
|
||||
</button>
|
||||
</Show>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(share) => share.id}
|
||||
emptyState={getOrganizationOutgoingSharesEmptyState()}
|
||||
desktopMinWidth="980px"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,40 +13,38 @@ export const OrganizationOverviewMembersSection: Component<
|
||||
> = (props) => (
|
||||
<div class="space-y-2 p-4 sm:p-6">
|
||||
<h4 class="text-sm font-semibold text-base-content">Membership</h4>
|
||||
<div class="mt-4 -mx-4 sm:mx-0 overflow-x-auto w-full">
|
||||
<PulseDataGrid
|
||||
data={props.state.members()}
|
||||
columns={[
|
||||
{
|
||||
key: 'userId',
|
||||
label: 'User',
|
||||
render: (member) => <span class="text-base-content">{member.userId}</span>,
|
||||
<PulseDataGrid
|
||||
class="mt-4"
|
||||
data={props.state.members()}
|
||||
columns={[
|
||||
{
|
||||
key: 'userId',
|
||||
label: 'User',
|
||||
render: (member) => <span class="text-base-content">{member.userId}</span>,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (member) => {
|
||||
const role = normalizeRole(member.role);
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (member) => {
|
||||
const role = normalizeRole(member.role);
|
||||
return (
|
||||
<span
|
||||
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'addedAt',
|
||||
label: 'Added',
|
||||
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
|
||||
},
|
||||
]}
|
||||
keyExtractor={(member) => member.userId}
|
||||
emptyState={getOrganizationOverviewMembersEmptyState()}
|
||||
desktopMinWidth="560px"
|
||||
class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border"
|
||||
/>
|
||||
</div>
|
||||
},
|
||||
{
|
||||
key: 'addedAt',
|
||||
label: 'Added',
|
||||
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
|
||||
},
|
||||
]}
|
||||
keyExtractor={(member) => member.userId}
|
||||
emptyState={getOrganizationOverviewMembersEmptyState()}
|
||||
desktopMinWidth="560px"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,77 +47,75 @@ export const RolesPanel: Component = () => {
|
||||
</Show>
|
||||
|
||||
<Show when={!state.loading() && state.featureGate.rbacEnabled()}>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<PulseDataGrid
|
||||
data={state.roles()}
|
||||
columns={[
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (role) => (
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-base-content flex items-center gap-1">
|
||||
{role.name}
|
||||
<Show when={role.isBuiltIn}>
|
||||
<BadgeCheck class="w-4 h-4 text-blue-500" />
|
||||
</Show>
|
||||
</span>
|
||||
<span class="text-xs text-muted">{role.description}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'permissions',
|
||||
label: 'Permissions',
|
||||
render: (role) => (
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={role.permissions}>
|
||||
{(perm) => (
|
||||
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-muted border border-border">
|
||||
{perm.action}:{perm.resource}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (role) => (
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Show when={!role.isBuiltIn}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.openEditRole(role)}
|
||||
class="p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300"
|
||||
title="Edit role"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.handleDeleteRole(role)}
|
||||
class="p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
|
||||
title="Delete role"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<PulseDataGrid
|
||||
data={state.roles()}
|
||||
columns={[
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Role',
|
||||
render: (role) => (
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-base-content flex items-center gap-1">
|
||||
{role.name}
|
||||
<Show when={role.isBuiltIn}>
|
||||
<span class="text-xs text-slate-400 italic">Read-only</span>
|
||||
<BadgeCheck class="w-4 h-4 text-blue-500" />
|
||||
</Show>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(role) => role.id}
|
||||
emptyState={getRolesEmptyState()}
|
||||
desktopMinWidth="620px"
|
||||
class="border-x-0 sm:border-x"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
<span class="text-xs text-muted">{role.description}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'permissions',
|
||||
label: 'Permissions',
|
||||
render: (role) => (
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={role.permissions}>
|
||||
{(perm) => (
|
||||
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-muted border border-border">
|
||||
{perm.action}:{perm.resource}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (role) => (
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Show when={!role.isBuiltIn}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.openEditRole(role)}
|
||||
class="p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300"
|
||||
title="Edit role"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.handleDeleteRole(role)}
|
||||
class="p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
|
||||
title="Delete role"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={role.isBuiltIn}>
|
||||
<span class="text-xs text-slate-400 italic">Read-only</span>
|
||||
</Show>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(role) => role.id}
|
||||
emptyState={getRolesEmptyState()}
|
||||
desktopMinWidth="620px"
|
||||
frame="flush"
|
||||
/>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
|
||||
|
||||
@@ -75,58 +75,56 @@ export const UserAssignmentsPanel: Component = () => {
|
||||
state.filteredAssignments().length > 0
|
||||
}
|
||||
>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<PulseDataGrid
|
||||
data={state.filteredAssignments()}
|
||||
columns={[
|
||||
{
|
||||
key: 'username',
|
||||
label: 'Username',
|
||||
render: (assignment) => (
|
||||
<span class="font-medium text-base-content">{assignment.username}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'assignedRoles',
|
||||
label: 'Assigned Roles',
|
||||
render: (assignment) => (
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={assignment.roleIds.length === 0}>
|
||||
<span class="text-xs text-slate-400 italic">No roles assigned</span>
|
||||
</Show>
|
||||
<For each={assignment.roleIds}>
|
||||
{(roleId) => (
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content border border-border">
|
||||
<Shield class="w-3 h-3" />
|
||||
{state.getRoleName(roleId)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (assignment) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.openManageAccess(assignment)}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
Manage Access
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(assignment) => assignment.username}
|
||||
emptyState={emptyStateCopy().title}
|
||||
desktopMinWidth="620px"
|
||||
class="border-x-0 sm:border-x"
|
||||
/>
|
||||
</div>
|
||||
<PulseDataGrid
|
||||
data={state.filteredAssignments()}
|
||||
columns={[
|
||||
{
|
||||
key: 'username',
|
||||
label: 'Username',
|
||||
render: (assignment) => (
|
||||
<span class="font-medium text-base-content">{assignment.username}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'assignedRoles',
|
||||
label: 'Assigned Roles',
|
||||
render: (assignment) => (
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Show when={assignment.roleIds.length === 0}>
|
||||
<span class="text-xs text-slate-400 italic">No roles assigned</span>
|
||||
</Show>
|
||||
<For each={assignment.roleIds}>
|
||||
{(roleId) => (
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content border border-border">
|
||||
<Shield class="w-3 h-3" />
|
||||
{state.getRoleName(roleId)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (assignment) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.openManageAccess(assignment)}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
Manage Access
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
keyExtractor={(assignment) => assignment.username}
|
||||
emptyState={emptyStateCopy().title}
|
||||
desktopMinWidth="620px"
|
||||
frame="flush"
|
||||
/>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/components/shared/Table';
|
||||
import {
|
||||
getPulseDataGridAlignClass,
|
||||
getPulseDataGridFrameClass,
|
||||
getPulseDataGridWidthAttr,
|
||||
isPulseDataGridInteractiveTarget,
|
||||
type PulseDataGridProps,
|
||||
@@ -36,12 +37,13 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
|
||||
'desktopMinWidth',
|
||||
'mobileMinWidth',
|
||||
'class',
|
||||
'frame',
|
||||
]);
|
||||
|
||||
const grid = usePulseDataGridState(local);
|
||||
|
||||
return (
|
||||
<div class={`overflow-hidden rounded-md border border-border bg-surface ${local.class || ''}`}>
|
||||
<div class={`${getPulseDataGridFrameClass(local.frame)} ${local.class || ''}`}>
|
||||
<Table
|
||||
wrapperClass="scrollbar-hide"
|
||||
class="w-full border-collapse"
|
||||
|
||||
@@ -163,9 +163,17 @@ import pmgMailGatewaySource from '@/components/PMG/MailGateway.tsx?raw';
|
||||
import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw';
|
||||
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
|
||||
import aiSettingsDialogsSource from '@/components/Settings/AISettingsDialogs.tsx?raw';
|
||||
import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw';
|
||||
import apiTokenManagerSource from '@/components/Settings/APITokenManager.tsx?raw';
|
||||
import generalSettingsPanelSource from '@/components/Settings/GeneralSettingsPanel.tsx?raw';
|
||||
import organizationAccessMembersSectionSource from '@/components/Settings/OrganizationAccessMembersSection.tsx?raw';
|
||||
import organizationIncomingSharesSectionSource from '@/components/Settings/OrganizationIncomingSharesSection.tsx?raw';
|
||||
import organizationOutgoingSharesSectionSource from '@/components/Settings/OrganizationOutgoingSharesSection.tsx?raw';
|
||||
import organizationOverviewMembersSectionSource from '@/components/Settings/OrganizationOverviewMembersSection.tsx?raw';
|
||||
import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
|
||||
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
|
||||
import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw';
|
||||
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
|
||||
|
||||
const sharedSources = import.meta.glob(['./*.tsx', './cards/*.tsx', './responsive/*.tsx'], {
|
||||
query: '?raw',
|
||||
@@ -593,10 +601,32 @@ describe('shared primitive guardrails', () => {
|
||||
expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"');
|
||||
expect(storageContentCardSource).toContain('<StoragePoolsTable');
|
||||
expect(pulseDataGridSource).toContain('wrapperClass="scrollbar-hide"');
|
||||
expect(pulseDataGridSource).toContain('getPulseDataGridFrameClass');
|
||||
expect(pulseDataGridSource).not.toContain(
|
||||
'<div class="overflow-x-auto touch-scroll scrollbar-hide">',
|
||||
);
|
||||
|
||||
for (const source of [
|
||||
agentProfilesPanelSource,
|
||||
apiTokenManagerSource,
|
||||
organizationAccessMembersSectionSource,
|
||||
organizationIncomingSharesSectionSource,
|
||||
organizationOutgoingSharesSectionSource,
|
||||
organizationOverviewMembersSectionSource,
|
||||
rolesPanelSource,
|
||||
userAssignmentsPanelSource,
|
||||
]) {
|
||||
expect(source).toContain('<PulseDataGrid');
|
||||
expect(source).not.toContain('overflow-x-auto');
|
||||
expect(source).not.toContain('-mx-4');
|
||||
expect(source).not.toContain('border-x-0 sm:border-x');
|
||||
}
|
||||
|
||||
expect(agentProfilesPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(2);
|
||||
expect(apiTokenManagerSource.match(/frame="flush"/g) ?? []).toHaveLength(1);
|
||||
expect(rolesPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1);
|
||||
expect(userAssignmentsPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1);
|
||||
|
||||
for (const source of [
|
||||
deployCandidatesStepSource,
|
||||
deployConfirmStepSource,
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('PulseDataGrid', () => {
|
||||
it('keeps the shared pulse data grid on shell, runtime, and model owners', () => {
|
||||
expect(pulseDataGridSource).toContain('usePulseDataGridState');
|
||||
expect(pulseDataGridSource).toContain('getPulseDataGridAlignClass');
|
||||
expect(pulseDataGridSource).toContain('getPulseDataGridFrameClass');
|
||||
expect(pulseDataGridSource).toContain('getPulseDataGridWidthAttr');
|
||||
expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget');
|
||||
expect(pulseDataGridSource).toContain("from '@/components/shared/Table'");
|
||||
@@ -41,6 +42,13 @@ describe('PulseDataGrid', () => {
|
||||
expect(pulseDataGridStateSource).toContain('reconcile(');
|
||||
|
||||
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridAlignClass');
|
||||
expect(pulseDataGridModelSource).toContain(
|
||||
"export type PulseDataGridFrame = 'default' | 'flush'",
|
||||
);
|
||||
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridFrameClass');
|
||||
expect(pulseDataGridModelSource).toContain(
|
||||
"flush: 'overflow-hidden rounded-none border-0 bg-surface'",
|
||||
);
|
||||
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridWidthAttr');
|
||||
expect(pulseDataGridModelSource).toContain('export const isPulseDataGridInteractiveTarget');
|
||||
expect(pulseDataGridModelSource).toContain('target.closest(');
|
||||
@@ -114,6 +122,25 @@ describe('PulseDataGrid', () => {
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the shared flush frame for grids embedded inside existing panels', () => {
|
||||
render(() => (
|
||||
<PulseDataGrid<TestRow>
|
||||
data={[{ id: '1', name: 'Tower' }]}
|
||||
columns={[{ key: 'name', label: 'Name' }]}
|
||||
keyExtractor={(row) => row.id}
|
||||
frame="flush"
|
||||
/>
|
||||
));
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
const frame = table.parentElement?.parentElement;
|
||||
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame).toHaveClass('rounded-none');
|
||||
expect(frame).toHaveClass('border-0');
|
||||
expect(frame).not.toHaveClass('rounded-md');
|
||||
});
|
||||
|
||||
it('keeps the same row DOM node when data refreshes with the same key', () => {
|
||||
const [rows, setRows] = createSignal<TestRow[]>([{ id: '1', name: 'Tower' }]);
|
||||
|
||||
|
||||
@@ -52,13 +52,26 @@ export interface PulseDataGridProps<T> {
|
||||
|
||||
/** Custom classes applied to the root container */
|
||||
class?: string;
|
||||
|
||||
/** Root frame treatment. Use flush when embedding directly inside an existing panel/card frame. */
|
||||
frame?: PulseDataGridFrame;
|
||||
}
|
||||
|
||||
export type PulseDataGridFrame = 'default' | 'flush';
|
||||
|
||||
const PULSE_DATA_GRID_FRAME_CLASS: Record<PulseDataGridFrame, string> = {
|
||||
default: 'overflow-hidden rounded-md border border-border bg-surface',
|
||||
flush: 'overflow-hidden rounded-none border-0 bg-surface',
|
||||
};
|
||||
|
||||
export type PulseDataGridStableRow<T> = {
|
||||
__pulseKey: string | number;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export const getPulseDataGridFrameClass = (frame?: PulseDataGridFrame) =>
|
||||
PULSE_DATA_GRID_FRAME_CLASS[frame ?? 'default'];
|
||||
|
||||
export const getPulseDataGridAlignClass = (align?: 'left' | 'center' | 'right') => {
|
||||
switch (align) {
|
||||
case 'center':
|
||||
|
||||
Reference in New Issue
Block a user