Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71c601255
|
||
|
|
74344e83e3
|
||
|
|
978adad7fc
|
No files matched your search
@@ -4,6 +4,8 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **Tools:** the `QQQ_TOOLS` widget provides a shared setup workspace for CLI/TUI, mobile scanners and Print Client, with platform-specific install commands, copyable scripts and instance connection details. An account-menu shortcut follows visible Tools metadata. Requires the Tools Qbit; existing device enrollment and permissions remain unchanged.
|
||||
|
||||
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
|
||||
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
|
||||
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Bug, ChevronDown, LogOut, Moon, Settings } from 'lucide-react'
|
||||
import { Bug, ChevronDown, LogOut, Moon, Settings, Wrench } from 'lucide-react'
|
||||
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { toolsTarget } from '@/components/tools/tools-catalog'
|
||||
|
||||
import { getPreferences } from '@/lib/api/preferences'
|
||||
import { useMediaQuery } from '@/lib/hooks/use-media-query'
|
||||
@@ -107,6 +110,8 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
|
||||
compactItems?: (close: (then?: () => void) => void) => React.ReactNode
|
||||
}) {
|
||||
const { theme, isDarkMode, setDarkMode } = useTheme()
|
||||
const metadata = useMetaData()
|
||||
const tools = toolsTarget(metadata.data)
|
||||
const capability = useQaCaptureCapability()
|
||||
const preferences = useQuery({ queryKey: queryKeys.preferencesIndex(), queryFn: getPreferences, retry: false })
|
||||
const qaSection = capability?.enabled && capability.reason === 'QA_MODE' ? capability.qaMode.preferenceSection : undefined
|
||||
@@ -127,6 +132,9 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
|
||||
<HeaderMenuSeparator />
|
||||
</div>
|
||||
)}
|
||||
{tools && <Link href={tools.path} role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-tools">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden="true" />{tools.label}
|
||||
</Link>}
|
||||
<Link href="/app/preferences" role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-preferences">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Preferences
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { axe } from 'jest-axe'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { QInstance } from '@/types'
|
||||
import ToolsWidget from './ToolsWidget'
|
||||
import { connectionUrl, toolsTarget } from './tools-catalog'
|
||||
import backendCatalog from '../../../tests/e2e/fixtures/tools-catalog.json'
|
||||
|
||||
const metadata = {
|
||||
apps: {},
|
||||
appTree: [],
|
||||
widgets: {},
|
||||
tables: {},
|
||||
processes: {},
|
||||
reports: {},
|
||||
branding: {},
|
||||
} as QInstance
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({ useMetaData: () => ({ data: metadata }) }))
|
||||
const terminal = {
|
||||
id: 'terminal',
|
||||
label: 'CLI & Terminal UI',
|
||||
summary: 'Use QQQ from a terminal.',
|
||||
platforms: 'Windows, macOS and Linux',
|
||||
available: true,
|
||||
downloadUrl: 'https://example.test/releases',
|
||||
downloadLabel: 'Get CLI & TUI',
|
||||
guideUrl: 'https://example.test/guide',
|
||||
note: 'Standalone includes both.',
|
||||
steps: ['Install the client.', 'Sign in.'],
|
||||
commands: ['qqq login --server {server}', 'qqq ui'],
|
||||
manageApp: 'printing',
|
||||
}
|
||||
const payload = {
|
||||
serverUrl: 'https://example.test/customer',
|
||||
tools: [terminal, { ...terminal, id: 'scanner', label: 'Mobile Scanners', available: false }],
|
||||
}
|
||||
|
||||
describe('Tools', () => {
|
||||
it('renders the actual Java payload with omitted empty command lists', () => {
|
||||
render(<ToolsWidget data={backendCatalog} />)
|
||||
expect(screen.getByRole('button', { name: 'Set up Print Client' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Set up Mobile Scanners' })).toBeInTheDocument()
|
||||
})
|
||||
it('renders available downloads, collapses unavailable tools and copies an instance-specific sign-in command', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const { container } = render(<ToolsWidget data={payload} />)
|
||||
expect(screen.getByRole('link', { name: /Get CLI/ })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.test/releases'
|
||||
)
|
||||
expect(container.querySelector('[data-qqq-id="tools-unavailable"]')).not.toHaveAttribute('open')
|
||||
expect(container.querySelector('[data-qqq-id="tools-card-scanner"] a')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set up CLI & Terminal UI' }))
|
||||
fireEvent.click(screen.getByLabelText('Copy CLI & Terminal UI command 1'))
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith('qqq login --server "https://example.test/customer"')
|
||||
)
|
||||
expect(screen.queryByText(/Open Printing/)).not.toBeInTheDocument()
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
|
||||
it('switches the setup workspace without hiding other tools', () => {
|
||||
render(<ToolsWidget data={backendCatalog} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set up Print Client' }))
|
||||
expect(screen.getByRole('heading', { name: 'Print Client' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Get Print Client/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Set up CLI & Terminal UI' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set up Mobile Scanners' }))
|
||||
expect(screen.getByRole('heading', { name: 'Mobile Scanners' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Get Print Client/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches platform installers and copies the selected command', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<ToolsWidget data={backendCatalog} />)
|
||||
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'windows' } })
|
||||
expect(screen.getByText('scoop install qqq')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('Copy Scoop install command 2'))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('scoop install qqq'))
|
||||
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'macos' } })
|
||||
expect(screen.getByText('brew install qrun-io/tap/qqq')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Install method'), {
|
||||
target: { value: 'curl installer' },
|
||||
})
|
||||
expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'linux' } })
|
||||
expect(screen.getByLabelText('Install method')).toHaveValue('curl installer')
|
||||
})
|
||||
|
||||
it('copies complete printer and mobile installer scripts with preserved newlines', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<ToolsWidget data={backendCatalog} />)
|
||||
for (const [label, id] of [
|
||||
['Print Client', 'printing'],
|
||||
['Mobile Scanners', 'scanner'],
|
||||
]) {
|
||||
fireEvent.click(screen.getByRole('button', { name: `Set up ${label}` }))
|
||||
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'windows' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy install script' }))
|
||||
const script = backendCatalog.tools.find((tool) => tool.id === id)!.installOptions[0]
|
||||
.commands![0]
|
||||
await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(script))
|
||||
expect(script).toContain('\n')
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'android' } })
|
||||
expect(screen.queryByRole('button', { name: 'Copy install script' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/In the handheld's Files app/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a recovery path when clipboard access fails', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(<ToolsWidget data={payload} />)
|
||||
fireEvent.click(screen.getByLabelText('Copy server address'))
|
||||
expect(
|
||||
await screen.findByText('Could not copy. Select and copy the text above.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rejects malformed payloads and unsafe download links', () => {
|
||||
const view = render(<ToolsWidget data={{}} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('could not be loaded')
|
||||
view.rerender(
|
||||
<ToolsWidget
|
||||
data={{ ...payload, tools: [{ ...terminal, downloadUrl: 'javascript:alert(1)' }] }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /Get CLI/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves public roots without losing proxy paths or accepting credentials and shell syntax', () => {
|
||||
expect(connectionUrl(null, '/tenant/qqq/v1', 'https://example.test')).toBe(
|
||||
'https://example.test/tenant'
|
||||
)
|
||||
expect(connectionUrl(null, 'https://api.test/qqq/v1/', 'https://example.test')).toBe(
|
||||
'https://api.test'
|
||||
)
|
||||
expect(connectionUrl(null, '/unknown', 'https://example.test')).toBeUndefined()
|
||||
expect(connectionUrl(null, '/qqq/v1', 'http://localhost:8077')).toBe('http://localhost:8077')
|
||||
for (const value of [
|
||||
'https://user:pass@example.test',
|
||||
'https://example.test?token=secret',
|
||||
'http://example.test',
|
||||
'https://example.test/$HOME',
|
||||
]) {
|
||||
expect(connectionUrl(value, '/qqq/v1', 'https://example.test')).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('finds the menu shortcut by visible widget metadata, including renamed apps', () => {
|
||||
const instance = {
|
||||
...metadata,
|
||||
apps: { downloads: { name: 'downloads', label: 'Company Tools', widgets: ['catalog'] } },
|
||||
widgets: {
|
||||
catalog: { name: 'catalog', label: 'Tools', type: 'QQQ_TOOLS', hasPermission: true },
|
||||
},
|
||||
appTree: [{ name: 'downloads', label: 'Company Tools', type: 'APP' as const }],
|
||||
}
|
||||
expect(toolsTarget(instance)?.path).toBe('/app/downloads')
|
||||
expect(toolsTarget({ ...instance, appTree: [] })).toBeUndefined()
|
||||
instance.widgets.catalog.hasPermission = false
|
||||
expect(toolsTarget(instance)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Companion tools with a compact chooser and focused setup workspace. */
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
Info,
|
||||
Monitor,
|
||||
Printer,
|
||||
Smartphone,
|
||||
Terminal,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import apiClient from '@/lib/api/client'
|
||||
import { useMetaData } from '@/lib/hooks/use-metadata'
|
||||
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
|
||||
import { connectionUrl, downloadLink, toolsCatalogSchema, type Tool } from './tools-catalog'
|
||||
|
||||
const actionClass =
|
||||
'inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
||||
|
||||
/** Copy without breaking commands across lines.
|
||||
* @param props - Text, accessible label and optional terminal treatment.
|
||||
* @returns Scrollable text and clipboard feedback.
|
||||
*/
|
||||
function CopyValue({
|
||||
value,
|
||||
label,
|
||||
terminal = false,
|
||||
}: {
|
||||
value: string
|
||||
label: string
|
||||
terminal?: boolean
|
||||
}) {
|
||||
const [status, setStatus] = useState('')
|
||||
const multiline = value.includes('\n')
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={`flex min-w-0 gap-2 ${multiline ? 'flex-col items-stretch' : 'items-center'} ${terminal ? 'py-2' : 'rounded-lg border border-border bg-background px-3 py-2'}`}
|
||||
>
|
||||
<code
|
||||
className={`min-w-0 flex-1 max-h-64 overflow-auto whitespace-pre py-1 text-xs leading-6 ${terminal ? 'text-slate-100' : 'text-foreground'}`}
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
>
|
||||
{value}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={multiline ? 'Copy install script' : `Copy ${label}`}
|
||||
title={`Copy ${label}`}
|
||||
className={`flex h-9 shrink-0 items-center justify-center gap-2 rounded-md ${multiline ? 'w-full border border-slate-600 px-3 text-xs' : 'w-9'} transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${terminal ? 'text-slate-300 hover:bg-white/10 hover:text-white' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}
|
||||
data-qqq-id={`tools-copy-${label.replace(/ /g, '-')}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setStatus('Copied')
|
||||
} catch {
|
||||
setStatus('Could not copy. Select and copy the text above.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status === 'Copied' ? (
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{multiline &&
|
||||
(status === 'Copied' ? 'Copied � paste into your terminal' : 'Copy install script')}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
role="status"
|
||||
className={
|
||||
status && status !== 'Copied'
|
||||
? `block py-1 text-xs ${terminal ? 'text-slate-200' : 'text-muted-foreground'}`
|
||||
: 'sr-only'
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Stable product icon with a fallback for future catalog entries.
|
||||
* @param props - Tool identifier and icon classes.
|
||||
* @returns Product icon.
|
||||
*/
|
||||
function ToolIcon({ id, className }: { id: string; className?: string }) {
|
||||
const Icon =
|
||||
(
|
||||
{ terminal: Terminal, scanner: Smartphone, printing: Printer } as Record<
|
||||
string,
|
||||
typeof Wrench
|
||||
>
|
||||
)[id] ?? Wrench
|
||||
return <Icon className={className} aria-hidden="true" />
|
||||
}
|
||||
|
||||
/** Platform and package-manager choices supplied by the catalog.
|
||||
* @param props - Selected tool with supported install methods.
|
||||
* @returns In-app installation instructions and copyable commands.
|
||||
*/
|
||||
function InstallInstructions({ tool }: { tool: Tool }) {
|
||||
const [platform, setPlatform] = useState(() => {
|
||||
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent.toLowerCase()
|
||||
return ua.includes('win')
|
||||
? 'windows'
|
||||
: ua.includes('mac')
|
||||
? 'macos'
|
||||
: ua.includes('android')
|
||||
? 'android'
|
||||
: 'linux'
|
||||
})
|
||||
const [method, setMethod] = useState('')
|
||||
const options = tool.installOptions
|
||||
if (options.length === 0) return null
|
||||
const platforms = options.filter(
|
||||
(option, index) => options.findIndex((other) => other.platform === option.platform) === index
|
||||
)
|
||||
const selectedPlatform = platforms.some((option) => option.platform === platform)
|
||||
? platform
|
||||
: platforms[0].platform
|
||||
const methods = options.filter((option) => option.platform === selectedPlatform)
|
||||
const selected = methods.find((option) => option.method === method) ?? methods[0]
|
||||
return (
|
||||
<div className="mb-6 border-b border-border pb-6" data-qqq-id="tools-install-instructions">
|
||||
<h3 className="mb-3 text-sm font-semibold">Install {tool.label}</h3>
|
||||
<div className="mb-3 flex flex-wrap gap-3">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
|
||||
Platform
|
||||
<select
|
||||
aria-label="Install platform"
|
||||
value={selectedPlatform}
|
||||
onChange={(event) => {
|
||||
setPlatform(event.target.value)
|
||||
setMethod('')
|
||||
}}
|
||||
data-qqq-id="tools-install-platform"
|
||||
className="min-h-10 min-w-0 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-ring"
|
||||
>
|
||||
{platforms.map((option) => (
|
||||
<option key={option.platform} value={option.platform}>
|
||||
{option.platformLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
|
||||
Install with
|
||||
<select
|
||||
aria-label="Install method"
|
||||
value={selected.method}
|
||||
onChange={(event) => setMethod(event.target.value)}
|
||||
data-qqq-id="tools-install-method"
|
||||
className="min-h-10 min-w-0 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-ring"
|
||||
>
|
||||
{methods.map((option) => (
|
||||
<option key={option.method}>{option.method}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">{selected.instructions}</p>
|
||||
{selected.commands.length > 0 && (
|
||||
<div className="min-w-0 overflow-hidden rounded-xl bg-slate-950 p-3">
|
||||
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
{selected.shell}
|
||||
</p>
|
||||
{selected.commands.map((command, index) => (
|
||||
<CopyValue
|
||||
key={command}
|
||||
terminal
|
||||
value={command}
|
||||
label={`${selected.method} install command ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render an available tool's setup in one consistently sized workspace.
|
||||
* @param props - Selected tool, public server and visible management destination.
|
||||
* @returns Setup panel.
|
||||
*/
|
||||
function SetupPanel({
|
||||
tool,
|
||||
server,
|
||||
management,
|
||||
}: {
|
||||
tool: Tool
|
||||
server?: string
|
||||
management?: { path: string; label: string }
|
||||
}) {
|
||||
const download = downloadLink(tool.downloadUrl)
|
||||
const guide = downloadLink(tool.guideUrl)
|
||||
return (
|
||||
<section
|
||||
id="tools-setup-panel"
|
||||
aria-labelledby="tools-setup-heading"
|
||||
className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm"
|
||||
data-qqq-id={`tools-setup-${tool.id}`}
|
||||
>
|
||||
<header className="flex flex-wrap items-start justify-between gap-5 border-b border-border px-5 py-5 sm:px-6">
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Setup guide
|
||||
</p>
|
||||
<h2 id="tools-setup-heading" className="text-xl font-semibold tracking-tight">
|
||||
{tool.label}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
{tool.summary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{download && (
|
||||
<a
|
||||
className={`${actionClass} bg-primary text-primary-foreground shadow-sm hover:bg-primary/90`}
|
||||
href={download}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-qqq-id={`tools-download-${tool.id}`}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4" aria-hidden="true" />
|
||||
{tool.downloadLabel}
|
||||
<span className="sr-only"> (opens in a new tab)</span>
|
||||
</a>
|
||||
)}
|
||||
{guide && (
|
||||
<a
|
||||
className={`${actionClass} border border-border hover:bg-muted`}
|
||||
href={guide}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-qqq-id={`tools-guide-${tool.id}`}
|
||||
>
|
||||
Installation guide
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only"> (opens in a new tab)</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="grid min-w-0 xl:grid-cols-2">
|
||||
<div className="min-w-0 px-5 py-6 sm:px-6">
|
||||
<InstallInstructions tool={tool} />
|
||||
<h3 className="mb-5 text-sm font-semibold">Connect and start using it</h3>
|
||||
<ol className="space-y-6">
|
||||
{tool.steps.map((step, index) => (
|
||||
<li key={index} className="flex gap-3">
|
||||
<span
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="pt-0.5 text-sm leading-relaxed">
|
||||
<span className="sr-only">Step {index + 1}. </span>
|
||||
{step}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{management && (
|
||||
<Link
|
||||
href={management.path}
|
||||
className={`${actionClass} mt-5 border border-border hover:bg-muted`}
|
||||
data-qqq-id={`tools-manage-${tool.id}`}
|
||||
>
|
||||
Open {management.label}
|
||||
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
className="min-w-0 space-y-5 border-t border-border bg-muted/25 px-5 py-6 sm:px-6 xl:border-l xl:border-t-0"
|
||||
aria-label="Connection details"
|
||||
>
|
||||
<div>
|
||||
<h3 className="mb-1 flex items-center gap-2 text-sm font-semibold">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
Connect to this instance
|
||||
</h3>
|
||||
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">
|
||||
Use this server address and sign in with your QQQ account.
|
||||
</p>
|
||||
{server ? (
|
||||
<CopyValue key={server} value={server} label="server address" />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ask your administrator for the public HTTPS server address before connecting a
|
||||
client.
|
||||
</p>
|
||||
)}
|
||||
{server && /^http:\/\//.test(server) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Local development only. Other devices need a reachable HTTPS address.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tool.commands.length > 0 && (
|
||||
<div className="min-w-0 overflow-hidden rounded-xl bg-slate-950 p-4 text-slate-100">
|
||||
<div className="mb-2 flex items-center gap-2 border-b border-slate-700 pb-3 text-xs font-medium text-slate-300">
|
||||
<Terminal className="h-4 w-4" aria-hidden="true" />
|
||||
Terminal commands
|
||||
</div>
|
||||
{tool.commands.map((command, index) =>
|
||||
command.includes('{server}') && !server ? null : (
|
||||
<CopyValue
|
||||
key={`${command}-${server}`}
|
||||
terminal
|
||||
label={`${tool.label} command ${index + 1}`}
|
||||
value={command.replace(/\{server\}/g, `"${server}"`)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
Your existing permissions apply. Each device signs in separately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="flex items-start gap-2 border-t border-border px-5 py-4 text-xs leading-relaxed text-muted-foreground sm:px-6">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{tool.note}
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render the catalog with one selected tool, keeping every available tool visible.
|
||||
* @param props - Standard widget payload.
|
||||
* @returns Tool chooser and setup panel, or a recoverable error.
|
||||
*/
|
||||
export default function ToolsWidget({ data }: { data: unknown }) {
|
||||
const metadata = useMetaData()
|
||||
const { navTargets } = useAppTreeRoutes(metadata.data)
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const parsed = toolsCatalogSchema.safeParse(data)
|
||||
if (!parsed.success)
|
||||
return (
|
||||
<p role="alert">
|
||||
Tools information could not be loaded. Refresh the page or contact your administrator.
|
||||
</p>
|
||||
)
|
||||
const catalog = parsed.data
|
||||
const server =
|
||||
typeof window === 'undefined'
|
||||
? undefined
|
||||
: connectionUrl(
|
||||
catalog.serverUrl,
|
||||
apiClient.getInstance().defaults.baseURL ?? '',
|
||||
window.location.origin
|
||||
)
|
||||
const available = catalog.tools.filter((tool) => tool.available)
|
||||
const unavailable = catalog.tools.filter((tool) => !tool.available)
|
||||
const selected = available.find((tool) => tool.id === selectedId) ?? available[0]
|
||||
return (
|
||||
<div className="space-y-5" data-qqq-id="tools-catalog">
|
||||
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
Your workspace, beyond the browser. Choose a tool to download it and connect to this
|
||||
instance.
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-3" aria-label="Choose a tool">
|
||||
{available.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
aria-pressed={selected?.id === tool.id}
|
||||
aria-controls="tools-setup-panel"
|
||||
aria-label={`Set up ${tool.label}`}
|
||||
data-qqq-id={`tools-card-${tool.id}`}
|
||||
onClick={() => setSelectedId(tool.id)}
|
||||
className={`group flex min-w-0 items-center gap-3 rounded-xl border p-4 text-left transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring md:items-start ${selected?.id === tool.id ? 'border-primary bg-primary/5 shadow-[inset_0_0_0_1px_var(--primary)]' : 'border-border bg-card hover:border-primary/40 hover:bg-muted/30'}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${selected?.id === tool.id ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold">{tool.label}</span>
|
||||
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
|
||||
{tool.platforms}
|
||||
</span>
|
||||
</span>
|
||||
{selected?.id === tool.id && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selected && (
|
||||
<SetupPanel
|
||||
key={selected.id}
|
||||
tool={selected}
|
||||
server={server}
|
||||
management={navTargets.find(
|
||||
(target) => target.nodeType === 'APP' && target.key === selected.manageApp
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{unavailable.length > 0 && (
|
||||
<details
|
||||
className="rounded-xl border border-dashed border-border px-4 py-3"
|
||||
data-qqq-id="tools-unavailable"
|
||||
>
|
||||
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
||||
More tools � {unavailable.length} not enabled
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
{unavailable.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="rounded-lg bg-muted/40 p-3"
|
||||
data-qqq-id={`tools-card-${tool.id}`}
|
||||
>
|
||||
<h2 className="text-sm font-medium">{tool.label}</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Not enabled on this instance. Ask your administrator about enabling this tool.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Tools catalog contract and metadata-based navigation. */
|
||||
import { z } from 'zod'
|
||||
import type { QInstance } from '@/types'
|
||||
import { buildRouteMap } from '@/lib/hooks/use-routes'
|
||||
|
||||
export const toolsCatalogSchema = z.object({
|
||||
serverUrl: z.string().nullish(),
|
||||
tools: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
summary: z.string(),
|
||||
platforms: z.string(),
|
||||
available: z.boolean(),
|
||||
downloadUrl: z.string().nullish(),
|
||||
downloadLabel: z.string(),
|
||||
guideUrl: z.string(),
|
||||
manageApp: z.string().nullish(),
|
||||
note: z.string(),
|
||||
steps: z.array(z.string()),
|
||||
commands: z.array(z.string()).default([]),
|
||||
installOptions: z
|
||||
.array(
|
||||
z.object({
|
||||
platform: z.string(),
|
||||
platformLabel: z.string(),
|
||||
method: z.string(),
|
||||
shell: z.string().default(''),
|
||||
instructions: z.string(),
|
||||
commands: z.array(z.string()).default([]),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
),
|
||||
})
|
||||
export type Tool = z.infer<typeof toolsCatalogSchema>['tools'][number]
|
||||
|
||||
/** Find the visible app that hosts the tools widget, independent of app naming.
|
||||
* @param metadata - Permission-filtered instance metadata.
|
||||
* @returns The Tools destination, if visible.
|
||||
*/
|
||||
export function toolsTarget(metadata: QInstance | undefined) {
|
||||
return buildRouteMap(metadata).navTargets.find(
|
||||
(target) =>
|
||||
target.nodeType === 'APP' &&
|
||||
metadata?.apps[target.key]?.widgets?.some(
|
||||
(name) =>
|
||||
metadata.widgets[name]?.type === 'QQQ_TOOLS' &&
|
||||
metadata.widgets[name]?.hasPermission !== false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** HTTPS external links only.
|
||||
* @param value - Configured download or guide URL.
|
||||
* @returns Safe external URL, or undefined.
|
||||
*/
|
||||
export function downloadLink(value: string | null | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'https:' && !url.username && !url.password ? url.href : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the client-facing server root, retaining reverse-proxy path prefixes.
|
||||
* @param configured - Optional explicit public server root.
|
||||
* @param apiBase - Configured UI API base.
|
||||
* @param origin - Browser origin for relative API paths.
|
||||
* @returns Safe server root, or undefined when configuration is ambiguous.
|
||||
*/
|
||||
export function connectionUrl(
|
||||
configured: string | null | undefined,
|
||||
apiBase: string,
|
||||
origin: string
|
||||
): string | undefined {
|
||||
try {
|
||||
const url = new URL(configured || apiBase, origin)
|
||||
if (!configured) {
|
||||
if (!url.pathname.replace(/\/+$/, '').endsWith('/qqq/v1')) return undefined
|
||||
url.pathname = url.pathname.replace(/\/qqq\/v1\/*$/, '')
|
||||
}
|
||||
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) return undefined
|
||||
if (url.username || url.password || url.search || url.hash) return undefined
|
||||
const value = url.href.replace(/\/+$/, '')
|
||||
// Safe inside quoted shell commands on Windows, macOS and Linux.
|
||||
return /^https?:\/\/[A-Za-z0-9.:/_[\]~%+-]+$/.test(value) ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ const LineChartWidget = lazy(() => import('./LineChartWidget').then((m) => ({ de
|
||||
const PieChartWidget = lazy(() => import('./PieChartWidget').then((m) => ({ default: m.PieChartWidget })))
|
||||
const FilterAndColumnsSetupWidget = lazy(() => import('./FilterAndColumnsSetupWidget').then((m) => ({ default: m.FilterAndColumnsSetupWidget })))
|
||||
const PivotTableSetupWidget = lazy(() => import('./PivotTableSetupWidget').then((m) => ({ default: m.PivotTableSetupWidget })))
|
||||
const ToolsWidget = lazy(() => import('@/components/tools/ToolsWidget'))
|
||||
const SqlWorkbenchWidget = lazy(() => import('@/components/sql-workbench/SqlWorkbenchWidget'))
|
||||
const AuthentikPolicyEditorWidget = lazy(() => import('@/components/authentik/AuthentikPolicyEditorWidget'))
|
||||
const RowBuilderWidget = lazy(() => import('./RowBuilderWidget').then((m) => ({ default: m.RowBuilderWidget })))
|
||||
@@ -152,6 +153,8 @@ export function WidgetRenderer({ widgetMetaData, data, recordContext, formContex
|
||||
}
|
||||
|
||||
switch (resolvedType) {
|
||||
case 'QQQ_TOOLS':
|
||||
return <Suspense fallback={SetupFallback}><ToolsWidget data={data} /></Suspense>
|
||||
case 'statistics':
|
||||
return 'count' in data
|
||||
? <QqqStatisticsWidget {...common} data={data} />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"serverUrl":"https://example.test/customer","tools":[{"id":"terminal","label":"CLI & Terminal UI","summary":"Work with your QQQ apps from a command line or an interactive terminal.","platforms":"Windows, macOS and Linux","available":true,"downloadUrl":"https://github.com/QRun-IO/qqq-cli/releases","downloadLabel":"Get CLI & TUI","guideUrl":"https://github.com/QRun-IO/qqq-cli#install","note":"Download the standalone binary for both CLI and TUI. The npm package includes only the CLI.","steps":["Sign in to this instance using the command below. Follow the sign-in prompts on your computer.","Run qqq ui for the terminal app, or qqq --help for command-line options."],"commands":["qqq login --server {server}","qqq whoami","qqq ui"],"installOptions":[{"platform":"windows","platformLabel":"Windows","method":"Scoop","shell":"PowerShell","instructions":"With Scoop installed, run these commands in PowerShell. This installs the CLI and terminal UI together.","commands":["scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket","scoop install qqq"]},{"platform":"macos","platformLabel":"macOS","method":"Homebrew","shell":"Terminal","instructions":"With Homebrew installed, run this command. It installs the CLI, terminal UI and shell completions.","commands":["brew install qrun-io/tap/qqq"]},{"platform":"macos","platformLabel":"macOS","method":"curl installer","shell":"Terminal","instructions":"No package manager required. Installs the CLI and terminal UI into ~/.local/bin and checks the release SHA-256 checksum. Ensure ~/.local/bin is on your PATH.","commands":["curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh"]},{"platform":"linux","platformLabel":"Linux","method":"curl installer","shell":"Shell","instructions":"No package manager required. Installs into ~/.local/bin and checks the release SHA-256 checksum. Ensure ~/.local/bin is on your PATH. On Alpine, install libstdc++ and libgcc first.","commands":["curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh"]},{"platform":"linux","platformLabel":"Linux","method":"Homebrew","shell":"Shell","instructions":"If you already use Homebrew on Linux, this installs the CLI, terminal UI and shell completions.","commands":["brew install qrun-io/tap/qqq"]}]},{"id":"scanner","label":"Mobile Scanners","summary":"Capture barcodes with an approved Android handheld and your QQQ scanning apps.","platforms":"Supported Android handhelds","available":true,"downloadUrl":"https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0","downloadLabel":"Get signed preview APK","guideUrl":"https://git.kof22.com/QRun-IO/qqq-android/src/branch/main/docs/INSTALLATION.md","manageApp":"mobileScannerAdmin","note":"Signed preview 1.0.0, not a stable release. Forge sign-in is required to download. Devices running a development-signed build need a planned migration; never uninstall or reset to bypass an installation refusal.","steps":["Enter this instance's server address and sign in with your own QQQ account.","Enroll the device and ask an administrator to approve it. Open an available scanning app after approval."],"installOptions":[{"platform":"windows","platformLabel":"Windows computer","method":"USB install - signed preview 1.0.0","shell":"PowerShell","instructions":"For a USB install, connect one handheld to this computer. On the handheld, open Settings > About phone, tap Build number seven times, then enable USB debugging in Developer options. Unlock it and accept the computer trust prompt. The script downloads Google's Platform Tools, opens the exact signed-preview release, verifies the downloaded APK and installs it. Forge login and APK download happen in your browser. Open PowerShell, paste the whole block and follow its prompts.","commands":["# QQQ Mobile signed preview 1.0.0 - run this whole block in PowerShell.\n# Connect ONE handheld by USB, enable USB debugging and accept its trust prompt.\n& {\n $ErrorActionPreference = 'Stop'\n $release = 'https://git.kof22.com/QRun-IO/qqq-android/releases/tag/scanner-v1.0.0'\n $expected = 'EE5AC896B8985AF6F11B2E883955925D6571A2913D74A4AACCDB564B79394D51'\n $folder = Join-Path ([IO.Path]::GetTempPath()) ('qqq-mobile-' + [guid]::NewGuid())\n New-Item -ItemType Directory -Path $folder | Out-Null\n Invoke-WebRequest 'https://dl.google.com/android/repository/platform-tools-latest-windows.zip' -OutFile \"$folder\\tools.zip\" -UseBasicParsing\n Expand-Archive -LiteralPath \"$folder\\tools.zip\" -DestinationPath $folder\n $adb = Join-Path $folder 'platform-tools\\adb.exe'\n Start-Process $release\n Write-Host 'Sign in on the release page and download QQQ-Mobile-1.0.0.apk to Downloads.'\n Read-Host 'After the download finishes, press Enter' | Out-Null\n $apk = Join-Path $env:USERPROFILE 'Downloads\\QQQ-Mobile-1.0.0.apk'\n if(-not (Test-Path -LiteralPath $apk)) {\n $apk = (Read-Host 'Paste the full path of the downloaded QQQ-Mobile-1.0.0.apk').TriLine truncated
|
||||
@@ -22,8 +22,11 @@ test.describe('Keyboard shortcuts', () => {
|
||||
await page.route('**/qqq/v1/preferences/keyboard', (route) => route.fulfill({ status: 404, json: { error: 'No keyboard section' } }))
|
||||
await page.goto('/app/person')
|
||||
await waitForAppReady(page)
|
||||
// Ensure no input is focused so single-key shortcuts fire
|
||||
await page.locator('body').click()
|
||||
// Clear focus without clicking a record at the center of the page.
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
})
|
||||
await expect(page).toHaveURL(/\/app\/person\/?$/)
|
||||
})
|
||||
|
||||
// ── '.' key — opens command palette ──────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import catalog from './fixtures/tools-catalog.json'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`Tools is discoverable and usable at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await setupApiMocks(page)
|
||||
const app = {
|
||||
name: 'qqqTools',
|
||||
label: 'Tools',
|
||||
widgets: ['qqqToolsCatalog'],
|
||||
children: [],
|
||||
sections: [],
|
||||
}
|
||||
await page.route(/\/qqq\/v1\/metaData(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
...METADATA,
|
||||
apps: { ...METADATA.apps, qqqTools: app },
|
||||
appTree: [
|
||||
...METADATA.appTree,
|
||||
{ name: app.name, label: app.label, type: 'APP', iconName: 'build' },
|
||||
],
|
||||
widgets: {
|
||||
qqqToolsCatalog: {
|
||||
name: 'qqqToolsCatalog',
|
||||
label: 'Take QQQ with you',
|
||||
type: 'QQQ_TOOLS',
|
||||
hasPermission: true,
|
||||
gridColumns: 12,
|
||||
isCard: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
await page.route('**/qqq/v1/widget/qqqToolsCatalog**', (route) =>
|
||||
route.fulfill({ json: catalog })
|
||||
)
|
||||
await page.goto('/app')
|
||||
await page.locator('[data-qqq-id="button-account"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Tools', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/app\/qqqTools$/)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'CLI & Terminal UI', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Set up Mobile Scanners', exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Set up Print Client', exact: true })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Set up Print Client', exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Print Client', exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="tools-download-printing"]')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Copy install script' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Set up Mobile Scanners', exact: true }).click()
|
||||
await page.getByLabel('Install platform').selectOption('windows')
|
||||
await expect(page.getByRole('button', { name: 'Copy install script' })).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="tools-download-scanner"]')).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
(await new AxeBuilder({ page }).include('[data-qqq-id="tools-catalog"]').analyze()).violations
|
||||
).toEqual([])
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`mobile-setup-${width}.png`),
|
||||
fullPage: true,
|
||||
})
|
||||
await page.getByRole('button', { name: 'Set up CLI & Terminal UI', exact: true }).click()
|
||||
await expect(
|
||||
page.getByText('qqq login --server "https://example.test/customer"', { exact: true })
|
||||
).toBeVisible()
|
||||
await page.getByLabel('Install platform').selectOption('windows')
|
||||
await expect(page.getByText('scoop install qqq', { exact: true })).toBeVisible()
|
||||
await page.getByLabel('Install platform').selectOption('macos')
|
||||
await expect(page.getByText('brew install qrun-io/tap/qqq', { exact: true })).toBeVisible()
|
||||
await page.getByLabel('Install platform').selectOption('linux')
|
||||
await expect(page.getByText(/curl -fsSL/)).toBeVisible()
|
||||
await page.getByLabel('Install platform').selectOption('windows')
|
||||
await expect(page.locator('[data-qqq-id="tools-manage-printing"]')).toHaveCount(0)
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBeTruthy()
|
||||
const accessibility = await new AxeBuilder({ page })
|
||||
.include('[data-qqq-id="tools-catalog"]')
|
||||
.analyze()
|
||||
expect(accessibility.violations).toEqual([])
|
||||
await page.screenshot({ path: test.info().outputPath(`tools-${width}.png`), fullPage: true })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user