Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3531c64f9
680 files changed
+139836
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
import { ThemeProvider } from './theme/ThemeProvider'
|
||||
import { SessionProvider } from './session/SessionProvider'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<SessionProvider />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as client from './client'
|
||||
import {
|
||||
deleteCluster,
|
||||
deleteSecondaryNode,
|
||||
getClusterState,
|
||||
initCluster,
|
||||
initJoinCluster,
|
||||
leaveCluster,
|
||||
primaryNodeName,
|
||||
promoteToPrimary,
|
||||
removeSecondaryNode,
|
||||
resyncCluster,
|
||||
setClusterOptions,
|
||||
updateIpAddress,
|
||||
updatePrimaryNode,
|
||||
type ClusterState,
|
||||
} from './admin-cluster'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function makeSpy() {
|
||||
return vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue({ kind: 'ok', data: { response: {}, server: 's' } })
|
||||
}
|
||||
|
||||
describe('cluster: state', () => {
|
||||
it('with no options it sends no parameter', async () => {
|
||||
const spy = makeSpy()
|
||||
await getClusterState('tok')
|
||||
expect(spy).toHaveBeenCalledWith('admin/cluster/state', { token: 'tok', body: {} })
|
||||
})
|
||||
|
||||
it('it asks for the server IPs only when told to', async () => {
|
||||
const spy = makeSpy()
|
||||
await getClusterState('tok', { node: 'ns1', includeServerIpAddresses: true })
|
||||
expect(spy.mock.calls[0][1]).toEqual({
|
||||
token: 'tok',
|
||||
body: { includeServerIpAddresses: 'true', node: 'ns1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cluster: initialisation', () => {
|
||||
it('`init` sends no `node`: the cluster is created on this server', async () => {
|
||||
const spy = makeSpy()
|
||||
await initCluster('tok', 'mycluster.tld', '10.0.0.1,10.0.0.2')
|
||||
expect(spy).toHaveBeenCalledWith('admin/cluster/init', {
|
||||
token: 'tok',
|
||||
body: { clusterDomain: 'mycluster.tld', primaryNodeIpAddresses: '10.0.0.1,10.0.0.2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('`initJoin` goes by POST with the seven fields', async () => {
|
||||
const spy = makeSpy()
|
||||
await initJoinCluster('tok', {
|
||||
secondaryNodeIpAddresses: '10.0.0.3',
|
||||
primaryNodeUrl: 'https://ns1.test',
|
||||
primaryNodeIpAddress: '',
|
||||
ignoreCertificateErrors: 'false',
|
||||
primaryNodeUsername: 'admin',
|
||||
primaryNodePassword: 'x',
|
||||
primaryNodeTotp: '',
|
||||
})
|
||||
expect(spy.mock.calls[0][0]).toBe('admin/cluster/initJoin')
|
||||
expect(spy.mock.calls[0][1]?.method).toBe('POST')
|
||||
expect(Object.keys(spy.mock.calls[0][1]?.body ?? {})).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cluster: actions on nodes', () => {
|
||||
it('each action goes to its endpoint with its node', async () => {
|
||||
const spy = makeSpy()
|
||||
await updateIpAddress('tok', '10.0.0.9', 'ns1')
|
||||
await updatePrimaryNode('tok', 'https://p.test', '10.0.0.1', 'ns2')
|
||||
await removeSecondaryNode('tok', '7', 'ns1')
|
||||
await deleteSecondaryNode('tok', '7', 'ns1')
|
||||
await promoteToPrimary('tok', true, 'ns2')
|
||||
await leaveCluster('tok', false, 'ns2')
|
||||
await deleteCluster('tok', true, 'ns1')
|
||||
await resyncCluster('tok', 'ns2')
|
||||
await setClusterOptions(
|
||||
'tok',
|
||||
{
|
||||
heartbeatRefreshIntervalSeconds: '30',
|
||||
heartbeatRetryIntervalSeconds: '10',
|
||||
configRefreshIntervalSeconds: '900',
|
||||
configRetryIntervalSeconds: '60',
|
||||
},
|
||||
'ns1',
|
||||
)
|
||||
|
||||
expect(spy.mock.calls.map((c) => c[0])).toEqual([
|
||||
'admin/cluster/updateIpAddress',
|
||||
'admin/cluster/secondary/updatePrimary',
|
||||
'admin/cluster/primary/removeSecondary',
|
||||
'admin/cluster/primary/deleteSecondary',
|
||||
'admin/cluster/secondary/promote',
|
||||
'admin/cluster/secondary/leave',
|
||||
'admin/cluster/primary/delete',
|
||||
'admin/cluster/secondary/resync',
|
||||
'admin/cluster/primary/setOptions',
|
||||
])
|
||||
})
|
||||
|
||||
it('the flags travel as the strings `true` / `false`', async () => {
|
||||
const spy = makeSpy()
|
||||
await promoteToPrimary('tok', true, 'ns2')
|
||||
await leaveCluster('tok', false, 'ns2')
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'admin/cluster/secondary/promote')?.[1]?.body).toEqual({
|
||||
forceDeletePrimary: 'true',
|
||||
node: 'ns2',
|
||||
})
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'admin/cluster/secondary/leave')?.[1]?.body).toEqual({
|
||||
forceLeave: 'false',
|
||||
node: 'ns2',
|
||||
})
|
||||
})
|
||||
|
||||
it('`setOptions` sends the four intervals plus the node', async () => {
|
||||
const spy = makeSpy()
|
||||
await setClusterOptions(
|
||||
'tok',
|
||||
{
|
||||
heartbeatRefreshIntervalSeconds: '30',
|
||||
heartbeatRetryIntervalSeconds: '10',
|
||||
configRefreshIntervalSeconds: '900',
|
||||
configRetryIntervalSeconds: '60',
|
||||
},
|
||||
'ns1',
|
||||
)
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({
|
||||
heartbeatRefreshIntervalSeconds: '30',
|
||||
heartbeatRetryIntervalSeconds: '10',
|
||||
configRefreshIntervalSeconds: '900',
|
||||
configRetryIntervalSeconds: '60',
|
||||
node: 'ns1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('primaryNodeName', () => {
|
||||
const node = (name: string, type: string) => ({
|
||||
id: 1,
|
||||
name,
|
||||
url: 'https://x',
|
||||
ipAddresses: [],
|
||||
type,
|
||||
state: 'Connected',
|
||||
})
|
||||
|
||||
it('with no state it returns an empty string', () => {
|
||||
expect(primaryNodeName(null)).toBe('')
|
||||
})
|
||||
|
||||
it('with the cluster uninitialised it returns an empty string even if there are nodes', () => {
|
||||
const s = {
|
||||
version: '15.4',
|
||||
dnsServerDomain: 'x',
|
||||
clusterInitialized: false,
|
||||
clusterNodes: [node('ns1', 'Primary')],
|
||||
} as ClusterState
|
||||
expect(primaryNodeName(s)).toBe('')
|
||||
})
|
||||
|
||||
it('it returns the name of the primary node', () => {
|
||||
const s = {
|
||||
version: '15.4',
|
||||
dnsServerDomain: 'x',
|
||||
clusterInitialized: true,
|
||||
clusterNodes: [node('ns2', 'Secondary'), node('ns1', 'Primary')],
|
||||
} as ClusterState
|
||||
expect(primaryNodeName(s)).toBe('ns1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The twelve cluster endpoints (`cluster.js`). It is Administration's sixth
|
||||
sub-tab and goes in a file of its own because in upstream it does too.
|
||||
|
||||
Five things to keep in front of you:
|
||||
|
||||
1. **Half the response disappears when there is no cluster.**
|
||||
`WriteClusterState` (WebServiceClusterApi.cs:60-75) writes `clusterDomain`,
|
||||
the four intervals and `clusterNodes` ONLY if `clusterInitialized`. On a
|
||||
standalone server the response is three fields. Typing them as required is a
|
||||
guaranteed bug; verified live against an instance with no cluster.
|
||||
|
||||
2. **A node's dates are OMITTED when they hold `default`**, they do not arrive
|
||||
as `null`: `upSince`, `lastSeen` and `configLastSynced` (same file, lines
|
||||
137-152). And `configLastSynced` only exists for the node itself when it is
|
||||
also a secondary.
|
||||
|
||||
3. **The permissions are asymmetric and do not follow the verb.** Nearly
|
||||
everything asks for `Administration.canDelete` (including `init`, `initJoin`
|
||||
and `promote`), but `setOptions`, `resync`, `updatePrimary` and
|
||||
`updateIpAddress` ask for `canModify`, and `state` asks for `canView`.
|
||||
Looked at one by one in WebServiceClusterApi.cs.
|
||||
|
||||
4. **`secondary/resync` returns no state.** It is the only one that does not
|
||||
call `WriteClusterState`: it answers a bare `ok` and the result is checked
|
||||
in the Logs.
|
||||
|
||||
5. **`initJoin` can answer `2fa-required`.** It is the only one of the family
|
||||
that does, because it authenticates against the primary node with a username
|
||||
and password. `apiRequest` already translates it to `two-factor-required`.
|
||||
|
||||
`node` is the cluster node the request is aimed at (DnsWebService.cs:2367); empty
|
||||
means "this server". They all send it except `init` and `initJoin`, which by
|
||||
definition run on the server being looked at.
|
||||
*/
|
||||
|
||||
export type ClusterNodeType = 'Primary' | 'Secondary' | (string & {})
|
||||
export type ClusterNodeState = 'Self' | 'Connected' | 'Unreachable' | (string & {})
|
||||
|
||||
export interface ClusterNode {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
ipAddresses: string[]
|
||||
type: ClusterNodeType
|
||||
state: ClusterNodeState
|
||||
/** New in v15.6. Always present for the node itself; for the others, only
|
||||
* once a heartbeat has brought it (WebServiceClusterApi.cs:138-150). */
|
||||
version?: string
|
||||
/** Omitted when it has never been up. */
|
||||
upSince?: string
|
||||
/** Omitted for the node itself and if it has never been seen. */
|
||||
lastSeen?: string
|
||||
/** Only for the node itself when it is a secondary, and only once it has synced. */
|
||||
configLastSynced?: string
|
||||
}
|
||||
|
||||
export interface ClusterState {
|
||||
version: string
|
||||
dnsServerDomain: string
|
||||
clusterInitialized: boolean
|
||||
/* The next five ONLY exist with the cluster initialised. */
|
||||
clusterDomain?: string
|
||||
heartbeatRefreshIntervalSeconds?: number
|
||||
heartbeatRetryIntervalSeconds?: number
|
||||
configRefreshIntervalSeconds?: number
|
||||
configRetryIntervalSeconds?: number
|
||||
clusterNodes?: ClusterNode[]
|
||||
/** Only with `includeServerIpAddresses=true`. */
|
||||
serverIpAddresses?: string[]
|
||||
}
|
||||
|
||||
type Env<T> = { response: T; server: string }
|
||||
export type ClusterOutcome = ApiOutcome<Env<ClusterState>>
|
||||
|
||||
export function getClusterState(
|
||||
token: string | null,
|
||||
opts: { node?: string; includeServerIpAddresses?: boolean } = {},
|
||||
): Promise<ClusterOutcome> {
|
||||
const body: Record<string, string> = {}
|
||||
if (opts.includeServerIpAddresses) body.includeServerIpAddresses = 'true'
|
||||
if (opts.node !== undefined) body.node = opts.node
|
||||
return apiRequest('admin/cluster/state', { token, body })
|
||||
}
|
||||
|
||||
/** `initializeNewCluster` (cluster.js:592). No `node`: it initialises here. */
|
||||
export function initCluster(
|
||||
token: string | null,
|
||||
clusterDomain: string,
|
||||
primaryNodeIpAddresses: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/init', {
|
||||
token,
|
||||
body: { clusterDomain, primaryNodeIpAddresses },
|
||||
})
|
||||
}
|
||||
|
||||
/** `initializeJoinCluster` (cluster.js:692). POST: it carries the primary node
|
||||
* administrator's password. It can answer `2fa-required`. */
|
||||
export function initJoinCluster(
|
||||
token: string | null,
|
||||
body: {
|
||||
secondaryNodeIpAddresses: string
|
||||
primaryNodeUrl: string
|
||||
primaryNodeIpAddress: string
|
||||
ignoreCertificateErrors: string
|
||||
primaryNodeUsername: string
|
||||
primaryNodePassword: string
|
||||
primaryNodeTotp: string
|
||||
},
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/initJoin', { token, method: 'POST', body })
|
||||
}
|
||||
|
||||
/** `updateSelfClusterNode` (cluster.js:326). */
|
||||
export function updateIpAddress(
|
||||
token: string | null,
|
||||
ipAddresses: string,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/updateIpAddress', { token, body: { ipAddresses, node } })
|
||||
}
|
||||
|
||||
/** `saveClusterOptions` (cluster.js:874). Only the primary can save it. */
|
||||
export function setClusterOptions(
|
||||
token: string | null,
|
||||
options: {
|
||||
heartbeatRefreshIntervalSeconds: string
|
||||
heartbeatRetryIntervalSeconds: string
|
||||
configRefreshIntervalSeconds: string
|
||||
configRetryIntervalSeconds: string
|
||||
},
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/primary/setOptions', { token, body: { ...options, node } })
|
||||
}
|
||||
|
||||
/** `deleteCluster` (cluster.js:975). */
|
||||
export function deleteCluster(
|
||||
token: string | null,
|
||||
forceDelete: boolean,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/primary/delete', {
|
||||
token,
|
||||
body: { forceDelete: String(forceDelete), node },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
`removeSecondaryClusterNode` (cluster.js:437) picks the endpoint by the "Force
|
||||
Remove Node" checkbox: checked, it deletes the secondary without telling it;
|
||||
unchecked, it asks it to leave. They are two different endpoints with the same
|
||||
body.
|
||||
*/
|
||||
export function deleteSecondaryNode(
|
||||
token: string | null,
|
||||
secondaryNodeId: string,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/primary/deleteSecondary', {
|
||||
token,
|
||||
body: { secondaryNodeId, node },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeSecondaryNode(
|
||||
token: string | null,
|
||||
secondaryNodeId: string,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/primary/removeSecondary', {
|
||||
token,
|
||||
body: { secondaryNodeId, node },
|
||||
})
|
||||
}
|
||||
|
||||
/** `leaveCluster` (cluster.js:930). */
|
||||
export function leaveCluster(
|
||||
token: string | null,
|
||||
forceLeave: boolean,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/secondary/leave', {
|
||||
token,
|
||||
body: { forceLeave: String(forceLeave), node },
|
||||
})
|
||||
}
|
||||
|
||||
/** `promoteToPrimaryClusterNode` (cluster.js:512). */
|
||||
export function promoteToPrimary(
|
||||
token: string | null,
|
||||
forceDeletePrimary: boolean,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/secondary/promote', {
|
||||
token,
|
||||
body: { forceDeletePrimary: String(forceDeletePrimary), node },
|
||||
})
|
||||
}
|
||||
|
||||
/** `resyncCluster` (cluster.js:761). The ONLY one that does not return the state. */
|
||||
export function resyncCluster(token: string | null, node: string): Promise<ApiOutcome> {
|
||||
return apiRequest('admin/cluster/secondary/resync', { token, body: { node } })
|
||||
}
|
||||
|
||||
/** `updatePrimaryClusterNode` (cluster.js:390). */
|
||||
export function updatePrimaryNode(
|
||||
token: string | null,
|
||||
primaryNodeUrl: string,
|
||||
primaryNodeIpAddresses: string,
|
||||
node: string,
|
||||
): Promise<ClusterOutcome> {
|
||||
return apiRequest('admin/cluster/secondary/updatePrimary', {
|
||||
token,
|
||||
body: { primaryNodeUrl, primaryNodeIpAddresses, node },
|
||||
})
|
||||
}
|
||||
|
||||
/** `getPrimaryClusterNodeName` (cluster.js:1013): the primary node's name, or an
|
||||
* empty string if the cluster is not initialised. It governs the `node` of
|
||||
* `permissions/set` and the one of `sessions/delete` for API tokens. */
|
||||
export function primaryNodeName(state: ClusterState | null): string {
|
||||
if (!state?.clusterInitialized) return ''
|
||||
return state.clusterNodes?.find((n) => n.type === 'Primary')?.name ?? ''
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as client from './client'
|
||||
import {
|
||||
createApiToken,
|
||||
createGroup,
|
||||
createUser,
|
||||
deleteAdminSession,
|
||||
deleteGroup,
|
||||
deleteUser,
|
||||
getGroup,
|
||||
getLdapConfig,
|
||||
getPermission,
|
||||
getSsoConfig,
|
||||
getUser,
|
||||
listGroups,
|
||||
listPermissions,
|
||||
listSessions,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
setGroup,
|
||||
setLdapConfig,
|
||||
setPermissions,
|
||||
setSsoConfig,
|
||||
setUser,
|
||||
testLdapConnection,
|
||||
} from './admin'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
/** A spy over `apiRequest` that always returns `ok` with an empty payload. */
|
||||
function makeSpy() {
|
||||
return vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue({ kind: 'ok', data: { response: {}, server: 's' } })
|
||||
}
|
||||
|
||||
describe('admin: sessions', () => {
|
||||
it('lists sending the cluster node', async () => {
|
||||
const spy = makeSpy()
|
||||
await listSessions('tok', 'ns1.test')
|
||||
expect(spy).toHaveBeenCalledWith('admin/sessions/list', {
|
||||
token: 'tok',
|
||||
body: { node: 'ns1.test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('with no cluster it sends the node as an empty string, it does not omit it', async () => {
|
||||
const spy = makeSpy()
|
||||
await listSessions('tok')
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'admin/sessions/list')?.[1]).toEqual({
|
||||
token: 'tok',
|
||||
body: { node: '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an API token with a user and a name', async () => {
|
||||
const spy = makeSpy()
|
||||
await createApiToken('tok', 'adrian', 'orbiter')
|
||||
expect(spy).toHaveBeenCalledWith('admin/sessions/createToken', {
|
||||
token: 'tok',
|
||||
body: { user: 'adrian', tokenName: 'orbiter' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deleting WITHOUT a node does not send the parameter: that is what the details modal does', async () => {
|
||||
const spy = makeSpy()
|
||||
await deleteAdminSession('tok', 'abcd')
|
||||
expect(spy).toHaveBeenCalledWith('admin/sessions/delete', {
|
||||
token: 'tok',
|
||||
body: { partialToken: 'abcd' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deleting with an empty node DOES send the parameter: that is what the tab does', async () => {
|
||||
const spy = makeSpy()
|
||||
await deleteAdminSession('tok', 'abcd', '')
|
||||
expect(spy).toHaveBeenCalledWith('admin/sessions/delete', {
|
||||
token: 'tok',
|
||||
body: { partialToken: 'abcd', node: '' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin: users', () => {
|
||||
it('lists with no parameters', async () => {
|
||||
const spy = makeSpy()
|
||||
await listUsers('tok')
|
||||
expect(spy).toHaveBeenCalledWith('admin/users/list', { token: 'tok' })
|
||||
})
|
||||
|
||||
it('creates by POST, which is what upstream does since it carries a password', async () => {
|
||||
const spy = makeSpy()
|
||||
await createUser('tok', 'Adrián', 'adrian', 's3cr3t')
|
||||
expect(spy).toHaveBeenCalledWith('admin/users/create', {
|
||||
token: 'tok',
|
||||
method: 'POST',
|
||||
body: { displayName: 'Adrián', user: 'adrian', pass: 's3cr3t' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reads asking for the group list as well', async () => {
|
||||
const spy = makeSpy()
|
||||
await getUser('tok', 'adrian')
|
||||
expect(spy).toHaveBeenCalledWith('admin/users/get', {
|
||||
token: 'tok',
|
||||
body: { user: 'adrian', includeGroups: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
it('the body of `set` is open: only what you want to change travels', async () => {
|
||||
const spy = makeSpy()
|
||||
await setUser('tok', { user: 'adrian', disabled: 'true' })
|
||||
expect(spy).toHaveBeenCalledWith('admin/users/set', {
|
||||
token: 'tok',
|
||||
body: { user: 'adrian', disabled: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
it('resetting the password uses the same endpoint but by POST', async () => {
|
||||
const spy = makeSpy()
|
||||
await resetUserPassword('tok', 'adrian', 'new')
|
||||
expect(spy).toHaveBeenCalledWith('admin/users/set', {
|
||||
token: 'tok',
|
||||
method: 'POST',
|
||||
body: { user: 'adrian', newPass: 'new' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes by name', async () => {
|
||||
const spy = makeSpy()
|
||||
await deleteUser('tok', 'adrian')
|
||||
expect(spy).toHaveBeenCalledWith('admin/users/delete', { token: 'tok', body: { user: 'adrian' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin: groups', () => {
|
||||
it('lists, creates, reads and deletes', async () => {
|
||||
const spy = makeSpy()
|
||||
await listGroups('tok')
|
||||
await createGroup('tok', 'Ops', 'the on-call team')
|
||||
await getGroup('tok', 'Ops')
|
||||
await deleteGroup('tok', 'Ops')
|
||||
|
||||
expect(spy.mock.calls.map((c) => c[0])).toEqual([
|
||||
'admin/groups/list',
|
||||
'admin/groups/create',
|
||||
'admin/groups/get',
|
||||
'admin/groups/delete',
|
||||
])
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'admin/groups/create')?.[1]).toEqual({
|
||||
token: 'tok',
|
||||
body: { group: 'Ops', description: 'the on-call team' },
|
||||
})
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'admin/groups/get')?.[1]).toEqual({
|
||||
token: 'tok',
|
||||
body: { group: 'Ops', includeUsers: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
it('`newGroup` does NOT travel if the name did not change', async () => {
|
||||
const spy = makeSpy()
|
||||
await setGroup('tok', 'Ops', 'desc', 'a,b')
|
||||
expect(spy).toHaveBeenCalledWith('admin/groups/set', {
|
||||
token: 'tok',
|
||||
body: { group: 'Ops', description: 'desc', members: 'a,b' },
|
||||
})
|
||||
})
|
||||
|
||||
it('`newGroup` travels when the name changed', async () => {
|
||||
const spy = makeSpy()
|
||||
await setGroup('tok', 'Ops', 'desc', 'a,b', 'Ops2')
|
||||
expect(spy.mock.calls[0][1]).toEqual({
|
||||
token: 'tok',
|
||||
body: { group: 'Ops', description: 'desc', members: 'a,b', newGroup: 'Ops2' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin: permissions', () => {
|
||||
it('reads asking for users and groups', async () => {
|
||||
const spy = makeSpy()
|
||||
await getPermission('tok', 'Zones')
|
||||
expect(spy).toHaveBeenCalledWith('admin/permissions/get', {
|
||||
token: 'tok',
|
||||
body: { section: 'Zones', includeUsersAndGroups: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
it('saves both tables serialised and the PRIMARY node of the cluster', async () => {
|
||||
const spy = makeSpy()
|
||||
await listPermissions('tok')
|
||||
await setPermissions('tok', 'Zones', 'ana|true|false|false', 'Ops|true|true|true', 'ns1.test')
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'admin/permissions/set')?.[1]).toEqual({
|
||||
token: 'tok',
|
||||
body: {
|
||||
section: 'Zones',
|
||||
userPermissions: 'ana|true|false|false',
|
||||
groupPermissions: 'Ops|true|true|true',
|
||||
node: 'ns1.test',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin: SSO', () => {
|
||||
it('reads asking for the local groups', async () => {
|
||||
const spy = makeSpy()
|
||||
await getSsoConfig('tok')
|
||||
expect(spy).toHaveBeenCalledWith('admin/sso/get', {
|
||||
token: 'tok',
|
||||
body: { includeGroups: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
it('saves by POST', async () => {
|
||||
const spy = makeSpy()
|
||||
await setSsoConfig('tok', { ssoEnabled: 'false' })
|
||||
expect(spy).toHaveBeenCalledWith('admin/sso/set', {
|
||||
token: 'tok',
|
||||
method: 'POST',
|
||||
body: { ssoEnabled: 'false' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin: LDAP', () => {
|
||||
it('reads asking for the local groups', async () => {
|
||||
const spy = makeSpy()
|
||||
await getLdapConfig('tok')
|
||||
expect(spy).toHaveBeenCalledWith('admin/ldap/get', {
|
||||
token: 'tok',
|
||||
body: { includeGroups: 'true' },
|
||||
})
|
||||
})
|
||||
|
||||
it('saves by POST', async () => {
|
||||
const spy = makeSpy()
|
||||
await setLdapConfig('tok', { ldapEnabled: 'false' })
|
||||
expect(spy).toHaveBeenCalledWith('admin/ldap/set', {
|
||||
token: 'tok',
|
||||
method: 'POST',
|
||||
body: { ldapEnabled: 'false' },
|
||||
})
|
||||
})
|
||||
|
||||
it('tests the connection by POST', async () => {
|
||||
const spy = makeSpy()
|
||||
await testLdapConnection('tok', { ldapServer: 'ldap.test' })
|
||||
expect(spy).toHaveBeenCalledWith('admin/ldap/test', {
|
||||
token: 'tok',
|
||||
method: 'POST',
|
||||
body: { ldapServer: 'ldap.test' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,417 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The `admin` family without the cluster: sessions, users, groups, permissions,
|
||||
SSO and LDAP. Twenty-one endpoints, all of them in `auth.js`. The cluster (twelve more)
|
||||
lives in `admin-cluster.ts` because it is another screen and another upstream
|
||||
file.
|
||||
|
||||
Six things about the server that are NOT deducible from reading upstream's
|
||||
JavaScript and that govern the screens:
|
||||
|
||||
1. **The permissions are asymmetric.** All of this hangs off the
|
||||
`Administration` section, but not off the same flag: listing and reading ask
|
||||
for `View`, creating and modifying ask for `Modify`, deleting asks for
|
||||
`Delete` … and `permissions/set` and `sso/set` ask for **`Delete`**, not
|
||||
`Modify` (WebServiceAuthApi.cs:1533 and 1692). Whoever draws the interface
|
||||
cannot deduce the permission from the name of the verb.
|
||||
|
||||
2. **`admin/sso/set` does NOT return `localGroups`.** `WriteSsoConfig` only
|
||||
writes them with `includeGroups`, and the `set` calls it with `false`
|
||||
(WebServiceAuthApi.cs:1790). Upstream survives because it keeps the list in
|
||||
a global variable when doing the `get`; here it has to be kept just the same.
|
||||
|
||||
3. **The client secret travels masked.** `WriteSsoConfig` returns
|
||||
`"************"` as soon as one is stored, and `SetSsoConfig` ignores that
|
||||
exact value (lines 339-342 and 1738). That is what allows saving the form
|
||||
without typing the secret again: it is sent back exactly as it arrived.
|
||||
|
||||
4. **Each endpoint returns a different shape of the same user.**
|
||||
`users/get?includeGroups=true` brings `groups` (every group on the server,
|
||||
for the dropdown); `users/set` does NOT bring it even though it does bring
|
||||
`memberOfGroups` and `sessions`; `users/list` and `users/create` bring none
|
||||
of the three. Verified against a v15.4 instance.
|
||||
|
||||
5. **The group list of `permissions/get` is NOT the one of `groups/list`.**
|
||||
The first includes `Everyone` and the second does not. Checked live.
|
||||
|
||||
6. **Deleting a session that does not exist DOES error** ("No such active
|
||||
session was found for partial token: …"), the opposite of most of the
|
||||
deletes in this API. Checked live.
|
||||
|
||||
`node` is the cluster node the request is aimed at (DnsWebService.cs:2367); empty
|
||||
means "this server". Only `sessions/list`, `sessions/delete` and
|
||||
`permissions/set` send it, and not always the same value: see the comments on
|
||||
each function.
|
||||
*/
|
||||
|
||||
export interface AdminSession {
|
||||
username: string
|
||||
isCurrentSession: boolean
|
||||
partialToken: string
|
||||
/** `Standard`, `ApiToken`, `ClusterApiToken` … anything else draws "Unknown". */
|
||||
type: string
|
||||
tokenName: string | null
|
||||
lastSeen: string
|
||||
lastSeenRemoteAddress: string
|
||||
lastSeenUserAgent: string
|
||||
}
|
||||
|
||||
/*
|
||||
`Local`, `RemoteSSO` or `RemoteLDAP` since v15.5 (`user.Type.ToString()`,
|
||||
WebServiceAuthApi.cs:137). Typed as `string` on purpose: upstream's `default:`
|
||||
branch writes whatever arrives as the label (auth.js:1164-1166), so an unknown
|
||||
value is drawn, not rejected.
|
||||
*/
|
||||
export type UserType = 'Local' | 'RemoteSSO' | 'RemoteLDAP' | (string & {})
|
||||
|
||||
export interface AdminUser {
|
||||
displayName: string
|
||||
username: string
|
||||
type: UserType
|
||||
/** Obsolete since v15.5 (WebServiceAuthApi.cs:138): still sent, no longer read. */
|
||||
isSsoUser: boolean
|
||||
/** OMITTED for `RemoteSSO` users: the server only writes it for `Local` and
|
||||
* `RemoteLDAP` (WebServiceAuthApi.cs:140-146). */
|
||||
totpEnabled?: boolean
|
||||
disabled: boolean
|
||||
previousSessionLoggedOn: string
|
||||
previousSessionRemoteAddress: string
|
||||
recentSessionLoggedOn: string
|
||||
recentSessionRemoteAddress: string
|
||||
}
|
||||
|
||||
export interface AdminUserDetails extends AdminUser {
|
||||
sessionTimeoutSeconds: number
|
||||
/** v15.5: written for every user type (WebServiceAuthApi.cs:158-172); `false`
|
||||
* for `Local`. It replaces `ssoManagedGroups`. */
|
||||
remotelyManagedGroups: boolean
|
||||
/** Obsolete, and since v15.5 only written for `RemoteSSO` users (line 161). */
|
||||
ssoManagedGroups?: boolean
|
||||
memberOfGroups: string[]
|
||||
sessions: AdminSession[]
|
||||
/** Only with `includeGroups=true`: EVERY group on the server. */
|
||||
groups?: string[]
|
||||
}
|
||||
|
||||
export interface AdminGroup {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface AdminGroupDetails extends AdminGroup {
|
||||
members: string[]
|
||||
/** Only with `includeUsers=true`: EVERY user on the server. */
|
||||
users?: string[]
|
||||
}
|
||||
|
||||
export interface UserPermission {
|
||||
username: string
|
||||
canView: boolean
|
||||
canModify: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface GroupPermission {
|
||||
name: string
|
||||
canView: boolean
|
||||
canModify: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface SectionPermission {
|
||||
section: string
|
||||
userPermissions: UserPermission[]
|
||||
groupPermissions: GroupPermission[]
|
||||
}
|
||||
|
||||
export interface SectionPermissionDetails extends SectionPermission {
|
||||
/** Only with `includeUsersAndGroups=true`. */
|
||||
users?: string[]
|
||||
/** Only with `includeUsersAndGroups=true`. Includes `Everyone`. */
|
||||
groups?: string[]
|
||||
}
|
||||
|
||||
export interface SsoGroupMapEntry {
|
||||
remoteGroup: string
|
||||
localGroup: string
|
||||
}
|
||||
|
||||
export interface SsoConfig {
|
||||
ssoEnabled: boolean
|
||||
ssoAuthority: string | null
|
||||
ssoClientId: string | null
|
||||
/** `"************"` when one is stored; `null` when not. */
|
||||
ssoClientSecret: string | null
|
||||
ssoMetadataAddress: string | null
|
||||
ssoScopes: string[]
|
||||
ssoAllowSignup: boolean
|
||||
ssoAllowSignupOnlyForMappedUsers: boolean
|
||||
ssoGroupMap: SsoGroupMapEntry[]
|
||||
/** Only in `sso/get?includeGroups=true`. Excludes `Everyone`. */
|
||||
localGroups?: string[]
|
||||
}
|
||||
|
||||
export interface LdapConfig {
|
||||
ldapEnabled: boolean
|
||||
/** `null` on a fresh install, like the four other strings below. */
|
||||
ldapServer: string | null
|
||||
ldapPort: number
|
||||
/** `None`, `StartTLS` or `LDAPS` (`LdapAuthSslOption.ToString()`). */
|
||||
ldapSslOption: string
|
||||
ldapIgnoreSslErrors: boolean
|
||||
ldapBindUsername: string | null
|
||||
/** `"************"` when one is stored; `null` when not (WebServiceAuthApi.cs:471-474). */
|
||||
ldapBindPassword: string | null
|
||||
ldapSearchBase: string | null
|
||||
ldapUserSearchFilter: string | null
|
||||
ldapGroupAttribute: string | null
|
||||
ldapAllowSignup: boolean
|
||||
ldapAllowSignupOnlyForMappedUsers: boolean
|
||||
ldapGroupMap: SsoGroupMapEntry[]
|
||||
/** Only in `ldap/get?includeGroups=true`. Excludes `Everyone` (line 509). */
|
||||
localGroups?: string[]
|
||||
}
|
||||
|
||||
export interface CreatedApiToken {
|
||||
username: string
|
||||
tokenName: string
|
||||
token: string
|
||||
}
|
||||
|
||||
type Env<T> = { response: T; server: string }
|
||||
|
||||
/* --------------------------------------------------------------- sessions */
|
||||
|
||||
/** `refreshAdminSessions` (auth.js:856). The `server` of the envelope is needed:
|
||||
* the "Create Token" button only shows if this server is the primary node. */
|
||||
export function listSessions(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<Env<{ sessions: AdminSession[] }>>> {
|
||||
return apiRequest('admin/sessions/list', { token, body: { node } })
|
||||
}
|
||||
|
||||
/** `createApiToken` (auth.js:988). Permission `Administration.canModify`. */
|
||||
export function createApiToken(
|
||||
token: string | null,
|
||||
user: string,
|
||||
tokenName: string,
|
||||
): Promise<ApiOutcome<Env<CreatedApiToken>>> {
|
||||
return apiRequest('admin/sessions/createToken', { token, body: { user, tokenName } })
|
||||
}
|
||||
|
||||
/*
|
||||
`deleteAdminSession` (auth.js:1040) and `deleteUserSession` (auth.js:1371) delete
|
||||
the same thing with DIFFERENT URLs, and it is not an oversight: from the Sessions
|
||||
tab a `node` always travels (the primary if the session is an API token, the
|
||||
chosen node in any other case), and from the user's details modal the `node`
|
||||
travels ONLY if the session is an API token. That is why `node` is optional here:
|
||||
`undefined` means "do not send the parameter".
|
||||
*/
|
||||
export function deleteAdminSession(
|
||||
token: string | null,
|
||||
partialToken: string,
|
||||
node?: string,
|
||||
): Promise<ApiOutcome> {
|
||||
const body: Record<string, string> = { partialToken }
|
||||
if (node !== undefined) body.node = node
|
||||
return apiRequest('admin/sessions/delete', { token, body })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ users */
|
||||
|
||||
export function listUsers(
|
||||
token: string | null,
|
||||
): Promise<ApiOutcome<Env<{ users: AdminUser[] }>>> {
|
||||
return apiRequest('admin/users/list', { token })
|
||||
}
|
||||
|
||||
/*
|
||||
`addUser` (auth.js:1178). Upstream sends it by POST because it carries a password.
|
||||
|
||||
`displayName` is optional and in fact the form does not validate it: if it goes
|
||||
empty, the server returns the username as the display name. Checked live.
|
||||
*/
|
||||
export function createUser(
|
||||
token: string | null,
|
||||
displayName: string,
|
||||
user: string,
|
||||
pass: string,
|
||||
): Promise<ApiOutcome<Env<AdminUser>>> {
|
||||
return apiRequest('admin/users/create', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body: { displayName, user, pass },
|
||||
})
|
||||
}
|
||||
|
||||
export function getUser(
|
||||
token: string | null,
|
||||
user: string,
|
||||
): Promise<ApiOutcome<Env<AdminUserDetails>>> {
|
||||
return apiRequest('admin/users/get', { token, body: { user, includeGroups: 'true' } })
|
||||
}
|
||||
|
||||
/*
|
||||
`admin/users/set` is a partial endpoint: the server only touches what arrives
|
||||
(`TryGetQueryOrForm`, WebServiceAuthApi.cs:1065-1225). Upstream leans on that for
|
||||
five different actions through the same endpoint (save the modal, enable,
|
||||
disable, clear the 2FA and reset the password), sending only the fields of that
|
||||
action each time. Hence the open body here.
|
||||
*/
|
||||
export function setUser(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
): Promise<ApiOutcome<Env<AdminUserDetails>>> {
|
||||
return apiRequest('admin/users/set', { token, body })
|
||||
}
|
||||
|
||||
/** `resetUserPassword` (auth.js:1572). By POST, since it carries the new password. */
|
||||
export function resetUserPassword(
|
||||
token: string | null,
|
||||
user: string,
|
||||
newPass: string,
|
||||
): Promise<ApiOutcome<Env<AdminUserDetails>>> {
|
||||
return apiRequest('admin/users/set', { token, method: 'POST', body: { user, newPass } })
|
||||
}
|
||||
|
||||
export function deleteUser(token: string | null, user: string): Promise<ApiOutcome> {
|
||||
return apiRequest('admin/users/delete', { token, body: { user } })
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- groups */
|
||||
|
||||
export function listGroups(
|
||||
token: string | null,
|
||||
): Promise<ApiOutcome<Env<{ groups: AdminGroup[] }>>> {
|
||||
return apiRequest('admin/groups/list', { token })
|
||||
}
|
||||
|
||||
export function createGroup(
|
||||
token: string | null,
|
||||
group: string,
|
||||
description: string,
|
||||
): Promise<ApiOutcome<Env<AdminGroup>>> {
|
||||
return apiRequest('admin/groups/create', { token, body: { group, description } })
|
||||
}
|
||||
|
||||
export function getGroup(
|
||||
token: string | null,
|
||||
group: string,
|
||||
): Promise<ApiOutcome<Env<AdminGroupDetails>>> {
|
||||
return apiRequest('admin/groups/get', { token, body: { group, includeUsers: 'true' } })
|
||||
}
|
||||
|
||||
/*
|
||||
`saveGroupDetails` (auth.js:1860). `newGroup` only travels if the name changed:
|
||||
always sending it would make the server rename the group to itself.
|
||||
|
||||
And the response is NOT the same as `groups/create`'s: this one brings `members`
|
||||
and that one does not. Checked live against a v15.4.
|
||||
*/
|
||||
export function setGroup(
|
||||
token: string | null,
|
||||
group: string,
|
||||
description: string,
|
||||
members: string,
|
||||
newGroup?: string,
|
||||
): Promise<ApiOutcome<Env<AdminGroupDetails>>> {
|
||||
const body: Record<string, string> = { group, description, members }
|
||||
if (newGroup !== undefined) body.newGroup = newGroup
|
||||
return apiRequest('admin/groups/set', { token, body })
|
||||
}
|
||||
|
||||
export function deleteGroup(token: string | null, group: string): Promise<ApiOutcome> {
|
||||
return apiRequest('admin/groups/delete', { token, body: { group } })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ permissions */
|
||||
|
||||
export function listPermissions(
|
||||
token: string | null,
|
||||
): Promise<ApiOutcome<Env<{ permissions: SectionPermission[] }>>> {
|
||||
return apiRequest('admin/permissions/list', { token })
|
||||
}
|
||||
|
||||
export function getPermission(
|
||||
token: string | null,
|
||||
section: string,
|
||||
): Promise<ApiOutcome<Env<SectionPermissionDetails>>> {
|
||||
return apiRequest('admin/permissions/get', {
|
||||
token,
|
||||
body: { section, includeUsersAndGroups: 'true' },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
`saveSectionPermissions` (auth.js:2114). Both tables travel serialised with `|`
|
||||
as the ONLY separator (between columns as well as between rows), which is what
|
||||
`serializeTableData` does (common.js:282). And the `node` is NOT the node chosen
|
||||
on the screen: it is always the cluster's PRIMARY node, or an empty string if
|
||||
there is no cluster.
|
||||
*/
|
||||
export function setPermissions(
|
||||
token: string | null,
|
||||
section: string,
|
||||
userPermissions: string,
|
||||
groupPermissions: string,
|
||||
node: string,
|
||||
): Promise<ApiOutcome<Env<SectionPermission>>> {
|
||||
return apiRequest('admin/permissions/set', {
|
||||
token,
|
||||
body: { section, userPermissions, groupPermissions, node },
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- SSO */
|
||||
|
||||
export function getSsoConfig(token: string | null): Promise<ApiOutcome<Env<SsoConfig>>> {
|
||||
return apiRequest('admin/sso/get', { token, body: { includeGroups: 'true' } })
|
||||
}
|
||||
|
||||
/** `saveAdminSsoConfig` (auth.js:2233). By POST because it carries the secret. */
|
||||
export function setSsoConfig(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
): Promise<ApiOutcome<Env<SsoConfig>>> {
|
||||
return apiRequest('admin/sso/set', { token, method: 'POST', body })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- LDAP */
|
||||
|
||||
/*
|
||||
The three LDAP endpoints, new in v15.5 (WebServiceAuthApi.cs:2059-2164). The same
|
||||
two server facts as SSO govern them:
|
||||
|
||||
· `ldap/set` does NOT return `localGroups`: it calls `WriteLdapConfig` with
|
||||
`includeGroups: false` (line 2136). Checked live against v15.5.1.
|
||||
· The bind password travels masked as `"************"` and `SetLdapConfig`
|
||||
ignores that exact value (line 2101); `ldap/test` swaps it for the stored one
|
||||
(line 2159).
|
||||
|
||||
Permissions, asymmetric like SSO's: `get` and `test` ask for
|
||||
`Administration.canView`, `set` asks for `Administration.canDelete` (lines 2063,
|
||||
2143 and 2076). The screen gates none of them: Administration filters nothing.
|
||||
*/
|
||||
|
||||
/** `refreshAdminLdapConfig` (auth.js:2379-2402). */
|
||||
export function getLdapConfig(token: string | null): Promise<ApiOutcome<Env<LdapConfig>>> {
|
||||
return apiRequest('admin/ldap/get', { token, body: { includeGroups: 'true' } })
|
||||
}
|
||||
|
||||
/** `saveAdminLdapConfig` (auth.js:2460-2524). By POST: it carries the bind password. */
|
||||
export function setLdapConfig(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
): Promise<ApiOutcome<Env<LdapConfig>>> {
|
||||
return apiRequest('admin/ldap/set', { token, method: 'POST', body })
|
||||
}
|
||||
|
||||
/** `testAdminLdapConnection` (auth.js:2526-2570). By POST as well. */
|
||||
export function testLdapConnection(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('admin/ldap/test', { token, method: 'POST', body })
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
buildUpload,
|
||||
downloadAndInstall,
|
||||
downloadAndUpdate,
|
||||
dnsAppLabels,
|
||||
getAppConfig,
|
||||
installApp,
|
||||
listApps,
|
||||
listStoreApps,
|
||||
setAppConfig,
|
||||
uninstallApp,
|
||||
updateApp,
|
||||
} from './apps'
|
||||
import * as client from './client'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function makeSpy() {
|
||||
return vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
}
|
||||
|
||||
describe('apps: read endpoints', () => {
|
||||
it('it lists the installed ones through `apps/list`, with the cluster node', async () => {
|
||||
// upstream sends `node` here too (zone.js:4440, when loading the record
|
||||
// modal's app names). With a single instance it goes empty.
|
||||
const spy = makeSpy()
|
||||
await listApps('t')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/list')
|
||||
expect(call).toBeDefined()
|
||||
expect(call![1]?.body).toEqual({ node: '' })
|
||||
})
|
||||
|
||||
it('it lists the store through `apps/listStoreApps`', async () => {
|
||||
const spy = makeSpy()
|
||||
await listStoreApps('t')
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'apps/listStoreApps')).toBeDefined()
|
||||
})
|
||||
|
||||
/*
|
||||
The `node` is the cluster's primary node: upstream ALWAYS reads the config from
|
||||
the primary so as not to read an unpropagated copy (apps.js:460). Without a
|
||||
cluster, what gets sent is the empty string, and the server ignores it.
|
||||
*/
|
||||
it('it asks for the config with `name` and with `node`, empty by default', async () => {
|
||||
const spy = makeSpy()
|
||||
await getAppConfig('t', 'NO DATA')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/config/get')
|
||||
expect(call![1]?.body).toEqual({ name: 'NO DATA', node: '' })
|
||||
})
|
||||
|
||||
it('it honours the node when one is passed', async () => {
|
||||
const spy = makeSpy()
|
||||
await getAppConfig('t', 'NO DATA', 'primary')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/config/get')
|
||||
expect(call![1]?.body?.node).toBe('primary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apps: write endpoints', () => {
|
||||
it('it installs from the store with name and url', async () => {
|
||||
const spy = makeSpy()
|
||||
await downloadAndInstall('t', 'NO DATA', 'https://x/y.zip')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/downloadAndInstall')
|
||||
expect(call![1]?.body).toEqual({ name: 'NO DATA', url: 'https://x/y.zip' })
|
||||
})
|
||||
|
||||
it('it updates from the store with name and url', async () => {
|
||||
const spy = makeSpy()
|
||||
await downloadAndUpdate('t', 'NO DATA', 'https://x/y.zip')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/downloadAndUpdate')
|
||||
expect(call![1]?.body).toEqual({ name: 'NO DATA', url: 'https://x/y.zip' })
|
||||
})
|
||||
|
||||
it('it uninstalls with the name', async () => {
|
||||
const spy = makeSpy()
|
||||
await uninstallApp('t', 'NO DATA')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/uninstall')
|
||||
expect(call![1]?.body).toEqual({ name: 'NO DATA' })
|
||||
})
|
||||
|
||||
it('it saves the config by POST', async () => {
|
||||
const spy = makeSpy()
|
||||
await setAppConfig('t', 'NO DATA', '{"a":1}')
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'apps/config/set')
|
||||
expect(call![1]?.method).toBe('POST')
|
||||
expect(call![1]?.body).toEqual({ name: 'NO DATA', config: '{"a":1}' })
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
Multipart upload. The client does not support it yet, so what is checked here is
|
||||
only what CAN be checked: the path and the shape of the FormData.
|
||||
*/
|
||||
describe('apps: zip upload', () => {
|
||||
it('it builds the install request with the name in the path and the zip in the form', () => {
|
||||
const file = new File(['zip'], 'app.zip')
|
||||
const req = buildUpload('apps/install', 'NO DATA', file)
|
||||
expect(req.path).toBe('apps/install?name=NO+DATA')
|
||||
expect(req.form.get('fileAppZip')).toBe(file)
|
||||
})
|
||||
|
||||
it('it builds the update one against `apps/update`', () => {
|
||||
const file = new File(['zip'], 'app.zip')
|
||||
expect(buildUpload('apps/update', 'A B', file).path).toBe('apps/update?name=A+B')
|
||||
})
|
||||
|
||||
it('installApp uploads the zip as multipart and with the name in the query', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} } as never)
|
||||
const zip = new File(['PK'], 'app.zip', { type: 'application/zip' })
|
||||
await installApp('t', 'NO DATA', zip)
|
||||
const [path, opts] = spy.mock.calls[0]
|
||||
// The server gives a 404 by GET, so the client forces POST on seeing `form`.
|
||||
expect(path).toBe('apps/install?name=NO+DATA')
|
||||
expect(opts?.form).toBeInstanceOf(FormData)
|
||||
expect((opts?.form as FormData).get('fileAppZip')).toBeInstanceOf(File)
|
||||
})
|
||||
|
||||
it('updateApp uploads the zip the same way, against apps/update', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} } as never)
|
||||
await updateApp('t', 'Split Horizon', new File(['PK'], 'app.zip'))
|
||||
expect(spy.mock.calls[0][0]).toBe('apps/update?name=Split+Horizon')
|
||||
expect(spy.mock.calls[0][1]?.form).toBeInstanceOf(FormData)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('dnsAppLabels', () => {
|
||||
const base = {
|
||||
classPath: 'X.App',
|
||||
description: '',
|
||||
isAppRecordRequestHandler: false,
|
||||
recordDataTemplate: null,
|
||||
isRequestController: false,
|
||||
isAuthoritativeRequestHandler: false,
|
||||
isRequestBlockingHandler: false,
|
||||
isQueryLogger: false,
|
||||
isQueryLogs: false,
|
||||
isPostProcessor: false,
|
||||
}
|
||||
|
||||
it('with no capability at all, the label is \"Generic\"', () => {
|
||||
expect(dnsAppLabels(base)).toEqual(['Generic'])
|
||||
})
|
||||
|
||||
it('it honours the upstream order and does not add \"Generic\" if there is any', () => {
|
||||
expect(
|
||||
dnsAppLabels({
|
||||
...base,
|
||||
isAppRecordRequestHandler: true,
|
||||
isQueryLogger: true,
|
||||
isPostProcessor: true,
|
||||
}),
|
||||
).toEqual(['APP Record', 'Query Logger', 'Post Processor'])
|
||||
})
|
||||
|
||||
it('it covers the seven capabilities with the upstream texts', () => {
|
||||
expect(
|
||||
dnsAppLabels({
|
||||
...base,
|
||||
isAppRecordRequestHandler: true,
|
||||
isRequestController: true,
|
||||
isAuthoritativeRequestHandler: true,
|
||||
isRequestBlockingHandler: true,
|
||||
isQueryLogger: true,
|
||||
isQueryLogs: true,
|
||||
isPostProcessor: true,
|
||||
}),
|
||||
).toEqual([
|
||||
'APP Record',
|
||||
'Access Control',
|
||||
'Authoritative',
|
||||
'Blocking',
|
||||
'Query Logger',
|
||||
'Query Logs',
|
||||
'Post Processor',
|
||||
])
|
||||
})
|
||||
})
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The nine endpoints of the `apps` family (upstream/master:www/js/apps.js).
|
||||
|
||||
Shapes verified with curl against a real instance (v15.x) on 2026-08-25, not
|
||||
deduced from the code:
|
||||
|
||||
1. `updateVersion`, `updateUrl` and `updateAvailable` are OPTIONAL on an
|
||||
installed app. The server only writes them if the app appears in the store
|
||||
catalog AND there is a version there compatible with this server
|
||||
(WebServiceAppsApi.cs:60-99). An app installed from your own zip never
|
||||
brings them. On top of that, `apps/list` queries the catalog with a 5 s
|
||||
timeout and, if it fails, returns the list WITHOUT those three fields on any
|
||||
app.
|
||||
|
||||
2. `installedApp` / `updatedApp` (what install, update, downloadAndInstall and
|
||||
downloadAndUpdate return) never bring them either: that `WriteAppAsJson` is
|
||||
called without the catalog. Upstream redraws the row with that response, so
|
||||
after installing, the "Store Update" button disappears until the next reload
|
||||
of the list. Here the list is reloaded and that is that.
|
||||
|
||||
3. `config/get` can return `config: null`, not only a string. It happens as
|
||||
soon as someone saves an empty config: `config/set` with an empty string
|
||||
stores it as null (WebServiceAppsApi.cs:494-496).
|
||||
|
||||
4. `apps/uninstall` of an app that does not exist answers `ok`, not an error.
|
||||
*/
|
||||
|
||||
export interface DnsAppDetail {
|
||||
classPath: string
|
||||
description: string
|
||||
isAppRecordRequestHandler: boolean
|
||||
recordDataTemplate: string | null
|
||||
isRequestController: boolean
|
||||
isAuthoritativeRequestHandler: boolean
|
||||
isRequestBlockingHandler: boolean
|
||||
isQueryLogger: boolean
|
||||
isQueryLogs: boolean
|
||||
isPostProcessor: boolean
|
||||
}
|
||||
|
||||
export interface InstalledApp {
|
||||
name: string
|
||||
description: string | null
|
||||
version: string
|
||||
updateVersion?: string
|
||||
updateUrl?: string
|
||||
updateAvailable?: boolean
|
||||
dnsApps: DnsAppDetail[]
|
||||
}
|
||||
|
||||
export interface StoreApp {
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
url: string
|
||||
size: string
|
||||
installed: boolean
|
||||
/** Only when `installed` is true. */
|
||||
installedVersion?: string
|
||||
/** Only when `installed` is true. */
|
||||
updateAvailable?: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
The capability labels of each of the app's classes, in the exact order of
|
||||
`getAppRowHtml` (apps.js:80-104). If there is none, upstream puts "Generic".
|
||||
*/
|
||||
export function dnsAppLabels(d: DnsAppDetail): string[] {
|
||||
const labels: string[] = []
|
||||
if (d.isAppRecordRequestHandler) labels.push('APP Record')
|
||||
if (d.isRequestController) labels.push('Access Control')
|
||||
if (d.isAuthoritativeRequestHandler) labels.push('Authoritative')
|
||||
if (d.isRequestBlockingHandler) labels.push('Blocking')
|
||||
if (d.isQueryLogger) labels.push('Query Logger')
|
||||
if (d.isQueryLogs) labels.push('Query Logs')
|
||||
if (d.isPostProcessor) labels.push('Post Processor')
|
||||
return labels.length > 0 ? labels : ['Generic']
|
||||
}
|
||||
|
||||
export function listApps(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { apps: InstalledApp[] } }>> {
|
||||
return apiRequest('apps/list', { token, body: { node } })
|
||||
}
|
||||
|
||||
export function listStoreApps(
|
||||
token: string | null,
|
||||
): Promise<ApiOutcome<{ response: { storeApps: StoreApp[] } }>> {
|
||||
return apiRequest('apps/listStoreApps', { token })
|
||||
}
|
||||
|
||||
export function downloadAndInstall(
|
||||
token: string | null,
|
||||
name: string,
|
||||
url: string,
|
||||
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
|
||||
return apiRequest('apps/downloadAndInstall', { token, body: { name, url } })
|
||||
}
|
||||
|
||||
export function downloadAndUpdate(
|
||||
token: string | null,
|
||||
name: string,
|
||||
url: string,
|
||||
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
|
||||
return apiRequest('apps/downloadAndUpdate', { token, body: { name, url } })
|
||||
}
|
||||
|
||||
export function uninstallApp(token: string | null, name: string): Promise<ApiOutcome> {
|
||||
return apiRequest('apps/uninstall', { token, body: { name } })
|
||||
}
|
||||
|
||||
/*
|
||||
`node` is the name of the cluster's PRIMARY node: upstream always reads from it
|
||||
so as not to show a config that has not propagated yet (apps.js:460). Without a
|
||||
cluster it sends the empty string and the server ignores it
|
||||
(DnsWebService.cs:2367-2370). The primary's name comes from
|
||||
`sessionData.info.clusterNodes`, which this console does not expose yet.
|
||||
*/
|
||||
export function getAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { config: string | null } }>> {
|
||||
return apiRequest('apps/config/get', { token, body: { name, node } })
|
||||
}
|
||||
|
||||
/*
|
||||
POST, because a config can be long and does not fit in a query. The server reads
|
||||
both parameters with `QueryOrForm`, so both go in the body. An empty config is
|
||||
stored as `null`: that is the server's doing, not ours.
|
||||
*/
|
||||
export function setAppConfig(
|
||||
token: string | null,
|
||||
name: string,
|
||||
config: string,
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('apps/config/set', { token, method: 'POST', body: { name, config } })
|
||||
}
|
||||
|
||||
/*
|
||||
--- Zip upload: pending multipart support in the client ---
|
||||
|
||||
`apps/install` and `apps/update` are the ONLY TWO calls of this family that are
|
||||
not an ordinary request: they are POST `multipart/form-data` with the zip
|
||||
(apps.js:348 and apps.js:392). The server requires `HasFormContentType` and takes
|
||||
`Form.Files[0]` (WebServiceAppsApi.cs:350-357 and 401-408), so:
|
||||
|
||||
- the field NAME of the file does not matter (verified with curl: uploading the
|
||||
zip as `anyNameAtAll` installs just the same). `fileAppZip` is sent here only
|
||||
out of fidelity to upstream;
|
||||
- `name` may go in the query, and that is what upstream does;
|
||||
- they are POST-only: by GET the server answers 404 (DnsWebService.cs:2183-2184).
|
||||
|
||||
`apiRequest` only knows how to send `x-www-form-urlencoded` and is not touched
|
||||
from here. `buildUpload` leaves the request built for the day the client accepts
|
||||
a `FormData`; that day `installApp`/`updateApp` become one line each.
|
||||
*/
|
||||
export interface UploadRequest {
|
||||
path: string
|
||||
form: FormData
|
||||
}
|
||||
|
||||
export function buildUpload(path: string, name: string, file: File): UploadRequest {
|
||||
const form = new FormData()
|
||||
form.append('fileAppZip', file)
|
||||
return { path: `${path}?${new URLSearchParams({ name }).toString()}`, form }
|
||||
}
|
||||
|
||||
|
||||
export function installApp(
|
||||
token: string | null,
|
||||
name: string,
|
||||
file: File,
|
||||
): Promise<ApiOutcome<{ response: { installedApp: InstalledApp } }>> {
|
||||
const { path, form } = buildUpload('apps/install', name, file)
|
||||
return apiRequest(path, { token, form })
|
||||
}
|
||||
|
||||
export function updateApp(
|
||||
token: string | null,
|
||||
name: string,
|
||||
file: File,
|
||||
): Promise<ApiOutcome<{ response: { updatedApp: InstalledApp } }>> {
|
||||
const { path, form } = buildUpload('apps/update', name, file)
|
||||
return apiRequest(path, { token, form })
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { allowDomain, blockDomain, mergeRecent, readRuleExport, recentBlocked } from './blocking'
|
||||
import { onSessionExpired } from './client'
|
||||
import * as logs from './logs'
|
||||
import type { QueryLogEntry } from './logs'
|
||||
import * as zonelists from './zonelists'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function serve(body: string) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body))
|
||||
}
|
||||
|
||||
describe('readRuleExport', () => {
|
||||
it('asks for the export with the session token and splits the lines', async () => {
|
||||
const spy = serve('ads.example.com\r\ndoubleclick.net\n\n')
|
||||
const r = await readRuleExport('blocked', 'T')
|
||||
expect(r).toEqual({ kind: 'ok', data: ['ads.example.com', 'doubleclick.net'] })
|
||||
const [url, init] = spy.mock.calls[0]
|
||||
expect(String(url)).toMatch(/api\/blocked\/export$/)
|
||||
expect((init as RequestInit).headers).toEqual({ Authorization: 'Bearer T' })
|
||||
})
|
||||
|
||||
it('an empty list is an empty array, not an error', async () => {
|
||||
serve('')
|
||||
expect(await readRuleExport('allowed', 'T')).toEqual({ kind: 'ok', data: [] })
|
||||
})
|
||||
|
||||
it('a JSON error answer is an error with the server message', async () => {
|
||||
serve(JSON.stringify({ status: 'error', errorMessage: 'Access was denied.' }))
|
||||
expect(await readRuleExport('allowed', 'T')).toEqual({ kind: 'error', message: 'Access was denied.' })
|
||||
})
|
||||
|
||||
it('an expired session is reported as such', async () => {
|
||||
serve(JSON.stringify({ status: 'invalid-token' }))
|
||||
expect(await readRuleExport('allowed', 'T')).toEqual({ kind: 'invalid-token' })
|
||||
})
|
||||
|
||||
it('an expired session ends the session, like every other call', async () => {
|
||||
const ended = vi.fn()
|
||||
onSessionExpired(ended)
|
||||
try {
|
||||
serve(JSON.stringify({ status: 'invalid-token' }))
|
||||
await readRuleExport('blocked', 'T')
|
||||
expect(ended).toHaveBeenCalledOnce()
|
||||
expect(ended).toHaveBeenCalledWith('T')
|
||||
} finally {
|
||||
onSessionExpired(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('an answer that is neither the list nor an envelope (a proxy page) is an error, as jQuery reports it', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('<html><body>502 Bad Gateway</body></html>', { status: 502, statusText: 'Bad Gateway' }),
|
||||
)
|
||||
expect(await readRuleExport('blocked', 'T')).toEqual({ kind: 'error', message: 'error - Bad Gateway' })
|
||||
})
|
||||
|
||||
it('a failing answer without a status text falls to upstream sentence, as jQuery does', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('<html>…</html>', { status: 502 }))
|
||||
expect(await readRuleExport('blocked', 'T')).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Unable to connect to the server. Please try again.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the envelope exactly as apiRequest does', async () => {
|
||||
serve(JSON.stringify({ status: 'error' }))
|
||||
expect(await readRuleExport('allowed', 'T')).toEqual({ kind: 'error', message: 'Unknown error.' })
|
||||
vi.restoreAllMocks()
|
||||
serve(JSON.stringify({ status: '2fa-required' }))
|
||||
expect(await readRuleExport('allowed', 'T')).toEqual({ kind: 'two-factor-required' })
|
||||
})
|
||||
|
||||
/* I1: after a change the rules are read back from the PRIMARY node, as the tree
|
||||
does (other-zones.js:269, 434). The node travels as apiRequest sends it. */
|
||||
it('asks the node given, as apiRequest sends it', async () => {
|
||||
const spy = serve('ads.example.com')
|
||||
await readRuleExport('blocked', 'T', 'dev.cluster.test')
|
||||
expect(String(spy.mock.calls[0][0])).toMatch(/api\/blocked\/export\?node=dev\.cluster\.test$/)
|
||||
})
|
||||
|
||||
it('sends no node for this server, and the aggregate as `apiRequest` sends it', async () => {
|
||||
const spy = serve('')
|
||||
await readRuleExport('blocked', 'T', '')
|
||||
await readRuleExport('allowed', 'T', 'cluster')
|
||||
expect(String(spy.mock.calls[0][0])).toMatch(/api\/blocked\/export$/)
|
||||
expect(String(spy.mock.calls[1][0])).toMatch(/api\/allowed\/export\?node=cluster$/)
|
||||
})
|
||||
|
||||
/* M5: a reverse proxy's 200 page (an auth wall, a captive portal) is not a list
|
||||
of zones; apiRequest would have said parsererror, and so does this. */
|
||||
it('a 2xx page that is not plain zone lines is an error, not rules', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('<!doctype html><title>Sign in</title>', { headers: { 'Content-Type': 'text/html' } }),
|
||||
)
|
||||
const r = await readRuleExport('blocked', 'T')
|
||||
expect(r.kind).toBe('error')
|
||||
expect(r.kind === 'error' && r.message).toMatch(/^parsererror - /)
|
||||
})
|
||||
|
||||
it('a body starting with < is refused even when it claims to be plain text', async () => {
|
||||
serve('<html><body>Login</body></html>')
|
||||
const r = await readRuleExport('blocked', 'T')
|
||||
expect(r.kind === 'error' && r.message).toMatch(/^parsererror - /)
|
||||
})
|
||||
|
||||
it('a 2xx with another content type is refused', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ads.example.com', { headers: { 'Content-Type': 'application/octet-stream' } }),
|
||||
)
|
||||
expect((await readRuleExport('blocked', 'T')).kind).toBe('error')
|
||||
})
|
||||
|
||||
it('the server answer, text/plain with no charset, is read as lines', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ads.example.com\n', { headers: { 'Content-Type': 'text/plain' } }),
|
||||
)
|
||||
expect(await readRuleExport('blocked', 'T')).toEqual({ kind: 'ok', data: ['ads.example.com'] })
|
||||
})
|
||||
|
||||
it('a request that never arrives uses upstream sentence', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('down'))
|
||||
expect(await readRuleExport('allowed', 'T')).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Unable to connect to the server. Please try again.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function entry(timestamp: string, qname: string, responseType = 'Blocked'): QueryLogEntry {
|
||||
return {
|
||||
rowNumber: 1, timestamp, clientIpAddress: '192.168.1.2', protocol: 'Udp', responseType,
|
||||
rcode: 'NoError', qname, qtype: 'A', qclass: 'IN', answer: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mergeRecent', () => {
|
||||
it('joins the classes, newest first, and keeps the limit', () => {
|
||||
const a = [entry('2026-10-01T10:00:05Z', 'a'), entry('2026-10-01T10:00:01Z', 'b')]
|
||||
const b = [entry('2026-10-01T10:00:03Z', 'c', 'UpstreamBlocked')]
|
||||
expect(mergeRecent([a, b, null], 2).map((e) => e.qname)).toEqual(['a', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('recentBlocked', () => {
|
||||
const app = { name: 'Query Logs (Sqlite)', classPath: 'QueryLogsSqlite.App' }
|
||||
const page = (entries: QueryLogEntry[]) =>
|
||||
({ kind: 'ok' as const, data: { response: { pageNumber: 1, totalPages: 1, totalEntries: entries.length, entries } } })
|
||||
|
||||
it('asks once per blocked class, newest first, ten per page, on the node given', async () => {
|
||||
const spy = vi.spyOn(logs, 'queryLogs').mockResolvedValue(page([]))
|
||||
await recentBlocked('T', app, 'node2')
|
||||
expect(spy.mock.calls.map(([, p]) => p.responseType)).toEqual(['Blocked', 'UpstreamBlocked', 'UpstreamBlockedCached'])
|
||||
expect(spy.mock.calls[0][1]).toMatchObject({
|
||||
name: app.name, classPath: app.classPath, pageNumber: '1', entriesPerPage: '10',
|
||||
descendingOrder: 'true', node: 'node2',
|
||||
})
|
||||
})
|
||||
|
||||
it('says it is partial when one class fails, and keeps the others', async () => {
|
||||
vi.spyOn(logs, 'queryLogs')
|
||||
.mockResolvedValueOnce(page([entry('2026-10-01T10:00:00Z', 'x')]))
|
||||
.mockResolvedValueOnce({ kind: 'error', message: 'boom' })
|
||||
.mockResolvedValueOnce(page([]))
|
||||
const r = await recentBlocked('T', app, '')
|
||||
expect(r).toEqual({ kind: 'ok', data: { entries: [entry('2026-10-01T10:00:00Z', 'x')], partial: true } })
|
||||
})
|
||||
|
||||
it('is an error when all three fail', async () => {
|
||||
vi.spyOn(logs, 'queryLogs').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await recentBlocked('T', app, '')).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowDomain and blockDomain', () => {
|
||||
const OK = { kind: 'ok' as const, data: {} }
|
||||
|
||||
it('Allow Domain deletes from Blocked and then adds to Allowed', async () => {
|
||||
const remove = vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
|
||||
const add = vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
|
||||
expect(await allowDomain('T', 'ads.test')).toEqual(OK)
|
||||
expect(remove).toHaveBeenCalledWith('blocked', 'T', 'ads.test')
|
||||
expect(add).toHaveBeenCalledWith('allowed', 'T', 'ads.test')
|
||||
expect(remove.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[0])
|
||||
})
|
||||
|
||||
it('Allow Domain stops if the delete fails', async () => {
|
||||
vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
|
||||
const add = vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
|
||||
expect(await allowDomain('T', 'ads.test')).toEqual({ kind: 'error', message: 'Access was denied.' })
|
||||
expect(add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('when the delete goes through and the add fails, both return the add error', async () => {
|
||||
vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue(OK)
|
||||
vi.spyOn(zonelists, 'addDomain').mockResolvedValue({ kind: 'error', message: 'Invalid domain name.' })
|
||||
expect(await allowDomain('T', 'x.test')).toEqual({ kind: 'error', message: 'Invalid domain name.' })
|
||||
expect(await blockDomain('T', 'x.test')).toEqual({ kind: 'error', message: 'Invalid domain name.' })
|
||||
})
|
||||
|
||||
it('Block Domain deletes from Allowed and then adds to Blocked, and stops if the delete fails', async () => {
|
||||
vi.spyOn(zonelists, 'deleteDomain').mockResolvedValue({ kind: 'error', message: 'Access was denied.' })
|
||||
const add = vi.spyOn(zonelists, 'addDomain').mockResolvedValue(OK)
|
||||
expect(await blockDomain('T', 'x.test')).toEqual({ kind: 'error', message: 'Access was denied.' })
|
||||
expect(add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { urlApi } from '../app/base'
|
||||
import { envelopeOutcome, type ApiOutcome } from './client'
|
||||
import { queryLogs, type QueryLogEntry } from './logs'
|
||||
import { addDomain, deleteDomain, type DomainList } from './zonelists'
|
||||
|
||||
/*
|
||||
The Blocking section's own reads. Every endpoint here already exists in the server;
|
||||
what is ours is only how their answers are combined.
|
||||
*/
|
||||
|
||||
/*
|
||||
`allowed/export` / `blocked/export` read as TEXT, for the Rules table. Upstream only
|
||||
opens them as a download (other-zones.js:554, 623); here the same answer is read in
|
||||
place. The good answer is `text/plain`, one zone per line
|
||||
(WebServiceOtherZonesApi.cs:306, 511); a failure is the usual JSON envelope.
|
||||
|
||||
`node` is the cluster node that answers, sent exactly as `apiRequest` sends it: the
|
||||
empty string travels as nothing, and `cluster` travels as it is and the server's
|
||||
proxy treats it as "this one". Rules reads the
|
||||
PRIMARY after a change (other-zones.js:269, 434): the change is made there, and a
|
||||
secondary still answers the old list until the cluster syncs.
|
||||
*/
|
||||
export async function readRuleExport(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<string[]>> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
let url = urlApi(`api/${list}/export`)
|
||||
if (node) url += '?node=' + encodeURIComponent(node)
|
||||
|
||||
let res: Response
|
||||
let text: string
|
||||
try {
|
||||
res = await fetch(url, { headers })
|
||||
text = await res.text()
|
||||
} catch {
|
||||
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
|
||||
}
|
||||
|
||||
/* A failure is the usual envelope, read where `apiRequest` reads it. */
|
||||
if (text.trimStart().startsWith('{')) {
|
||||
try {
|
||||
const env = JSON.parse(text) as { status?: string; errorMessage?: string }
|
||||
if (env.status != null && env.status !== 'ok') return envelopeOutcome<string[]>(env, token)
|
||||
} catch {
|
||||
/* Not JSON: a zone name cannot start with `{`, but the server owns the format. */
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Anything else that is not a 2xx (a reverse proxy's 502 page, a 404 page) is not
|
||||
a list. common.js:186-193 reports it as jQuery does: `textStatus - errorThrown`,
|
||||
i.e. `error - <status text>`, and upstream's own sentence when the status text
|
||||
is empty.
|
||||
*/
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: res.statusText === ''
|
||||
? 'Unable to connect to the server. Please try again.'
|
||||
: `error - ${res.statusText}`,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A 2xx that is not the list: a reverse proxy's sign-in or captive page answers 200
|
||||
with HTML, and read as lines it would fill the table with markup. The server's own
|
||||
answer is `text/plain` (checked against v15.5.1) and a zone name cannot start with
|
||||
`<`. It is reported as `apiRequest` reports any answer it cannot read: jQuery's
|
||||
`parsererror - <reason>`.
|
||||
*/
|
||||
const type = res.headers.get('content-type')
|
||||
const plain = type == null || type.toLowerCase().startsWith('text/plain')
|
||||
if (!plain || text.trimStart().startsWith('<')) {
|
||||
let reason = 'SyntaxError: not a list of zones'
|
||||
try {
|
||||
JSON.parse(text)
|
||||
} catch (e) {
|
||||
reason = String(e)
|
||||
}
|
||||
return { kind: 'error', message: `parsererror - ${reason}` }
|
||||
}
|
||||
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== '')
|
||||
return { kind: 'ok', data: lines }
|
||||
}
|
||||
|
||||
/* The three response types `totalBlocked` adds up (StatsManager.cs:329-341). */
|
||||
export const BLOCKED_TYPES = ['Blocked', 'UpstreamBlocked', 'UpstreamBlockedCached'] as const
|
||||
|
||||
export function mergeRecent(
|
||||
pages: readonly (readonly QueryLogEntry[] | null)[],
|
||||
limit: number,
|
||||
): QueryLogEntry[] {
|
||||
return pages
|
||||
.flatMap((p) => p ?? [])
|
||||
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/*
|
||||
The latest blocked queries, for the Overview panel. `logs/query` filters by ONE
|
||||
response type (WebServiceLogsApi.cs:182) while the Blocked figure counts three, so
|
||||
it asks three times and merges. Exact without paging: the newest N of the union are
|
||||
always among the newest N of each class.
|
||||
*/
|
||||
export async function recentBlocked(
|
||||
token: string | null,
|
||||
app: { name: string; classPath: string },
|
||||
node: string,
|
||||
limit = 10,
|
||||
): Promise<ApiOutcome<{ entries: QueryLogEntry[]; partial: boolean }>> {
|
||||
const outcomes = await Promise.all(
|
||||
BLOCKED_TYPES.map((responseType) =>
|
||||
queryLogs(token, {
|
||||
name: app.name,
|
||||
classPath: app.classPath,
|
||||
pageNumber: '1',
|
||||
entriesPerPage: String(limit),
|
||||
descendingOrder: 'true',
|
||||
start: '',
|
||||
end: '',
|
||||
clientIpAddress: '',
|
||||
protocol: '',
|
||||
responseType,
|
||||
rcode: '',
|
||||
qname: '',
|
||||
qtype: '',
|
||||
qclass: '',
|
||||
node,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const pages = outcomes.map((o) => (o.kind === 'ok' ? o.data.response.entries : null))
|
||||
const failed = outcomes.find((o) => o.kind !== 'ok')
|
||||
if (pages.every((p) => p == null) && failed != null) return failed as ApiOutcome<never>
|
||||
return { kind: 'ok', data: { entries: mergeRecent(pages, limit), partial: failed != null } }
|
||||
}
|
||||
|
||||
/* `allowDomain` (other-zones.js:637): out of Blocked first, then into Allowed. */
|
||||
export async function allowDomain(token: string | null, domain: string): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('blocked', token, domain)
|
||||
if (removed.kind !== 'ok') return removed
|
||||
return addDomain('allowed', token, domain)
|
||||
}
|
||||
|
||||
/* `blockDomain` (other-zones.js:686): out of Allowed first, then into Blocked. */
|
||||
export async function blockDomain(token: string | null, domain: string): Promise<ApiOutcome> {
|
||||
const removed = await deleteDomain('allowed', token, domain)
|
||||
if (removed.kind !== 'ok') return removed
|
||||
return addDomain('blocked', token, domain)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { formatLabel, localiseLabels } from './chart-labels'
|
||||
|
||||
/*
|
||||
What `main.js:2673-2686` does to the main chart's labels once it has asked with
|
||||
`utc=true`: the server sends instants in UTC (`2026-10-01T11:29:00.0000000Z`) and the
|
||||
label format; day, month and year buckets are written in UTC (the server groups them
|
||||
by UTC day), anything with an hour in the viewer's local time.
|
||||
|
||||
Each half runs in a zone PINNED where it can fail: the UTC formats west of Greenwich
|
||||
(`America/Los_Angeles`, UTC−7 in October), where a midnight-UTC instant is still the day before
|
||||
in local time; the local formats east (Tokyo, UTC+9), where an evening hour in UTC is
|
||||
already the next day. In a runner on UTC, or in Madrid at midnight UTC, the two halves
|
||||
could not tell local from UTC apart. Node re-reads `TZ` when it is assigned, and
|
||||
`vi.stubEnv` assigns it (through `vi`, not `process`: `src/` compiles without node's
|
||||
types, and a `process` here passes the suite and breaks the build).
|
||||
*/
|
||||
function inZone(zone: string) {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv('TZ', zone)
|
||||
})
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
}
|
||||
|
||||
describe('day, month and year buckets are written in UTC (runner pinned to UTC−7)', () => {
|
||||
inZone('America/Los_Angeles')
|
||||
|
||||
it('the zone is really pinned: midnight UTC is still the day before here', () => {
|
||||
expect(new Date('2026-09-30T00:00:00Z').getDate()).toBe(29)
|
||||
})
|
||||
|
||||
it('Last Week and Last Month, MM/DD', () => {
|
||||
expect(formatLabel('2026-09-30T00:00:00.0000000Z', 'MM/DD')).toBe('09/30')
|
||||
})
|
||||
|
||||
it('DD/MM', () => {
|
||||
expect(formatLabel('2026-09-30T00:00:00.0000000Z', 'DD/MM')).toBe('30/09')
|
||||
})
|
||||
|
||||
it('Last Year, MM/YYYY: the first of the month stays in its month and year', () => {
|
||||
expect(formatLabel('2026-01-01T00:00:00.0000000Z', 'MM/YYYY')).toBe('01/2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('anything with an hour is written in local time (runner pinned to UTC+9)', () => {
|
||||
inZone('Asia/Tokyo')
|
||||
|
||||
it('the zone is really pinned: 23:00 UTC is already 08:00 the next day here', () => {
|
||||
const d = new Date('2026-09-30T23:00:00Z')
|
||||
expect([d.getDate(), d.getHours()]).toEqual([1, 8])
|
||||
})
|
||||
|
||||
it('Last Hour, HH:mm', () => {
|
||||
expect(formatLabel('2026-10-01T11:29:00.0000000Z', 'HH:mm')).toBe('20:29')
|
||||
})
|
||||
|
||||
it('Last Day, MM/DD HH:00, with the 00 written as it is', () => {
|
||||
expect(formatLabel('2026-09-30T23:00:00.0000000Z', 'MM/DD HH:00')).toBe('10/01 08:00')
|
||||
})
|
||||
|
||||
it('DD/MM HH:00', () => {
|
||||
expect(formatLabel('2026-09-30T23:00:00.0000000Z', 'DD/MM HH:00')).toBe('01/10 08:00')
|
||||
})
|
||||
|
||||
it('a short custom range, MM/DD HH:mm', () => {
|
||||
expect(formatLabel('2026-09-30T20:01:00.0000000Z', 'MM/DD HH:mm')).toBe('10/01 05:01')
|
||||
})
|
||||
|
||||
it('DD/MM HH:mm', () => {
|
||||
expect(formatLabel('2026-09-30T20:01:00.0000000Z', 'DD/MM HH:mm')).toBe('01/10 05:01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('what is not an instant', () => {
|
||||
it('a label that is not an instant is left as it came', () => {
|
||||
expect(formatLabel('11:29', 'HH:mm')).toBe('11:29')
|
||||
})
|
||||
})
|
||||
|
||||
describe('localiseLabels', () => {
|
||||
inZone('America/Los_Angeles')
|
||||
|
||||
it('rewrites every label with the chart format and leaves the series alone', () => {
|
||||
const chart = {
|
||||
labelFormat: 'MM/DD',
|
||||
labels: ['2026-09-29T00:00:00.0000000Z', '2026-09-30T00:00:00.0000000Z'],
|
||||
datasets: [{ label: 'Total', data: [1, 2] }],
|
||||
}
|
||||
expect(localiseLabels(chart)).toEqual({ ...chart, labels: ['09/29', '09/30'], instants: chart.labels })
|
||||
})
|
||||
|
||||
it('without a format the chart passes through untouched', () => {
|
||||
const chart = { labels: ['A', 'AAAA'], datasets: [{ label: 'Type', data: [1, 2] }] }
|
||||
expect(localiseLabels(chart)).toBe(chart)
|
||||
})
|
||||
|
||||
it('retains UTC instants if an already formatted chart is formatted again', () => {
|
||||
const chart = { labelFormat: 'MM/DD', labels: ['2026-09-30T00:00:00Z'], datasets: [] }
|
||||
const once = localiseLabels(chart)
|
||||
expect(localiseLabels(once).instants).toEqual(chart.labels)
|
||||
})
|
||||
|
||||
it('does not create instants from parseable legacy date labels', () => {
|
||||
const chart = { labelFormat: 'MM/DD', labels: ['09/30'], datasets: [] }
|
||||
expect(localiseLabels(chart)).toEqual(chart)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ChartData } from './dashboard'
|
||||
|
||||
/*
|
||||
The main chart's labels, written as upstream writes them.
|
||||
|
||||
Upstream asks `dashboard/stats/get` with `utc=true` (main.js:2619) and the server then
|
||||
labels the chart with instants in UTC (`2026-10-01T11:29:00.0000000Z`) plus a
|
||||
`labelFormat`. main.js:2673-2686 formats each one with moment:
|
||||
|
||||
· `MM/DD`, `DD/MM`, `MM/YYYY` (day, month and year buckets) in UTC: the server
|
||||
groups them by UTC day, and shifting them to the viewer's zone would put a day's
|
||||
queries under the day before;
|
||||
· anything else (`HH:mm`, `MM/DD HH:00`, `MM/DD HH:mm`) in the viewer's LOCAL time.
|
||||
|
||||
Without `utc` the server writes the labels itself in ITS zone, which is what this
|
||||
console showed until 2026-10-01: in a harness on UTC seen from Madrid, the chart's
|
||||
axis ran two hours behind every other time on the screen.
|
||||
|
||||
Only the moment tokens the server sends are read (YYYY, MM, DD, HH, mm); everything
|
||||
else is written as it is (the `00` of `HH:00`, the `/`, the space). A label that is
|
||||
not an instant (no `utc`, an older server) is left as it came, where moment would
|
||||
write "Invalid date".
|
||||
*/
|
||||
|
||||
const UTC_FORMATS = new Set(['MM/DD', 'DD/MM', 'MM/YYYY'])
|
||||
|
||||
const pad = (n: number, width = 2) => String(n).padStart(width, '0')
|
||||
|
||||
export function formatLabel(label: string, format: string): string {
|
||||
const at = Date.parse(label)
|
||||
if (Number.isNaN(at) || !/^\d{4}-\d{2}-\d{2}T/.test(label)) return label
|
||||
const d = new Date(at)
|
||||
const utc = UTC_FORMATS.has(format)
|
||||
const parts: Record<string, string> = {
|
||||
YYYY: pad(utc ? d.getUTCFullYear() : d.getFullYear(), 4),
|
||||
MM: pad((utc ? d.getUTCMonth() : d.getMonth()) + 1),
|
||||
DD: pad(utc ? d.getUTCDate() : d.getDate()),
|
||||
HH: pad(utc ? d.getUTCHours() : d.getHours()),
|
||||
mm: pad(utc ? d.getUTCMinutes() : d.getMinutes()),
|
||||
}
|
||||
return format.replace(/YYYY|MM|DD|HH|mm/g, (token) => parts[token])
|
||||
}
|
||||
|
||||
/** The chart with its labels formatted; a chart without `labelFormat` passes through. */
|
||||
export function localiseLabels(chart: ChartData): ChartData {
|
||||
const format = chart.labelFormat
|
||||
if (format == null) return chart
|
||||
/* Keep the original instants if the chart is formatted again. A legacy label
|
||||
such as "09/30" can be parsed as a date, but is not a server UTC boundary. */
|
||||
const instants = chart.instants ?? (
|
||||
chart.labels.every((l) => /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(l) && Number.isFinite(Date.parse(l)))
|
||||
? chart.labels
|
||||
: undefined
|
||||
)
|
||||
return { ...chart, labels: chart.labels.map((l) => formatLabel(l, format)), ...(instants != null ? { instants } : {}) }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { apiRequest } from './client'
|
||||
import { forgetRoot } from '../app/base'
|
||||
|
||||
function mockFetch(payload: unknown) {
|
||||
const spy = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => payload })
|
||||
vi.stubGlobal('fetch', spy)
|
||||
return spy
|
||||
}
|
||||
|
||||
beforeEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('hands over the raw JSON when the status is ok', async () => {
|
||||
// user/login and user/session/get return the session FLAT, with no `response`
|
||||
// wrapper. Everything else does wrap it. That is why the client does not
|
||||
// unwrap: like upstream's HTTPRequest, it hands over the JSON as it came.
|
||||
mockFetch({ status: 'ok', token: 'abc', displayName: 'Administrator' })
|
||||
const r = await apiRequest('user/login')
|
||||
expect(r).toEqual({ kind: 'ok', data: { status: 'ok', token: 'abc', displayName: 'Administrator' } })
|
||||
})
|
||||
|
||||
it('hands over the wrapped ones too, untouched', async () => {
|
||||
mockFetch({ status: 'ok', response: { zones: [] } })
|
||||
const r = await apiRequest('zones/list')
|
||||
expect(r).toEqual({ kind: 'ok', data: { status: 'ok', response: { zones: [] } } })
|
||||
})
|
||||
|
||||
it('sends the token as a Bearer header', async () => {
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
await apiRequest('user/session/get', { token: 'abc123' })
|
||||
expect(spy.mock.calls[0][1].headers.Authorization).toBe('Bearer abc123')
|
||||
})
|
||||
|
||||
/*
|
||||
They used to be relative so as to survive `X-Forwarded-Prefix`, and that
|
||||
stopped working when the console's routes became real ones: from
|
||||
`/settings/logging/`, a relative `api/status` asks for
|
||||
`/settings/logging/api/status` and gets a 404. Checked in the browser. Now they
|
||||
hang off the application's root, which still knows the prefix.
|
||||
*/
|
||||
it('hangs the paths off the application root, not off the current directory', async () => {
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
await apiRequest('user/login')
|
||||
expect(spy.mock.calls[0][0]).toBe('/api/user/login')
|
||||
})
|
||||
|
||||
it('and honours the proxy prefix', async () => {
|
||||
const meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'route')
|
||||
meta.setAttribute('content', 'settings/logging')
|
||||
document.head.appendChild(meta)
|
||||
window.history.replaceState(null, '', '/dns/settings/logging/')
|
||||
forgetRoot()
|
||||
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
await apiRequest('user/login')
|
||||
expect(spy.mock.calls[0][0]).toBe('/dns/api/user/login')
|
||||
|
||||
meta.remove()
|
||||
window.history.replaceState(null, '', '/')
|
||||
forgetRoot()
|
||||
})
|
||||
|
||||
it('puts the body in the query when it is a GET', async () => {
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
await apiRequest('zones/list', { body: { zone: 'home.test' } })
|
||||
expect(spy.mock.calls[0][0]).toBe('/api/zones/list?zone=home.test')
|
||||
})
|
||||
|
||||
it('encodes the body as a form when it is a POST', async () => {
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
await apiRequest('user/login', { method: 'POST', body: { user: 'admin', pass: 'a b&c' } })
|
||||
const [url, init] = spy.mock.calls[0]
|
||||
expect(url).toBe('/api/user/login')
|
||||
expect(init.body).toBe('user=admin&pass=a+b%26c')
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
})
|
||||
|
||||
it('tells an invalid token apart', async () => {
|
||||
mockFetch({ status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
|
||||
expect(await apiRequest('user/session/get')).toEqual({ kind: 'invalid-token' })
|
||||
})
|
||||
|
||||
it('tells apart that the second factor is needed', async () => {
|
||||
// Literal verified in DnsWebService.cs:2530
|
||||
mockFetch({ status: '2fa-required', errorMessage: 'TOTP required' })
|
||||
expect(await apiRequest('user/login')).toEqual({ kind: 'two-factor-required' })
|
||||
})
|
||||
|
||||
it('returns the error message from the server', async () => {
|
||||
mockFetch({ status: 'error', errorMessage: 'Invalid username or password for user: admin' })
|
||||
expect(await apiRequest('user/login')).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Invalid username or password for user: admin',
|
||||
})
|
||||
})
|
||||
|
||||
it('an answer that is not JSON is reported as jQuery reports it (common.js:193)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ json: () => Promise.reject(new SyntaxError('Unexpected token <')) }),
|
||||
)
|
||||
expect(await apiRequest('user/login')).toEqual({
|
||||
kind: 'error',
|
||||
message: 'parsererror - SyntaxError: Unexpected token <',
|
||||
})
|
||||
})
|
||||
|
||||
it('turns a network failure into a readable error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')))
|
||||
expect(await apiRequest('user/login')).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Unable to connect to the server. Please try again.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('multipart uploads', () => {
|
||||
it('sends FormData and does NOT set Content-Type by hand', async () => {
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
const file = new File(['zone-file'], 'home.test.zone', { type: 'text/plain' })
|
||||
await apiRequest('zones/import', { token: 't', body: { zone: 'home.test' }, file: { field: 'fileZone', file } })
|
||||
const [url, init] = spy.mock.calls[0]
|
||||
expect(url).toBe('/api/zones/import')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBeInstanceOf(FormData)
|
||||
// The browser sets the boundary; setting it by hand breaks the upload.
|
||||
expect(init.headers['Content-Type']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the ordinary fields travel inside the FormData, not in the query', async () => {
|
||||
const spy = mockFetch({ status: 'ok' })
|
||||
const file = new File(['x'], 'a.txt')
|
||||
await apiRequest('zones/import', { body: { zone: 'home.test', overwrite: 'true' }, file: { field: 'f', file } })
|
||||
const [url, init] = spy.mock.calls[0]
|
||||
expect(url).not.toContain('?')
|
||||
const fd = init.body as FormData
|
||||
expect(fd.get('zone')).toBe('home.test')
|
||||
expect(fd.get('overwrite')).toBe('true')
|
||||
expect(fd.get('f')).toBeInstanceOf(File)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the cluster node parameter', () => {
|
||||
/*
|
||||
The server reads `node` centrally and proxies the request, so every call can
|
||||
carry it. Eight screens offer the choice upstream and none did here.
|
||||
*/
|
||||
it('travels in the query when a node is chosen', async () => {
|
||||
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' })),
|
||||
)
|
||||
spy.mockClear()
|
||||
await apiRequest('dashboard/stats/get', { node: 'node-2' })
|
||||
expect(String(spy.mock.calls[0]?.[0])).toContain('node=node-2')
|
||||
})
|
||||
|
||||
/* "cluster" is upstream's word for the aggregate and it travels as it is: the
|
||||
Dashboard's figures only add every node up when the server receives it
|
||||
(WebServiceDashboardApi.cs:281-282). Checked live on a v15.6.0 cluster: 74
|
||||
queries with it, 69 (the local node) without. */
|
||||
it('is sent for the aggregate, as upstream sends it', async () => {
|
||||
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' })),
|
||||
)
|
||||
spy.mockClear()
|
||||
await apiRequest('dashboard/stats/get', { node: 'cluster' })
|
||||
expect(String(spy.mock.calls[0]?.[0])).toContain('node=cluster')
|
||||
})
|
||||
|
||||
it('is not sent when empty or absent', async () => {
|
||||
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' })),
|
||||
)
|
||||
spy.mockClear()
|
||||
await apiRequest('dashboard/stats/get', { node: '' })
|
||||
await apiRequest('dashboard/stats/get')
|
||||
expect(String(spy.mock.calls[0]?.[0])).not.toContain('node=')
|
||||
expect(String(spy.mock.calls[1]?.[0])).not.toContain('node=')
|
||||
})
|
||||
|
||||
it('is encoded, since a node name may carry anything', async () => {
|
||||
const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' })),
|
||||
)
|
||||
spy.mockClear()
|
||||
await apiRequest('dashboard/stats/get', { node: 'dns 1.home' })
|
||||
expect(String(spy.mock.calls[0]?.[0])).toContain('node=dns%201.home')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { urlApi } from '../app/base'
|
||||
/*
|
||||
The console's HTTP client. A replica of the old console's `HTTPRequest` helper
|
||||
(upstream/master:DnsServerCore/www/js/common.js:28).
|
||||
|
||||
Three things that are the server's contract and not our preferences:
|
||||
|
||||
1. Paths go RELATIVE and without a leading slash. The server honours
|
||||
X-Forwarded-Prefix and mounts a PathBase (DnsWebService.cs:1943-1945), so a
|
||||
leading slash breaks any install behind a proxy with a prefix.
|
||||
|
||||
2. The response is NOT unwrapped. `user/login` and `user/session/get` return
|
||||
the session FLAT; every other endpoint wraps it in `response`. Verified
|
||||
against a v15.4 instance. Just like upstream, the JSON is handed over as it
|
||||
came and the caller decides.
|
||||
|
||||
3. The four values of `status` are `ok`, `2fa-required`, `invalid-token` and
|
||||
`error`. Verified in DnsWebService.cs:2475-2543.
|
||||
*/
|
||||
|
||||
export type ApiOutcome<T = unknown> =
|
||||
| { kind: 'ok'; data: T }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'invalid-token' }
|
||||
| { kind: 'two-factor-required' }
|
||||
|
||||
export interface ApiOptions {
|
||||
method?: 'GET' | 'POST'
|
||||
body?: Record<string, string>
|
||||
token?: string | null
|
||||
/*
|
||||
Which cluster node answers this request.
|
||||
|
||||
It is not per-endpoint: the server reads `node` centrally in
|
||||
`DnsWebService.cs:2355` and proxies the whole request to that node, so any call
|
||||
can carry it. The empty string means "this one" and is not sent.
|
||||
|
||||
`cluster`, the aggregate, IS sent, as upstream sends it (main.js:2622, 2935):
|
||||
the proxy skips it (`DnsWebService.cs:2356`), and `dashboard/stats/get` and
|
||||
`dashboard/stats/getTop` read it themselves to add every node up
|
||||
(`WebServiceDashboardApi.cs:281-282, 801-802`). Dropping it, as this client did
|
||||
until v1.2.1, made the Dashboard's "Cluster" show the local node alone.
|
||||
|
||||
Upstream offers the choice on eight screens and remembers it per screen
|
||||
(`ui/ClusterNodeSelect`).
|
||||
*/
|
||||
node?: string | null
|
||||
/*
|
||||
Multipart upload. The console has five: install and update app, import zone,
|
||||
import records and restore settings (apps.js:348,392 · zone.js:1273,3039 ·
|
||||
main.js:3169). They go with FormData, not with an encoded form, so
|
||||
Content-Type must NOT be set by hand: the browser sets it with its boundary.
|
||||
The fields of `body` travel inside the FormData too.
|
||||
*/
|
||||
file?: { field: string; file: File }
|
||||
/** Alternative to `file` when the caller already built the FormData. */
|
||||
form?: FormData
|
||||
/*
|
||||
Plain-text body. One single screen asks for it: importing a zone by pasting
|
||||
the file into a textarea sends the raw text with `Content-Type: text/plain`
|
||||
instead of multipart (zone.js:1281). The server tells them apart by that type.
|
||||
*/
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
status?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/*
|
||||
What to do when the server says the session is no longer valid.
|
||||
|
||||
Upstream ALWAYS ends the session: `invalid-token` calls `showPageLogin()` (which
|
||||
clears the token and shows the login) in the sixty-four calls that declare it,
|
||||
and in the ones that do not, it falls through to the `window.location = "/"` of
|
||||
`common.js:147`.
|
||||
|
||||
Here nobody did. Every screen showed "Invalid token or session expired." and
|
||||
stayed where it was, with the console apparently usable and every action failing
|
||||
one after another; to get back in you had to know that a reload was due. Beyond
|
||||
being awkward, this is an administration console: it must not stay standing with
|
||||
a session the server has already rejected.
|
||||
|
||||
It is solved in a single place, here, and not in the thirty screens, because the
|
||||
rule is one: `SessionProvider` registers it, since it is the one that holds the
|
||||
session.
|
||||
*/
|
||||
let onExpired: ((token?: string | null) => void) | null = null
|
||||
|
||||
export function onSessionExpired(fn: ((token?: string | null) => void) | null): void {
|
||||
onExpired = fn
|
||||
}
|
||||
|
||||
/*
|
||||
The envelope read in ONE place. `apiRequest` uses it, and so do the few calls that
|
||||
cannot go through it because their good answer is not JSON (`allowed/export`,
|
||||
`blocked/export`): when those fail they answer this same envelope, and an
|
||||
`invalid-token` there has to end the session like anywhere else.
|
||||
*/
|
||||
export function envelopeOutcome<T>(payload: Envelope, token?: string | null): ApiOutcome<T> {
|
||||
switch (payload.status) {
|
||||
case 'ok':
|
||||
return { kind: 'ok', data: payload as T }
|
||||
case 'invalid-token':
|
||||
onExpired?.(token)
|
||||
return { kind: 'invalid-token' }
|
||||
case '2fa-required':
|
||||
return { kind: 'two-factor-required' }
|
||||
default:
|
||||
return { kind: 'error', message: payload.errorMessage ?? 'Unknown error.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T = unknown>(
|
||||
path: string,
|
||||
opts: ApiOptions = {},
|
||||
): Promise<ApiOutcome<T>> {
|
||||
const { method = 'GET', body, token } = opts
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
let url = urlApi(`api/${path}`)
|
||||
if (opts.node) {
|
||||
url += (url.includes('?') ? '&' : '?') + 'node=' + encodeURIComponent(opts.node)
|
||||
}
|
||||
const init: RequestInit & { headers: Record<string, string> } = { method, headers }
|
||||
|
||||
if (opts.form) {
|
||||
init.method = 'POST'
|
||||
init.body = opts.form
|
||||
} else if (opts.text != null) {
|
||||
init.method = 'POST'
|
||||
headers['Content-Type'] = 'text/plain'
|
||||
init.body = opts.text
|
||||
} else if (opts.file) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(body ?? {})) fd.append(k, v)
|
||||
fd.append(opts.file.field, opts.file.file)
|
||||
init.method = 'POST'
|
||||
init.body = fd
|
||||
// No Content-Type by hand: the browser adds the boundary.
|
||||
} else if (body) {
|
||||
const encoded = new URLSearchParams(body).toString()
|
||||
if (method === 'POST') {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
init.body = encoded
|
||||
} else {
|
||||
url += (url.includes('?') ? '&' : '?') + encoded
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
common.js:182-196. A request that never gets an answer is jQuery's
|
||||
`textStatus === "error"` with an empty `errorThrown`, and upstream says so with
|
||||
its own sentence; any other failure (an answer that is not JSON) is shown as
|
||||
jQuery's `textStatus - errorThrown`. The sentence had been replaced with one of
|
||||
this console's own until 2026-09-30.
|
||||
*/
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url, init)
|
||||
} catch {
|
||||
return { kind: 'error', message: 'Unable to connect to the server. Please try again.' }
|
||||
}
|
||||
let payload: Envelope
|
||||
try {
|
||||
payload = (await res.json()) as Envelope
|
||||
} catch (e) {
|
||||
return { kind: 'error', message: `parsererror - ${String(e)}` }
|
||||
}
|
||||
|
||||
return envelopeOutcome<T>(payload, token)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as client from './client'
|
||||
import { getMetrics, LIVE_KEYS } from './dashboard'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const RESPONSE = {
|
||||
uptimestamp: '2026-10-03T19:12:21.2010965Z',
|
||||
uptimeSeconds: 1609,
|
||||
lifetimeCounters: {
|
||||
totalQueries: 50, totalNoError: 16, totalServerFailure: 19, totalNxDomain: 15, totalRefused: 0,
|
||||
totalAuthoritative: 25, totalRecursive: 19, totalCached: 0, totalBlocked: 6, totalDropped: 0,
|
||||
totalClients: 2,
|
||||
},
|
||||
}
|
||||
|
||||
describe('getMetrics', () => {
|
||||
it('asks dashboard/metrics/json of the given node and unwraps `response`', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { response: RESPONSE } } as never)
|
||||
const r = await getMetrics('tok', 'node2.example')
|
||||
expect(spy).toHaveBeenCalledWith('dashboard/metrics/json', { token: 'tok', node: 'node2.example' })
|
||||
expect(r).toEqual({ kind: 'ok', data: RESPONSE })
|
||||
})
|
||||
|
||||
it('passes a failure through as it came', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await getMetrics('tok')).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
|
||||
it('a response of another shape is a failure, not a crash', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { response: { uptimestamp: 'x' } } } as never)
|
||||
expect((await getMetrics('tok')).kind).toBe('error')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} } as never)
|
||||
expect((await getMetrics('tok')).kind).toBe('error')
|
||||
})
|
||||
|
||||
it('the ten live keys are the summable counters, Clients excluded', () => {
|
||||
expect(LIVE_KEYS).toEqual([
|
||||
'totalQueries', 'totalNoError', 'totalServerFailure', 'totalNxDomain', 'totalRefused',
|
||||
'totalAuthoritative', 'totalRecursive', 'totalCached', 'totalBlocked', 'totalDropped',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([undefined, -1, Number.NaN, Number.POSITIVE_INFINITY, '50'])('rejects an invalid live counter: %s', async (counter) => {
|
||||
const malformed = { ...RESPONSE, lifetimeCounters: { ...RESPONSE.lifetimeCounters, totalQueries: counter } }
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { response: malformed } } as never)
|
||||
expect((await getMetrics('tok')).kind).toBe('error')
|
||||
})
|
||||
|
||||
it('requires the restart marker to distinguish counter generations', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { response: { ...RESPONSE, uptimestamp: undefined } } } as never)
|
||||
expect((await getMetrics('tok')).kind).toBe('error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { getDashboardStats, getTop, deleteAllStats, RANGES, RANGE_LABEL } from './dashboard'
|
||||
import * as client from './client'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const response = (r: unknown) => ({ kind: 'ok' as const, data: { status: 'ok', response: r } })
|
||||
|
||||
describe('dashboard', () => {
|
||||
it('the six ranges are the upstream ones, with their literal labels', () => {
|
||||
expect(RANGES).toEqual(['LastHour','LastDay','LastWeek','LastMonth','LastYear','Custom'])
|
||||
expect(RANGE_LABEL.LastHour).toBe('Last Hour')
|
||||
expect(RANGE_LABEL.LastMonth).toBe('Last Month')
|
||||
})
|
||||
|
||||
it('it asks for the range and unwraps response', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(response({ stats: { totalQueries: 7 } }))
|
||||
const r = await getDashboardStats('t', 'LastDay')
|
||||
expect(spy.mock.calls[0][0]).toBe('dashboard/stats/get')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ type: 'LastDay', utc: 'true' })
|
||||
expect(r.kind === 'ok' && r.data.stats.totalQueries).toBe(7)
|
||||
})
|
||||
|
||||
/* main.js:2619 asks with `utc=true` and 2673-2686 writes the labels in the viewer's
|
||||
time: the Dashboard and Blocking's Overview get them already written. */
|
||||
it('it asks in UTC and hands back the main chart labelled in the viewer\'s time', async () => {
|
||||
const iso = '2026-10-01T11:29:00.0000000Z'
|
||||
const d = new Date(iso)
|
||||
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(
|
||||
response({ stats: {}, mainChartData: { labelFormat: 'HH:mm', labels: [iso], datasets: [] } }),
|
||||
)
|
||||
const r = await getDashboardStats('t', 'LastHour')
|
||||
expect(r.kind === 'ok' && r.data.mainChartData.labels).toEqual([hhmm])
|
||||
})
|
||||
|
||||
it('it only sends start and end when the range is Custom', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(response({}))
|
||||
await getDashboardStats('t', 'LastHour', { start: 'a', end: 'b' })
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ type: 'LastHour', utc: 'true' })
|
||||
spy.mockClear()
|
||||
await getDashboardStats('t', 'Custom', { start: 'a', end: 'b' })
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ type: 'Custom', utc: 'true', start: 'a', end: 'b' })
|
||||
})
|
||||
|
||||
/*
|
||||
This test used to ask for `null` "so the screen does not blow up". Not blowing
|
||||
up was the problem: with `null` the Dashboard could not tell a failure apart
|
||||
from a server with no traffic, and drew eleven zeros.
|
||||
*/
|
||||
it('it raises the server failure, so the Dashboard can state it', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await getDashboardStats('t')).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
|
||||
it('getTop asks for the list type and the limit', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(
|
||||
response({ topClients: [{ name: '10.0.1.42', hits: 12 }] }),
|
||||
)
|
||||
const r = await getTop('t', 'LastHour', 'TopClients')
|
||||
/* main.js:2932 does not send `utc` here: the tops carry no times. */
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ type: 'LastHour', statsType: 'TopClients', limit: '1000' })
|
||||
expect(r[0].name).toBe('10.0.1.42')
|
||||
})
|
||||
|
||||
it('getTop returns an empty list on failure', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'x' })
|
||||
expect(await getTop('t', 'LastHour', 'TopDomains')).toEqual([])
|
||||
})
|
||||
|
||||
it('deleteAllStats calls the right endpoint', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await deleteAllStats('t')
|
||||
expect(spy.mock.calls[0][0]).toBe('dashboard/stats/deleteAll')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { localiseLabels } from './chart-labels'
|
||||
|
||||
/*
|
||||
The three endpoints of the `dashboard` family.
|
||||
|
||||
The response of `stats/get` comes wrapped in `response` and brings FOUR chart
|
||||
sets, not two: the main line one and three breakdowns (response type, query type
|
||||
and protocol). Verified against v15.4.
|
||||
*/
|
||||
|
||||
export const RANGES = ['LastHour', 'LastDay', 'LastWeek', 'LastMonth', 'LastYear', 'Custom'] as const
|
||||
export type Range = (typeof RANGES)[number]
|
||||
|
||||
/** Upstream's literal labels for each range. */
|
||||
export const RANGE_LABEL: Record<Range, string> = {
|
||||
LastHour: 'Last Hour',
|
||||
LastDay: 'Last Day',
|
||||
LastWeek: 'Last Week',
|
||||
LastMonth: 'Last Month',
|
||||
LastYear: 'Last Year',
|
||||
Custom: 'Custom',
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
totalQueries: number
|
||||
totalNoError: number
|
||||
totalServerFailure: number
|
||||
totalNxDomain: number
|
||||
totalRefused: number
|
||||
totalAuthoritative: number
|
||||
totalRecursive: number
|
||||
totalCached: number
|
||||
totalBlocked: number
|
||||
totalDropped: number
|
||||
totalClients: number
|
||||
zones: number
|
||||
cachedEntries: number
|
||||
allowedZones: number
|
||||
blockedZones: number
|
||||
allowListZones: number
|
||||
blockListZones: number
|
||||
}
|
||||
|
||||
/** Chart.js's format, which is the one the server emits. */
|
||||
export interface ChartData {
|
||||
labelFormat?: string
|
||||
labels: string[]
|
||||
/** The labels as the server sent them, before `localiseLabels` wrote them for the
|
||||
* eye: UTC instants, each the END of its minute (or hour, day...). The live Last
|
||||
* Hour reads the last one to know how far the server has counted. */
|
||||
instants?: string[]
|
||||
datasets: { label: string; data: number[]; backgroundColor?: string | string[] }[]
|
||||
}
|
||||
|
||||
/*
|
||||
A top is NOT just a name and a hit count. A client also brings the domain it
|
||||
resolved and whether the server was rate-limiting it, and upstream draws both:
|
||||
the domain under the name, and the row in orange with "(rate limited)" after it.
|
||||
The two fields only exist in `TopClients`. Checked against v15.4.
|
||||
*/
|
||||
export interface TopEntry {
|
||||
name: string
|
||||
hits: number
|
||||
/** Only on clients: the domain it resolved. Empty draws no domain line (#22). */
|
||||
domain?: string
|
||||
/** Only on clients. */
|
||||
rateLimited?: boolean
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
stats: Stats
|
||||
mainChartData: ChartData
|
||||
queryResponseChartData: ChartData
|
||||
queryTypeChartData: ChartData
|
||||
protocolTypeChartData: ChartData
|
||||
topClients: TopEntry[]
|
||||
topDomains: TopEntry[]
|
||||
topBlockedDomains: TopEntry[]
|
||||
}
|
||||
|
||||
export async function getDashboardStats(
|
||||
token: string | null,
|
||||
type: Range = 'LastHour',
|
||||
range?: { start: string; end: string },
|
||||
/* Which cluster node answers. The server proxies centrally, so it is the same
|
||||
parameter on every call (`ui/ClusterNodeSelect`). */
|
||||
node?: string,
|
||||
): Promise<ApiOutcome<DashboardStats>> {
|
||||
/* `utc=true` as main.js:2619 sends it: the main chart comes labelled with UTC
|
||||
instants, and `localiseLabels` writes them as main.js:2673-2686 does. */
|
||||
const body: Record<string, string> = { type, utc: 'true' }
|
||||
if (type === 'Custom' && range) {
|
||||
body.start = range.start
|
||||
body.end = range.end
|
||||
}
|
||||
const outcome = await apiRequest<{ response: DashboardStats }>('dashboard/stats/get', { token, body, node })
|
||||
/*
|
||||
Returns the whole outcome and not `DashboardStats | null`.
|
||||
|
||||
With `null` the Dashboard could not tell "the server has served no queries"
|
||||
apart from "the call fell over", and both were drawn the same: eleven tiles at
|
||||
zero and "No queries for this period.". It is the console's worst lie (whoever
|
||||
administers a DNS and reads that concludes their server is receiving no
|
||||
traffic) and the easiest to believe, because it looks exactly like a normal
|
||||
response.
|
||||
*/
|
||||
if (outcome.kind !== 'ok') return outcome
|
||||
const data = outcome.data.response
|
||||
return {
|
||||
kind: 'ok',
|
||||
data: data.mainChartData != null ? { ...data, mainChartData: localiseLabels(data.mainChartData) } : data,
|
||||
}
|
||||
}
|
||||
|
||||
export type TopKind = 'TopClients' | 'TopDomains' | 'TopBlockedDomains'
|
||||
|
||||
export async function getTop(
|
||||
token: string | null,
|
||||
statsType: Range,
|
||||
type: TopKind,
|
||||
limit = 1000,
|
||||
node?: string,
|
||||
): Promise<TopEntry[]> {
|
||||
const outcome = await apiRequest<{ response: Record<string, TopEntry[]> }>('dashboard/stats/getTop', {
|
||||
token,
|
||||
node,
|
||||
body: { type: statsType, statsType: type, limit: String(limit) },
|
||||
})
|
||||
if (outcome.kind !== 'ok') return []
|
||||
const r = outcome.data.response
|
||||
return r.topClients ?? r.topDomains ?? r.topBlockedDomains ?? []
|
||||
}
|
||||
|
||||
export function deleteAllStats(token: string | null): Promise<ApiOutcome> {
|
||||
return apiRequest('dashboard/stats/deleteAll', { token })
|
||||
}
|
||||
|
||||
/*
|
||||
`dashboard/metrics/json`: the server's lifetime counters, current to the query
|
||||
(a query shows on the very next read). Upstream documents it in APIDOCS.md
|
||||
("Get Metrics (JSON)", marked experimental) but its console does not call it;
|
||||
this console reads it to keep Last Hour moving between reloads (deviation 5 in
|
||||
CONVENTIONS.md). `node=cluster` does NOT aggregate, it answers the local node, so
|
||||
the aggregate is summed by the caller, node by node. Checked against v15.6.0.
|
||||
|
||||
`totalClients` counts distinct clients, so differences of it mean nothing: it is
|
||||
not one of the live keys.
|
||||
*/
|
||||
export const LIVE_KEYS = [
|
||||
'totalQueries', 'totalNoError', 'totalServerFailure', 'totalNxDomain', 'totalRefused',
|
||||
'totalAuthoritative', 'totalRecursive', 'totalCached', 'totalBlocked', 'totalDropped',
|
||||
] as const
|
||||
export type LiveKey = (typeof LIVE_KEYS)[number]
|
||||
export type LiveCounters = Record<LiveKey, number>
|
||||
|
||||
export interface Metrics {
|
||||
uptimestamp: string
|
||||
lifetimeCounters: LiveCounters & { totalClients: number }
|
||||
}
|
||||
|
||||
export async function getMetrics(token: string | null, node?: string): Promise<ApiOutcome<Metrics>> {
|
||||
const outcome = await apiRequest<{ response: Metrics }>('dashboard/metrics/json', { token, node })
|
||||
if (outcome.kind !== 'ok') return outcome
|
||||
/* Experimental upstream: a response of another shape is a failure, not a crash. */
|
||||
const data = outcome.data?.response
|
||||
if (data == null || typeof data.uptimestamp !== 'string' ||
|
||||
typeof data.lifetimeCounters !== 'object' || data.lifetimeCounters == null ||
|
||||
LIVE_KEYS.some((key) => typeof data.lifetimeCounters[key] !== 'number' ||
|
||||
!Number.isFinite(data.lifetimeCounters[key]) || data.lifetimeCounters[key] < 0)) {
|
||||
return { kind: 'error', message: 'Unexpected metrics response.' }
|
||||
}
|
||||
return { kind: 'ok', data }
|
||||
}
|
||||
|
||||
/*
|
||||
The hours of a span, for the Dashboard's week by hour (deviation 7 in
|
||||
CONVENTIONS.md). The same `stats/get` as `getDashboardStats` with `type=Custom`,
|
||||
asked separately so the period's own reads stay what they are; only the main
|
||||
chart is read, its labels kept as the UTC instants the server wrote.
|
||||
*/
|
||||
export async function getHours(
|
||||
token: string | null,
|
||||
start: string,
|
||||
end: string,
|
||||
node?: string,
|
||||
): Promise<ApiOutcome<ChartData>> {
|
||||
const body = { type: 'Custom', utc: 'true', start, end }
|
||||
const outcome = await apiRequest<{ response: DashboardStats }>('dashboard/stats/get', { token, body, node })
|
||||
if (outcome.kind !== 'ok') return outcome
|
||||
const chart = outcome.data.response?.mainChartData
|
||||
if (chart == null) return { kind: 'error', message: 'Unexpected statistics response.' }
|
||||
return { kind: 'ok', data: localiseLabels(chart) }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import * as client from './client'
|
||||
import {
|
||||
convertToDynamicLease,
|
||||
convertToReservedLease,
|
||||
deleteScope,
|
||||
disableScope,
|
||||
enableScope,
|
||||
getScope,
|
||||
listLeases,
|
||||
listScopes,
|
||||
removeLease,
|
||||
setScope,
|
||||
type DhcpScope,
|
||||
} from './dhcp'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { kind: 'ok' as const, data }
|
||||
}
|
||||
|
||||
/* A scope exactly as a freshly installed v15.4 instance really returns it:
|
||||
WITHOUT `domainSearchList`, `serverAddress`, `serverHostName`, `bootFileName`,
|
||||
`winsServers`, `ntpServers`, `staticRoutes`, `vendorInfo`,
|
||||
`capwapAcIpAddresses`, `tftpServerAddresses` or `genericOptions`. */
|
||||
const SCOPE_REAL: DhcpScope = {
|
||||
name: 'Default',
|
||||
startingAddress: '192.168.1.1',
|
||||
endingAddress: '192.168.1.254',
|
||||
subnetMask: '255.255.255.0',
|
||||
leaseTimeDays: 1,
|
||||
leaseTimeHours: 0,
|
||||
leaseTimeMinutes: 0,
|
||||
offerDelayTime: 0,
|
||||
pingCheckEnabled: false,
|
||||
pingCheckTimeout: 1000,
|
||||
pingCheckRetries: 2,
|
||||
domainName: 'home',
|
||||
dnsUpdates: true,
|
||||
dnsOverwriteForDynamicLease: false,
|
||||
dnsTtl: 900,
|
||||
routerAddress: '192.168.1.1',
|
||||
useThisDnsServer: true,
|
||||
dnsServers: ['172.23.0.2'],
|
||||
exclusions: [{ startingAddress: '192.168.1.1', endingAddress: '192.168.1.10' }],
|
||||
reservedLeases: [],
|
||||
allowOnlyReservedLeases: false,
|
||||
blockLocallyAdministeredMacAddresses: false,
|
||||
ignoreClientIdentifierOption: true,
|
||||
}
|
||||
|
||||
describe('api/dhcp: leases', () => {
|
||||
it('leases/list asks for the node and returns the list', async () => {
|
||||
const spy = vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue(ok({ response: { leases: [{ scope: 'Default' }] } }))
|
||||
|
||||
const leases = await listLeases('tok')
|
||||
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'dhcp/leases/list')!
|
||||
expect(call[1]).toMatchObject({ token: 'tok', body: { node: '' } })
|
||||
expect(leases.kind === 'ok' && leases.data).toHaveLength(1)
|
||||
})
|
||||
|
||||
/*
|
||||
This test claimed the opposite ("returns an empty list if the server fails")
|
||||
and was pinning the bug in place: an empty list and a fallen call draw the
|
||||
same, so the screen said "No Lease Found" when what had happened was that
|
||||
there was no response. Now the failure rises as it is, with its message, and it
|
||||
is the screen that decides what to show.
|
||||
*/
|
||||
it('leases/list raises the failure from the server, not an empty list', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await listLeases('tok')).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
|
||||
it('and a genuinely empty list is still an empty list', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({ response: { leases: [] } }))
|
||||
expect(await listLeases('tok')).toEqual({ kind: 'ok', data: [] })
|
||||
})
|
||||
|
||||
it('the three actions on a lease send scope and clientIdentifier', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
|
||||
await removeLease('tok', 'Default', '1-AA')
|
||||
await convertToReservedLease('tok', 'Default', '1-AA')
|
||||
await convertToDynamicLease('tok', 'Default', '1-AA')
|
||||
|
||||
const body = { name: 'Default', clientIdentifier: '1-AA', node: '' }
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'dhcp/leases/remove')![1]?.body).toEqual(body)
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'dhcp/leases/convertToReserved')![1]?.body).toEqual(
|
||||
body,
|
||||
)
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'dhcp/leases/convertToDynamic')![1]?.body).toEqual(
|
||||
body,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('api/dhcp: scopes', () => {
|
||||
it('scopes/list asks for the node and returns the list', async () => {
|
||||
const spy = vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue(ok({ response: { scopes: [{ name: 'Default' }] } }))
|
||||
|
||||
const scopes = await listScopes('tok')
|
||||
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'dhcp/scopes/list')![1]?.body).toEqual({ node: '' })
|
||||
expect(scopes.kind === 'ok' && scopes.data[0].name).toBe('Default')
|
||||
})
|
||||
|
||||
/*
|
||||
This test claimed the opposite ("returns an empty list if the server fails")
|
||||
and was pinning the bug in place: an empty list and a fallen call draw the
|
||||
same, so the screen said "No Scope Found" when what had happened was that
|
||||
there was no response. Now the failure rises as it is, with its message, and it
|
||||
is the screen that decides what to show.
|
||||
*/
|
||||
it('scopes/list raises the failure from the server, not an empty list', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await listScopes('tok')).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
|
||||
it('scopes/get asks for the name and returns the scope without over-unwrapping', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({ response: SCOPE_REAL }))
|
||||
|
||||
const s = await getScope('tok', 'Default')
|
||||
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'dhcp/scopes/get')![1]?.body).toEqual({
|
||||
name: 'Default',
|
||||
node: '',
|
||||
})
|
||||
expect(s?.name).toBe('Default')
|
||||
// The keys the server omits stay absent: no nulls are invented.
|
||||
expect(s?.staticRoutes).toBeUndefined()
|
||||
expect(s?.winsServers).toBeUndefined()
|
||||
})
|
||||
|
||||
it('scopes/get returns null if the server fails', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await getScope('tok', 'Default')).toBeNull()
|
||||
})
|
||||
|
||||
it('scopes/set goes by POST, with the node in the QUERY and the rest in the body', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
|
||||
await setScope('tok', { name: 'Default' }, 'node-1')
|
||||
|
||||
const call = spy.mock.calls.find((c) => String(c[0]).startsWith('dhcp/scopes/set'))!
|
||||
expect(call[0]).toBe('dhcp/scopes/set?node=node-1')
|
||||
expect(call[1]).toMatchObject({ method: 'POST', body: { name: 'Default' } })
|
||||
// The node is NOT duplicated in the body.
|
||||
expect(call[1]?.body).not.toHaveProperty('node')
|
||||
})
|
||||
|
||||
it('enable, disable and delete send the name and the node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
|
||||
await enableScope('tok', 'Default')
|
||||
await disableScope('tok', 'Default')
|
||||
await deleteScope('tok', 'Default')
|
||||
|
||||
for (const route of ['dhcp/scopes/enable', 'dhcp/scopes/disable', 'dhcp/scopes/delete']) {
|
||||
expect(spy.mock.calls.find((c) => c[0] === route)![1]?.body).toEqual({
|
||||
name: 'Default',
|
||||
node: '',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The `dhcp` family: ten endpoints, all of them in upstream's `dhcp.js`.
|
||||
|
||||
Five things about the server that are NOT deducible from looking at the
|
||||
JavaScript and that govern the two screens:
|
||||
|
||||
1. **Null fields are OMITTED, they do not arrive as `null`.**
|
||||
`WebServiceDhcpApi.cs:127-364` writes each optional property inside an
|
||||
`if (… is not null)`. In `scopes/list`, `interfaceAddress` is missing as
|
||||
soon as the scope is not bound to an interface; in `scopes/get` the missing
|
||||
ones are `domainName`, `domainSearchList`, `serverAddress`,
|
||||
`serverHostName`, `bootFileName`, `routerAddress`, `dnsServers`,
|
||||
`winsServers`, `ntpServers`, `ntpServerDomainNames`, `staticRoutes`,
|
||||
`vendorInfo`, `capwapAcIpAddresses`, `tftpServerAddresses`,
|
||||
`genericOptions` and `exclusions`. Verified against a freshly installed
|
||||
v15.4 instance, which returns a scope without half of those keys. Typing
|
||||
them as required fails. `reservedLeases` is the exception: it is ALWAYS
|
||||
written, even if it is `[]`.
|
||||
|
||||
2. **`scopes/set` sends the body by POST but the `node` in the QUERY**
|
||||
(dhcp.js:558-567). That is honoured to the letter.
|
||||
|
||||
3. **Renaming is the same endpoint**: if the name changed, the OLD name is sent
|
||||
in `name` and the new one in `newName` (dhcp.js:486-493). No `newName` on a
|
||||
new scope.
|
||||
|
||||
4. **`dnsServers` is not sent when `useThisDnsServer` is checked**
|
||||
(dhcp.js:565). It is not a cosmetic detail: the server keeps the ones it had
|
||||
stored, and in fact `scopes/get` still returns them.
|
||||
|
||||
5. **The permissions are asymmetric.** `WebServiceDhcpApi.cs`: the three `list`
|
||||
and `get` ask for `DhcpServer.View`; `scopes/set`, `enable`, `disable` and
|
||||
the two lease conversions ask for `Modify`; but `scopes/delete` and
|
||||
`leases/remove` ask for `Delete`. Deleting a scope is NOT `Modify`.
|
||||
*/
|
||||
|
||||
/** A lease from `dhcp/leases/list`. Every field is always written
|
||||
* (`WebServiceDhcpApi.cs:90-101`), but `hostName` can come `null`. */
|
||||
export interface DhcpLease {
|
||||
scope: string
|
||||
/** `Dynamic` or `Reserved`. */
|
||||
type: string
|
||||
/** Format `AA-BB-CC-DD-EE-FF` (BitConverter.ToString). */
|
||||
hardwareAddress: string
|
||||
clientIdentifier: string
|
||||
address: string
|
||||
hostName: string | null
|
||||
leaseObtained: string
|
||||
leaseExpires: string
|
||||
}
|
||||
|
||||
/** A row of `dhcp/scopes/list`. `interfaceAddress` is missing if it is null. */
|
||||
export interface DhcpScopeRow {
|
||||
name: string
|
||||
enabled: boolean
|
||||
startingAddress: string
|
||||
endingAddress: string
|
||||
subnetMask: string
|
||||
networkAddress: string
|
||||
broadcastAddress: string
|
||||
interfaceAddress?: string
|
||||
}
|
||||
|
||||
export interface StaticRoute {
|
||||
destination: string
|
||||
subnetMask: string
|
||||
router: string
|
||||
}
|
||||
|
||||
export interface VendorInfo {
|
||||
identifier: string
|
||||
information: string
|
||||
}
|
||||
|
||||
export interface GenericOption {
|
||||
code: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface Exclusion {
|
||||
startingAddress: string
|
||||
endingAddress: string
|
||||
}
|
||||
|
||||
export interface ReservedLease {
|
||||
hostName: string | null
|
||||
hardwareAddress: string
|
||||
address: string
|
||||
comments: string | null
|
||||
}
|
||||
|
||||
/** `dhcp/scopes/get`. Only the keys the server writes unconditionally are
|
||||
* required; the rest are optional because they are OMITTED (see the header). */
|
||||
export interface DhcpScope {
|
||||
name: string
|
||||
startingAddress: string
|
||||
endingAddress: string
|
||||
subnetMask: string
|
||||
leaseTimeDays: number
|
||||
leaseTimeHours: number
|
||||
leaseTimeMinutes: number
|
||||
offerDelayTime: number
|
||||
pingCheckEnabled: boolean
|
||||
pingCheckTimeout: number
|
||||
pingCheckRetries: number
|
||||
domainName?: string
|
||||
domainSearchList?: string[]
|
||||
dnsUpdates: boolean
|
||||
dnsOverwriteForDynamicLease: boolean
|
||||
dnsTtl: number
|
||||
serverAddress?: string
|
||||
serverHostName?: string
|
||||
bootFileName?: string
|
||||
routerAddress?: string
|
||||
useThisDnsServer: boolean
|
||||
dnsServers?: string[]
|
||||
winsServers?: string[]
|
||||
ntpServers?: string[]
|
||||
ntpServerDomainNames?: string[]
|
||||
staticRoutes?: StaticRoute[]
|
||||
vendorInfo?: VendorInfo[]
|
||||
capwapAcIpAddresses?: string[]
|
||||
tftpServerAddresses?: string[]
|
||||
genericOptions?: GenericOption[]
|
||||
exclusions?: Exclusion[]
|
||||
/** Always written, even if it comes empty. */
|
||||
reservedLeases: ReservedLease[]
|
||||
allowOnlyReservedLeases: boolean
|
||||
blockLocallyAdministeredMacAddresses: boolean
|
||||
ignoreClientIdentifierOption: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
Returns the whole outcome, not a list.
|
||||
|
||||
It used to return `[]` when the server failed, and that looked prudent ("the
|
||||
screen does not blow up if the request falls over". It was the opposite: an empty
|
||||
list and a failure draw the same, so the screen said "No Lease Found" when what
|
||||
had happened was that the call never arrived. That is worse than an error,
|
||||
because nobody suspects a response that looks normal.
|
||||
|
||||
By returning the `ApiOutcome` (as the list screens already did, and those did
|
||||
warn), the type forces the two apart, and the message the server sent is kept as
|
||||
well, which is what upstream shows.
|
||||
*/
|
||||
/** `dhcp/leases/list` (dhcp.js:46). */
|
||||
export async function listLeases(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<DhcpLease[]>> {
|
||||
const outcome = await apiRequest<{ response: { leases: DhcpLease[] } }>('dhcp/leases/list', {
|
||||
token,
|
||||
body: { node },
|
||||
})
|
||||
return outcome.kind === 'ok'
|
||||
? { kind: 'ok', data: outcome.data.response?.leases ?? [] }
|
||||
: outcome
|
||||
}
|
||||
|
||||
/*
|
||||
The three actions on a lease are identified by the pair `name` (the scope) +
|
||||
`clientIdentifier`. The server also accepts `hardwareAddress` as an alternative
|
||||
(`WebServiceDhcpApi.cs:775-790`), but upstream ALWAYS sends the clientIdentifier,
|
||||
so here too.
|
||||
*/
|
||||
export function removeLease(
|
||||
token: string | null,
|
||||
name: string,
|
||||
clientIdentifier: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('dhcp/leases/remove', { token, body: { name, clientIdentifier, node } })
|
||||
}
|
||||
|
||||
export function convertToReservedLease(
|
||||
token: string | null,
|
||||
name: string,
|
||||
clientIdentifier: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('dhcp/leases/convertToReserved', {
|
||||
token,
|
||||
body: { name, clientIdentifier, node },
|
||||
})
|
||||
}
|
||||
|
||||
export function convertToDynamicLease(
|
||||
token: string | null,
|
||||
name: string,
|
||||
clientIdentifier: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('dhcp/leases/convertToDynamic', {
|
||||
token,
|
||||
body: { name, clientIdentifier, node },
|
||||
})
|
||||
}
|
||||
|
||||
/** `dhcp/scopes/list` (dhcp.js:220). See `listLeases` for why it returns the
|
||||
* whole outcome and not a list. */
|
||||
export async function listScopes(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<DhcpScopeRow[]>> {
|
||||
const outcome = await apiRequest<{ response: { scopes: DhcpScopeRow[] } }>('dhcp/scopes/list', {
|
||||
token,
|
||||
body: { node },
|
||||
})
|
||||
return outcome.kind === 'ok'
|
||||
? { kind: 'ok', data: outcome.data.response?.scopes ?? [] }
|
||||
: outcome
|
||||
}
|
||||
|
||||
/** `dhcp/scopes/get` (dhcp.js:377). `null` if the server fails. */
|
||||
export async function getScope(
|
||||
token: string | null,
|
||||
name: string,
|
||||
node = '',
|
||||
): Promise<DhcpScope | null> {
|
||||
const outcome = await apiRequest<{ response: DhcpScope }>('dhcp/scopes/get', {
|
||||
token,
|
||||
body: { name, node },
|
||||
})
|
||||
return outcome.kind === 'ok' ? outcome.data.response : null
|
||||
}
|
||||
|
||||
/*
|
||||
`dhcp/scopes/set` (dhcp.js:558). POST with the encoded body and the `node` in
|
||||
the query, exactly like upstream. The body is built by `buildBody` in
|
||||
`screens/dhcp/model.ts`, which is the one that knows the form.
|
||||
*/
|
||||
export function setScope(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`dhcp/scopes/set?node=${encodeURIComponent(node)}`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function enableScope(token: string | null, name: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('dhcp/scopes/enable', { token, body: { name, node } })
|
||||
}
|
||||
|
||||
export function disableScope(token: string | null, name: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('dhcp/scopes/disable', { token, body: { name, node } })
|
||||
}
|
||||
|
||||
export function deleteScope(token: string | null, name: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('dhcp/scopes/delete', { token, body: { name, node } })
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { resolve, prepareServer, TYPES, PROTOCOLS } from './dnsclient'
|
||||
import * as client from './client'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('dnsClient', () => {
|
||||
it('it offers the 28 types and the 5 protocols of upstream', () => {
|
||||
expect(TYPES).toHaveLength(28)
|
||||
expect(TYPES[0]).toBe('A')
|
||||
expect(TYPES).toContain('AXFR')
|
||||
expect(PROTOCOLS).toEqual(['UDP','TCP','TLS','HTTPS','QUIC'])
|
||||
})
|
||||
|
||||
it('it sends the parameters under the upstream names', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await resolve('t', { server: 'this-server', domain: 'home.test', type: 'A', protocol: 'UDP', dnssec: true })
|
||||
expect(spy.mock.calls[0][0]).toBe('dnsClient/resolve')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({
|
||||
server: 'this-server', domain: 'home.test', type: 'A',
|
||||
protocol: 'UDP', dnssec: 'true', eDnsClientSubnet: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('it only adds import=true when importing is asked for', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await resolve('t', { server: 's', domain: 'd', type: 'A', protocol: 'UDP', dnssec: false })
|
||||
expect(spy.mock.calls[0][1]?.body?.import).toBeUndefined()
|
||||
spy.mockClear()
|
||||
await resolve('t', { server: 's', domain: 'd', type: 'A', protocol: 'UDP', dnssec: false, runImport: true })
|
||||
expect(spy.mock.calls[0][1]?.body?.import).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareServer', () => {
|
||||
it('it extracts what sits between braces: that is what gets sent', () => {
|
||||
expect(prepareServer('This Server {this-server}', 'UDP').server).toBe('this-server')
|
||||
expect(prepareServer('Cloudflare {1.1.1.1} (DNS-over-TLS)', 'TLS').server).toBe('1.1.1.1')
|
||||
})
|
||||
|
||||
it('with no braces, it sends the text as it is', () => {
|
||||
expect(prepareServer('8.8.8.8', 'UDP').server).toBe('8.8.8.8')
|
||||
})
|
||||
|
||||
it('it forces UDP for recursive-resolver and system-dns', () => {
|
||||
expect(prepareServer('Recursive Resolver {recursive-resolver}', 'TLS').protocol).toBe('UDP')
|
||||
expect(prepareServer('System DNS {system-dns}', 'HTTPS').protocol).toBe('UDP')
|
||||
})
|
||||
|
||||
it('it honours the chosen protocol for any other server', () => {
|
||||
expect(prepareServer('{1.1.1.1}', 'TLS').protocol).toBe('TLS')
|
||||
})
|
||||
|
||||
it('empty braces are an empty server, even though the field has text', () => {
|
||||
expect(prepareServer('Server {}', 'UDP').server).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
`api/dnsClient/resolve` (dnsclient.js:166). The same endpoint resolves and, with
|
||||
`import=true`, also imports the resolved records into this server, which is why
|
||||
the "Resolve" and "Import" buttons share a call.
|
||||
*/
|
||||
|
||||
export const TYPES = ['A','NS','CNAME','SOA','PTR','MX','TXT','RP','AAAA','SRV','NAPTR','DNAME','DS','SSHFP','RRSIG','NSEC','DNSKEY','NSEC3','NSEC3PARAM','TLSA','ZONEMD','SVCB','HTTPS','URI','CAA','ANY','AXFR','ANAME'] as const
|
||||
export const PROTOCOLS = ['UDP','TCP','TLS','HTTPS','QUIC'] as const
|
||||
|
||||
/*
|
||||
Preparing the server before querying (dnsclient.js:99-119). Two things that are
|
||||
not obvious and that get lost if they are not replicated:
|
||||
|
||||
1. The field shows labels like "This Server {this-server}" and what gets sent is
|
||||
ONLY what sits between the first "{" and the last "}".
|
||||
2. If the server is `recursive-resolver` or `system-dns`, the protocol is
|
||||
FORCED to UDP, however much the dropdown says otherwise.
|
||||
|
||||
And the empty-server warning is checked AFTER extracting, not before: a label
|
||||
like "{}" is an empty server even though the field has text.
|
||||
*/
|
||||
export function prepareServer(
|
||||
server: string,
|
||||
protocol: string,
|
||||
): { server: string; protocol: string } {
|
||||
const forceUdp = server.includes('recursive-resolver') || server.includes('system-dns')
|
||||
|
||||
let s = server
|
||||
const i = s.indexOf('{')
|
||||
if (i > -1) {
|
||||
const j = s.lastIndexOf('}')
|
||||
s = s.substring(i + 1, j)
|
||||
}
|
||||
|
||||
return { server: s.trim(), protocol: forceUdp ? 'UDP' : protocol }
|
||||
}
|
||||
|
||||
export interface ResolveParams {
|
||||
server: string
|
||||
domain: string
|
||||
type: string
|
||||
protocol: string
|
||||
dnssec: boolean
|
||||
eDnsClientSubnet?: string
|
||||
runImport?: boolean
|
||||
/** The node selector's value; it travels as `node` (dnsclient.js:154 and 166). */
|
||||
node?: string
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
result: unknown
|
||||
rawResponses?: string[]
|
||||
warningMessage?: string
|
||||
}
|
||||
|
||||
export function resolve(
|
||||
token: string | null,
|
||||
p: ResolveParams,
|
||||
): Promise<ApiOutcome<{ response: ResolveResult }>> {
|
||||
const body: Record<string, string> = {
|
||||
server: p.server,
|
||||
domain: p.domain,
|
||||
type: p.type,
|
||||
protocol: p.protocol,
|
||||
dnssec: String(p.dnssec),
|
||||
eDnsClientSubnet: p.eDnsClientSubnet ?? '',
|
||||
}
|
||||
if (p.runImport) body.import = 'true'
|
||||
return apiRequest<{ response: ResolveResult }>('dnsClient/resolve', { token, body, node: p.node })
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
signZone,
|
||||
unsignZone,
|
||||
viewDs,
|
||||
getProperties,
|
||||
addPrivateKey,
|
||||
updatePrivateKey,
|
||||
deletePrivateKey,
|
||||
publishAllPrivateKeys,
|
||||
activateKskDnsKey,
|
||||
rolloverDnsKey,
|
||||
retireDnsKey,
|
||||
convertToNSEC,
|
||||
convertToNSEC3,
|
||||
updateNSEC3Params,
|
||||
updateDnsKeyTtl,
|
||||
planNxProof,
|
||||
} from './dnssec'
|
||||
import * as client from './client'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
const env = (r: unknown) => ({ kind: 'ok' as const, data: { status: 'ok', response: r } })
|
||||
|
||||
const baseSignature = { dnsKeyTtl: '3600', zskRolloverDays: '30', nxProof: 'NSEC' as const }
|
||||
|
||||
describe('private keys stay out of request URLs', () => {
|
||||
it('signing and adding a private key send PEM markers in POST bodies', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' }), { headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
const marker = '-----BEGIN PRIVATE KEY-----\nDUMMY-KEY-MARKER\n-----END PRIVATE KEY-----'
|
||||
await signZone('dummy-token', 'home.test', {
|
||||
...baseSignature, algorithm: 'ECDSA', curve: 'P256',
|
||||
pemKskPrivateKey: marker, pemZskPrivateKey: marker,
|
||||
})
|
||||
await addPrivateKey('dummy-token', 'home.test', {
|
||||
keyType: 'KeySigningKey', algorithm: 'ECDSA', curve: 'P256',
|
||||
rolloverDays: '0', pemPrivateKey: marker,
|
||||
})
|
||||
for (const [url, init] of fetchSpy.mock.calls) {
|
||||
expect(new URL(String(url), 'https://fixture.test').search).not.toContain('pem')
|
||||
expect(String(url)).not.toContain('DUMMY-KEY-MARKER')
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(new URLSearchParams(String(init?.body)).toString()).toContain('DUMMY-KEY-MARKER')
|
||||
expect(init?.headers).toMatchObject({ Authorization: 'Bearer dummy-token' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('signing and unsigning', () => {
|
||||
it('ECDSA sends `curve` and NOT the RSA parameters', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await signZone('t', 'home.test', { ...baseSignature, algorithm: 'ECDSA', curve: 'P256' })
|
||||
const body = spy.mock.calls[0][1]?.body
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/dnssec/sign')
|
||||
expect(body).toMatchObject({ algorithm: 'ECDSA', curve: 'P256' })
|
||||
expect(body).not.toHaveProperty('hashAlgorithm')
|
||||
expect(body).not.toHaveProperty('kskKeySize')
|
||||
})
|
||||
|
||||
it('RSA sends hash and sizes, and NOT `curve`', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await signZone('t', 'home.test', {
|
||||
...baseSignature,
|
||||
algorithm: 'RSA',
|
||||
hashAlgorithm: 'SHA256',
|
||||
kskKeySize: '2048',
|
||||
zskKeySize: '1280',
|
||||
})
|
||||
const body = spy.mock.calls[0][1]?.body
|
||||
expect(body).toMatchObject({ hashAlgorithm: 'SHA256', kskKeySize: '2048', zskKeySize: '1280' })
|
||||
expect(body).not.toHaveProperty('curve')
|
||||
})
|
||||
|
||||
it('NSEC3 adds iterations and salt; NSEC does not send them', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await signZone('t', 'home.test', {
|
||||
...baseSignature,
|
||||
algorithm: 'ECDSA',
|
||||
curve: 'P256',
|
||||
nxProof: 'NSEC3',
|
||||
iterations: '5',
|
||||
saltLength: '8',
|
||||
})
|
||||
expect(spy.mock.calls[0][1]?.body).toMatchObject({ iterations: '5', saltLength: '8' })
|
||||
|
||||
await signZone('t', 'home.test', { ...baseSignature, algorithm: 'ECDSA', curve: 'P256' })
|
||||
expect(spy.mock.calls[1][1]?.body).not.toHaveProperty('iterations')
|
||||
})
|
||||
|
||||
it('the PEMs always travel, even when empty', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await signZone('t', 'home.test', { ...baseSignature, algorithm: 'ECDSA', curve: 'P256' })
|
||||
expect(spy.mock.calls[0][1]?.body).toMatchObject({
|
||||
pemKskPrivateKey: '',
|
||||
pemZskPrivateKey: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('unsign only sends the zone and the node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await unsignZone('t', 'home.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/dnssec/unsign')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ zone: 'home.test', node: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reads', () => {
|
||||
it('viewDS tolerates there being no DS records', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(env({ name: 'home.test' }))
|
||||
expect(await viewDs('t', 'home.test')).toMatchObject({ dsRecords: [] })
|
||||
})
|
||||
|
||||
it('properties/get tolerates there being no keys', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(env({ name: 'home.test', dnsKeyTtl: 3600 }))
|
||||
expect(await getProperties('t', 'home.test')).toMatchObject({ dnssecPrivateKeys: [] })
|
||||
})
|
||||
|
||||
it('both return null if the call fails', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'x' })
|
||||
expect(await viewDs('t', 'home.test')).toBeNull()
|
||||
expect(await getProperties('t', 'home.test')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the nine actions on keys', () => {
|
||||
it('each one calls its endpoint with the keyTag as a string', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
|
||||
await updatePrivateKey('t', 'home.test', 47895, '30')
|
||||
await deletePrivateKey('t', 'home.test', 47895)
|
||||
await publishAllPrivateKeys('t', 'home.test')
|
||||
await activateKskDnsKey('t', 'home.test', 47895)
|
||||
await rolloverDnsKey('t', 'home.test', 47895)
|
||||
await retireDnsKey('t', 'home.test', 47895)
|
||||
await updateDnsKeyTtl('t', 'home.test', '7200')
|
||||
|
||||
expect(spy.mock.calls.map((c) => c[0])).toEqual([
|
||||
'zones/dnssec/properties/updatePrivateKey',
|
||||
'zones/dnssec/properties/deletePrivateKey',
|
||||
'zones/dnssec/properties/publishAllPrivateKeys',
|
||||
'zones/dnssec/properties/activateKskDnsKey',
|
||||
'zones/dnssec/properties/rolloverDnsKey',
|
||||
'zones/dnssec/properties/retireDnsKey',
|
||||
'zones/dnssec/properties/updateDnsKeyTtl',
|
||||
])
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({
|
||||
zone: 'home.test',
|
||||
keyTag: '47895',
|
||||
rolloverDays: '30',
|
||||
node: '',
|
||||
})
|
||||
expect(spy.mock.calls[6][1]?.body).toMatchObject({ ttl: '7200' })
|
||||
})
|
||||
|
||||
it('adding a key splits the parameters by algorithm, as when signing', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await addPrivateKey('t', 'home.test', {
|
||||
keyType: 'KeySigningKey',
|
||||
algorithm: 'RSA',
|
||||
hashAlgorithm: 'SHA512',
|
||||
keySize: '4096',
|
||||
rolloverDays: '0',
|
||||
})
|
||||
const body = spy.mock.calls[0][1]?.body
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/dnssec/properties/addPrivateKey')
|
||||
expect(body).toMatchObject({ hashAlgorithm: 'SHA512', keySize: '4096', pemPrivateKey: '' })
|
||||
expect(body).not.toHaveProperty('curve')
|
||||
})
|
||||
|
||||
it('the three proof-of-non-existence changes go to their endpoint', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await convertToNSEC('t', 'home.test')
|
||||
await convertToNSEC3('t', 'home.test', '5', '8')
|
||||
await updateNSEC3Params('t', 'home.test', '5', '8')
|
||||
expect(spy.mock.calls.map((c) => c[0])).toEqual([
|
||||
'zones/dnssec/properties/convertToNSEC',
|
||||
'zones/dnssec/properties/convertToNSEC3',
|
||||
'zones/dnssec/properties/updateNSEC3Params',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('planNxProof: the decision table of changeDnssecNxProof', () => {
|
||||
const zeros = { iterations: '0', saltLength: '0' }
|
||||
|
||||
it('NSEC to NSEC calls nobody', () => {
|
||||
expect(planNxProof('NSEC', 'NSEC', zeros, zeros)).toEqual({ action: 'none' })
|
||||
})
|
||||
|
||||
it('NSEC → NSEC3 converts, with the new values', () => {
|
||||
expect(planNxProof('NSEC', 'NSEC3', zeros, { iterations: '5', saltLength: '8' })).toEqual({
|
||||
action: 'convertToNSEC3',
|
||||
iterations: '5',
|
||||
saltLength: '8',
|
||||
})
|
||||
})
|
||||
|
||||
it('NSEC3 → NSEC3 with no changes calls nobody', () => {
|
||||
const v = { iterations: '5', saltLength: '8' }
|
||||
expect(planNxProof('NSEC3', 'NSEC3', v, v)).toEqual({ action: 'none' })
|
||||
})
|
||||
|
||||
it('NSEC3 → NSEC3 with changes updates the parameters', () => {
|
||||
expect(
|
||||
planNxProof('NSEC3', 'NSEC3', { iterations: '5', saltLength: '8' }, { iterations: '6', saltLength: '8' }),
|
||||
).toEqual({ action: 'updateNSEC3Params', iterations: '6', saltLength: '8' })
|
||||
})
|
||||
|
||||
it('NSEC3 → NSEC converts back', () => {
|
||||
expect(planNxProof('NSEC3', 'NSEC', { iterations: '5', saltLength: '8' }, zeros)).toEqual({
|
||||
action: 'convertToNSEC',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,373 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The 15 DNSSEC endpoints of the `zones` family: `sign`, `unsign`, `viewDS` and
|
||||
the 12 of `zones/dnssec/properties/*`. They are kept apart from zones.ts because
|
||||
they are a whole sub-system with a vocabulary of their own (keys, states,
|
||||
proofs of non-existence) and because together they are half of zone management.
|
||||
|
||||
A replica of zone.js:6539-7400.
|
||||
|
||||
Three things that are contract and not our preference:
|
||||
|
||||
1. **They all carry `node`**, the cluster node the request is aimed at
|
||||
(`optZonesClusterNode`). With a single instance it goes empty, and that is
|
||||
how upstream sends it: the string `&node=` travels all the same.
|
||||
|
||||
2. **The algorithm's parameters are mutually exclusive**: RSA sends
|
||||
`hashAlgorithm` and the key sizes; ECDSA and EDDSA send `curve`. Upstream
|
||||
builds the query with a `switch` and does NOT send the ones of the algorithm
|
||||
that was not chosen. Sending them all would be changing the request.
|
||||
|
||||
3. **`nxProof` only adds `iterations` and `saltLength` when it is NSEC3.** With
|
||||
NSEC they do not travel.
|
||||
*/
|
||||
|
||||
export type Algorithm = 'RSA' | 'ECDSA' | 'EDDSA'
|
||||
export type NxProof = 'NSEC' | 'NSEC3'
|
||||
export type KeyKind = 'KeySigningKey' | 'ZoneSigningKey'
|
||||
|
||||
/** States of a private key, in the order of upstream's life cycle. */
|
||||
export type KeyState = 'Generated' | 'Published' | 'Ready' | 'Active' | 'Retired' | 'Revoked'
|
||||
|
||||
export interface PrivateKey {
|
||||
keyTag: number
|
||||
keyType: KeyKind
|
||||
algorithm: string
|
||||
algorithmNumber: number
|
||||
state: KeyState
|
||||
stateChangedOn: string
|
||||
/** Only arrives while the key is `Published`. */
|
||||
stateReadyBy?: string
|
||||
/** Only arrives while the key is `Ready`. */
|
||||
stateActiveBy?: string
|
||||
isRetiring: boolean
|
||||
rolloverDays: number
|
||||
}
|
||||
|
||||
export interface DnssecPropertiesData {
|
||||
name: string
|
||||
type: string
|
||||
disabled: boolean
|
||||
dnssecStatus: string
|
||||
dnsKeyTtl: number
|
||||
dnssecPrivateKeys: PrivateKey[]
|
||||
/** Only with NSEC3. With NSEC the server omits them, it does not send them as zero. */
|
||||
nsec3Iterations?: number
|
||||
nsec3SaltLength?: number
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
digestType: string
|
||||
/** Arrives as a STRING, not as a number, however much the name says "Number". */
|
||||
digestTypeNumber: string
|
||||
digest: string
|
||||
}
|
||||
|
||||
export interface DsRecord {
|
||||
keyTag: number
|
||||
dnsKeyState: KeyState
|
||||
/** It may not come: it only exists while the key is waiting to be ready. */
|
||||
dnsKeyStateReadyBy?: string
|
||||
isRetiring: boolean
|
||||
algorithm: string
|
||||
algorithmNumber: number
|
||||
publicKey: string
|
||||
digests: Digest[]
|
||||
}
|
||||
|
||||
export interface InfoDs {
|
||||
name: string
|
||||
type: string
|
||||
disabled: boolean
|
||||
dnssecStatus: string
|
||||
dsRecords: DsRecord[]
|
||||
}
|
||||
|
||||
/** Key-generation parameters when signing. See `signPrimaryZone`. */
|
||||
export interface SignOptions {
|
||||
algorithm: Algorithm
|
||||
/** RSA */
|
||||
hashAlgorithm?: string
|
||||
kskKeySize?: string
|
||||
zskKeySize?: string
|
||||
/** ECDSA and EDDSA */
|
||||
curve?: string
|
||||
pemKskPrivateKey?: string
|
||||
pemZskPrivateKey?: string
|
||||
dnsKeyTtl: string
|
||||
zskRolloverDays: string
|
||||
nxProof: NxProof
|
||||
/** Only with NSEC3. */
|
||||
iterations?: string
|
||||
saltLength?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `zones/dnssec/sign`. The order and the split of parameters replicates
|
||||
* `signPrimaryZone` (zone.js:6578): the PEMs ALWAYS travel, even when empty,
|
||||
* because upstream concatenates them unconditionally.
|
||||
*/
|
||||
export function signZone(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
o: SignOptions,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
const body: Record<string, string> = {
|
||||
zone,
|
||||
algorithm: o.algorithm,
|
||||
pemKskPrivateKey: o.pemKskPrivateKey ?? '',
|
||||
pemZskPrivateKey: o.pemZskPrivateKey ?? '',
|
||||
dnsKeyTtl: o.dnsKeyTtl,
|
||||
zskRolloverDays: o.zskRolloverDays,
|
||||
nxProof: o.nxProof,
|
||||
}
|
||||
|
||||
if (o.nxProof === 'NSEC3') {
|
||||
body.iterations = o.iterations ?? '0'
|
||||
body.saltLength = o.saltLength ?? '0'
|
||||
}
|
||||
|
||||
switch (o.algorithm) {
|
||||
case 'RSA':
|
||||
body.hashAlgorithm = o.hashAlgorithm ?? 'SHA256'
|
||||
body.kskKeySize = o.kskKeySize ?? '2048'
|
||||
body.zskKeySize = o.zskKeySize ?? '1280'
|
||||
break
|
||||
case 'ECDSA':
|
||||
case 'EDDSA':
|
||||
body.curve = o.curve ?? ''
|
||||
break
|
||||
}
|
||||
|
||||
body.node = node
|
||||
return apiRequest('zones/dnssec/sign', { token, method: 'POST', body })
|
||||
}
|
||||
|
||||
/** `zones/dnssec/unsign` (zone.js:6681). */
|
||||
export function unsignZone(token: string | null, zone: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/unsign', { token, body: { zone, node } })
|
||||
}
|
||||
|
||||
/** `zones/dnssec/viewDS` (zone.js:6734). */
|
||||
export async function viewDs(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<InfoDs | null> {
|
||||
const outcome = await apiRequest<{ response: InfoDs }>('zones/dnssec/viewDS', {
|
||||
token,
|
||||
body: { zone, node },
|
||||
})
|
||||
if (outcome.kind !== 'ok') return null
|
||||
const r = outcome.data.response
|
||||
return { ...r, dsRecords: r.dsRecords ?? [] }
|
||||
}
|
||||
|
||||
/** `zones/dnssec/properties/get` (zone.js:6836). */
|
||||
export async function getProperties(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<DnssecPropertiesData | null> {
|
||||
const outcome = await apiRequest<{ response: DnssecPropertiesData }>(
|
||||
'zones/dnssec/properties/get',
|
||||
{ token, body: { zone, node } },
|
||||
)
|
||||
if (outcome.kind !== 'ok') return null
|
||||
const r = outcome.data.response
|
||||
return { ...r, dnssecPrivateKeys: r.dnssecPrivateKeys ?? [] }
|
||||
}
|
||||
|
||||
/** Parameters of `addPrivateKey`. See `addDnssecPrivateKey` (zone.js:7191). */
|
||||
export interface NewKeyOptions {
|
||||
keyType: KeyKind
|
||||
algorithm: Algorithm
|
||||
pemPrivateKey?: string
|
||||
rolloverDays: string
|
||||
hashAlgorithm?: string
|
||||
keySize?: string
|
||||
curve?: string
|
||||
}
|
||||
|
||||
export function addPrivateKey(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
o: NewKeyOptions,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
const body: Record<string, string> = {
|
||||
zone,
|
||||
keyType: o.keyType,
|
||||
algorithm: o.algorithm,
|
||||
pemPrivateKey: o.pemPrivateKey ?? '',
|
||||
rolloverDays: o.rolloverDays,
|
||||
}
|
||||
|
||||
switch (o.algorithm) {
|
||||
case 'RSA':
|
||||
body.hashAlgorithm = o.hashAlgorithm ?? 'SHA256'
|
||||
body.keySize = o.keySize ?? '2048'
|
||||
break
|
||||
case 'ECDSA':
|
||||
case 'EDDSA':
|
||||
body.curve = o.curve ?? ''
|
||||
break
|
||||
}
|
||||
|
||||
body.node = node
|
||||
return apiRequest('zones/dnssec/properties/addPrivateKey', { token, method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updatePrivateKey(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
keyTag: number,
|
||||
rolloverDays: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/updatePrivateKey', {
|
||||
token,
|
||||
body: { zone, keyTag: String(keyTag), rolloverDays, node },
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePrivateKey(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
keyTag: number,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/deletePrivateKey', {
|
||||
token,
|
||||
body: { zone, keyTag: String(keyTag), node },
|
||||
})
|
||||
}
|
||||
|
||||
export function publishAllPrivateKeys(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/publishAllPrivateKeys', {
|
||||
token,
|
||||
body: { zone, node },
|
||||
})
|
||||
}
|
||||
|
||||
export function activateKskDnsKey(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
keyTag: number,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/activateKskDnsKey', {
|
||||
token,
|
||||
body: { zone, keyTag: String(keyTag), node },
|
||||
})
|
||||
}
|
||||
|
||||
export function rolloverDnsKey(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
keyTag: number,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/rolloverDnsKey', {
|
||||
token,
|
||||
body: { zone, keyTag: String(keyTag), node },
|
||||
})
|
||||
}
|
||||
|
||||
export function retireDnsKey(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
keyTag: number,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/retireDnsKey', {
|
||||
token,
|
||||
body: { zone, keyTag: String(keyTag), node },
|
||||
})
|
||||
}
|
||||
|
||||
export function convertToNSEC(token: string | null, zone: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/convertToNSEC', { token, body: { zone, node } })
|
||||
}
|
||||
|
||||
export function convertToNSEC3(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
iterations: string,
|
||||
saltLength: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/convertToNSEC3', {
|
||||
token,
|
||||
body: { zone, iterations, saltLength, node },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNSEC3Params(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
iterations: string,
|
||||
saltLength: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/updateNSEC3Params', {
|
||||
token,
|
||||
body: { zone, iterations, saltLength, node },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateDnsKeyTtl(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
ttl: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/dnssec/properties/updateDnsKeyTtl', {
|
||||
token,
|
||||
body: { zone, ttl, node },
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
Which endpoint the proof-of-non-existence "Change" button calls is not the
|
||||
component's decision: it is a table from upstream (`changeDnssecNxProof`,
|
||||
zone.js:7251) and it is replicated here whole, including the case where nobody is
|
||||
called and the success alert is drawn all the same.
|
||||
|
||||
current → chosen what happens
|
||||
NSEC → NSEC nothing, but the success alert is drawn anyway
|
||||
NSEC → NSEC3 convertToNSEC3
|
||||
NSEC3 → NSEC3, unchanged nothing, but the success alert is drawn anyway
|
||||
NSEC3 → NSEC3, changed updateNSEC3Params
|
||||
NSEC3 → NSEC convertToNSEC
|
||||
*/
|
||||
export type PlanNxProof =
|
||||
| { action: 'none' }
|
||||
| { action: 'convertToNSEC' }
|
||||
| { action: 'convertToNSEC3'; iterations: string; saltLength: string }
|
||||
| { action: 'updateNSEC3Params'; iterations: string; saltLength: string }
|
||||
|
||||
export function planNxProof(
|
||||
current: NxProof,
|
||||
chosen: NxProof,
|
||||
current2: { iterations: string; saltLength: string },
|
||||
blanks: { iterations: string; saltLength: string },
|
||||
): PlanNxProof {
|
||||
if (current === 'NSEC') {
|
||||
if (chosen === 'NSEC') return { action: 'none' }
|
||||
return { action: 'convertToNSEC3', ...blanks }
|
||||
}
|
||||
|
||||
if (chosen === 'NSEC') return { action: 'convertToNSEC' }
|
||||
|
||||
if (current2.iterations === blanks.iterations && current2.saltLength === blanks.saltLength) {
|
||||
return { action: 'none' }
|
||||
}
|
||||
return { action: 'updateNSEC3Params', ...blanks }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import * as client from './client'
|
||||
import * as user from './user'
|
||||
import {
|
||||
deleteAllLogs,
|
||||
deleteLog,
|
||||
downloadLogText,
|
||||
exportLogsCsv,
|
||||
listLogFiles,
|
||||
openLogDownload,
|
||||
queryLogs,
|
||||
type QueryLogsParams,
|
||||
} from './logs'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { kind: 'ok' as const, data }
|
||||
}
|
||||
|
||||
const FILTERS: QueryLogsParams = {
|
||||
name: 'Query Logs (Sqlite)',
|
||||
classPath: 'QueryLogsSqlite.App',
|
||||
pageNumber: '1',
|
||||
entriesPerPage: '25',
|
||||
descendingOrder: 'true',
|
||||
start: '',
|
||||
end: '',
|
||||
clientIpAddress: '',
|
||||
protocol: '',
|
||||
responseType: '',
|
||||
rcode: '',
|
||||
qname: '',
|
||||
qtype: '',
|
||||
qclass: '',
|
||||
}
|
||||
|
||||
describe('api/logs: files', () => {
|
||||
it('logs/list asks for the node and returns the files', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(
|
||||
ok({ response: { logFiles: [{ fileName: '2026-08-26', size: '2.96 KB' }] } }),
|
||||
)
|
||||
|
||||
const files = await listLogFiles('tok')
|
||||
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'logs/list')![1]?.body).toEqual({ node: '' })
|
||||
expect(files.kind === 'ok' && files.data[0].fileName).toBe('2026-08-26')
|
||||
})
|
||||
|
||||
/*
|
||||
This test claimed the opposite ("returns an empty list if the server fails")
|
||||
and was pinning the bug in place: an empty list and a fallen call draw the
|
||||
same, so the screen said "No Log File Was Found" when what had happened was
|
||||
that there was no response. Now the failure rises as it is, with its message,
|
||||
and it is the screen that decides what to show.
|
||||
*/
|
||||
it('logs/list raises the failure from the server, not an empty list', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await listLogFiles('tok')).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
|
||||
it('logs/delete sends the file in `log`, NOT in `fileName`', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
await deleteLog('tok', '2026-08-26')
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'logs/delete')![1]?.body).toEqual({
|
||||
log: '2026-08-26',
|
||||
node: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs/deleteAll only sends the node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
await deleteAllLogs('tok')
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'logs/deleteAll')![1]?.body).toEqual({ node: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('api/logs: download for the viewer', () => {
|
||||
it('asks for the first 2 MB with the token in the header and returns the text', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ text: () => Promise.resolve('[2026] ok\n') })
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const text = await downloadLogText('tok', '2026-08-26')
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('/api/logs/download?fileName=2026-08-26&limit=2&node=')
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer tok' })
|
||||
expect(text).toBe('[2026] ok\n')
|
||||
})
|
||||
|
||||
it('if the server answers a JSON error it returns it FORMATTED, to draw in the viewer', async () => {
|
||||
const body = JSON.stringify({ status: 'error', errorMessage: "Could not find file 'x'." })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ text: () => Promise.resolve(body) }))
|
||||
|
||||
const text = await downloadLogText('tok', 'nope')
|
||||
|
||||
expect(text).toBe(JSON.stringify(JSON.parse(body), null, 2))
|
||||
expect(text).toContain('"status": "error"')
|
||||
})
|
||||
|
||||
it('returns null if the request does not even go out', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
|
||||
expect(await downloadLogText('tok', '2026-08-26')).toBeNull()
|
||||
})
|
||||
|
||||
it('the Download button asks for the WHOLE file, with `ts` and without `limit`', async () => {
|
||||
const spy = vi.spyOn(user, 'openDownload').mockResolvedValue({ ok: true })
|
||||
|
||||
await openLogDownload('tok', '2026-08-26', 'node-1')
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'tok',
|
||||
'logs/download',
|
||||
{ fileName: '2026-08-26', node: 'node-1' },
|
||||
{ ts: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('api/logs: query and export', () => {
|
||||
it('logs/query sends the fourteen filters plus the node', async () => {
|
||||
const spy = vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue(ok({ response: { pageNumber: 1, totalPages: 1, totalEntries: 0, entries: [] } }))
|
||||
|
||||
await queryLogs('tok', FILTERS)
|
||||
|
||||
expect(spy.mock.calls.find((c) => c[0] === 'logs/query')![1]?.body).toEqual({
|
||||
...FILTERS,
|
||||
node: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('logs/export does NOT send the three paging parameters nor `ts`', async () => {
|
||||
const spy = vi.spyOn(user, 'openDownload').mockResolvedValue({ ok: true })
|
||||
|
||||
await exportLogsCsv('tok', { ...FILTERS, qname: 'home.test' })
|
||||
|
||||
const [, route, params, options] = spy.mock.calls[0]
|
||||
expect(route).toBe('logs/export')
|
||||
expect(params).not.toHaveProperty('pageNumber')
|
||||
expect(params).not.toHaveProperty('entriesPerPage')
|
||||
expect(params).not.toHaveProperty('descendingOrder')
|
||||
expect(params).toMatchObject({ name: 'Query Logs (Sqlite)', qname: 'home.test', node: '' })
|
||||
expect(options).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { openDownload } from './user'
|
||||
import { urlApi } from '../app/base'
|
||||
|
||||
/*
|
||||
The `logs` family: six endpoints, all of them in upstream's `logs.js`.
|
||||
|
||||
Five things to know before touching this screen:
|
||||
|
||||
1. **`logs/download` does NOT return JSON.** On success it answers `text/plain`
|
||||
and the whole file (`Content-Disposition: attachment`); on error it answers
|
||||
`application/json` and the usual envelope. Checked against a v15.4 instance.
|
||||
That is why it can NOT go through `apiRequest`, which does `res.json()` and
|
||||
would turn any log into a network failure. Upstream asks for it with
|
||||
`isTextResponse: true` and, if what arrives carries `status`, draws it
|
||||
formatted inside the viewer's own `<pre>` (logs.js:170-172): the error reads
|
||||
as if it were the file's content. That is replicated.
|
||||
|
||||
2. **The viewer asks only for the first 2 MB** (`limit=2`, which the server
|
||||
multiplies by 1024*1024 in `WebServiceLogsApi.cs:100`). The "Download"
|
||||
button asks for the WHOLE file: it goes without `limit`.
|
||||
|
||||
3. **`logs/download` and `logs/export` are downloads with a single-use token**,
|
||||
via `window.open`, because the browser has to receive the
|
||||
`Content-Disposition`. They go through `openDownload` in `api/user.ts`.
|
||||
|
||||
4. **`logs/list` returns the name WITHOUT extension** (`fileName` comes from
|
||||
`Path.GetFileNameWithoutExtension`) and the size already formatted as a
|
||||
string (`"20.48 KB"`). The delete is asked for with that same name, but the
|
||||
parameter is called `log`, not `fileName`: the two endpoints do not share a
|
||||
parameter name. Mixing them up gives a 500.
|
||||
|
||||
5. **`logs/query` validates on the server with `Enum.Parse`**: a `qtype` that
|
||||
is not a real type answers `Requested value 'ZZZ' was not found.`. There is
|
||||
no prior client-side validation for that field, and none is added.
|
||||
|
||||
Permissions (`WebServiceLogsApi.cs`): `list`, `download`, `query` and `export`
|
||||
ask for `Logs.View`; `delete` and `deleteAll` ask for `Logs.Delete`.
|
||||
*/
|
||||
|
||||
export interface LogFile {
|
||||
/** Without extension: the server already strips it. */
|
||||
fileName: string
|
||||
/** Already formatted by the server, e.g. `"20.48 KB"`. */
|
||||
size: string
|
||||
}
|
||||
|
||||
/** A row of `logs/query`. `responseRtt` is OMITTED when there is no measurement,
|
||||
* and `qname`, `qtype`, `qclass` and `answer` arrive as an explicit `null` when
|
||||
* the entry has no question or no answer (`WebServiceLogsApi.cs:221-234`). */
|
||||
export interface QueryLogEntry {
|
||||
rowNumber: number
|
||||
timestamp: string
|
||||
clientIpAddress: string
|
||||
protocol: string
|
||||
responseType: string
|
||||
responseRtt?: number
|
||||
rcode: string
|
||||
qname: string | null
|
||||
qtype: string | null
|
||||
qclass: string | null
|
||||
answer: string | null
|
||||
}
|
||||
|
||||
export interface QueryLogPage {
|
||||
pageNumber: number
|
||||
totalPages: number
|
||||
totalEntries: number
|
||||
entries: QueryLogEntry[]
|
||||
}
|
||||
|
||||
/** The query's filters, in the order upstream builds them into the URL. */
|
||||
export interface QueryLogsParams {
|
||||
name: string
|
||||
classPath: string
|
||||
pageNumber: string
|
||||
entriesPerPage: string
|
||||
descendingOrder: string
|
||||
start: string
|
||||
end: string
|
||||
clientIpAddress: string
|
||||
protocol: string
|
||||
responseType: string
|
||||
rcode: string
|
||||
qname: string
|
||||
qtype: string
|
||||
qclass: string
|
||||
node?: string
|
||||
}
|
||||
|
||||
/*
|
||||
Returns the whole outcome, not a list.
|
||||
|
||||
It used to return `[]` when the server failed, and that looked prudent ("the
|
||||
screen does not blow up if the request falls over". It was the opposite: an empty
|
||||
list and a failure draw the same, so the screen said "No Log File Was Found" when
|
||||
what had happened was that the call never arrived. That is worse than an error,
|
||||
because nobody suspects a response that looks normal.
|
||||
|
||||
By returning the `ApiOutcome` (as the list screens already did, and those did
|
||||
warn), the type forces the two apart, and the message the server sent is kept as
|
||||
well, which is what upstream shows.
|
||||
*/
|
||||
/** `logs/list` (logs.js:112). */
|
||||
export async function listLogFiles(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<LogFile[]>> {
|
||||
const outcome = await apiRequest<{ response: { logFiles: LogFile[] } }>('logs/list', {
|
||||
token,
|
||||
body: { node },
|
||||
})
|
||||
return outcome.kind === 'ok'
|
||||
? { kind: 'ok', data: outcome.data.response?.logFiles ?? [] }
|
||||
: outcome
|
||||
}
|
||||
|
||||
/*
|
||||
`logs/download` for the VIEWER (logs.js:161). It does not go through
|
||||
`apiRequest` because the good response is not JSON; see point 1 of the header.
|
||||
What is drawn when the server answers an error is replicated too: the formatted
|
||||
JSON, inside the viewer itself.
|
||||
*/
|
||||
export async function downloadLogText(
|
||||
token: string | null,
|
||||
fileName: string,
|
||||
node = '',
|
||||
limit = '2',
|
||||
): Promise<string | null> {
|
||||
const query = new URLSearchParams({ fileName, limit, node })
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
let text: string
|
||||
try {
|
||||
const res = await fetch(urlApi(`api/logs/download?${query.toString()}`), { headers })
|
||||
text = await res.text()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// logs.js:170: `if (response.status != null) response = JSON.stringify(...)`.
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed !== null && typeof parsed === 'object' && 'status' in parsed) {
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
} catch {
|
||||
/* It was not JSON: it is the log. */
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** The viewer's "Download" button (logs.js:186): the WHOLE file, without
|
||||
* `limit`, with a single-use token and in a new tab.
|
||||
*
|
||||
* It carries `ts` (the cache-buster) because upstream adds it here
|
||||
* (`"&ts=" + (new Date().getTime())`). `logs/export` does NOT carry it. */
|
||||
export function openLogDownload(
|
||||
token: string | null,
|
||||
fileName: string,
|
||||
node = '',
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
return openDownload(token, 'logs/download', { fileName, node }, { ts: true })
|
||||
}
|
||||
|
||||
/** `logs/delete` (logs.js:212). Careful: the parameter is called `log`. */
|
||||
export function deleteLog(token: string | null, log: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('logs/delete', { token, body: { log, node } })
|
||||
}
|
||||
|
||||
export function deleteAllLogs(token: string | null, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('logs/deleteAll', { token, body: { node } })
|
||||
}
|
||||
|
||||
/** `logs/query` (logs.js:436). Returns the outcome unwrapped so the screen can
|
||||
* tell a server error apart from an empty page. */
|
||||
export async function queryLogs(
|
||||
token: string | null,
|
||||
params: QueryLogsParams,
|
||||
): Promise<ApiOutcome<{ response: QueryLogPage }>> {
|
||||
const body: Record<string, string> = { ...params, node: params.node ?? '' }
|
||||
return apiRequest<{ response: QueryLogPage }>('logs/query', { token, body })
|
||||
}
|
||||
|
||||
/** `logs/export` (logs.js:678). The same filters as `query` MINUS the three
|
||||
* paging ones: `pageNumber`, `entriesPerPage` and `descendingOrder` do not
|
||||
* travel. And without `ts`: of the console's six downloads, only the settings
|
||||
* backup and `logs/download` carry it (verified in main.js:3100, logs.js:196,
|
||||
* zone.js:1322, other-zones.js:554 and 623). */
|
||||
export function exportLogsCsv(
|
||||
token: string | null,
|
||||
params: QueryLogsParams,
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
const { name, classPath, start, end, clientIpAddress, protocol, responseType, rcode, qname, qtype, qclass } = params
|
||||
return openDownload(token, 'logs/export', {
|
||||
name,
|
||||
classPath,
|
||||
start,
|
||||
end,
|
||||
clientIpAddress,
|
||||
protocol,
|
||||
responseType,
|
||||
rcode,
|
||||
qname,
|
||||
qtype,
|
||||
qclass,
|
||||
node: params.node ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
The options of the form's dropdowns, with the labels and the literal values of
|
||||
index.html:3300-3399. The first option of the four response filters is the empty
|
||||
one and comes selected.
|
||||
*/
|
||||
export const ENTRIES_PER_PAGE = ['10', '25', '50', '100', '250', '500'] as const
|
||||
|
||||
export const PROTOCOLS: { value: string; label: string }[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'Udp', label: 'UDP' },
|
||||
{ value: 'Tcp', label: 'TCP' },
|
||||
{ value: 'Tls', label: 'TLS' },
|
||||
{ value: 'Https', label: 'HTTPS' },
|
||||
{ value: 'Quic', label: 'QUIC' },
|
||||
{ value: 'UdpProxy', label: 'UDP Proxy' },
|
||||
{ value: 'TcpProxy', label: 'TCP Proxy' },
|
||||
]
|
||||
|
||||
export const RESPONSE_TYPES: { value: string; label: string }[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'Authoritative', label: 'Authoritative' },
|
||||
{ value: 'Recursive', label: 'Recursive' },
|
||||
{ value: 'Cached', label: 'Cached' },
|
||||
{ value: 'Blocked', label: 'Blocked' },
|
||||
{ value: 'UpstreamBlocked', label: 'UpstreamBlocked' },
|
||||
{ value: 'UpstreamBlockedCached', label: 'UpstreamBlockedCached' },
|
||||
]
|
||||
|
||||
export const RCODES: { value: string; label: string }[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'NoError', label: 'No Error' },
|
||||
{ value: 'FormatError', label: 'Format Error' },
|
||||
{ value: 'ServerFailure', label: 'Server Failure' },
|
||||
{ value: 'NxDomain', label: 'NX Domain' },
|
||||
{ value: 'NotImplemented', label: 'Not Implemented' },
|
||||
{ value: 'Refused', label: 'Refused' },
|
||||
{ value: 'YXDomain', label: 'YX Domain' },
|
||||
{ value: 'YXRRSet', label: 'YX RRSet' },
|
||||
{ value: 'NXRRSet', label: 'NX RRSet' },
|
||||
{ value: 'NotAuth', label: 'Not Auth' },
|
||||
{ value: 'NotZone', label: 'Not Zone' },
|
||||
]
|
||||
|
||||
export const QCLASSES = ['', 'IN', 'CS', 'CH', 'HS', 'NONE', 'ANY'] as const
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
getRecords,
|
||||
addRecord,
|
||||
deleteRecord,
|
||||
recordIdentity,
|
||||
flattenSvcParams,
|
||||
flattenGlue,
|
||||
deletionBody,
|
||||
stateChangeBody,
|
||||
fullDomain,
|
||||
zoneHasSvcbAutoHint,
|
||||
type ResourceRecord,
|
||||
} from './records'
|
||||
import * as client from './client'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
const env = (r: unknown) => ({ kind: 'ok' as const, data: { status: 'ok', response: r } })
|
||||
|
||||
/** A record with the bare minimum, so as not to repeat twelve fields in each case. */
|
||||
function rec(type: string, rData: Record<string, unknown>, extra: Partial<ResourceRecord> = {}): ResourceRecord {
|
||||
return {
|
||||
name: 'www',
|
||||
type,
|
||||
ttl: 3600,
|
||||
ttlString: '1h',
|
||||
disabled: false,
|
||||
rData,
|
||||
dnssecStatus: 'Unknown',
|
||||
lastUsedOn: '0001-01-01T00:00:00',
|
||||
lastModified: '2026-08-26T10:00:00Z',
|
||||
expiryTtl: 0,
|
||||
expiryTtlString: '',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
describe('zones/records: transport', () => {
|
||||
it('getRecords does NOT paginate: it asks with listZone=true and no page parameters', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(
|
||||
env({ zone: { name: 'home.test' }, records: [] }),
|
||||
)
|
||||
await getRecords('t', 'home.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/records/get')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({
|
||||
domain: 'home.test',
|
||||
zone: 'home.test',
|
||||
listZone: 'true',
|
||||
node: '',
|
||||
})
|
||||
expect(spy.mock.calls[0][1]?.body).not.toHaveProperty('pageNumber')
|
||||
})
|
||||
|
||||
it('it returns null if the call fails', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'invalid-token' })
|
||||
expect(await getRecords('t', 'x')).toBeNull()
|
||||
})
|
||||
|
||||
it('add and delete are POST and carry `node` in the QUERY, not in the body', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await addRecord('t', { zone: 'home.test' })
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/records/add?node=')
|
||||
expect(spy.mock.calls[0][1]?.method).toBe('POST')
|
||||
expect(spy.mock.calls[0][1]?.body).not.toHaveProperty('node')
|
||||
|
||||
await deleteRecord('t', { zone: 'home.test' }, 'node-2')
|
||||
expect(spy.mock.calls[1][0]).toBe('zones/records/delete?node=node-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('identity of a record', () => {
|
||||
it('A sends the IP and the SVCB hints flag', () => {
|
||||
expect(recordIdentity(rec('A', { ipAddress: '10.0.0.1' }), { updateSvcbHints: true })).toEqual({
|
||||
ipAddress: '10.0.0.1',
|
||||
updateSvcbHints: 'true',
|
||||
})
|
||||
})
|
||||
|
||||
it('NS carries the glue when disabling and NOT when deleting', () => {
|
||||
const r = rec('NS', { nameServer: 'ns1.home.test' }, { glueRecords: ['10.0.0.1', '10.0.0.2'] })
|
||||
expect(recordIdentity(r)).toEqual({
|
||||
nameServer: 'ns1.home.test',
|
||||
glue: '10.0.0.1, 10.0.0.2',
|
||||
})
|
||||
expect(recordIdentity(r, { forDeletion: true })).toEqual({ nameServer: 'ns1.home.test' })
|
||||
})
|
||||
|
||||
it('CNAME, DNAME and APP contribute nothing to the delete (zone.js:6420-6510)', () => {
|
||||
expect(recordIdentity(rec('CNAME', { cname: 'a.b' }), { forDeletion: true })).toEqual({})
|
||||
expect(recordIdentity(rec('DNAME', { dname: 'a.b' }), { forDeletion: true })).toEqual({})
|
||||
expect(
|
||||
recordIdentity(rec('APP', { appName: 'x', classPath: 'y', data: 'z' }), {
|
||||
forDeletion: true,
|
||||
}),
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('TXT identifies by the base64 strings, comma-joined', () => {
|
||||
const r = rec('TXT', { text: 'a b', characterStringsBase64: ['YQ==', 'Yg=='] })
|
||||
expect(recordIdentity(r)).toEqual({ characterStringsBase64: 'YQ==,Yg==' })
|
||||
})
|
||||
|
||||
it('SVCB flattens svcParams and turns an empty target into the root', () => {
|
||||
const r = rec('SVCB', {
|
||||
svcPriority: 1,
|
||||
svcTargetName: '',
|
||||
svcParams: { alpn: 'h2', port: '443' },
|
||||
autoIpv4Hint: true,
|
||||
autoIpv6Hint: false,
|
||||
})
|
||||
expect(recordIdentity(r)).toEqual({
|
||||
svcPriority: '1',
|
||||
svcTargetName: '.',
|
||||
svcParams: 'alpn|h2|port|443',
|
||||
autoIpv4Hint: 'true',
|
||||
autoIpv6Hint: 'false',
|
||||
})
|
||||
})
|
||||
|
||||
it('an empty svcParams travels as the string \"false\", not as an empty string', () => {
|
||||
expect(flattenSvcParams({})).toBe('false')
|
||||
expect(flattenSvcParams(undefined)).toBe('false')
|
||||
})
|
||||
|
||||
it('with no glue, `glue` is an empty string', () => {
|
||||
expect(flattenGlue(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('FWD only drags the proxy along when the type has one', () => {
|
||||
const noProxy = rec('FWD', { protocol: 'Udp', forwarder: '1.1.1.1', priority: 1, dnssecValidation: true, proxyType: 'DefaultProxy' })
|
||||
expect(recordIdentity(noProxy)).not.toHaveProperty('proxyAddress')
|
||||
|
||||
const withProxy = rec('FWD', { protocol: 'Udp', forwarder: '1.1.1.1', priority: 1, dnssecValidation: false, proxyType: 'Socks5', proxyAddress: '10.0.0.9', proxyPort: 1080, proxyUsername: 'u', proxyPassword: 'p' })
|
||||
expect(recordIdentity(withProxy)).toMatchObject({
|
||||
proxyAddress: '10.0.0.9',
|
||||
proxyPort: '1080',
|
||||
proxyUsername: 'u',
|
||||
proxyPassword: 'p',
|
||||
})
|
||||
})
|
||||
|
||||
it('an unknown type sends `rdata` only if the record brings it', () => {
|
||||
expect(recordIdentity(rec('TYPE65280', { value: 'ABCD' }))).toEqual({ rdata: 'ABCD' })
|
||||
expect(recordIdentity(rec('TYPE65280', {}))).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('complete bodies', () => {
|
||||
it('the delete sends zone, domain and type, and the root goes as a dot', () => {
|
||||
const r = rec('MX', { preference: 10, exchange: 'mail.home.test' }, { name: '' })
|
||||
expect(deletionBody('home.test', r)).toEqual({
|
||||
zone: 'home.test',
|
||||
domain: '.',
|
||||
type: 'MX',
|
||||
preference: '10',
|
||||
exchange: 'mail.home.test',
|
||||
})
|
||||
})
|
||||
|
||||
it('disabling resends the whole record with disable=true', () => {
|
||||
const r = rec('MX', { preference: 10, exchange: 'mail.home.test' }, { comments: 'note', expiryTtl: 60 })
|
||||
expect(stateChangeBody('home.test', r, true, false)).toEqual({
|
||||
zone: 'home.test',
|
||||
domain: 'www',
|
||||
type: 'MX',
|
||||
ttl: '3600',
|
||||
disable: 'true',
|
||||
comments: 'note',
|
||||
expiryTtl: '60',
|
||||
preference: '10',
|
||||
exchange: 'mail.home.test',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loose rules of upstream', () => {
|
||||
it('the full name: empty is @, @ is the zone, and the root closes with a dot', () => {
|
||||
expect(fullDomain('home.test', '')).toBe('home.test')
|
||||
expect(fullDomain('home.test', '@')).toBe('home.test')
|
||||
expect(fullDomain('home.test', 'www')).toBe('www.home.test')
|
||||
expect(fullDomain('.', 'www')).toBe('www.')
|
||||
})
|
||||
|
||||
it('without the record list loaded, the SVCB hints are asked for anyway', () => {
|
||||
// zone.js:4690: the default case is `true`, not `false`.
|
||||
expect(zoneHasSvcbAutoHint(null, true, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('it only asks for hints if some SVCB/HTTPS has the automatic one of that family', () => {
|
||||
const svcb = rec('SVCB', { autoIpv4Hint: true, autoIpv6Hint: false })
|
||||
expect(zoneHasSvcbAutoHint([svcb], true, false)).toBe(true)
|
||||
expect(zoneHasSvcbAutoHint([svcb], false, true)).toBe(false)
|
||||
expect(zoneHasSvcbAutoHint([rec('A', { ipAddress: '1.1.1.1' })], true, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,379 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
|
||||
/*
|
||||
The four endpoints of `zones/records/*`: get, add, update and delete.
|
||||
|
||||
They are kept apart from zones.ts because they share something nobody else does:
|
||||
to touch a record you have to **identify it by its content**. The server hands
|
||||
out no identifiers; it is sent the record's full rdata exactly as it stands today
|
||||
and it looks up which one it is. That means delete, disable and edit all have to
|
||||
rebuild those parameters from the record already on screen, and that rebuilding
|
||||
is the most delicate part of zone management.
|
||||
|
||||
A replica of zone.js:4707 (add), 5584 (update), 6225 (updateRecordState) and
|
||||
6400 (delete).
|
||||
|
||||
Four things that come as a surprise and belong to upstream:
|
||||
|
||||
1. **All three are POST**, with the body encoded as a form. `node` is the only
|
||||
parameter that travels in the query.
|
||||
|
||||
2. **`delete` has no case for CNAME, DNAME, SOA or APP.** All four fall to the
|
||||
`default`, which only sends `rdata` if it exists, and it exists for none of
|
||||
them. That is: for those types the server receives zone+domain+type and
|
||||
nothing else. It is not an oversight of ours; that is how it is in
|
||||
zone.js:6420-6510.
|
||||
|
||||
3. **Deleting an NS does not send `glue`; disabling it does.** Same pair of
|
||||
actions, different set of parameters.
|
||||
|
||||
4. **Disabling a record is a `records/update`**, not an endpoint of its own:
|
||||
the whole record is resent with `disable=true`.
|
||||
*/
|
||||
|
||||
export interface ResourceRecord {
|
||||
name: string
|
||||
type: string
|
||||
ttl: number
|
||||
ttlString: string
|
||||
disabled: boolean
|
||||
rData: Record<string, unknown>
|
||||
dnssecStatus: string
|
||||
lastUsedOn: string
|
||||
lastModified: string
|
||||
expiryTtl: number
|
||||
expiryTtlString: string
|
||||
comments?: string
|
||||
/** Only on NS records with glue. */
|
||||
glueRecords?: string[]
|
||||
}
|
||||
|
||||
export interface ZoneDetails {
|
||||
name: string
|
||||
type: string
|
||||
disabled: boolean
|
||||
/** A Catalog or Forwarder zone does NOT bring it: those types cannot be signed. */
|
||||
dnssecStatus?: string
|
||||
internal?: boolean
|
||||
soaSerial?: number
|
||||
catalog?: string | null
|
||||
notifyFailed?: boolean
|
||||
notifyFailedFor?: string[]
|
||||
}
|
||||
|
||||
export interface ZoneRecordsData {
|
||||
zone: ZoneDetails
|
||||
records: ResourceRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* `zones/records/get`. It does NOT paginate: upstream asks for it with
|
||||
* `listZone=true`, receives every record and paginates on the client
|
||||
* (zone.js:3079). Verified against v15.4: sending it `recordsPerPage` changes
|
||||
* nothing.
|
||||
*/
|
||||
export async function getRecords(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<ZoneRecordsData | null> {
|
||||
const outcome = await apiRequest<{ response: ZoneRecordsData }>('zones/records/get', {
|
||||
token,
|
||||
body: { domain: zone, zone, listZone: 'true', node },
|
||||
})
|
||||
if (outcome.kind !== 'ok') return null
|
||||
const r = outcome.data.response
|
||||
return { zone: r.zone, records: r.records ?? [] }
|
||||
}
|
||||
|
||||
export interface AddRecordResponse {
|
||||
response: { addedRecord: ResourceRecord; zone: ZoneDetails }
|
||||
}
|
||||
export interface EditResponse {
|
||||
response: { updatedRecord: ResourceRecord; zone: ZoneDetails }
|
||||
}
|
||||
|
||||
export function addRecord(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<AddRecordResponse>> {
|
||||
return apiRequest<AddRecordResponse>(`zones/records/add?node=${encodeURIComponent(node)}`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateRecord(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<EditResponse>> {
|
||||
return apiRequest<EditResponse>(`zones/records/update?node=${encodeURIComponent(node)}`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRecord(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`zones/records/delete?node=${encodeURIComponent(node)}`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Identity of a record ─────────────────────────────────────────────── */
|
||||
|
||||
const s = (v: unknown): string => (v == null ? '' : String(v))
|
||||
|
||||
/**
|
||||
* `svcParams` travels flattened as `key|value|key|value`, and an empty list
|
||||
* travels as the string `"false"`, which is what comes out of concatenating the
|
||||
* boolean upstream reduces it to (zone.js:5990-6002).
|
||||
*/
|
||||
export function flattenSvcParams(params: unknown): string {
|
||||
const obj = (params ?? {}) as Record<string, unknown>
|
||||
const parts: string[] = []
|
||||
for (const [k, v] of Object.entries(obj)) parts.push(k, s(v))
|
||||
return parts.length === 0 ? 'false' : parts.join('|')
|
||||
}
|
||||
|
||||
/** `data-record-glue`: the addresses joined by ", " (zone.js:3700-3712). */
|
||||
export function flattenGlue(glue: string[] | undefined): string {
|
||||
return (glue ?? []).join(', ')
|
||||
}
|
||||
|
||||
/** `data-record-character-strings-base64`: comma-joined (zone.js:3797-3803). */
|
||||
export function flattenCharacterStrings(r: Record<string, unknown>): string {
|
||||
const list = (r.characterStringsBase64 ?? []) as string[]
|
||||
return list.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameters that identify an existing record to the server. It is what
|
||||
* `deleteRecord` and `updateRecordState` send, and also the "old" half of a
|
||||
* `records/update`.
|
||||
*
|
||||
* `forDeletion` tells the two splits apart, and they are NOT the same: when
|
||||
* deleting, NS goes without `glue`, and CNAME, DNAME, SOA and APP contribute
|
||||
* nothing.
|
||||
*/
|
||||
export function recordIdentity(
|
||||
record: ResourceRecord,
|
||||
options: { forDeletion?: boolean; updateSvcbHints?: boolean } = {},
|
||||
): Record<string, string> {
|
||||
const d = record.rData
|
||||
const deletion = options.forDeletion === true
|
||||
const out: Record<string, string> = {}
|
||||
|
||||
switch (record.type) {
|
||||
case 'A':
|
||||
case 'AAAA':
|
||||
out.ipAddress = s(d.ipAddress)
|
||||
out.updateSvcbHints = String(options.updateSvcbHints ?? false)
|
||||
break
|
||||
|
||||
case 'NS':
|
||||
out.nameServer = s(d.nameServer)
|
||||
if (!deletion) out.glue = flattenGlue(record.glueRecords)
|
||||
break
|
||||
|
||||
case 'CNAME':
|
||||
if (!deletion) out.cname = s(d.cname)
|
||||
break
|
||||
|
||||
case 'PTR':
|
||||
out.ptrName = s(d.ptrName)
|
||||
break
|
||||
|
||||
case 'MX':
|
||||
out.preference = s(d.preference)
|
||||
out.exchange = s(d.exchange)
|
||||
break
|
||||
|
||||
case 'TXT':
|
||||
out.characterStringsBase64 = flattenCharacterStrings(d)
|
||||
break
|
||||
|
||||
case 'RP':
|
||||
out.mailbox = s(d.mailbox)
|
||||
out.txtDomain = s(d.txtDomain)
|
||||
break
|
||||
|
||||
case 'SRV':
|
||||
out.priority = s(d.priority)
|
||||
out.weight = s(d.weight)
|
||||
out.port = s(d.port)
|
||||
out.target = s(d.target)
|
||||
break
|
||||
|
||||
case 'NAPTR':
|
||||
out.naptrOrder = s(d.order)
|
||||
out.naptrPreference = s(d.preference)
|
||||
out.naptrFlags = s(d.flags)
|
||||
out.naptrServices = s(d.services)
|
||||
out.naptrRegexp = s(d.regexp)
|
||||
out.naptrReplacement = s(d.replacement)
|
||||
break
|
||||
|
||||
case 'DNAME':
|
||||
if (!deletion) out.dname = s(d.dname)
|
||||
break
|
||||
|
||||
case 'DS':
|
||||
out.keyTag = s(d.keyTag)
|
||||
out.algorithm = s(d.algorithm)
|
||||
out.digestType = s(d.digestType)
|
||||
out.digest = s(d.digest)
|
||||
break
|
||||
|
||||
case 'SSHFP':
|
||||
out.sshfpAlgorithm = s(d.algorithm)
|
||||
out.sshfpFingerprintType = s(d.fingerprintType)
|
||||
out.sshfpFingerprint = s(d.fingerprint)
|
||||
break
|
||||
|
||||
case 'TLSA':
|
||||
out.tlsaCertificateUsage = s(d.certificateUsage)
|
||||
out.tlsaSelector = s(d.selector)
|
||||
out.tlsaMatchingType = s(d.matchingType)
|
||||
out.tlsaCertificateAssociationData = s(d.certificateAssociationData)
|
||||
break
|
||||
|
||||
case 'SVCB':
|
||||
case 'HTTPS':
|
||||
out.svcPriority = s(d.svcPriority)
|
||||
// An empty target is sent as the root, same as in the row (zone.js:4071).
|
||||
out.svcTargetName = s(d.svcTargetName) === '' ? '.' : s(d.svcTargetName)
|
||||
out.svcParams = flattenSvcParams(d.svcParams)
|
||||
if (!deletion) {
|
||||
out.autoIpv4Hint = String(d.autoIpv4Hint === true)
|
||||
out.autoIpv6Hint = String(d.autoIpv6Hint === true)
|
||||
}
|
||||
break
|
||||
|
||||
case 'URI':
|
||||
out.uriPriority = s(d.priority)
|
||||
out.uriWeight = s(d.weight)
|
||||
out.uri = s(d.uri)
|
||||
break
|
||||
|
||||
case 'CAA':
|
||||
out.flags = s(d.flags)
|
||||
out.tag = s(d.tag)
|
||||
out.value = s(d.value)
|
||||
break
|
||||
|
||||
case 'ANAME':
|
||||
out.aname = s(d.aname)
|
||||
break
|
||||
|
||||
case 'FWD':
|
||||
out.protocol = s(d.protocol)
|
||||
out.forwarder = s(d.forwarder)
|
||||
if (!deletion) {
|
||||
out.forwarderPriority = s(d.priority)
|
||||
out.dnssecValidation = String(d.dnssecValidation === true)
|
||||
out.proxyType = s(d.proxyType)
|
||||
if (d.proxyType === 'Http' || d.proxyType === 'Socks5') {
|
||||
out.proxyAddress = s(d.proxyAddress)
|
||||
out.proxyPort = s(d.proxyPort)
|
||||
out.proxyUsername = s(d.proxyUsername)
|
||||
out.proxyPassword = s(d.proxyPassword)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'APP':
|
||||
if (!deletion) {
|
||||
out.appName = s(d.appName)
|
||||
out.classPath = s(d.classPath)
|
||||
out.recordData = s(d.data)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
// `rdata` only if the record brings it: when deleting, upstream checks for null.
|
||||
if (d.value != null) out.rdata = s(d.value)
|
||||
break
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* `zoneHasSvcbAutoHint` (zone.js:4689). When touching an A or an AAAA the server
|
||||
* has to be told whether it must redo the automatic hints of any SVCB/HTTPS in
|
||||
* the zone. **Without the record list it returns `true`**, not `false`: with the
|
||||
* zone unloaded upstream would rather ask for the update.
|
||||
*/
|
||||
export function zoneHasSvcbAutoHint(
|
||||
records: ResourceRecord[] | null,
|
||||
ipv4: boolean,
|
||||
ipv6: boolean,
|
||||
): boolean {
|
||||
if (records == null) return true
|
||||
|
||||
for (const r of records) {
|
||||
if (r.type !== 'SVCB' && r.type !== 'HTTPS') continue
|
||||
const d = r.rData
|
||||
if ((d.autoIpv4Hint === true && ipv4) || (d.autoIpv6Hint === true && ipv6)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A record's full name from the sub-domain typed into the form
|
||||
* (zone.js:4713-4725). Empty is `@`, `@` is the zone, and at the root the
|
||||
* sub-domain is closed with a dot.
|
||||
*/
|
||||
export function fullDomain(zone: string, subDomain: string): string {
|
||||
const sub = subDomain === '' ? '@' : subDomain
|
||||
if (sub === '@') return zone
|
||||
if (zone === '.') return `${sub}.`
|
||||
return `${sub}.${zone}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a `records/delete`: the identity plus zone, domain and type.
|
||||
* An empty name is the root (zone.js:6410-6411).
|
||||
*/
|
||||
export function deletionBody(zone: string, record: ResourceRecord): Record<string, string> {
|
||||
return {
|
||||
zone,
|
||||
domain: record.name === '' ? '.' : record.name,
|
||||
type: record.type,
|
||||
...recordIdentity(record, { forDeletion: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of the `records/update` that only changes the state. It resends the
|
||||
* whole record (ttl, comments and expiry included) with `disable` set.
|
||||
* `newDomain` is not sent: the name does not change (zone.js:6225-6390).
|
||||
*/
|
||||
export function stateChangeBody(
|
||||
zone: string,
|
||||
record: ResourceRecord,
|
||||
disable: boolean,
|
||||
updateSvcbHints: boolean,
|
||||
): Record<string, string> {
|
||||
const domain = record.name === '' ? '.' : record.name
|
||||
return {
|
||||
zone,
|
||||
domain,
|
||||
type: record.type,
|
||||
ttl: String(record.ttl),
|
||||
disable: String(disable),
|
||||
comments: record.comments ?? '',
|
||||
expiryTtl: String(record.expiryTtl),
|
||||
...recordIdentity(record, { updateSvcbHints }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import * as client from './client'
|
||||
import {
|
||||
BACKUP_ITEMS,
|
||||
flushCache,
|
||||
forceUpdateBlockLists,
|
||||
getSettings,
|
||||
backupParams,
|
||||
restoreSettings,
|
||||
initialBackupSelection,
|
||||
setSettings,
|
||||
temporaryDisableBlocking,
|
||||
type DnsSettings, getTsigKeyNames } from './settings'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { kind: 'ok' as const, data }
|
||||
}
|
||||
|
||||
describe('api/settings', () => {
|
||||
it('settings/get asks with an empty node and returns the content of `response`', async () => {
|
||||
const spy = vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue(ok({ response: { dnsServerDomain: 'dns.test' } }))
|
||||
|
||||
const s = await getSettings('tok')
|
||||
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'settings/get')!
|
||||
expect(call[1]).toMatchObject({ token: 'tok', body: { node: '' } })
|
||||
expect(s?.dnsServerDomain).toBe('dns.test')
|
||||
})
|
||||
|
||||
it('settings/get returns null if the server fails, instead of blowing up', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await getSettings('tok')).toBeNull()
|
||||
})
|
||||
|
||||
it('settings/set goes by POST with the body as it is', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({ response: {} }))
|
||||
|
||||
await setSettings('tok', { node: '', dnsServerDomain: 'dns.test' })
|
||||
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'settings/set')!
|
||||
expect(call[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
token: 'tok',
|
||||
body: { node: '', dnsServerDomain: 'dns.test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('settings/set propagates the errorMessage from the server', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'Invalid port.' })
|
||||
const outcome = await setSettings('tok', {})
|
||||
expect(outcome).toEqual({ kind: 'error', message: 'Invalid port.' })
|
||||
})
|
||||
|
||||
it('settings/forceUpdateBlockLists is called with no parameters', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
expect(await forceUpdateBlockLists('tok')).toBe(true)
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'settings/forceUpdateBlockLists')!
|
||||
expect(call[1]).toEqual({ token: 'tok' })
|
||||
})
|
||||
|
||||
it('settings/temporaryDisableBlocking sends the minutes and returns until when', async () => {
|
||||
const spy = vi
|
||||
.spyOn(client, 'apiRequest')
|
||||
.mockResolvedValue(ok({ response: { temporaryDisableBlockingTill: '2026-08-25T14:00:00Z' } }))
|
||||
|
||||
const till = await temporaryDisableBlocking('tok', '15')
|
||||
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'settings/temporaryDisableBlocking')!
|
||||
expect(call[1]).toMatchObject({ body: { minutes: '15' } })
|
||||
expect(till).toBe('2026-08-25T14:00:00Z')
|
||||
})
|
||||
|
||||
it('cache/flush sends an empty node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({}))
|
||||
expect(await flushCache('tok')).toBe(true)
|
||||
const call = spy.mock.calls.find((c) => c[0] === 'cache/flush')!
|
||||
expect(call[1]).toMatchObject({ body: { node: '' } })
|
||||
})
|
||||
|
||||
it('the initial backup selection checks everything except the logs', () => {
|
||||
const s = initialBackupSelection()
|
||||
expect(Object.keys(s)).toEqual(BACKUP_ITEMS.map((e) => e.key))
|
||||
expect(s.logs).toBe(false)
|
||||
expect(s.authConfig).toBe(true)
|
||||
})
|
||||
|
||||
it('the backup parameters send the thirteen items as true/false', () => {
|
||||
const p = backupParams({ ...initialBackupSelection(), zones: false })
|
||||
expect(p.zones).toBe('false')
|
||||
expect(p.stats).toBe('true')
|
||||
expect(p.node).toBe('')
|
||||
expect(Object.keys(p)).toHaveLength(BACKUP_ITEMS.length + 1)
|
||||
})
|
||||
|
||||
it('settings/restore sends the file by multipart and the options in the query', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(ok({ response: {} }))
|
||||
|
||||
const file = new File(['zip'], 'backup.zip')
|
||||
await restoreSettings('tok', file, initialBackupSelection(), true)
|
||||
|
||||
const call = spy.mock.calls.find((c) => c[0].startsWith('settings/restore'))!
|
||||
expect(call[0]).toContain('deleteExistingFiles=true')
|
||||
expect(call[0]).toContain('logs=false')
|
||||
expect(call[0]).toContain('authConfig=true')
|
||||
expect(call[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
token: 'tok',
|
||||
file: { field: 'fileBackupZip', file: file },
|
||||
})
|
||||
// The body does NOT carry the options: upstream sends them by query only.
|
||||
expect(call[1]?.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settings/restore passes invalid-token through as it is', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'invalid-token' })
|
||||
const outcome = await restoreSettings('tok', new File([''], 'b.zip'), {}, false)
|
||||
expect(outcome.kind).toBe('invalid-token')
|
||||
})
|
||||
})
|
||||
|
||||
// The type has to accept the real response without the absent null keys.
|
||||
const _shape: Partial<DnsSettings> = { temporaryDisableBlockingTill: undefined }
|
||||
void _shape
|
||||
|
||||
describe('getTsigKeyNames', () => {
|
||||
it('Zones consumes it, not Settings: it returns the list of names', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({
|
||||
kind: 'ok', data: { status: 'ok', response: { tsigKeyNames: ['transfer-key'] } },
|
||||
} as never)
|
||||
expect(await getTsigKeyNames('t')).toEqual(['transfer-key'])
|
||||
})
|
||||
|
||||
it('it returns an empty list on failure, without breaking the zone options modal', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'invalid-token' })
|
||||
expect(await getTsigKeyNames('t')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,376 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { settingsChanged } from '../lib/settings-signal'
|
||||
|
||||
/*
|
||||
The `settings` family. Six endpoints come from the old console:
|
||||
|
||||
settings/get main.js:891 (refreshDnsSettings)
|
||||
settings/set main.js:2189 (saveDnsSettings, POST)
|
||||
settings/forceUpdateBlockLists main.js:2346
|
||||
settings/temporaryDisableBlocking main.js:2373
|
||||
settings/backup main.js:3067 (download, single-use token)
|
||||
settings/restore main.js:3137 (POST multipart)
|
||||
|
||||
The screen's seventh control, "Flush Cache", is NOT of this family: it calls
|
||||
`cache/flush` (other-zones.js:20). It is declared here because the Settings
|
||||
action bar was its first consumer. The Cache screen calls its own copy, in
|
||||
`api/zonelists.ts`.
|
||||
|
||||
Three things checked against a v15.4 instance that are NOT deducible from the code:
|
||||
|
||||
1. `settings/get` OMITS the null keys instead of sending them as null. On a
|
||||
fresh install neither `temporaryDisableBlockingTill`, nor
|
||||
`blockListNextUpdatedOn`, nor `clusterNodes` appear. That is why they are
|
||||
optional here and `loadDnsSettings` compares them with `== null`: absent and
|
||||
null count the same. `blockListUrls`, `proxy`, `defaultResponsiblePerson`
|
||||
and the four certificate paths, on the other hand, do arrive explicitly as
|
||||
`null`.
|
||||
2. `proxy` is a nested object (`type`/`address`/`port`/`username`/`password`/
|
||||
`bypass`) when reading, but when saving it is sent FLAT: `proxyType`,
|
||||
`proxyAddress`… And when the type is `none` NO other proxy field is sent at
|
||||
all (main.js:2122-2123): the bypass list is lost on purpose.
|
||||
3. `settings/set` returns the complete settings, just like `get`, and upstream
|
||||
uses them to redraw the form. That is how the server's sanitising shows.
|
||||
*/
|
||||
|
||||
export interface QpmPrefixLimit {
|
||||
prefix: number
|
||||
udpLimit: number
|
||||
tcpLimit: number
|
||||
}
|
||||
|
||||
export interface TsigKey {
|
||||
keyName: string
|
||||
sharedSecret: string
|
||||
algorithmName: string
|
||||
}
|
||||
|
||||
export interface NetProxy {
|
||||
type: string
|
||||
address: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
bypass: string[]
|
||||
}
|
||||
|
||||
export interface DnsSettings {
|
||||
version: string
|
||||
uptimestamp: string
|
||||
clusterInitialized?: boolean
|
||||
clusterNodes?: string[]
|
||||
|
||||
// General: local parameters
|
||||
dnsServerDomain: string
|
||||
dnsServerLocalEndPoints: string[] | null
|
||||
dnsServerIPv4SourceAddresses: string[] | null
|
||||
dnsServerIPv6SourceAddresses: string[] | null
|
||||
|
||||
// General: zone default values
|
||||
defaultRecordTtl: number
|
||||
defaultNsRecordTtl: number
|
||||
defaultSoaRecordTtl: number
|
||||
defaultResponsiblePerson: string | null
|
||||
useSoaSerialDateScheme: boolean
|
||||
minSoaRefresh: number
|
||||
minSoaRetry: number
|
||||
zoneTransferAllowedNetworks: string[]
|
||||
notifyAllowedNetworks: string[]
|
||||
|
||||
// General: software update
|
||||
dnsServerEnableCheckForUpdate: boolean
|
||||
dnsAppsEnableAutomaticUpdate: boolean
|
||||
|
||||
// General: IPv6 and socket pool
|
||||
ipv6Mode: string
|
||||
preferIPv6: boolean
|
||||
enableUdpSocketPool: boolean
|
||||
socketPoolExcludedPorts: number[]
|
||||
|
||||
// General: EDNS, DNSSEC and ECS
|
||||
udpPayloadSize: number
|
||||
dnssecValidation: boolean
|
||||
eDnsClientSubnet: boolean
|
||||
eDnsClientSubnetIPv4PrefixLength: number
|
||||
eDnsClientSubnetIPv6PrefixLength: number
|
||||
eDnsClientSubnetIpv4Override: string | null
|
||||
eDnsClientSubnetIpv6Override: string | null
|
||||
|
||||
// General: queries-per-minute limit
|
||||
qpmPrefixLimitsIPv4: QpmPrefixLimit[]
|
||||
qpmPrefixLimitsIPv6: QpmPrefixLimit[]
|
||||
qpmLimitSampleMinutes: number
|
||||
qpmLimitUdpTruncationPercentage: number
|
||||
qpmLimitBypassList: string[]
|
||||
|
||||
// General: advanced options
|
||||
clientTimeout: number
|
||||
tcpSendTimeout: number
|
||||
tcpReceiveTimeout: number
|
||||
quicIdleTimeout: number
|
||||
quicMaxInboundStreams: number
|
||||
listenBacklog: number
|
||||
udpSendBufferSizeKB: number
|
||||
udpReceiveBufferSizeKB: number
|
||||
maxConcurrentResolutionsPerCore: number
|
||||
|
||||
// Web Service
|
||||
webServiceLocalAddresses: string[]
|
||||
webServiceHttpPort: number
|
||||
webServiceEnableHttpUnixSocket: boolean
|
||||
webServiceHttpUnixSocket: string | null
|
||||
webServiceEnableTlsUnixSocket: boolean
|
||||
webServiceTlsUnixSocket: string | null
|
||||
webServiceEnableTls: boolean
|
||||
webServiceEnableHttp3: boolean
|
||||
webServiceHttpToTlsRedirect: boolean
|
||||
webServiceUseSelfSignedTlsCertificate: boolean
|
||||
webServiceTlsPort: number
|
||||
webServiceReverseProxyAddresses: string[]
|
||||
webServiceRealIpHeader: string
|
||||
webServiceCspFrameAncestorsHeader: string
|
||||
webServiceTlsCertificatePath: string | null
|
||||
webServiceTlsCertificatePassword: string | null
|
||||
|
||||
// Optional Protocols
|
||||
enableEDnsClientSubnetSourceAddress: boolean
|
||||
enableDnsOverUdpProxy: boolean
|
||||
enableDnsOverTcpProxy: boolean
|
||||
enableDnsOverHttp: boolean
|
||||
enableDnsOverHttpUnixSocket: boolean
|
||||
enableDnsOverHttpsUnixSocket: boolean
|
||||
enableDnsOverTls: boolean
|
||||
enableDnsOverHttps: boolean
|
||||
enableDnsOverHttp3: boolean
|
||||
enableDnsOverQuic: boolean
|
||||
enableDnsOverHttpHelpRedirect: boolean
|
||||
dnsOverUdpProxyPort: number
|
||||
dnsOverTcpProxyPort: number
|
||||
dnsOverHttpPort: number
|
||||
dnsOverHttpUnixSocket: string | null
|
||||
dnsOverHttpsUnixSocket: string | null
|
||||
dnsOverTlsPort: number
|
||||
dnsOverHttpsPort: number
|
||||
dnsOverQuicPort: number
|
||||
dnsReverseProxyNetworkACL: string[]
|
||||
dnsOverHttpRealIpHeader: string
|
||||
dnsTlsCertificatePath: string | null
|
||||
dnsTlsCertificatePassword: string | null
|
||||
|
||||
// TSIG
|
||||
tsigKeys: TsigKey[] | null
|
||||
|
||||
// Recursion
|
||||
recursion: string
|
||||
recursionNetworkACL: string[]
|
||||
randomizeName: boolean
|
||||
qnameMinimization: boolean
|
||||
locallyServedDnsZones: boolean
|
||||
resolverRetries: number
|
||||
resolverTimeout: number
|
||||
resolverConcurrency: number
|
||||
resolverMaxStackCount: number
|
||||
|
||||
// Cache
|
||||
saveCache: boolean
|
||||
serveStale: boolean
|
||||
serveStaleTtl: number
|
||||
serveStaleAnswerTtl: number
|
||||
serveStaleResetTtl: number
|
||||
serveStaleMaxWaitTime: number
|
||||
cacheMaximumEntries: number
|
||||
cacheMinimumRecordTtl: number
|
||||
cacheMaximumRecordTtl: number
|
||||
cacheNegativeRecordTtl: number
|
||||
cacheFailureRecordTtl: number
|
||||
/** New in v15.6: a v15.5 server does not send it. */
|
||||
enableCachePrefetch?: boolean
|
||||
cachePrefetchEligibility: number
|
||||
cachePrefetchTrigger: number
|
||||
|
||||
// Blocking
|
||||
enableBlocking: boolean
|
||||
allowTxtBlockingReport: boolean
|
||||
temporaryDisableBlockingTill?: string | null
|
||||
blockingBypassList: string[]
|
||||
blockingType: string
|
||||
blockingAnswerTtl: number
|
||||
customBlockingAddresses: string[]
|
||||
blockListUrls: string[] | null
|
||||
blockListUpdateIntervalHours: number
|
||||
blockListNextUpdatedOn?: string | null
|
||||
|
||||
// Proxy & Forwarders
|
||||
proxy: NetProxy | null
|
||||
forwarders: string[] | null
|
||||
forwarderProtocol: string
|
||||
concurrentForwarding: boolean
|
||||
forwarderRetries: number
|
||||
forwarderTimeout: number
|
||||
forwarderConcurrency: number
|
||||
|
||||
// Logging
|
||||
enableLogging: boolean
|
||||
loggingType: string
|
||||
ignoreResolverLogs: boolean
|
||||
logQueries: boolean
|
||||
noStackTrace: boolean
|
||||
useLocalTime: boolean
|
||||
logFolder: string
|
||||
maxLogFileDays: number
|
||||
enableInMemoryStats: boolean
|
||||
maxStatFileDays: number
|
||||
}
|
||||
|
||||
/** `settings/get` with the whole `ApiOutcome`, for a screen that must say the
|
||||
* server's message when the read fails (the Lists tab of Blocking): `null` alone
|
||||
* cannot carry it. Same call, same parameters as `getSettings`. */
|
||||
export async function readSettings(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<DnsSettings>> {
|
||||
const outcome = await apiRequest<{ response: DnsSettings }>('settings/get', {
|
||||
token,
|
||||
body: { node },
|
||||
})
|
||||
return outcome.kind === 'ok' ? { kind: 'ok', data: outcome.data.response } : outcome
|
||||
}
|
||||
|
||||
/** `settings/get`. `node` exists for cluster mode; with a single instance
|
||||
* upstream sends the empty string and the server answers with its own settings. */
|
||||
export async function getSettings(
|
||||
token: string | null,
|
||||
node = '',
|
||||
): Promise<DnsSettings | null> {
|
||||
const outcome = await readSettings(token, node)
|
||||
return outcome.kind === 'ok' ? outcome.data : null
|
||||
}
|
||||
|
||||
/*
|
||||
The envelope of `settings/set` and `settings/restore`. `server` is the domain of
|
||||
the server that ANSWERED (the chosen node's when the request was proxied) and it
|
||||
sits next to `status`, outside `response` (DnsWebService.cs:2478). Upstream
|
||||
compares it with the session's domain before following the web console to a new
|
||||
address (main.js:2216, 3187).
|
||||
*/
|
||||
export interface SettingsEnvelope {
|
||||
response: DnsSettings
|
||||
server: string
|
||||
}
|
||||
|
||||
/** `settings/set`. Goes by POST with the body urlencoded, just like upstream,
|
||||
* and returns the settings already sanitised by the server so the form can be
|
||||
* redrawn. The whole `ApiOutcome` is returned because the screen needs the
|
||||
* server's `errorMessage` for the `Error!` alert. */
|
||||
export async function setSettings(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
): Promise<ApiOutcome<SettingsEnvelope>> {
|
||||
const outcome = await apiRequest<SettingsEnvelope>('settings/set', {
|
||||
token,
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
// For the sidebar's Blocking mark (`lib/settings-signal.ts`).
|
||||
if (outcome.kind === 'ok') settingsChanged()
|
||||
return outcome
|
||||
}
|
||||
|
||||
/** `settings/forceUpdateBlockLists`. No parameters: forces the download now. */
|
||||
export async function forceUpdateBlockLists(token: string | null): Promise<boolean> {
|
||||
const outcome = await apiRequest('settings/forceUpdateBlockLists', { token })
|
||||
return outcome.kind === 'ok'
|
||||
}
|
||||
|
||||
/** `settings/temporaryDisableBlocking`. Returns until when it stays disabled. */
|
||||
export async function temporaryDisableBlocking(
|
||||
token: string | null,
|
||||
minutes: string,
|
||||
): Promise<string | null> {
|
||||
const outcome = await apiRequest<{ response: { temporaryDisableBlockingTill: string } }>(
|
||||
'settings/temporaryDisableBlocking',
|
||||
{ token, body: { minutes } },
|
||||
)
|
||||
if (outcome.kind === 'ok') settingsChanged()
|
||||
return outcome.kind === 'ok' ? (outcome.data.response?.temporaryDisableBlockingTill ?? null) : null
|
||||
}
|
||||
|
||||
/** The thirteen items of the backup, in upstream's order and with its labels
|
||||
* (index.html:6291-6385). `logs` is the only one that does NOT come checked. */
|
||||
export const BACKUP_ITEMS = [
|
||||
{ key: 'authConfig', label: 'Authentication Config File (auth.config)' },
|
||||
{ key: 'clusterConfig', label: 'Cluster Config File (cluster.config)' },
|
||||
{ key: 'webServiceSettings', label: 'Web Service Config And Certificate File (webservice.config, *.pfx & *.p12)' },
|
||||
{ key: 'dnsSettings', label: 'DNS Config And Certificate File (dns.config, *.pfx & *.p12)' },
|
||||
{ key: 'logSettings', label: 'Log Config File (log.config)' },
|
||||
{ key: 'zones', label: 'DNS Zone Files (*.zone)' },
|
||||
{ key: 'allowedZones', label: 'Allowed Zones File (allowed.config)' },
|
||||
{ key: 'blockedZones', label: 'Blocked Zones File (blocked.config)' },
|
||||
{ key: 'blockLists', label: 'Block List Config And Cache Files (blocklist.config)' },
|
||||
{ key: 'apps', label: 'DNS Apps' },
|
||||
{ key: 'scopes', label: 'DHCP Scope Files (*.scope)' },
|
||||
{ key: 'stats', label: 'Dashboard Stats Files (*.stat, *.dstat)' },
|
||||
{ key: 'logs', label: 'Log Files (*.log)' },
|
||||
] as const
|
||||
|
||||
export type BackupItem = (typeof BACKUP_ITEMS)[number]['key']
|
||||
|
||||
/** Initial state of the two modals: everything checked except the logs
|
||||
* (`resetBackupSettingsModal`, main.js:3049; `resetRestoreSettingsModal`, 3116). */
|
||||
export function initialBackupSelection(): Record<string, boolean> {
|
||||
return Object.fromEntries(BACKUP_ITEMS.map((e) => [e.key, e.key !== 'logs']))
|
||||
}
|
||||
|
||||
export function backupParams(
|
||||
selection: Record<string, boolean>,
|
||||
node = '',
|
||||
): Record<string, string> {
|
||||
const params: Record<string, string> = {}
|
||||
for (const e of BACKUP_ITEMS) params[e.key] = String(selection[e.key] === true)
|
||||
params.node = node
|
||||
return params
|
||||
}
|
||||
|
||||
/*
|
||||
`settings/restore` sends the file by multipart and the rest of the options in the
|
||||
QUERY, not in the body (main.js:3170). That is honoured to the letter: the path
|
||||
comes with the query already built and `apiRequest` only adds the FormData with
|
||||
the zip.
|
||||
*/
|
||||
export async function restoreSettings(
|
||||
token: string | null,
|
||||
file: File,
|
||||
selection: Record<string, boolean>,
|
||||
deleteExistingFiles: boolean,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<SettingsEnvelope>> {
|
||||
const query = new URLSearchParams(backupParams(selection, node))
|
||||
query.set('deleteExistingFiles', String(deleteExistingFiles))
|
||||
|
||||
const outcome = await apiRequest<SettingsEnvelope>(`settings/restore?${query.toString()}`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
file: { field: 'fileBackupZip', file: file },
|
||||
})
|
||||
if (outcome.kind === 'ok') settingsChanged()
|
||||
return outcome
|
||||
}
|
||||
|
||||
/** `cache/flush` (other-zones.js:20). It lives here on loan: see the header. */
|
||||
export async function flushCache(token: string | null, node = ''): Promise<boolean> {
|
||||
const outcome = await apiRequest('cache/flush', { token, body: { node } })
|
||||
return outcome.kind === 'ok'
|
||||
}
|
||||
|
||||
/*
|
||||
`settings/getTsigKeyNames` belongs to this family but **it is consumed by the
|
||||
Zones screen** (zone.js), to pick the TSIG key in a zone's options and in a
|
||||
secondary's SOA. It lives here by family, not by screen.
|
||||
*/
|
||||
export async function getTsigKeyNames(token: string | null, node = ''): Promise<string[]> {
|
||||
const outcome = await apiRequest<{ response: { tsigKeyNames: string[] } }>(
|
||||
'settings/getTsigKeyNames',
|
||||
{ token, body: { node } },
|
||||
)
|
||||
return outcome.kind === 'ok' ? (outcome.data.response?.tsigKeyNames ?? []) : []
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { getStatus } from './status'
|
||||
import * as client from './client'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('it is asked for without a token: it is a public endpoint', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', hasDefaultCredentials: false, ssoEnabled: true },
|
||||
})
|
||||
await getStatus()
|
||||
expect(spy.mock.calls[0][0]).toBe('status')
|
||||
expect(spy.mock.calls[0][1]?.token).toBeUndefined()
|
||||
})
|
||||
|
||||
it('it reads the flat response, with no response wrapper', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', hasDefaultCredentials: true, ssoEnabled: false },
|
||||
})
|
||||
const r = await getStatus()
|
||||
expect(r?.hasDefaultCredentials).toBe(true)
|
||||
expect(r?.ssoEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('it returns null on failure, without blowing up the login', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
expect(await getStatus()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiRequest } from './client'
|
||||
|
||||
/*
|
||||
`api/status` is public: it is called WITHOUT a token, from the login screen
|
||||
(main.js:48-60). It governs two behaviours:
|
||||
|
||||
· ssoEnabled -> shows or hides the "Sign in with SSO" button.
|
||||
· hasDefaultCredentials -> on a fresh install, the console logs itself IN with
|
||||
admin/admin.
|
||||
|
||||
The response comes FLAT, with no `response` wrapper, same as login and
|
||||
session/get. Verified against v15.4.
|
||||
*/
|
||||
export interface ServerStatus {
|
||||
hasDefaultCredentials: boolean
|
||||
ssoEnabled: boolean
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<ServerStatus | null> {
|
||||
const outcome = await apiRequest<ServerStatus>('status')
|
||||
return outcome.kind === 'ok' ? outcome.data : null
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { checkForUpdate, deleteSession, openDownload, DISABLE_UPDATE_NOTIFICATION_KEY } from './user'
|
||||
import * as client from './client'
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('it sends the partialToken', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await deleteSession('t', 'abc123')
|
||||
expect(spy.mock.calls[0][0]).toBe('user/session/delete')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ partialToken: 'abc123' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForUpdate', () => {
|
||||
it('it does not call the server if the notice is silenced', async () => {
|
||||
localStorage.setItem(DISABLE_UPDATE_NOTIFICATION_KEY, 'true')
|
||||
const spy = vi.spyOn(client, 'apiRequest')
|
||||
expect(await checkForUpdate('t')).toEqual({ kind: 'skipped' })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an explicit force skips the silence', async () => {
|
||||
localStorage.setItem(DISABLE_UPDATE_NOTIFICATION_KEY, 'true')
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await checkForUpdate('t', true)
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('it calls the server when it is not silenced', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await checkForUpdate('t')
|
||||
expect(spy.mock.calls[0][0]).toBe('user/checkForUpdate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openDownload', () => {
|
||||
it('it asks for a single-use token and puts it in the query', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', response: { token: 'single' } },
|
||||
})
|
||||
const open = vi.fn()
|
||||
vi.stubGlobal('open', open)
|
||||
const r = await openDownload('t', 'settings/backup', { zones: 'true' })
|
||||
expect(r.ok).toBe(true)
|
||||
expect(r.url).toContain('api/settings/backup?')
|
||||
expect(r.url).toContain('token=single')
|
||||
expect(r.url).toContain('zones=true')
|
||||
expect(open).toHaveBeenCalledWith(r.url, '_blank')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('it opens nothing if it cannot get the token', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'invalid-token' })
|
||||
const open = vi.fn()
|
||||
vi.stubGlobal('open', open)
|
||||
expect((await openDownload('t', 'settings/backup')).ok).toBe(false)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { urlApi } from '../app/base'
|
||||
|
||||
/*
|
||||
The endpoints of the `user` family. Thirteen in all, implemented together, even
|
||||
though `createSingleUseToken` has no consumer in the console.
|
||||
*/
|
||||
|
||||
export interface SessionRow {
|
||||
username: string
|
||||
isCurrentSession: boolean
|
||||
partialToken: string
|
||||
type: string
|
||||
tokenName: string | null
|
||||
lastSeen: string
|
||||
lastSeenRemoteAddress: string
|
||||
lastSeenUserAgent: string
|
||||
}
|
||||
|
||||
export function deleteSession(token: string | null, partialToken: string): Promise<ApiOutcome> {
|
||||
return apiRequest('user/session/delete', { token, body: { partialToken } })
|
||||
}
|
||||
|
||||
/*
|
||||
main.js:734-740: the update notice can be silenced persistently, and while it
|
||||
is silenced the endpoint is NOT EVEN called. Only an explicit `force` skips that
|
||||
preference.
|
||||
*/
|
||||
export const DISABLE_UPDATE_NOTIFICATION_KEY = 'disableUpdateNotification'
|
||||
|
||||
/*
|
||||
Everything the endpoint answers when there IS an update. Only `updateAvailable`
|
||||
is guaranteed: upstream checks every other field against null before showing it
|
||||
(`main.js`, checkForUpdate), because the release feed does not always carry them.
|
||||
Reproduced field by field so the notice can say the same as upstream's.
|
||||
*/
|
||||
export interface UpdateInfo {
|
||||
updateAvailable: boolean
|
||||
/** False when the administrator turned *Check for update* off (WebServiceApi.cs:96). */
|
||||
dnsServerEnableCheckForUpdate?: boolean
|
||||
updateVersion?: string | null
|
||||
currentVersion?: string | null
|
||||
updateTitle?: string | null
|
||||
updateMessage?: string | null
|
||||
downloadLink?: string | null
|
||||
instructionsLink?: string | null
|
||||
changeLogLink?: string | null
|
||||
}
|
||||
|
||||
/*
|
||||
And the two that WRITE it, which did not exist: the preference was read and
|
||||
nothing could set it, so the branch above was unreachable and the user had no way
|
||||
to silence the notice. Restored on 2026-09-04, checking About against upstream.
|
||||
|
||||
They are `localStorage` and not an endpoint because that is what upstream does
|
||||
(`main.js:714-732`): the preference is this browser's, not the server's, so
|
||||
silencing it on your laptop does not silence it on someone else's.
|
||||
*/
|
||||
export function silenceUpdateNotification(): void {
|
||||
localStorage.setItem(DISABLE_UPDATE_NOTIFICATION_KEY, 'true')
|
||||
}
|
||||
|
||||
/* Upstream writes the string `false` rather than removing the key. Same thing to
|
||||
`checkForUpdate`, and the same thing left behind in storage. */
|
||||
export function unsilenceUpdateNotification(): void {
|
||||
localStorage.setItem(DISABLE_UPDATE_NOTIFICATION_KEY, 'false')
|
||||
}
|
||||
|
||||
export function updateNotificationSilenced(): boolean {
|
||||
return localStorage.getItem(DISABLE_UPDATE_NOTIFICATION_KEY) === 'true'
|
||||
}
|
||||
|
||||
export async function checkForUpdate(
|
||||
token: string | null,
|
||||
force = false,
|
||||
): Promise<ApiOutcome<{ response: UpdateInfo }> | { kind: 'skipped' }> {
|
||||
if (!force && localStorage.getItem(DISABLE_UPDATE_NOTIFICATION_KEY) === 'true') {
|
||||
return { kind: 'skipped' }
|
||||
}
|
||||
return apiRequest('user/checkForUpdate', { token })
|
||||
}
|
||||
|
||||
/*
|
||||
The console's downloads do not go by XHR: a single-use token is asked for and a
|
||||
window is opened with it in the query. Six places use it (settings backup, log
|
||||
download and export, zone export, and allowed and blocked export), all of them in
|
||||
later phases. It lives here because the endpoint belongs to this family.
|
||||
*/
|
||||
export async function openDownload(
|
||||
token: string | null,
|
||||
path: string,
|
||||
params: Record<string, string> = {},
|
||||
/*
|
||||
`ts` is a cache-buster that upstream adds on ONLY TWO of the six downloads
|
||||
(the settings backup, main.js:3100, and a log download, logs.js:202) and not
|
||||
on the other four: exporting a zone, exporting allowed, exporting blocked and
|
||||
`logs/export` (logs.js:696). The server ignores it, but the URL that gets
|
||||
opened is not the same, so where it goes and where it does not is replicated.
|
||||
*/
|
||||
options: { ts?: boolean } = {},
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
const outcome = await apiRequest<{ response: { token: string } }>('user/createSingleUseToken', {
|
||||
token,
|
||||
})
|
||||
if (outcome.kind !== 'ok') return { ok: false }
|
||||
|
||||
/*
|
||||
The seventh place in the `outcome.data.response` debt, and the only one whose
|
||||
guard is deliberately optional.
|
||||
|
||||
The other six returned a list or a value, so `?.` with its `??` was the right
|
||||
answer: with no data, an empty list. Here what would be missing is the token of
|
||||
a freshly opened session, and a silent `undefined` would build a
|
||||
`token=undefined` the server would reject later and further away. A session has
|
||||
to fail where it happens.
|
||||
|
||||
It is checked explicitly rather than left to blow up on a dereference, which is
|
||||
what it used to do: the `TypeError` was the same symptom for "the server said
|
||||
something else" as for a programming bug.
|
||||
*/
|
||||
const single = outcome.data.response?.token
|
||||
if (single == null) return { ok: false }
|
||||
|
||||
/* It is called `single` and not `token` because it is NOT the session's (that
|
||||
is the parameter above): it is the one-shot one this endpoint issues for the
|
||||
download. Having them under the same name was asking for them to be
|
||||
confused. */
|
||||
const query = new URLSearchParams({ ...params, token: single })
|
||||
if (options.ts === true) {
|
||||
query.set('ts', String(performance.timeOrigin + performance.now()))
|
||||
}
|
||||
const url = urlApi(`api/${path}?${query.toString()}`)
|
||||
window.open(url, '_blank')
|
||||
return { ok: true, url }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
addDomain,
|
||||
deleteDomain,
|
||||
deleteCacheNode,
|
||||
parentDomain,
|
||||
exportDomains,
|
||||
importDomains,
|
||||
cleanList,
|
||||
listNode,
|
||||
flushCache,
|
||||
flushList,
|
||||
} from './zonelists'
|
||||
import * as client from './client'
|
||||
import * as user from './user'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const RESPONSE = {
|
||||
kind: 'ok' as const,
|
||||
data: { status: 'ok', response: { domain: 'home.test', zones: ['a.home.test'], records: [] } },
|
||||
}
|
||||
|
||||
describe('listNode', () => {
|
||||
it('it calls the endpoint of each list with domain and node, like upstream', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(RESPONSE)
|
||||
|
||||
await listNode('cache', 't', 'home.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('cache/list')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ domain: 'home.test', node: '' })
|
||||
|
||||
spy.mockClear()
|
||||
await listNode('allowed', 't', '')
|
||||
expect(spy.mock.calls[0][0]).toBe('allowed/list')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ domain: '', node: '' })
|
||||
|
||||
spy.mockClear()
|
||||
await listNode('blocked', 't', '')
|
||||
expect(spy.mock.calls[0][0]).toBe('blocked/list')
|
||||
})
|
||||
|
||||
it('it only sends direction when navigating upwards', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(RESPONSE)
|
||||
|
||||
await listNode('cache', 't', 'home.test')
|
||||
expect(spy.mock.calls[0][1]?.body?.direction).toBeUndefined()
|
||||
|
||||
spy.mockClear()
|
||||
await listNode('cache', 't', 'home.test', 'up')
|
||||
expect(spy.mock.calls[0][1]?.body?.direction).toBe('up')
|
||||
})
|
||||
|
||||
/* other-zones.js:105 writes `domain.toLowerCase();` without assigning: in
|
||||
JavaScript strings are immutable, so the domain travels EXACTLY as typed. */
|
||||
it('it does not lowercase the domain: upstream does not either', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(RESPONSE)
|
||||
await listNode('cache', 't', 'HOME.Test')
|
||||
expect(spy.mock.calls[0][1]?.body?.domain).toBe('HOME.Test')
|
||||
})
|
||||
|
||||
it('it unwraps the node, which is what the screen needs', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(RESPONSE)
|
||||
const r = await listNode('cache', 't', 'x')
|
||||
expect(r.kind).toBe('ok')
|
||||
if (r.kind !== 'ok') return
|
||||
expect(r.data.domain).toBe('home.test')
|
||||
expect(r.data.zones).toEqual(['a.home.test'])
|
||||
})
|
||||
|
||||
/* Upstream draws the server's errorMessage when a list fails; with null that
|
||||
text would be lost. */
|
||||
it('it keeps the error message from the server', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'error', message: 'boom' })
|
||||
const r = await listNode('cache', 't', 'x')
|
||||
expect(r).toEqual({ kind: 'error', message: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parentDomain', () => {
|
||||
it('it replicates getParentDomain from other-zones.js', () => {
|
||||
expect(parentDomain('a.b.home.test')).toBe('b.home.test')
|
||||
expect(parentDomain('home.test')).toBe('test')
|
||||
// A single-label domain has the root as its parent, which is "".
|
||||
expect(parentDomain('test')).toBe('')
|
||||
// The root has no parent: null, which is what hides the [up] link.
|
||||
expect(parentDomain('')).toBeNull()
|
||||
expect(parentDomain(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanList', () => {
|
||||
it('it replicates cleanTextList from common.js', () => {
|
||||
expect(cleanList('a.test\nb.test')).toBe('a.test,b.test')
|
||||
expect(cleanList('a.test\n\n\nb.test')).toBe('a.test,b.test')
|
||||
expect(cleanList('\na.test\n')).toBe('a.test')
|
||||
expect(cleanList('')).toBe('')
|
||||
// A text of nothing but newlines collapses to an empty string: the comma at
|
||||
// the ends is stripped too. Upstream also checks `=== ","` when validating
|
||||
// the import; no input reaches that branch, but it is replicated all the same.
|
||||
expect(cleanList('\n')).toBe('')
|
||||
expect(cleanList('\n\n\n')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache', () => {
|
||||
it('flushCache calls cache/flush with the cluster node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await flushCache('t')
|
||||
expect(spy.mock.calls[0][0]).toBe('cache/flush')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ node: '' })
|
||||
})
|
||||
|
||||
it('deleteNodeCache calls cache/delete with domain and node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await deleteCacheNode('t', 'home.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('cache/delete')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ domain: 'home.test', node: '' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowed and blocked', () => {
|
||||
it('addDomain and deleteDomain use the endpoint of their list', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
|
||||
await addDomain('allowed', 't', 'home.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('allowed/add')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ domain: 'home.test' })
|
||||
|
||||
spy.mockClear()
|
||||
await deleteDomain('blocked', 't', 'ads.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('blocked/delete')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ domain: 'ads.test' })
|
||||
})
|
||||
|
||||
/* the allowed/blocked flush does NOT carry `node`: upstream sends it on cache only. */
|
||||
it('flushList does not send node', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await flushList('blocked', 't')
|
||||
expect(spy.mock.calls[0][0]).toBe('blocked/flush')
|
||||
expect(spy.mock.calls[0][1]?.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('importDomains goes by POST and with the field name of each list', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
|
||||
await importDomains('allowed', 't', 'a.test,b.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('allowed/import')
|
||||
expect(spy.mock.calls[0][1]?.method).toBe('POST')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ allowedZones: 'a.test,b.test' })
|
||||
|
||||
spy.mockClear()
|
||||
await importDomains('blocked', 't', 'c.test')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({ blockedZones: 'c.test' })
|
||||
})
|
||||
|
||||
/* The export does not go by XHR: it asks for a single-use token and opens a window. */
|
||||
it('exportDomains goes through openDownload', async () => {
|
||||
const spy = vi.spyOn(user, 'openDownload').mockResolvedValue({ ok: true })
|
||||
await exportDomains('allowed', 't')
|
||||
expect(spy.mock.calls[0][1]).toBe('allowed/export')
|
||||
spy.mockClear()
|
||||
await exportDomains('blocked', 't')
|
||||
expect(spy.mock.calls[0][1]).toBe('blocked/export')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { openDownload } from './user'
|
||||
|
||||
/*
|
||||
The three domain lists that are NOT authoritative zones: `cache`, `allowed` and
|
||||
`blocked`. Fifteen endpoints, all of them in `other-zones.js`.
|
||||
|
||||
All three are navigated the same way (a tree of domains, one node at a time) and
|
||||
their three `list` endpoints return the same envelope: `domain`, `domainIdn`,
|
||||
`zones` and `records`. That is why they share a module and share a screen.
|
||||
|
||||
Four things about the server that are not deducible from looking at upstream's
|
||||
JavaScript and that govern the screen:
|
||||
|
||||
1. **The server can RETURN A DOMAIN DIFFERENT FROM THE ONE ASKED FOR.**
|
||||
`WebServiceOtherZonesApi.cs:88-120` walks down the chain on its own while
|
||||
the node has no records and has exactly one child: asking for `org` with a
|
||||
single `example.org` underneath returns `example.org` and ITS records. With
|
||||
`direction=up` the same condition walks up. That is why whoever draws the
|
||||
tree must ALWAYS use `response.domain`, never the domain it asked for.
|
||||
|
||||
2. **`zones` brings FULL domain names**, not labels: the server concatenates
|
||||
`subZone + "." + domain` (same source, lines 133-146) and converts them to
|
||||
Unicode if they are IDN. They are navigated as they come, recomposing
|
||||
nothing.
|
||||
|
||||
3. **`records` does NOT have the same shape in cache as in allowed/blocked.**
|
||||
`WebServiceZonesApi.cs:68` receives `authoritativeZoneRecords`: `false` for
|
||||
cache and `true` for allowed and blocked. In cache the `ttl` is the
|
||||
already-composed STRING (`"218 (3m38s)"`) and `responseMetadata`,
|
||||
`dnssecRecords`, `eDnsClientSubnet`, `nameServerMetadata` and `lastUsedOn`
|
||||
come along; in allowed and blocked the `ttl` is a NUMBER with its own
|
||||
`ttlString` apart, and `disabled`, `comments`, `lastModified` and
|
||||
`expiryTtl` come along. A stale cache record comes out with `ttl: "0 (0s)"`
|
||||
(same source, line 126).
|
||||
|
||||
4. `node` is the cluster node the request is aimed at
|
||||
(`DnsWebService.cs:2367`). Empty means "this server". Upstream sends it on
|
||||
`cache/list`, `cache/flush`, `cache/delete` and on the `list` of allowed and
|
||||
blocked; it does NOT send it on the rest.
|
||||
*/
|
||||
|
||||
export type List = 'cache' | 'allowed' | 'blocked'
|
||||
export type DomainList = Extract<List, 'allowed' | 'blocked'>
|
||||
|
||||
/** Metadata of the DNS response that left the record in the cache. */
|
||||
export interface ResponseMetadata {
|
||||
nameServer: string | null
|
||||
protocol: string
|
||||
datagramSize: string
|
||||
roundTripTime: string
|
||||
}
|
||||
|
||||
/** Name-server health, only on cached NS records. */
|
||||
export interface NameServerMetadata {
|
||||
totalQueries: number
|
||||
answerRate: string
|
||||
smoothedRoundTripTime: string
|
||||
smoothedPenaltyRoundTripTime: string
|
||||
netRoundTripTime: string
|
||||
isMisconfigured: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
The open index is not laziness: the screen draws `rData` by walking its keys, so
|
||||
a record type the server adds tomorrow still shows in full instead of
|
||||
disappearing.
|
||||
*/
|
||||
export interface DnsRecord {
|
||||
name: string
|
||||
nameIdn?: string
|
||||
type: string
|
||||
/** The string `"218 (3m38s)"` in cache; a number in allowed and blocked. */
|
||||
ttl: number | string
|
||||
ttlString?: string
|
||||
rData: Record<string, unknown>
|
||||
dnssecStatus?: string
|
||||
// cache only
|
||||
dnssecRecords?: string[]
|
||||
eDnsClientSubnet?: string
|
||||
nameServerMetadata?: NameServerMetadata
|
||||
responseMetadata?: ResponseMetadata
|
||||
// allowed and blocked only
|
||||
disabled?: boolean
|
||||
comments?: string
|
||||
lastModified?: string
|
||||
expiryTtl?: number
|
||||
expiryTtlString?: string
|
||||
// both
|
||||
glueRecords?: string[]
|
||||
lastUsedOn?: string
|
||||
[field: string]: unknown
|
||||
}
|
||||
|
||||
export interface ListNode {
|
||||
domain: string
|
||||
/** Only comes if the domain is an IDN; then it is what gets shown. */
|
||||
domainIdn?: string
|
||||
zones: string[]
|
||||
records: DnsRecord[]
|
||||
}
|
||||
|
||||
/*
|
||||
`getParentDomain` (other-zones.js:80-94). Returns "" for a single-label domain
|
||||
(the parent is the root) and `null` only for the root, which is what hides the
|
||||
[up] link in upstream.
|
||||
*/
|
||||
export function parentDomain(domain: string | null | undefined): string | null {
|
||||
if (domain == null || domain === '') return null
|
||||
const i = domain.indexOf('.')
|
||||
return i === -1 ? '' : domain.substring(i + 1)
|
||||
}
|
||||
|
||||
/*
|
||||
`cleanTextList` (common.js:326-340). Newline to comma, repeated commas to one,
|
||||
and out with the ones at the ends. A text of nothing but newlines collapses to
|
||||
"," and that is why upstream checks for it separately when validating the
|
||||
import.
|
||||
*/
|
||||
export function cleanList(text: string): string {
|
||||
let t = text.replace(/\n/g, ',')
|
||||
while (t.indexOf(',,') !== -1) t = t.replace(/,,/g, ',')
|
||||
if (t.startsWith(',')) t = t.substring(1)
|
||||
if (t.endsWith(',')) t = t.substring(0, t.length - 1)
|
||||
return t
|
||||
}
|
||||
|
||||
/*
|
||||
`refreshCachedZonesList` / `refreshAllowedZonesList` / `refreshBlockedZonesList`.
|
||||
|
||||
Upstream writes `domain.toLowerCase();` WITHOUT assigning the result
|
||||
(other-zones.js:105, 285, 421). In JavaScript strings are immutable, so that line
|
||||
does nothing and the domain travels exactly as it was typed. The real behaviour
|
||||
is replicated, not the intent: the server already takes care of the case.
|
||||
|
||||
It returns the `ApiOutcome` with the node already unwrapped instead of
|
||||
`ListNode | null` because in upstream a `list` that fails does NOT stay quiet:
|
||||
the error handler of `HTTPRequest` (common.js) draws the server's `errorMessage`
|
||||
as an alert. With `null` that text would be lost, and losing a text is losing
|
||||
behaviour.
|
||||
*/
|
||||
export async function listNode(
|
||||
list: List,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
direction?: 'up',
|
||||
node = '',
|
||||
): Promise<ApiOutcome<ListNode>> {
|
||||
const body: Record<string, string> = { domain, node }
|
||||
if (direction != null) body.direction = direction
|
||||
|
||||
const outcome = await apiRequest<{ response: ListNode }>(`${list}/list`, { token, body })
|
||||
return outcome.kind === 'ok' ? { kind: 'ok', data: outcome.data.response } : outcome
|
||||
}
|
||||
|
||||
/** `flushDnsCache` (other-zones.js:20). */
|
||||
export function flushCache(token: string | null, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('cache/flush', { token, body: { node } })
|
||||
}
|
||||
|
||||
/** `deleteCachedZone` (other-zones.js:52): deletes the node and all its records. */
|
||||
export function deleteCacheNode(
|
||||
token: string | null,
|
||||
domain: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('cache/delete', { token, body: { domain, node } })
|
||||
}
|
||||
|
||||
/** `allowZone` / `blockZone`. */
|
||||
export function addDomain(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/add`, { token, body: { domain } })
|
||||
}
|
||||
|
||||
/** `deleteAllowedZone` / `deleteBlockedZone`. */
|
||||
export function deleteDomain(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
domain: string,
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/delete`, { token, body: { domain } })
|
||||
}
|
||||
|
||||
/** `flushAllowedZone` / `flushBlockedZone`. No `node`, unlike the rest. */
|
||||
export function flushList(list: DomainList, token: string | null): Promise<ApiOutcome> {
|
||||
return apiRequest(`${list}/flush`, { token })
|
||||
}
|
||||
|
||||
/*
|
||||
`importAllowedZones` / `importBlockedZones`. It goes by POST and the field is
|
||||
named differently in each list. The server splits on commas
|
||||
(`WebServiceOtherZonesApi.cs:271`), so the text has to arrive already cleaned by
|
||||
`cleanList`.
|
||||
*/
|
||||
export function importDomains(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
zones: string,
|
||||
): Promise<ApiOutcome> {
|
||||
const field = list === 'allowed' ? 'allowedZones' : 'blockedZones'
|
||||
return apiRequest(`${list}/import`, { token, method: 'POST', body: { [field]: zones } })
|
||||
}
|
||||
|
||||
/*
|
||||
`exportAllowedZones` / `exportBlockedZones`. The response is a `text/plain` with
|
||||
`Content-Disposition: attachment` (WebServiceOtherZonesApi.cs:296-306), not JSON:
|
||||
that is why it cannot go by XHR and is opened in a window with a single-use
|
||||
token.
|
||||
*/
|
||||
export function exportDomains(
|
||||
list: DomainList,
|
||||
token: string | null,
|
||||
): Promise<{ ok: boolean; url?: string }> {
|
||||
return openDownload(token, `${list}/export`)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
listZones,
|
||||
importZone,
|
||||
exportZone,
|
||||
neverUsed,
|
||||
serializePermissions,
|
||||
getZoneOptions,
|
||||
getZonePermissions,
|
||||
setZonePermissions,
|
||||
listCatalogs,
|
||||
convertZone,
|
||||
exportZoneText,
|
||||
saveZoneFile,
|
||||
ZONE_TYPES,
|
||||
} from './zones'
|
||||
import * as client from './client'
|
||||
import * as user from './user'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
const env = (r: unknown) => ({ kind: 'ok' as const, data: { status: 'ok', response: r } })
|
||||
|
||||
describe('zones', () => {
|
||||
it('offers the seven zone types of upstream', () => {
|
||||
expect(ZONE_TYPES).toEqual(['Primary','Secondary','Stub','Forwarder','SecondaryForwarder','Catalog','SecondaryCatalog'])
|
||||
})
|
||||
|
||||
it('listZones paginates on the SERVER: it sends pageNumber and zonesPerPage', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(
|
||||
env({ zones: [], pageNumber: 2, totalPages: 5, totalZones: 47 }),
|
||||
)
|
||||
const r = await listZones('t', { pageNumber: 2, zonesPerPage: 10, filterName: 'ca' })
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/list')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({
|
||||
filterName: 'ca',
|
||||
filterType: '',
|
||||
pageNumber: '2',
|
||||
zonesPerPage: '10',
|
||||
node: '',
|
||||
})
|
||||
expect(r).toEqual({ kind: 'ok', data: { zones: [], pageNumber: 2, totalPages: 5, totalZones: 47 } })
|
||||
})
|
||||
|
||||
it('if the server omits the pagination, it is filled in without breaking', async () => {
|
||||
// Checked on v15.4: without pageNumber the response brings only `zones`.
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue(env({ zones: [{ name: 'a' }, { name: 'b' }] }))
|
||||
const r = await listZones('t')
|
||||
expect(r).toMatchObject({ kind: 'ok', data: { pageNumber: 1, totalPages: 1, totalZones: 2 } })
|
||||
})
|
||||
|
||||
/*
|
||||
`listZones` raises the whole failure and the rest still return `null`.
|
||||
|
||||
The difference is not a whim: the zone list is the first thing drawn on
|
||||
entering the screen, so its failure is the one the user sees and it has to be
|
||||
told with its reason. It used to say "Unable to connect to the server. Please try again." even
|
||||
when the server had answered. The other three feed dialogs that already warn on
|
||||
their own.
|
||||
*/
|
||||
it('listZones raises the failure with its reason; the rest return null', async () => {
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'invalid-token' })
|
||||
expect(await listZones('t')).toEqual({ kind: 'invalid-token' })
|
||||
expect(await getZoneOptions('t', 'x')).toBeNull()
|
||||
expect(await getZonePermissions('t', 'x')).toBeNull()
|
||||
expect(await listCatalogs('t')).toBeNull()
|
||||
})
|
||||
|
||||
it('recognises the .NET minimum date as "never used"', () => {
|
||||
expect(neverUsed('0001-01-01T00:00:00')).toBe(true)
|
||||
expect(neverUsed('2026-08-25T13:10:29Z')).toBe(false)
|
||||
expect(neverUsed('')).toBe(true)
|
||||
})
|
||||
|
||||
it('importing by file goes as multipart, with the fileImportZone field', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
const file = new File(['$ORIGIN home.test.'], 'home.zone')
|
||||
await importZone('t', 'home.test', { file }, {
|
||||
overwrite: true,
|
||||
overwriteZone: false,
|
||||
overwriteSoaSerial: false,
|
||||
})
|
||||
// The switches travel in the QUERY, not in the body (zone.js:1287).
|
||||
expect(spy.mock.calls[0][0]).toContain('zones/import?')
|
||||
expect(spy.mock.calls[0][0]).toContain('overwrite=true')
|
||||
expect(spy.mock.calls[0][0]).toContain('overwriteZone=false')
|
||||
expect(spy.mock.calls[0][1]?.file?.field).toBe('fileImportZone')
|
||||
})
|
||||
|
||||
it('importing by pasting the text goes as text/plain, not as multipart', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await importZone('t', 'home.test', { text: '@ 3600 IN A 10.0.0.1' }, {
|
||||
overwrite: false,
|
||||
overwriteZone: true,
|
||||
overwriteSoaSerial: false,
|
||||
})
|
||||
expect(spy.mock.calls[0][1]?.text).toBe('@ 3600 IN A 10.0.0.1')
|
||||
expect(spy.mock.calls[0][1]?.file).toBeUndefined()
|
||||
})
|
||||
|
||||
it('exporting a zone goes through the single-use token and WITHOUT `ts`', async () => {
|
||||
// zone.js:1322 does not add the cache-buster that the log downloads and the
|
||||
// settings backup do carry. The URL has to come out the same.
|
||||
const spy = vi.spyOn(user, 'openDownload').mockResolvedValue({ ok: true })
|
||||
await exportZone('t', 'home.test')
|
||||
expect(spy).toHaveBeenCalledWith('t', 'zones/export', { zone: 'home.test', node: '' })
|
||||
})
|
||||
|
||||
it('options/get asks for the available catalogs in the same call', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(env({ name: 'home.test' }))
|
||||
await getZoneOptions('t', 'home.test')
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/options/get')
|
||||
expect(spy.mock.calls[0][1]?.body).toMatchObject({ includeAvailableCatalogZoneNames: 'true' })
|
||||
})
|
||||
|
||||
it('permissions/get asks for users and groups, and tolerates absent lists', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue(env({ section: 'Zones' }))
|
||||
const r = await getZonePermissions('t', 'home.test')
|
||||
expect(spy.mock.calls[0][1]?.body).toMatchObject({ includeUsersAndGroups: 'true' })
|
||||
expect(r).toMatchObject({ userPermissions: [], groupPermissions: [] })
|
||||
})
|
||||
|
||||
it('the permissions serialise as name|view|modify|delete per row', () => {
|
||||
expect(
|
||||
serializePermissions([
|
||||
{ name: 'admin', canView: true, canModify: true, canDelete: false },
|
||||
{ name: 'ana', canView: true, canModify: false, canDelete: false },
|
||||
]),
|
||||
).toBe('admin|true|true|false|ana|true|false|false')
|
||||
})
|
||||
|
||||
it('an empty permissions table serialises as an empty string', () => {
|
||||
expect(serializePermissions([])).toBe('')
|
||||
})
|
||||
|
||||
it('permissions/set sends both tables already serialised', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await setZonePermissions('t', 'home.test', 'a|true|true|true', '')
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/permissions/set')
|
||||
expect(spy.mock.calls[0][1]?.body).toEqual({
|
||||
zone: 'home.test',
|
||||
userPermissions: 'a|true|true|true',
|
||||
groupPermissions: '',
|
||||
node: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('every call carries `node`, even when it goes empty', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await convertZone('t', 'home.test', 'Secondary')
|
||||
expect(spy.mock.calls[0][1]?.body).toHaveProperty('node', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Zone File (v15.5)', () => {
|
||||
it('reads the zone as TEXT from zones/export, with zone and an empty node', async () => {
|
||||
const file = '$ORIGIN home.test.\n@ 900 IN SOA ns hostadmin 3 900 300 604800 900\n'
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ text: () => Promise.resolve(file) })
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const text = await exportZoneText('tok', 'home.test')
|
||||
|
||||
// zone.js:1256 in v15.5.1: `api/zones/export?zone=…&node=…`.
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
expect(url).toBe('/api/zones/export?zone=home.test&node=')
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer tok' })
|
||||
expect(text).toBe(file)
|
||||
})
|
||||
|
||||
it('an answer that carries status goes into the editor FORMATTED, not as an alert', async () => {
|
||||
// What v15.5.1 answers for a zone that does not exist, checked live.
|
||||
const body = JSON.stringify({
|
||||
server: 'ref.technitium-ui.test',
|
||||
status: 'error',
|
||||
errorMessage: 'No such zone was found: nope.test',
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ text: () => Promise.resolve(body) }))
|
||||
|
||||
const text = await exportZoneText('tok', 'nope.test')
|
||||
|
||||
expect(text).toBe(JSON.stringify(JSON.parse(body), null, 2))
|
||||
expect(text).toContain('"status": "error"')
|
||||
})
|
||||
|
||||
it('CRLF from a Windows server arrives as LF, as the browser textarea leaves it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ text: () => Promise.resolve('a\r\nb\r\n') }))
|
||||
expect(await exportZoneText('tok', 'home.test')).toBe('a\nb\n')
|
||||
})
|
||||
|
||||
it('returns null if the request does not even go out', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
|
||||
expect(await exportZoneText('tok', 'home.test')).toBeNull()
|
||||
})
|
||||
|
||||
it('saving is a POST to zones/import with overwriteZone=true and NO overwrite, raw text/plain body', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ status: 'ok', response: {} }) })
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
|
||||
const r = await saveZoneFile('tok', 'home.test', 'www 3600 IN A 10.0.0.9', true, 'node-2')
|
||||
|
||||
expect(r.kind).toBe('ok')
|
||||
const [url, init] = fetchSpy.mock.calls[0]
|
||||
// zone.js:1293 in v15.5.1, parameter for parameter and in the same order.
|
||||
expect(url).toBe('/api/zones/import?zone=home.test&overwriteZone=true&overwriteSoaSerial=true&node=node-2')
|
||||
expect(url).not.toContain('overwrite=')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers['Content-Type']).toBe('text/plain')
|
||||
expect(init.body).toBe('www 3600 IN A 10.0.0.9')
|
||||
})
|
||||
|
||||
it('overwriteSoaSerial travels as false when unchecked', async () => {
|
||||
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: {} })
|
||||
await saveZoneFile('t', 'home.test', '', false)
|
||||
expect(spy.mock.calls[0][0]).toBe('zones/import?zone=home.test&overwriteZone=true&overwriteSoaSerial=false&node=')
|
||||
expect(spy.mock.calls[0][1]).toMatchObject({ method: 'POST', text: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import { apiRequest, type ApiOutcome } from './client'
|
||||
import { openDownload } from './user'
|
||||
import { urlApi } from '../app/base'
|
||||
|
||||
/*
|
||||
The `zones` family, the zone-management part: 15 endpoints. The 4 of
|
||||
`zones/records/*` live in records.ts and the 15 of DNSSEC in dnssec.ts. Between
|
||||
the three they add up to the 34 endpoints of zone management.
|
||||
|
||||
TWO DIFFERENT PAGINATIONS, and this one is easy to get wrong:
|
||||
|
||||
· `zones/list` PAGINATES ON THE SERVER: it is sent `pageNumber` and
|
||||
`zonesPerPage`, and returns `pageNumber`, `totalPages` and `totalZones`.
|
||||
Careful: those three fields **only appear if you send `pageNumber`**;
|
||||
without it the response brings only `zones`.
|
||||
|
||||
· `zones/records/get` does NOT paginate (see records.ts).
|
||||
|
||||
`node` is the cluster node the request is aimed at (`optZonesClusterNode`). With
|
||||
a single instance it goes empty, and that is how upstream sends it: the string
|
||||
`&node=` travels all the same in the 40 calls of zone.js.
|
||||
*/
|
||||
|
||||
/*
|
||||
Be suspicious of every optional field. Checked against a freshly installed v15.4:
|
||||
|
||||
· `zones/list` does NOT return the five that govern the state (`isExpired`,
|
||||
`validationFailed`, `syncFailed`, `expiry`) nor `nameIdn` except on
|
||||
secondary, expired or internationalised-name zones.
|
||||
|
||||
· And the least expected one: **a Catalog or Forwarder zone omits
|
||||
`dnssecStatus` and `hasDnssecPrivateKeys`**, and the Catalog also omits
|
||||
`catalog`. They are types that cannot be signed, so the server does not even
|
||||
write them. Declaring them required lies about half the list.
|
||||
*/
|
||||
export interface Zone {
|
||||
name: string
|
||||
type: string
|
||||
lastModified: string
|
||||
disabled: boolean
|
||||
soaSerial: number | null
|
||||
catalog?: string | null
|
||||
dnssecStatus?: string
|
||||
hasDnssecPrivateKeys?: boolean
|
||||
notifyFailed: boolean
|
||||
notifyFailedFor: string[]
|
||||
expiry?: string
|
||||
isExpired?: boolean
|
||||
validationFailed?: boolean
|
||||
syncFailed?: boolean
|
||||
/** Internationalised name; only on zones with non-ASCII characters. */
|
||||
nameIdn?: string
|
||||
/** Only on the server's internal zones: they cannot be deleted or edited. */
|
||||
internal?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The state drawn in the row, with the EXACT priority of `refreshZones`
|
||||
* (zone.js:733-745). It is neither alphabetical nor by severity: it is
|
||||
* upstream's chain of `else if` and the order matters: a zone that is expired
|
||||
* and has a failed notify says "Expired", not "Notify Failed".
|
||||
*/
|
||||
export type ZoneState = 'Disabled' | 'Expired' | 'Validation Failed' | 'Sync Failed' | 'Notify Failed' | 'Enabled'
|
||||
|
||||
export function zoneState(z: Zone): ZoneState {
|
||||
if (z.disabled) return 'Disabled'
|
||||
if (z.isExpired) return 'Expired'
|
||||
if (z.validationFailed) return 'Validation Failed'
|
||||
if (z.syncFailed) return 'Sync Failed'
|
||||
if (z.notifyFailed) return 'Notify Failed'
|
||||
return 'Enabled'
|
||||
}
|
||||
|
||||
/** The type's text: only the two "Secondary…" split into two words. */
|
||||
export function typeLabel(type: string): string {
|
||||
if (type === 'SecondaryForwarder') return 'Secondary Forwarder'
|
||||
if (type === 'SecondaryCatalog') return 'Secondary Catalog'
|
||||
return type
|
||||
}
|
||||
|
||||
/** The name that is drawn: the root is `<root>` and an IDN carries both. */
|
||||
export function zoneNameOf(z: Pick<Zone, 'name' | 'nameIdn'>): string {
|
||||
const name = z.name === '' ? '.' : z.name
|
||||
if (z.nameIdn == null) return name === '.' ? '<root>' : name
|
||||
return `${z.nameIdn} (${name})`
|
||||
}
|
||||
|
||||
export interface ZoneList {
|
||||
zones: Zone[]
|
||||
pageNumber: number
|
||||
totalPages: number
|
||||
totalZones: number
|
||||
}
|
||||
|
||||
export const ZONE_TYPES = ['Primary','Secondary','Stub','Forwarder','SecondaryForwarder','Catalog','SecondaryCatalog'] as const
|
||||
|
||||
/** Page sizes of upstream's dropdown (index.html, `optZonesPerPage`). */
|
||||
export const ZONES_PER_PAGE = [10, 25, 50, 100, 250, 500] as const
|
||||
|
||||
export async function listZones(
|
||||
token: string | null,
|
||||
options: {
|
||||
filterName?: string
|
||||
filterType?: string
|
||||
pageNumber?: number
|
||||
zonesPerPage?: number
|
||||
node?: string
|
||||
} = {},
|
||||
): Promise<ApiOutcome<ZoneList>> {
|
||||
const {
|
||||
filterName = '',
|
||||
filterType = '',
|
||||
pageNumber = 1,
|
||||
zonesPerPage = 10,
|
||||
node = '',
|
||||
} = options
|
||||
const outcome = await apiRequest<{ response: ZoneList }>('zones/list', {
|
||||
token,
|
||||
body: {
|
||||
filterName,
|
||||
filterType,
|
||||
pageNumber: String(pageNumber),
|
||||
zonesPerPage: String(zonesPerPage),
|
||||
node,
|
||||
},
|
||||
})
|
||||
/*
|
||||
Returns the whole outcome and not `ZoneList | null`.
|
||||
|
||||
With `null` the screen did not know WHY it had failed and always said "Unable
|
||||
to reach the DNS server.", which is a concrete claim (the network is down) and
|
||||
was false in the two cases that actually happen: the server answering an error,
|
||||
and the server rejecting the session. In the second one it also sent someone to
|
||||
go look at the network when what they had to do was log back in.
|
||||
*/
|
||||
if (outcome.kind !== 'ok') return outcome
|
||||
const r = outcome.data.response
|
||||
return {
|
||||
kind: 'ok',
|
||||
data: {
|
||||
zones: r.zones ?? [],
|
||||
pageNumber: r.pageNumber ?? 1,
|
||||
totalPages: r.totalPages ?? 1,
|
||||
totalZones: r.totalZones ?? (r.zones?.length ?? 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** "never" when the date is .NET's minimum, which is what upstream shows. */
|
||||
export function neverUsed(iso: string): boolean {
|
||||
return !iso || iso.startsWith('0001-01-01')
|
||||
}
|
||||
|
||||
/**
|
||||
* `zones/create` (zone.js:2911). It is a POST but **the parameters go in the
|
||||
* query**, not in the body: the body is reserved for the optional zone file,
|
||||
* which travels as multipart in the `fileImportZone` field. Without a file,
|
||||
* upstream sends a POST with no body at all.
|
||||
*/
|
||||
export function createZone(
|
||||
token: string | null,
|
||||
params: Record<string, string>,
|
||||
file?: File | null,
|
||||
node = '',
|
||||
): Promise<ApiOutcome<{ response: { domain: string } }>> {
|
||||
const query = new URLSearchParams({ ...params, node })
|
||||
return apiRequest<{ response: { domain: string } }>(`zones/create?${query.toString()}`, {
|
||||
token,
|
||||
method: 'POST',
|
||||
...(file ? { file: { field: 'fileImportZone', file } } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteZone(token: string | null, zone: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/delete', { token, body: { zone, node } })
|
||||
}
|
||||
|
||||
export interface BulkDelete {
|
||||
response: { deleted: string[]; failed: Record<string, string> }
|
||||
}
|
||||
|
||||
/**
|
||||
* The bulk delete uses the SAME endpoint with the parameter in plural (`zones=`,
|
||||
* comma-separated) and returns two lists: what was deleted and what failed. It
|
||||
* is not a different endpoint, however much it looks like one (zone.js:1140).
|
||||
*/
|
||||
export function deleteZones(
|
||||
token: string | null,
|
||||
zones: string[],
|
||||
node = '',
|
||||
): Promise<ApiOutcome<BulkDelete>> {
|
||||
return apiRequest<BulkDelete>('zones/delete', {
|
||||
token,
|
||||
body: { zones: zones.join(','), node },
|
||||
})
|
||||
}
|
||||
export function enableZone(token: string | null, zone: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/enable', { token, body: { zone, node } })
|
||||
}
|
||||
export function disableZone(token: string | null, zone: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/disable', { token, body: { zone, node } })
|
||||
}
|
||||
export function resyncZone(token: string | null, zone: string, node = ''): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/resync', { token, body: { zone, node } })
|
||||
}
|
||||
export function cloneZone(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
sourceZone: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/clone', { token, body: { zone, sourceZone, node } })
|
||||
}
|
||||
export function convertZone(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
type: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/convert', { token, body: { zone, type, node } })
|
||||
}
|
||||
export async function listCatalogs(token: string | null, node = ''): Promise<string[] | null> {
|
||||
const outcome = await apiRequest<{ response: { catalogZoneNames: string[] } }>(
|
||||
'zones/catalogs/list',
|
||||
{ token, body: { node } },
|
||||
)
|
||||
if (outcome.kind !== 'ok') return null
|
||||
return outcome.data.response?.catalogZoneNames ?? []
|
||||
}
|
||||
|
||||
/* ── Zone options ─────────────────────────────────────────────────────── */
|
||||
|
||||
export interface UpdatePolicy {
|
||||
tsigKeyName: string
|
||||
domain: string
|
||||
allowedTypes: string[]
|
||||
}
|
||||
|
||||
export interface ZoneOptions {
|
||||
name: string
|
||||
type: string
|
||||
dnssecStatus: string
|
||||
disabled: boolean
|
||||
catalog: string | null
|
||||
notifyFailed?: boolean
|
||||
notifyFailedFor?: string[]
|
||||
/** Only on zones that belong to a catalog. */
|
||||
overrideCatalogQueryAccess?: boolean
|
||||
overrideCatalogZoneTransfer?: boolean
|
||||
overrideCatalogNotify?: boolean
|
||||
primaryNameServerAddresses?: string[]
|
||||
primaryZoneTransferProtocol?: string
|
||||
primaryZoneTransferTsigKeyName?: string
|
||||
validateZone?: boolean
|
||||
queryAccess: string
|
||||
queryAccessNetworkACL: string[]
|
||||
zoneTransfer: string
|
||||
zoneTransferNetworkACL: string[]
|
||||
zoneTransferTsigKeyNames: string[]
|
||||
notify: string
|
||||
notifyNameServers: string[]
|
||||
notifySecondaryCatalogsNameServers?: string[]
|
||||
update: string
|
||||
updateNetworkACL: string[]
|
||||
updateSecurityPolicies: UpdatePolicy[]
|
||||
/** Only if asked for with `includeAvailableCatalogZoneNames`. */
|
||||
availableCatalogZoneNames?: string[]
|
||||
/** Only if asked for with `includeAvailableTsigKeyNames`. */
|
||||
availableTsigKeyNames?: string[]
|
||||
/*
|
||||
The two that govern the locking of nearly the whole form and that only appear
|
||||
on zones belonging to a catalog. `isSecondaryCatalogMember` means the zone is
|
||||
administered by a secondary catalog, and then half the controls are visible but
|
||||
cannot be touched.
|
||||
*/
|
||||
isSecondaryCatalogMember?: boolean
|
||||
overrideCatalogPrimaryNameServers?: boolean
|
||||
}
|
||||
|
||||
export async function getZoneOptions(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<ZoneOptions | null> {
|
||||
const outcome = await apiRequest<{ response: ZoneOptions }>('zones/options/get', {
|
||||
token,
|
||||
body: {
|
||||
zone,
|
||||
includeAvailableCatalogZoneNames: 'true',
|
||||
includeAvailableTsigKeyNames: 'true',
|
||||
node,
|
||||
},
|
||||
})
|
||||
return outcome.kind === 'ok' ? outcome.data.response : null
|
||||
}
|
||||
|
||||
/** The body is built by the screen, which is the one that validates (see screens/zones/options.ts). */
|
||||
export function setZoneOptions(
|
||||
token: string | null,
|
||||
body: Record<string, string>,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/options/set', { token, body: { ...body, node } })
|
||||
}
|
||||
|
||||
/* ── Zone permissions ─────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
The subject's name is NOT called the same in the two tables: a user permission
|
||||
brings `username` and a group one brings `name`. Verified against v15.4. Treating
|
||||
them as the same shape leaves half the table blank.
|
||||
*/
|
||||
export interface UserPermission {
|
||||
username: string
|
||||
canView: boolean
|
||||
canModify: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface GroupPermission {
|
||||
name: string
|
||||
canView: boolean
|
||||
canModify: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface ZonePermissions {
|
||||
section: string
|
||||
subItem: string
|
||||
userPermissions: UserPermission[]
|
||||
groupPermissions: GroupPermission[]
|
||||
/** Only if asked for with `includeUsersAndGroups`. */
|
||||
users?: string[]
|
||||
groups?: string[]
|
||||
}
|
||||
|
||||
export async function getZonePermissions(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<ZonePermissions | null> {
|
||||
const outcome = await apiRequest<{ response: ZonePermissions }>('zones/permissions/get', {
|
||||
token,
|
||||
body: { zone, includeUsersAndGroups: 'true', node },
|
||||
})
|
||||
if (outcome.kind !== 'ok') return null
|
||||
const r = outcome.data.response
|
||||
return {
|
||||
...r,
|
||||
userPermissions: r.userPermissions ?? [],
|
||||
groupPermissions: r.groupPermissions ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `serializeTableData` with 4 columns (common.js:282): the name and the three
|
||||
* booleans of each row, all joined by `|` into a single string.
|
||||
*/
|
||||
export function serializePermissions(
|
||||
rows: { name: string; canView: boolean; canModify: boolean; canDelete: boolean }[],
|
||||
): string {
|
||||
const output: string[] = []
|
||||
for (const f of rows) {
|
||||
output.push(f.name, String(f.canView), String(f.canModify), String(f.canDelete))
|
||||
}
|
||||
return output.join('|')
|
||||
}
|
||||
|
||||
export function setZonePermissions(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
userPermissions: string,
|
||||
groupPermissions: string,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
return apiRequest('zones/permissions/set', {
|
||||
token,
|
||||
body: { zone, userPermissions, groupPermissions, node },
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Import and export ────────────────────────────────────────────────── */
|
||||
|
||||
export interface ImportOptions {
|
||||
overwrite: boolean
|
||||
overwriteZone: boolean
|
||||
overwriteSoaSerial: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* `zones/import` (zone.js:1251). It has **two ways of sending the file**, and
|
||||
* the difference is not cosmetic: uploading it goes as multipart in the
|
||||
* `fileImportZone` field; pasting it into the textarea goes as **raw plain
|
||||
* text** with `Content-Type: text/plain`. The server tells them apart by that
|
||||
* type.
|
||||
*
|
||||
* The three switches always travel in the query, same as the zone.
|
||||
*/
|
||||
export function importZone(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
source: { file: File } | { text: string },
|
||||
options: ImportOptions,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
const query = new URLSearchParams({
|
||||
zone,
|
||||
overwrite: String(options.overwrite),
|
||||
overwriteZone: String(options.overwriteZone),
|
||||
overwriteSoaSerial: String(options.overwriteSoaSerial),
|
||||
node,
|
||||
})
|
||||
const route = `zones/import?${query.toString()}`
|
||||
|
||||
if ('file' in source) {
|
||||
return apiRequest(route, { token, method: 'POST', file: { field: 'fileImportZone', file: source.file } })
|
||||
}
|
||||
return apiRequest(route, { token, method: 'POST', text: source.text })
|
||||
}
|
||||
|
||||
/**
|
||||
* `zones/export` (zone.js:1315). One of the project's six downloads: there is no
|
||||
* `fetch`, a single-use token is asked for and a window is opened. Without that
|
||||
* token the download would travel with the session one in the URL.
|
||||
*/
|
||||
export function exportZone(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<{ ok: boolean }> {
|
||||
// No `ts`: upstream does not add it on this download (zone.js:1322).
|
||||
return openDownload(token, 'zones/export', { zone, node })
|
||||
}
|
||||
|
||||
/* ── Edit Zone File (v15.5) ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* `zones/export` for the "Edit Zone File" dialog (`showEditZoneFileModal`,
|
||||
* zone.js:1238-1278 in v15.5.1). Not the download above: here the file is read
|
||||
* into a textarea, so it is a plain `fetch` and not a single-use token.
|
||||
*
|
||||
* It cannot go through `apiRequest` for the same reason as `logs/download`: on
|
||||
* success the server answers `text/plain` with the zone file
|
||||
* (WebServiceZonesApi.cs:2011), and on failure `application/json` with the
|
||||
* usual envelope. Upstream asks with `isTextResponse: true`, so jQuery parses
|
||||
* the JSON one and `if (response.status != null)` puts it FORMATTED into the
|
||||
* textarea (zone.js:1262-1263): a missing zone, a denied permission and even an
|
||||
* `invalid-token` read as if they were the file's content, with no alert. That
|
||||
* is replicated.
|
||||
*
|
||||
* Line breaks are normalised to `\n`: upstream does `.val(response)` and the
|
||||
* browser normalises the textarea's value on the way in, so what it later
|
||||
* sends back never carries `\r` (see CONVENTIONS.md on `\r\n`).
|
||||
*
|
||||
* `null` = the request never arrived. Upstream's `error` branch then leaves the
|
||||
* editor hidden and shows the alert in the dialog.
|
||||
*/
|
||||
export async function exportZoneText(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
node = '',
|
||||
): Promise<string | null> {
|
||||
// zone.js:1256: `api/zones/export?zone=…&node=…`, `&node=` even when empty.
|
||||
const query = new URLSearchParams({ zone, node })
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
let text: string
|
||||
try {
|
||||
const res = await fetch(urlApi(`api/zones/export?${query.toString()}`), { headers })
|
||||
text = await res.text()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (parsed !== null && typeof parsed === 'object' && 'status' in parsed) {
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
} catch {
|
||||
/* It was not JSON: it is the zone file. */
|
||||
}
|
||||
return text.replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Saving the "Edit Zone File" dialog (`saveEditZoneFile`, zone.js:1280-1316 in
|
||||
* v15.5.1). The same endpoint as importing by pasting (raw `text/plain`
|
||||
* body) with a different query: `overwriteZone=true` fixed, the dialog's
|
||||
* `overwriteSoaSerial`, and **no `overwrite`** at all (zone.js:1293). The
|
||||
* server defaults that one to `true` (WebServiceZonesApi.cs:1930); it is left
|
||||
* out because upstream leaves it out.
|
||||
*
|
||||
* `overwriteZone=true` deletes every record in the zone before importing,
|
||||
* NS included: an empty body leaves the zone with its SOA alone. Checked
|
||||
* against v15.5.1.
|
||||
*/
|
||||
export function saveZoneFile(
|
||||
token: string | null,
|
||||
zone: string,
|
||||
text: string,
|
||||
overwriteSoaSerial: boolean,
|
||||
node = '',
|
||||
): Promise<ApiOutcome> {
|
||||
const query = new URLSearchParams({
|
||||
zone,
|
||||
overwriteZone: 'true',
|
||||
overwriteSoaSerial: String(overwriteSoaSerial),
|
||||
node,
|
||||
})
|
||||
return apiRequest(`zones/import?${query.toString()}`, { token, method: 'POST', text })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
The sidebar's mark on Blocking: a small pill at the entry's end. Its tones are the
|
||||
Overview block's (`blocking-state.ts`, `blockingMark`): amber for a pause the user
|
||||
can lift, neutral otherwise. On the active entry, which is already amber, it keeps
|
||||
only its outline, in the entry's own ink.
|
||||
*/
|
||||
.mark {
|
||||
margin-left: auto;
|
||||
padding: 0 var(--s-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-full);
|
||||
background: var(--pan2);
|
||||
color: var(--mute);
|
||||
font-size: var(--t-1);
|
||||
font-weight: var(--text-weight);
|
||||
letter-spacing: var(--text-tracking);
|
||||
text-transform: uppercase;
|
||||
/* No taller than the entry's own line, so the entry keeps its height. */
|
||||
line-height: 1.3;
|
||||
}
|
||||
.mark[data-tone='acc'] { background: var(--acc-bg); border-color: var(--acc-bd); color: var(--acc-ink); }
|
||||
[data-active='true'] > .mark { background: transparent; border-color: currentColor; color: inherit; }
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { BlockingMark as Mark } from '../screens/blocking/blocking-state'
|
||||
import text from '../ui/text.module.css'
|
||||
import styles from './BlockingMark.module.css'
|
||||
|
||||
/*
|
||||
The sidebar's mark on Blocking: whether blocking is disabled or paused, from
|
||||
anywhere in the console. OURS; what it says and when it reads are in
|
||||
`screens/blocking/blocking-state.ts`. The only count or state the sidebar carries.
|
||||
|
||||
The link's accessible name gains the sentence (`Blocking, paused until 14:30`)
|
||||
through hidden text, so the link keeps a name made of its own content; the short
|
||||
word is drawn and hidden from assistive technology, which already has the
|
||||
sentence. The Shell puts the same sentence in the link's `title`.
|
||||
*/
|
||||
|
||||
export function BlockingMark({ mark }: { mark: Mark | null }) {
|
||||
if (mark == null) return null
|
||||
return (
|
||||
<>
|
||||
<span className={text.sr}>{mark.label.replace(/^Blocking/, '')}</span>
|
||||
<span className={styles.mark} data-tone={mark.tone} aria-hidden="true">
|
||||
{mark.text}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
The chrome slot.
|
||||
|
||||
It exists because the design puts the node selector at the top, and the selector
|
||||
**belongs to each screen**: the Dashboard remembers its choice in
|
||||
`dashboardClusterNode` and `Settings` its own in `settingsClusterNode`, each with
|
||||
its own options. A single selector in the chrome would have to invent a shared
|
||||
memory and would change the behaviour of eight screens at a stroke.
|
||||
|
||||
So the chrome provides the PLACE and the screen provides the CONTROL. What these
|
||||
tests protect is exactly that separation.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SlotProvider, InChrome } from './ChromeSlot'
|
||||
|
||||
describe('the slot in the chrome', () => {
|
||||
it('what a screen puts in it shows up in the slot', () => {
|
||||
render(
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<>
|
||||
<div data-testid="slot" ref={slot} />
|
||||
<main>
|
||||
<InChrome>
|
||||
<button>Selector</button>
|
||||
</InChrome>
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('slot')).toContainElement(screen.getByRole('button', { name: 'Selector' }))
|
||||
})
|
||||
|
||||
/* With no slot yet it does not blow up: it simply draws nothing. */
|
||||
it('with no slot it draws nothing and does not throw', () => {
|
||||
render(
|
||||
<SlotProvider>
|
||||
{() => (
|
||||
<InChrome>
|
||||
<button>Selector</button>
|
||||
</InChrome>
|
||||
)}
|
||||
</SlotProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Selector' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/*
|
||||
And the slot stays EMPTY if nobody uses it, which is the case on seven of the
|
||||
eight screens. Without this, they would all gain a blank space at the top.
|
||||
*/
|
||||
it('the slot stays empty when no screen uses it', () => {
|
||||
render(
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<>
|
||||
<div data-testid="slot" ref={slot} />
|
||||
<main>nothing to hang</main>
|
||||
</>
|
||||
)}
|
||||
</SlotProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('slot')).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/*
|
||||
A chrome slot, so that a screen can put a control at the TOP without the chrome
|
||||
having to know which one.
|
||||
|
||||
Why it is needed: the Dashboard design puts the node selector at the top of the
|
||||
chrome, "because it does not filter a region, it reframes the screen". But the
|
||||
selector **belongs to the screen**, not to the chrome: the Dashboard remembers its
|
||||
choice in `dashboardClusterNode` and `Settings` its own in `settingsClusterNode`,
|
||||
and each offers its own options. A single selector in the chrome would have to
|
||||
invent a shared memory the console does not have, and would change the behaviour
|
||||
of eight screens at a stroke.
|
||||
|
||||
So the chrome provides **the place** and the screen provides **the control, its
|
||||
state and its memory**. When the other seven come to migrate, each will bring its
|
||||
own here without any of them hearing about the others.
|
||||
|
||||
## Where the slot is, and why not in a top bar
|
||||
|
||||
The design draws it in a top bar at 1440. **This console has no such bar**:
|
||||
`Shell.module.css:48` leaves it at `display:none` when wide, and that is reasoned:
|
||||
it measured 1224×52 with 78 % of the width empty and pushed every section title
|
||||
down to `y=76`. Reviving it to hang a control off would be undoing a measured
|
||||
decision in order to follow a drawing to the letter.
|
||||
|
||||
The slot goes, instead, **above the content and on the left**, which is the
|
||||
position the drawing gives it relative to what is below. When narrow it sits just
|
||||
under the bar that does exist.
|
||||
*/
|
||||
const Slot = createContext<HTMLElement | null>(null)
|
||||
|
||||
export function SlotProvider({
|
||||
children,
|
||||
}: {
|
||||
/** Receives the `ref` to hang on the slot. */
|
||||
children: (ref: (n: HTMLElement | null) => void) => ReactNode
|
||||
}) {
|
||||
/*
|
||||
State and not a `ref`: with a `ref` the first render has no node and whoever
|
||||
portals into it never hears that there is one now; there is no re-render to
|
||||
report it.
|
||||
*/
|
||||
const [node, setNode] = useState<HTMLElement | null>(null)
|
||||
return <Slot.Provider value={node}>{children(setNode)}</Slot.Provider>
|
||||
}
|
||||
|
||||
/** Whatever a screen wants to put there. With no slot yet, it draws nothing. */
|
||||
export function InChrome({ children }: { children: ReactNode }) {
|
||||
const node = useContext(Slot)
|
||||
if (node == null) return null
|
||||
return createPortal(children, node)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import { Shell, type ShellSession } from './Shell'
|
||||
import { ThemeProvider } from '../theme/ThemeProvider'
|
||||
import * as client from '../api/client'
|
||||
import * as settings from '../api/settings'
|
||||
import type { DnsSettings } from '../api/settings'
|
||||
import { settingsChanged } from '../lib/settings-signal'
|
||||
import { forgetRoot } from './route'
|
||||
|
||||
/*
|
||||
The sidebar's mark on Blocking (`BlockingMark.tsx`, `blocking-state.ts`): drawn when
|
||||
blocking is disabled or paused, with the time it comes back in the link's name and
|
||||
title, and nothing when the session cannot read the setting.
|
||||
*/
|
||||
|
||||
function session(settingsView = true, settingsModify = true): ShellSession {
|
||||
const all = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
info: {
|
||||
version: '15.6',
|
||||
uptimestamp: '2026-10-15T08:00:00Z',
|
||||
dnsServerDomain: 'dns.example.net',
|
||||
permissions: Object.fromEntries(all.map((s) => [
|
||||
s,
|
||||
s === 'Settings'
|
||||
? { canView: settingsView, canModify: settingsModify, canDelete: false }
|
||||
: { canView: true, canModify: true, canDelete: true },
|
||||
])),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mount(s = session()) {
|
||||
return render(<ThemeProvider><Shell session={s} onLogout={() => {}} /></ThemeProvider>)
|
||||
}
|
||||
|
||||
const entry = () => within(screen.getByRole('navigation', { name: 'Sections' })).getByRole('link', { name: /^Blocking/ })
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
window.history.replaceState(null, '', '/about/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Shell: the Blocking mark', () => {
|
||||
it('none while blocking is on', async () => {
|
||||
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as DnsSettings)
|
||||
mount()
|
||||
await vi.waitFor(() => expect(read).toHaveBeenCalled())
|
||||
expect(entry()).toHaveAccessibleName('Blocking')
|
||||
expect(entry()).not.toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('disabled: Off, and the name says so', async () => {
|
||||
vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
mount()
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
|
||||
expect(entry()).toHaveTextContent('Off')
|
||||
expect(entry()).toHaveAttribute('title', 'Blocking, disabled')
|
||||
})
|
||||
|
||||
it('paused: the time it comes back in the name and the title, and it goes when the pause ends', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date(2026, 9, 15, 14, 0, 0))
|
||||
const till = new Date(2026, 9, 15, 14, 5, 0).toISOString()
|
||||
vi.spyOn(settings, 'getSettings')
|
||||
.mockResolvedValueOnce({ enableBlocking: false, temporaryDisableBlockingTill: till } as DnsSettings)
|
||||
.mockResolvedValue({ enableBlocking: true } as DnsSettings)
|
||||
mount()
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, paused until 14:05'))
|
||||
expect(entry()).toHaveAttribute('title', 'Blocking, paused until 14:05')
|
||||
expect(entry()).toHaveTextContent('Paused')
|
||||
await act(() => vi.advanceTimersByTimeAsync(5 * 60_000 + 1_000))
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking'))
|
||||
})
|
||||
|
||||
it('follows a change made elsewhere in the console', async () => {
|
||||
vi.spyOn(settings, 'getSettings')
|
||||
.mockResolvedValueOnce({ enableBlocking: true } as DnsSettings)
|
||||
.mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
mount()
|
||||
await vi.waitFor(() => expect(settings.getSettings).toHaveBeenCalledTimes(1))
|
||||
act(() => settingsChanged())
|
||||
await vi.waitFor(() => expect(entry()).toHaveAccessibleName('Blocking, disabled'))
|
||||
})
|
||||
|
||||
it('without Settings.canView it reads nothing and marks nothing', async () => {
|
||||
const read = vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: false } as DnsSettings)
|
||||
mount(session(false, false))
|
||||
act(() => settingsChanged())
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
expect(entry()).toHaveAccessibleName('Blocking')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { StrictMode } from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Shell, type ShellSession } from './Shell'
|
||||
import { ThemeProvider } from '../theme/ThemeProvider'
|
||||
import * as client from '../api/client'
|
||||
import * as dashboard from '../api/dashboard'
|
||||
import * as dnsclient from '../api/dnsclient'
|
||||
import * as apps from '../api/apps'
|
||||
import * as logs from '../api/logs'
|
||||
import type { InstalledApp } from '../api/apps'
|
||||
import { forgetRoot } from './route'
|
||||
|
||||
/*
|
||||
The jumps between screens, end to end through the Shell (`app/handoff.ts`):
|
||||
the row menu asks, the Shell switches section, and the target screen arrives
|
||||
filled in and runs its query, once.
|
||||
*/
|
||||
|
||||
const chart = { labels: ['a', 'b'], datasets: [{ label: 'Total', data: [1, 2] }] }
|
||||
const STATS = {
|
||||
stats: {
|
||||
totalQueries: 10, totalNoError: 10, totalServerFailure: 0, totalNxDomain: 0,
|
||||
totalRefused: 0, totalAuthoritative: 0, totalRecursive: 10, totalCached: 0,
|
||||
totalBlocked: 0, totalDropped: 0, totalClients: 2,
|
||||
zones: 1, cachedEntries: 0, allowedZones: 0, blockedZones: 0,
|
||||
allowListZones: 0, blockListZones: 0,
|
||||
},
|
||||
mainChartData: chart,
|
||||
queryResponseChartData: chart,
|
||||
queryTypeChartData: chart,
|
||||
protocolTypeChartData: chart,
|
||||
topClients: [{ name: '10.0.0.1', domain: 'pc.home.test', hits: 12, rateLimited: false }],
|
||||
topDomains: [{ name: 'github.com', hits: 7 }],
|
||||
topBlockedDomains: [],
|
||||
}
|
||||
|
||||
const APP: InstalledApp = {
|
||||
name: 'Query Logs (Sqlite)',
|
||||
description: 'Logs all incoming DNS requests.',
|
||||
version: '8.0',
|
||||
dnsApps: [
|
||||
{
|
||||
classPath: 'QueryLogsSqlite.App',
|
||||
description: 'Logs queries.',
|
||||
recordDataTemplate: null,
|
||||
isAppRecordRequestHandler: false,
|
||||
isRequestController: false,
|
||||
isAuthoritativeRequestHandler: false,
|
||||
isRequestBlockingHandler: false,
|
||||
isQueryLogger: true,
|
||||
isQueryLogs: true,
|
||||
isPostProcessor: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const ENTRY = {
|
||||
rowNumber: 1,
|
||||
timestamp: '2026-08-26T05:32:14Z',
|
||||
clientIpAddress: '10.0.0.1',
|
||||
protocol: 'Udp',
|
||||
responseType: 'Recursive',
|
||||
rcode: 'NoError',
|
||||
qname: 'api.github.com',
|
||||
qtype: 'HTTPS',
|
||||
qclass: 'IN',
|
||||
answer: null,
|
||||
}
|
||||
|
||||
function session(canViewDnsClient = true): ShellSession {
|
||||
const sections = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
||||
return {
|
||||
token: 'tok',
|
||||
displayName: 'Administrator',
|
||||
username: 'admin',
|
||||
type: 'Local',
|
||||
info: {
|
||||
version: '15.6',
|
||||
uptimestamp: '2026-08-25T13:07:31Z',
|
||||
dnsServerDomain: 'dns.example.net',
|
||||
permissions: Object.fromEntries(
|
||||
sections.map((s) => [
|
||||
s,
|
||||
{ canView: s === 'DnsClient' ? canViewDnsClient : true, canModify: true, canDelete: true },
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mount(s: ShellSession = session()) {
|
||||
return render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<Shell session={s} onLogout={() => {}} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
let resolve: ReturnType<typeof vi.spyOn>
|
||||
let query: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
forgetRoot()
|
||||
window.history.replaceState(null, '', '/')
|
||||
vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: {} } } as never)
|
||||
vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'ok', data: STATS } as never)
|
||||
resolve = vi.spyOn(dnsclient, 'resolve').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', response: { result: { answer: 'ok' } } },
|
||||
} as never)
|
||||
vi.spyOn(apps, 'listApps').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', response: { apps: [APP] } },
|
||||
} as never)
|
||||
query = vi.spyOn(logs, 'queryLogs').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: 1, entries: [ENTRY] } },
|
||||
} as never)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
async function pick(row: string, item: string) {
|
||||
await userEvent.click(await screen.findByRole('button', { name: `Actions for ${row}` }))
|
||||
await userEvent.click(screen.getByRole('menuitem', { name: item }))
|
||||
}
|
||||
|
||||
const sidebar = () => screen.getByRole('navigation', { name: 'Sections' })
|
||||
|
||||
describe('Shell: the jumps of the row menus', () => {
|
||||
it('Query DNS Server opens DNS Client filled in and runs the query once', async () => {
|
||||
mount()
|
||||
await pick('github.com', 'Query DNS Server')
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'DNS Client' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1))
|
||||
expect(resolve.mock.calls[0][1]).toMatchObject({
|
||||
server: 'this-server',
|
||||
domain: 'github.com',
|
||||
type: 'A',
|
||||
protocol: 'UDP',
|
||||
dnssec: false,
|
||||
eDnsClientSubnet: '',
|
||||
node: '',
|
||||
})
|
||||
expect(window.location.pathname).toBe('/dnsclient/')
|
||||
})
|
||||
|
||||
it('coming back to DNS Client later finds a clean form and runs nothing', async () => {
|
||||
mount()
|
||||
await pick('github.com', 'Query DNS Server')
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1))
|
||||
|
||||
await userEvent.click(within(sidebar()).getByRole('link', { name: 'Dashboard' }))
|
||||
await userEvent.click(within(sidebar()).getByRole('link', { name: 'DNS Client' }))
|
||||
|
||||
expect(await screen.findByLabelText('Domain')).toHaveValue('')
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('Show Query Logs opens Logs › Query Logs filtered by the client and queries', async () => {
|
||||
mount()
|
||||
await pick('10.0.0.1', 'Show Query Logs')
|
||||
|
||||
expect(await screen.findByText('api.github.com')).toBeInTheDocument()
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
expect(query.mock.calls[0][1]).toMatchObject({ qname: '', clientIpAddress: '10.0.0.1', node: '' })
|
||||
expect(screen.getByLabelText('Client IP Address')).toHaveValue('10.0.0.1')
|
||||
expect(window.location.pathname).toBe('/logs/query-logs/')
|
||||
})
|
||||
|
||||
it('from a Query Logs row, Query DNS Server goes on to DNS Client with the type of the row', async () => {
|
||||
mount()
|
||||
await pick('github.com', 'Show Query Logs')
|
||||
expect(await screen.findByText('api.github.com')).toBeInTheDocument()
|
||||
expect(query.mock.calls[0][1]).toMatchObject({ qname: 'github.com', clientIpAddress: '' })
|
||||
|
||||
await pick('row 1', 'Query DNS Server')
|
||||
expect(await screen.findByRole('heading', { name: 'DNS Client' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledTimes(1))
|
||||
expect(resolve.mock.calls[0][1]).toMatchObject({ domain: 'api.github.com', type: 'HTTPS' })
|
||||
})
|
||||
|
||||
it('a section the user cannot see is not opened', async () => {
|
||||
mount(session(false))
|
||||
await pick('github.com', 'Query DNS Server')
|
||||
expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/* Side panel with nested sub-items. Decided by measuring: 72 px of chrome
|
||||
against 163 with tabs on top, and the second row of stacked tabs disappears. */
|
||||
.shell { min-height:100vh; background:var(--bg); color:var(--ink); display:grid; grid-template-columns:240px 1fr; align-items:start; }
|
||||
.side { background:var(--pan); border-right:1px solid var(--line); align-self:stretch; position:sticky; top:0; height:100vh; overflow:auto; display:flex; flex-direction:column; }
|
||||
.sbrand { display:flex; align-items:center; gap: var(--s-6); padding: 0 var(--s-10); height:48px; border-bottom:1px solid var(--line); font-weight: 600; letter-spacing:-.01em; color:var(--ink); }
|
||||
/*
|
||||
Technitium's logo, not a "T" in an amber square.
|
||||
|
||||
The initial was our own invention: the logo exists, it travels in
|
||||
`public/img/logo.png` since the scaffolding and upstream uses it. Replacing
|
||||
someone else's product's mark with a letter of our own is exactly what a fork
|
||||
must not do.
|
||||
*/
|
||||
.mark { width:22px; height:22px; flex:none; }
|
||||
/*
|
||||
The items sat 1 px from each other with 7 px of vertical padding: the list read
|
||||
as a block of text, not as twelve destinations. And there was no separation
|
||||
between groups at all, so "About" weighed the same as "Zones".
|
||||
|
||||
Now there are three groups, separated by space and not by a line: what gets
|
||||
operated daily (Dashboard … DNS Client), what gets configured (Settings, DHCP,
|
||||
Administration) and what gets consulted (Logs, About).
|
||||
*/
|
||||
.slist { padding: var(--s-9) var(--s-6); display:flex; flex-direction:column; gap: var(--s-1); }
|
||||
.group { display:flex; flex-direction:column; gap: var(--s-1); }
|
||||
.group + .group { margin-top: var(--s-9); }
|
||||
.s { display:flex; text-decoration:none; align-items:center; gap: var(--s-7); height:32px; padding: 0 var(--s-5); border-radius: var(--r-2); font:400 var(--t-4)/1.3 var(--font-ui); color:var(--mute); background:none; border:0; cursor:pointer; text-align:left; white-space:nowrap; width:100%; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.s:hover { background:var(--hover); color:var(--ink); }
|
||||
.s[data-active='true'] { background:var(--pan2); color:var(--ink); font-weight: 500; }
|
||||
.s[data-active='true'] .ico { opacity:1; }
|
||||
.ico { display:inline-flex; flex:none; opacity:.85; }
|
||||
.sub { display:flex; flex-direction:column; gap: var(--s-1); margin: var(--s-3) 0 var(--s-5) var(--s-11); padding-left: var(--s-7); border-left:1px solid var(--line); }
|
||||
.s2 { display:block; text-decoration:none; padding: var(--s-4) var(--s-7); border-radius: var(--r-2); font:400 var(--t-4)/1.3 var(--font-ui); color:var(--mute); background:none; border:0; cursor:pointer; text-align:left; white-space:nowrap; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.s2:hover { color:var(--ink); }
|
||||
.s2[aria-current='page'] { color:var(--acc-ink); font-weight: 550; background:var(--pan2); box-shadow: var(--edge); }
|
||||
.main { min-width:0; display:flex; flex-direction:column; min-height:100vh; }
|
||||
/*
|
||||
`--stuck-top`: how much of the window's top is already taken by something pinned,
|
||||
for whatever pins inside the content (the section index's strip, a dense form's
|
||||
block titles). Nothing on a wide screen; the top bar below 900.
|
||||
*/
|
||||
.shell { --stuck-top: 0px; }
|
||||
/*
|
||||
The header ONLY exists on a narrow screen.
|
||||
|
||||
It carried the server's domain and the account menu, both stuck to the right
|
||||
edge: 1224×52 with 78 % of the width empty (952 px out of 1224) and pushing every
|
||||
section's title down to `y=76`. Its two tenants now live at the foot of the side
|
||||
panel, which had 377 px free (42 % of its height). On narrow screens it is
|
||||
needed, because the side panel is a closed drawer and its trigger has to be
|
||||
outside it.
|
||||
*/
|
||||
.rtop { display:none; }
|
||||
/* At the foot of the side panel, hence `margin-top:auto`: that is what pushes it
|
||||
down however short the list of sections is. */
|
||||
.footSide { margin-top:auto; padding: var(--s-8) var(--s-9); border-top:1px solid var(--line); display:flex; flex-direction:column; gap: var(--s-4); }
|
||||
/* It opens upwards: it sits at the foot of the window, not under a header. */
|
||||
.body { padding: var(--s-11) var(--s-11) var(--s-11); min-width:0; flex:1; }
|
||||
/* A screen that reads a file (View Logs) is as tall as the window and not as its
|
||||
content: it marks its root and gets a flex column to grow into, down to the
|
||||
footer. No other screen is touched. */
|
||||
.body:has(> [data-fills-window]) { display:flex; flex-direction:column; }
|
||||
|
||||
/* The page's foot: it drops to the bottom when the section is short and comes
|
||||
after the content when it is long, which is where upstream's `div#footer` is. */
|
||||
.footMain {
|
||||
flex: none;
|
||||
padding: var(--s-9) var(--s-11);
|
||||
border-top: 1px solid var(--line2);
|
||||
text-align: center;
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
/*
|
||||
Below 900 px the side panel stops taking a column and becomes a drawer.
|
||||
|
||||
This is NOT parity with upstream: upstream does not declare a `viewport`, so on a
|
||||
phone it draws at ~980 virtual px and is dealt with by zooming. We do declare it,
|
||||
which means we promise to adapt; without this the promise was false and the
|
||||
twelve sections overflowed. And the most frequent use case for a home DNS is
|
||||
looking at it from a phone when something has stopped resolving.
|
||||
*/
|
||||
|
||||
/* The drawer's trigger and the header's mark only exist on a narrow screen; on a
|
||||
wide one the side panel is already in sight. */
|
||||
.hamburger,
|
||||
.topBrand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The padding goes here and not inside the media query: without declaring it,
|
||||
the browser puts in its own (1px 6px), and that is a value we did not choose. */
|
||||
.hamburger { padding: 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell { grid-template-columns: 1fr; --stuck-top: var(--top-bar); }
|
||||
|
||||
.rtop {
|
||||
display: flex; align-items: center; gap: var(--s-6);
|
||||
padding: 0 var(--s-9); height: var(--top-bar);
|
||||
border-bottom: 1px solid var(--line); background: var(--pan);
|
||||
box-shadow: var(--edge); font-size: var(--t-4); color: var(--mute);
|
||||
position: sticky; top: 0; z-index: var(--layer-sticky);
|
||||
}
|
||||
|
||||
.side {
|
||||
position: fixed;
|
||||
z-index: var(--layer-drawer);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 264px;
|
||||
max-width: 84vw;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.18s ease;
|
||||
box-shadow: var(--shadow-modal);
|
||||
}
|
||||
|
||||
.side[data-open='true'] { transform: none; }
|
||||
|
||||
.veil {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--layer-veil);
|
||||
background: var(--veil);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: auto;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-size: var(--t-5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topBrand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-5);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rtop { padding: 0 var(--s-9); gap: var(--s-7); }
|
||||
.body { padding: var(--s-9); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.side { transition: none; }
|
||||
}
|
||||
|
||||
/*
|
||||
The chrome slot: where a screen can put a control that reframes everything below
|
||||
it: today only the Dashboard's node selector.
|
||||
|
||||
It goes above the content and on the left, which is the position the Dashboard
|
||||
design gives it relative to what is below. NOT in a top bar: this console has no
|
||||
top bar when wide, and removing it was a measured decision (see above). Reviving
|
||||
it to hang a control off would be undoing that in order to follow a drawing.
|
||||
|
||||
`:empty` is what keeps it from taking up room when nobody uses it: without it,
|
||||
seven of eight screens would gain a blank gap.
|
||||
*/
|
||||
.chromeSlot { display: flex; align-items: center; gap: var(--s-6); margin-bottom: var(--s-8); }
|
||||
.chromeSlot:empty { display: none; }
|
||||
@@ -0,0 +1,665 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { visibleSections, type Permission } from './sections'
|
||||
import { toTrail, writeRoute, readRoute, translateLegacyRoute, plainClick } from './route'
|
||||
import { ChangePassword } from '../screens/modals/ChangePassword'
|
||||
import { ChangeTheme } from '../screens/modals/ChangeTheme'
|
||||
import { Configure2FA } from '../screens/modals/Configure2FA'
|
||||
import { CreateApiToken } from '../screens/modals/CreateApiToken'
|
||||
import { MyProfile } from '../screens/modals/MyProfile'
|
||||
import { Dashboard } from '../screens/dashboard/Dashboard'
|
||||
import { DnsClient } from '../screens/dnsclient/DnsClient'
|
||||
import { About } from '../screens/about/About'
|
||||
import { Apps } from '../screens/apps/Apps'
|
||||
import { Cache } from '../screens/lists/Lists'
|
||||
import { Blocking } from '../screens/blocking/Blocking'
|
||||
import { Settings, type RecordSettings, type ServerInfo } from '../screens/settings/Settings'
|
||||
import { Zones } from '../screens/zones/Zones'
|
||||
import { Dhcp } from '../screens/dhcp/Dhcp'
|
||||
import { Logs } from '../screens/logs/Logs'
|
||||
import { Admin } from '../screens/admin/Admin'
|
||||
import { SlotProvider } from './ChromeSlot'
|
||||
import {
|
||||
HandoffContext,
|
||||
targetNode,
|
||||
type DnsClientRequest,
|
||||
type Handoff,
|
||||
type QueryLogsRequest,
|
||||
} from './handoff'
|
||||
import styles from './Shell.module.css'
|
||||
import { Icon, type IconName } from '../ui/Icon'
|
||||
import { publicUrl } from './base'
|
||||
import { Menu } from '../ui/Menu'
|
||||
import { ThisServerName } from '../ui/ClusterNodeSelect'
|
||||
import { FooterLinks } from '../ui/FooterLinks'
|
||||
import { Versions } from './Versions'
|
||||
import { BlockingMark } from './BlockingMark'
|
||||
import { useBlockingMark } from '../screens/blocking/blocking-state'
|
||||
import { Confirm } from '../ui/Confirm'
|
||||
import { Notifier } from '../ui/Notifier'
|
||||
import type { Notice } from '../lib/notice'
|
||||
import {
|
||||
silenceUpdateNotification,
|
||||
unsilenceUpdateNotification,
|
||||
updateNotificationSilenced,
|
||||
} from '../api/user'
|
||||
|
||||
type ModalId = 'profile' | 'password' | 'twofa' | 'token' | 'theme'
|
||||
|
||||
/** One glyph per section. No icon dependency: the server's CSP does not allow a
|
||||
* CDN and an icon font would have to ship as a file in www/. */
|
||||
/* The section icons live in `ui/Icon`, drawn; here we only say which one each
|
||||
section carries. Blocking carries Blocked's: it is the section that took its place. */
|
||||
/*
|
||||
The sidebar's three groups. It is not a new taxonomy: it is upstream's own order,
|
||||
with a gap where the kind of task already changed. What you operate daily, what you
|
||||
configure and what you consult.
|
||||
*/
|
||||
const GROUPS: string[][] = [
|
||||
['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'],
|
||||
['settings', 'dhcp', 'admin'],
|
||||
['logs', 'about'],
|
||||
]
|
||||
|
||||
const ICONS: Record<string, IconName> = {
|
||||
dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked',
|
||||
apps: 'apps', dnsclient: 'dnsclient', settings: 'settings',
|
||||
dhcp: 'dhcp', admin: 'admin', logs: 'logs', about: 'about',
|
||||
}
|
||||
|
||||
export interface ShellSession {
|
||||
token: string
|
||||
displayName: string
|
||||
username: string
|
||||
/** `Local`, `RemoteSSO` or `RemoteLDAP` since v15.5 (flat in `user/session/get`
|
||||
* and `user/login`, WebServiceAuthApi.cs:74). */
|
||||
type?: string
|
||||
/** Obsolete since v15.5 (line 75): still sent, no longer read. */
|
||||
isSsoUser?: boolean
|
||||
/** Omitted for a `RemoteSSO` user (lines 77-82). */
|
||||
totpEnabled?: boolean
|
||||
info?: {
|
||||
version: string
|
||||
uptimestamp: string
|
||||
dnsServerDomain: string
|
||||
permissions?: Record<string, Permission>
|
||||
clusterInitialized?: boolean
|
||||
/** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */
|
||||
clusterDomain?: string
|
||||
clusterNodes?: { name: string; type: string }[]
|
||||
/* The record defaults the zone forms show (WebServiceAuthApi.cs:96-100). */
|
||||
defaultRecordTtl?: number
|
||||
defaultNsRecordTtl?: number
|
||||
defaultSoaRecordTtl?: number
|
||||
useSoaSerialDateScheme?: boolean
|
||||
dnssecValidation?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function Shell({
|
||||
session,
|
||||
onLogout,
|
||||
forcePasswordChange = false,
|
||||
}: {
|
||||
session: ShellSession
|
||||
onLogout: () => void
|
||||
/** A factory-credentials login: Change Password opens at once with `admin` filled in (auth.js:283-284). */
|
||||
forcePasswordChange?: boolean
|
||||
}) {
|
||||
const permissions = session.info?.permissions
|
||||
// Memoised: if it is recreated on every render, the `hashchange` resubscribes on each.
|
||||
const sections = useMemo(() => visibleSections(permissions), [permissions])
|
||||
/* The starting section comes from the address bar if it carries one, and only
|
||||
if not, from the first visible one. See `app/route.ts` for the reasoning. */
|
||||
/* Read once, on mount. `/allowed/` and `/blocked/` are Blocking's Rules tab now:
|
||||
the bar is rewritten (`replaceState`) before the route is read, here in the
|
||||
initialiser and not in the render body, so the side effect runs once; see
|
||||
`app/route.ts`. */
|
||||
const [initialRoute] = useState(() => {
|
||||
translateLegacyRoute()
|
||||
return readRoute(sections)
|
||||
})
|
||||
const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
const [modal, setModal] = useState<ModalId | null>(forcePasswordChange ? 'password' : null)
|
||||
/* The factory password travels only with the opening the login caused; from the
|
||||
user menu `showChangePasswordModal()` is called with nothing (main.js). */
|
||||
const [forcedCurrentPassword, setForcedCurrentPassword] = useState<string | undefined>(
|
||||
forcePasswordChange ? 'admin' : undefined,
|
||||
)
|
||||
const [sub, setSub] = useState<string | null>(initialRoute?.sub ?? null)
|
||||
const [displayName, setDisplayName] = useState(session.displayName)
|
||||
const [totpEnabled, setTotpEnabled] = useState(session.totpEnabled ?? false)
|
||||
|
||||
/*
|
||||
Silencing the update notice (`main.js:702-732`), which this console could read
|
||||
and not write: `checkForUpdate` already refused to call the endpoint while the
|
||||
preference was on, but nothing could ever turn it on, so the branch was
|
||||
unreachable and the user had no way to stop being told. Restored on 2026-09-04,
|
||||
checking About against upstream.
|
||||
|
||||
It is a per-browser preference and not a server setting, which is why it lives
|
||||
in `localStorage` and why silencing it on your laptop does not silence it
|
||||
anywhere else.
|
||||
*/
|
||||
const [silenced, setSilenced] = useState(() => updateNotificationSilenced())
|
||||
const [askSilence, setAskSilence] = useState(false)
|
||||
const [chromeNotice, setChromeNotice] = useState<Notice | null>(null)
|
||||
/*
|
||||
Once hidden in a session, the mark does not come back, and that is upstream's
|
||||
behaviour, not an oversight of ours: `disableUpdateNotification` calls
|
||||
`$("#lnkUpdateAvailable").hide()` and `enableUpdateNotification` never shows it
|
||||
again. You get the notice back by logging in again. Replicated as it stands, the
|
||||
same way `Quick Add`'s substring comparison is.
|
||||
*/
|
||||
const [markHidden, setMarkHidden] = useState(false)
|
||||
|
||||
function silence() {
|
||||
setAskSilence(false)
|
||||
silenceUpdateNotification()
|
||||
setSilenced(true)
|
||||
setMarkHidden(true)
|
||||
setChromeNotice({
|
||||
type: 'success',
|
||||
title: 'Notification Disabled!',
|
||||
text: 'Update notification was disabled successfully.',
|
||||
})
|
||||
}
|
||||
|
||||
/* Enabling does NOT re-check, exactly as upstream: it writes the preference and
|
||||
says so. The next check is the next login. */
|
||||
function unsilence() {
|
||||
unsilenceUpdateNotification()
|
||||
setSilenced(false)
|
||||
setChromeNotice({
|
||||
type: 'success',
|
||||
title: 'Notification Enabled!',
|
||||
text: 'Update notification was enabled successfully.',
|
||||
})
|
||||
}
|
||||
|
||||
function open(id: ModalId) {
|
||||
setModal(id)
|
||||
}
|
||||
|
||||
/*
|
||||
`sessionData.info` is not fixed at login upstream: every load of this server's
|
||||
settings, and every save or restore of them, rewrites its domain, version and
|
||||
start time and redraws the tab title, the header and About
|
||||
(`updateDnsSettingsDataAndGui`, main.js:1158-1166). Settings reports them here.
|
||||
*/
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null)
|
||||
/* The record defaults, from login until Settings loads them again
|
||||
(main.js:1194-1196 and 1228). */
|
||||
const [recordSettings, setRecordSettings] = useState<RecordSettings | null>(null)
|
||||
const info = session.info && serverInfo ? { ...session.info, ...serverInfo } : session.info
|
||||
|
||||
// main.js: the document title carries the server domain and the version.
|
||||
const title = info ? `${info.dnsServerDomain} - Technitium DNS Server v${info.version}` : null
|
||||
useEffect(() => {
|
||||
if (title != null) document.title = title
|
||||
}, [title])
|
||||
|
||||
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */
|
||||
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'))
|
||||
|
||||
/*
|
||||
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
|
||||
and the request land in the same render, so the target mounts already holding
|
||||
it; the effect below then forgets it, because the target has taken what it
|
||||
needs, and coming back later through the sidebar must find a clean screen.
|
||||
|
||||
A section the user cannot see is not opened: there would be no screen to land
|
||||
on.
|
||||
*/
|
||||
const [pending, setPending] = useState<
|
||||
{ dnsclient: DnsClientRequest } | { logs: QueryLogsRequest } | null
|
||||
>(null)
|
||||
useEffect(() => {
|
||||
if (pending != null) setPending(null)
|
||||
}, [pending])
|
||||
|
||||
const handoff = useMemo<Handoff>(() => {
|
||||
const visible = (id: string) => sections.some((s) => s.id === id)
|
||||
return {
|
||||
queryDnsServer(domain, type, node) {
|
||||
if (!visible('dnsclient')) return
|
||||
setPending({ dnsclient: { domain, type: type ?? 'A', node: targetNode(node) } })
|
||||
setActive('dnsclient')
|
||||
setSub(null)
|
||||
setDrawer(false)
|
||||
},
|
||||
showQueryLogs(domain, clientIp, node, filters) {
|
||||
if (!visible('logs')) return
|
||||
setPending({ logs: { domain, clientIp, node: targetNode(node), ...(filters != null ? { filters } : {}) } })
|
||||
setActive('logs')
|
||||
setSub('Query Logs')
|
||||
setDrawer(false)
|
||||
},
|
||||
canShowQueryLogs: visible('logs'),
|
||||
}
|
||||
}, [sections])
|
||||
|
||||
/*
|
||||
The effective sub. A section with sub-sections is ALWAYS in one of them, so
|
||||
`null` means "the first one".
|
||||
|
||||
It is resolved here and not in each branch (there were five loose
|
||||
`sub ?? 'General'`, and the sidebar repeated the same workaround to decide what
|
||||
to mark) because a rule spread around is precisely the one that goes out of
|
||||
sync: the menu said "General" and the address bar said `/settings/`, which is
|
||||
half a page.
|
||||
*/
|
||||
const currentSub = current?.subs != null ? (sub ?? current.subs[0] ?? null) : null
|
||||
|
||||
/*
|
||||
The URL follows the state, and the state follows the URL. The guard is in
|
||||
`writeRoute`, which does not touch history if the route is already the current
|
||||
one: without it, responding to `popstate` by setting the state would write the
|
||||
route again.
|
||||
*/
|
||||
// The first write normalises the URL and must leave no history entry.
|
||||
const firstRender = useRef(true)
|
||||
|
||||
const activeRef = useRef({ section: current?.id ?? 'about', sub })
|
||||
useEffect(() => {
|
||||
if (current == null) return
|
||||
activeRef.current = { section: current.id, sub: currentSub }
|
||||
writeRoute({ section: current.id, sub: currentSub }, firstRender.current)
|
||||
firstRender.current = false
|
||||
}, [current, sub, currentSub])
|
||||
|
||||
useEffect(() => {
|
||||
function onChanged() {
|
||||
translateLegacyRoute()
|
||||
const r = readRoute(sections)
|
||||
if (r == null) {
|
||||
// A route that does not resolve left the bar and the screen saying different
|
||||
// things. The screen rules: the URL is corrected.
|
||||
writeRoute({ section: activeRef.current.section, sub: activeRef.current.sub }, true)
|
||||
return
|
||||
}
|
||||
/*
|
||||
A half address (`/settings/`, with no sub) is completed here and not in the
|
||||
effect that writes the route: on going "back" the state does not change
|
||||
(`sub` was already `null`), so that effect does not run again and the bar was
|
||||
left saying half a page.
|
||||
|
||||
And it is completed by replacing: pushing would leave a new entry pointing at
|
||||
where we already are, and the back button would go round in circles.
|
||||
*/
|
||||
const section = sections.find((x) => x.id === r.section)
|
||||
const own = r.sub ?? section?.subs?.[0] ?? null
|
||||
if (own !== r.sub) writeRoute({ section: r.section, sub: own }, true)
|
||||
|
||||
setActive((v) => (v === r.section ? v : r.section))
|
||||
setSub((v) => (v === own ? v : own))
|
||||
}
|
||||
window.addEventListener('popstate', onChanged)
|
||||
return () => window.removeEventListener('popstate', onChanged)
|
||||
}, [sections])
|
||||
|
||||
return (
|
||||
<HandoffContext.Provider value={handoff}>
|
||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<div className={styles.shell}>
|
||||
{drawer && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.veil}
|
||||
aria-label="Close menu"
|
||||
onClick={() => setDrawer(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={styles.side} data-open={drawer}>
|
||||
<div className={styles.sbrand}>
|
||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
|
||||
</div>
|
||||
<nav className={styles.slist} role="navigation" aria-label="Sections">
|
||||
{/*
|
||||
This is a navigation menu, and until now it claimed to be a `tablist`.
|
||||
|
||||
It was declared as tabs when the console had no addresses: twelve
|
||||
`role="tab"` over a single panel. Once it gained real routes
|
||||
(`app/route.ts`) the description stopped being true (the ARIA guidance is
|
||||
explicit: if activating the element leads to another URL, it is a link,
|
||||
not a tab), and on top of that the sub-sections hung INSIDE the `tablist`
|
||||
as loose buttons, which is a child that role does not allow. It announced
|
||||
"tab 3 of 12" and navigated.
|
||||
|
||||
Now they are links with real `href`s: they open in a new tab, they are
|
||||
copied and bookmarked. The plain click is intercepted by the application
|
||||
(there is no reload); the modifier click is passed through to the
|
||||
browser, which is what the routes existing as files is for. The active
|
||||
section is marked with `data-active` (that is visual state) and
|
||||
`aria-current="page"` is reserved for ONE thing: the page you are on,
|
||||
which is the sub-section when there is one.
|
||||
*/}
|
||||
{GROUPS.map((group, g) => (
|
||||
<div className={styles.group} key={g}>
|
||||
{sections.filter((sec) => group.includes(sec.id)).map((sec) => {
|
||||
const active = sec.id === current?.id
|
||||
const first = sec.subs?.[0] ?? null
|
||||
return (
|
||||
<div key={sec.id}>
|
||||
<a
|
||||
className={styles.s}
|
||||
href={toTrail({ section: sec.id, sub: first })}
|
||||
data-active={active}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={sec.id === 'blocking' ? blockingNow?.label : undefined}
|
||||
onClick={(e) => {
|
||||
if (!plainClick(e)) return
|
||||
e.preventDefault()
|
||||
setActive(sec.id)
|
||||
setSub(first)
|
||||
setDrawer(false)
|
||||
}}
|
||||
>
|
||||
<span className={styles.ico}>
|
||||
<Icon name={ICONS[sec.id] ?? 'about'} size={16} />
|
||||
</span>
|
||||
{sec.label}
|
||||
{sec.id === 'blocking' && <BlockingMark mark={blockingNow} />}
|
||||
</a>
|
||||
{/*
|
||||
The sub-items are NOT here any more. The design puts a
|
||||
section's sub-navigation under its title, for a reason:
|
||||
nested here, nine panes run out of room the moment the rail
|
||||
drops to 60 px. What the sidebar gets back is
|
||||
its own shape: twelve entries, always the same twelve, instead
|
||||
of fourteen, eighteen or twenty-one depending on where you are.
|
||||
See `ui/SubTabs`.
|
||||
*/}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* The server and the account, at the foot. When narrow you reach it via the drawer. */}
|
||||
<div className={styles.footSide}>
|
||||
{/*
|
||||
Upstream's footer links, recovered.
|
||||
|
||||
Upstream has them in a `div#footer` hanging off the `body`, so they are
|
||||
visible on ALL its screens, the login one included: Technitium, Blog,
|
||||
Donate, DNS Client and GitHub. There were none here, and two of them
|
||||
(technitium.com and dnsclient.net) appeared nowhere else in the console:
|
||||
they had been lost entirely.
|
||||
|
||||
"About" is not repeated because in this redesign it is a sidebar section,
|
||||
right above.
|
||||
*/}
|
||||
{/*
|
||||
What is running, and whether there is anything newer. The server's
|
||||
domain heads it because the block is about that server, which is the
|
||||
pairing upstream makes in its panel title.
|
||||
*/}
|
||||
<Versions
|
||||
token={session.token}
|
||||
serverVersion={info?.version}
|
||||
domain={info?.dnsServerDomain}
|
||||
markHidden={markHidden}
|
||||
canCheckConsole={permissions?.DnsClient?.canView === true}
|
||||
/>
|
||||
<Menu label={displayName} text={displayName} anchor="left" asRow>
|
||||
{(close) => (
|
||||
<>
|
||||
<button type="button" onClick={() => { close(); open('profile') }}>
|
||||
My Profile
|
||||
</button>
|
||||
{/* main.js:71-87 (v15.5.1) switches on `sessionData.type`:
|
||||
`RemoteSSO` hides both, `RemoteLDAP` hides only the password
|
||||
(the directory owns it, the 2FA is still this server's) and
|
||||
`Local` or anything else shows both. */}
|
||||
{session.type !== 'RemoteSSO' && session.type !== 'RemoteLDAP' && (
|
||||
<button type="button" onClick={() => { close(); open('password') }}>
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
{session.type !== 'RemoteSSO' && (
|
||||
<button type="button" onClick={() => { close(); open('twofa') }}>
|
||||
Configure 2FA
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { close(); open('token') }}>
|
||||
Create API Token
|
||||
</button>
|
||||
{/* index.html:71: after the account's own entries, before the
|
||||
update notification. */}
|
||||
<button type="button" onClick={() => { close(); open('theme') }}>
|
||||
Change Theme
|
||||
</button>
|
||||
{/*
|
||||
One entry and not two: upstream declares both and hides one
|
||||
(`index.html:72-73`, `initUpdateNotificationMenu`), which is how
|
||||
you write a toggle in jQuery. The literal of each is upstream's.
|
||||
*/}
|
||||
{silenced ? (
|
||||
<button type="button" onClick={() => { close(); unsilence() }}>
|
||||
Enable Update Notification
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => { close(); setAskSilence(true) }}>
|
||||
Disable Update Notification
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => { close(); onLogout() }}>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={styles.main}>
|
||||
{/*
|
||||
On a WIDE screen there is no header: it only carried the server domain and
|
||||
the account menu, both pinned to the right edge, and it measured 1224×52
|
||||
with 78 % of the width empty (952 px out of 1224), pushing every section's
|
||||
title down to `y=76`. Both things move down to the foot of the sidebar,
|
||||
which had 377 px free, 42 % of its height.
|
||||
|
||||
On a NARROW screen it is needed, because the sidebar is a closed drawer and
|
||||
its trigger has to live outside.
|
||||
*/}
|
||||
<header className={styles.rtop}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.hamburger}
|
||||
aria-label="Menu"
|
||||
aria-expanded={drawer}
|
||||
onClick={() => setDrawer((v) => !v)}
|
||||
>
|
||||
<Icon name="menu" size={18} />
|
||||
</button>
|
||||
<span className={styles.topBrand}>
|
||||
<img className={styles.mark} src={publicUrl('img/logo.png')} alt="" width={22} height={22} /> Technitium
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its
|
||||
own URL this is not a panel being switched, it is the page. */}
|
||||
<main className={styles.body} id="panel-section">
|
||||
{/*
|
||||
The chrome slot: the chrome provides the PLACE, the screen provides the
|
||||
control. See `ChromeSlot.tsx`: the node selector belongs to each screen,
|
||||
with its memory and its options, and none of them hears about the others.
|
||||
|
||||
It stays empty while nobody uses it, and then it takes up nothing: it is a
|
||||
`div` with no content.
|
||||
*/}
|
||||
<div ref={slot} className={styles.chromeSlot} />
|
||||
{/* The chrome's own alert slot. Upstream's `showAlert` with no
|
||||
destination is a page-level alert, and until now the chrome had
|
||||
nowhere to put one. */}
|
||||
<Notifier notice={chromeNotice} onClose={() => setChromeNotice(null)} />
|
||||
{current?.id === 'dashboard' ? (
|
||||
<Dashboard
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
/>
|
||||
) : current?.id === 'dnsclient' ? (
|
||||
<DnsClient
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
request={pending != null && 'dnsclient' in pending ? pending.dnsclient : undefined}
|
||||
/>
|
||||
) : current?.id === 'about' ? (
|
||||
<About info={info} />
|
||||
) : current?.id === 'apps' ? (
|
||||
<Apps token={session.token} />
|
||||
) : current?.id === 'cache' ? (
|
||||
<Cache token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} />
|
||||
) : current?.id === 'blocking' ? (
|
||||
<Blocking
|
||||
token={session.token}
|
||||
sub={currentSub}
|
||||
onSubChange={setSub}
|
||||
permissions={permissions}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
serverDomain={info?.dnsServerDomain}
|
||||
/>
|
||||
) : current?.id === 'zones' ? (
|
||||
<Zones
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
canModify={permissions?.Zones?.canModify !== false}
|
||||
canDelete={permissions?.Zones?.canDelete !== false}
|
||||
clusterDomain={session.info?.clusterDomain}
|
||||
useSoaSerialDateScheme={(recordSettings ?? session.info)?.useSoaSerialDateScheme === true}
|
||||
dnssecValidation={(recordSettings ?? session.info)?.dnssecValidation !== false}
|
||||
recordTtls={{
|
||||
defaultRecordTtl: (recordSettings ?? session.info)?.defaultRecordTtl ?? 3600,
|
||||
defaultNsRecordTtl: (recordSettings ?? session.info)?.defaultNsRecordTtl ?? 14400,
|
||||
defaultSoaRecordTtl: (recordSettings ?? session.info)?.defaultSoaRecordTtl ?? 900,
|
||||
}}
|
||||
/>
|
||||
) : current?.id === 'dhcp' ? (
|
||||
<Dhcp
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
sub={currentSub ?? 'Leases'}
|
||||
onSubChange={setSub}
|
||||
canModify={permissions?.DhcpServer?.canModify !== false}
|
||||
canDelete={permissions?.DhcpServer?.canDelete !== false}
|
||||
/>
|
||||
) : current?.id === 'logs' ? (
|
||||
/* "Delete All Stats" lives on the Logs screen but asks for the
|
||||
Dashboard's permission (WebServiceLogsApi.cs:135). Not an oversight. */
|
||||
<Logs
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
sub={currentSub ?? 'View Logs'}
|
||||
onSubChange={setSub}
|
||||
canDeleteLogs={permissions?.Logs?.canDelete !== false}
|
||||
canDeleteStats={permissions?.Dashboard?.canDelete !== false}
|
||||
request={pending != null && 'logs' in pending ? pending.logs : undefined}
|
||||
/>
|
||||
) : current?.id === 'admin' ? (
|
||||
/* No permission props on purpose: upstream hides and disables nothing
|
||||
inside Administration, only the whole section (main.js:165). */
|
||||
<Admin token={session.token} sub={currentSub ?? 'Sessions'} onSubChange={setSub} />
|
||||
) : current?.id === 'settings' ? (
|
||||
/* main.js:906-930: three different permissions in a single bar:
|
||||
saving requires Settings.canModify, flushing the cache
|
||||
Cache.canDelete, and backup/restore Settings.canDelete. */
|
||||
<Settings
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
sub={currentSub ?? 'General'}
|
||||
onSubChange={setSub}
|
||||
canModify={permissions?.Settings?.canModify !== false}
|
||||
canFlushCache={permissions?.Cache?.canDelete !== false}
|
||||
canBackup={permissions?.Settings?.canDelete !== false}
|
||||
serverDomain={session.info?.dnsServerDomain}
|
||||
onServerInfo={setServerInfo}
|
||||
onRecordSettings={setRecordSettings}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{/*
|
||||
Upstream's links, at the foot of the page and not in the side panel.
|
||||
|
||||
Upstream hangs them off a `div#footer` on the `body`, below everything:
|
||||
they are destinations you go to when you have finished, not navigation you
|
||||
use while working. In the side panel they sat between the sections and the
|
||||
account, competing with both; here they close the page, which is where
|
||||
they were.
|
||||
*/}
|
||||
<footer className={styles.footMain}>
|
||||
<FooterLinks />
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<MyProfile
|
||||
open={modal === 'profile'}
|
||||
onOpenChange={(o) => setModal(o ? 'profile' : null)}
|
||||
token={session.token}
|
||||
onSaved={setDisplayName}
|
||||
onNotice={setChromeNotice}
|
||||
/>
|
||||
<ChangePassword
|
||||
open={modal === 'password'}
|
||||
onOpenChange={(o) => {
|
||||
setModal(o ? 'password' : null)
|
||||
if (!o) setForcedCurrentPassword(undefined)
|
||||
}}
|
||||
totpEnabled={totpEnabled}
|
||||
token={session.token}
|
||||
username={session.username}
|
||||
currentPassword={forcedCurrentPassword}
|
||||
onNotice={setChromeNotice}
|
||||
/>
|
||||
<Configure2FA
|
||||
open={modal === 'twofa'}
|
||||
onOpenChange={(o) => setModal(o ? 'twofa' : null)}
|
||||
token={session.token}
|
||||
username={session.username}
|
||||
onChanged={setTotpEnabled}
|
||||
onNotice={setChromeNotice}
|
||||
/>
|
||||
<CreateApiToken
|
||||
open={modal === 'token'}
|
||||
onOpenChange={(o) => setModal(o ? 'token' : null)}
|
||||
username={session.username}
|
||||
token={session.token}
|
||||
/>
|
||||
<ChangeTheme
|
||||
open={modal === 'theme'}
|
||||
onOpenChange={(o) => setModal(o ? 'theme' : null)}
|
||||
/>
|
||||
|
||||
{/* Upstream asks with a native `confirm()`; this console asks with its own,
|
||||
and the sentence is upstream's, whole. */}
|
||||
<Confirm
|
||||
open={askSilence}
|
||||
title="Disable Update Notification"
|
||||
text={
|
||||
'Disabling update notification will prevent the Web Console from showing new update notification when you login. You will have to manually find out if a new update is available.\n\nAre you sure you want to disable update notification?'
|
||||
}
|
||||
label="Disable"
|
||||
onClose={() => setAskSilence(false)}
|
||||
onConfirm={silence}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</ThisServerName.Provider>
|
||||
</HandoffContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
The versions block, at the foot of the side panel.
|
||||
|
||||
It is the same idea NetBird uses at the bottom-left of its dashboard: the
|
||||
components that make up what you are looking at, each with the version it is
|
||||
running, in a quiet framed block that is out of the way but never off screen.
|
||||
Here there are two, and they are genuinely two different things that update
|
||||
separately: the DNS server, which Technitium ships, and this console, which does
|
||||
not come with it.
|
||||
|
||||
Upstream keeps the server's domain in the panel title next to "DNS Server". Same
|
||||
pairing here: the domain heads the block that talks about that server.
|
||||
*/
|
||||
.block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-3);
|
||||
background: var(--pan2);
|
||||
padding: var(--s-5) var(--s-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
/* The server's domain wraps at its dots only (`ui/DomainName`): `break-all` cut it
|
||||
wherever the card's edge fell, mid-label. A name with no dot, which DomainName
|
||||
leaves as plain text, still breaks rather than leave the card. */
|
||||
.host {
|
||||
font-family: var(--font-mono);
|
||||
overflow-wrap: break-word;
|
||||
font-size: var(--t-1);
|
||||
color: var(--faint);
|
||||
padding-bottom: var(--s-3);
|
||||
border-bottom: 1px solid var(--line2);
|
||||
margin-bottom: var(--s-2);
|
||||
}
|
||||
|
||||
/* Label left, version right: the eye reads down the versions in one column.
|
||||
When a mark leaves no room beside the label, the version and its mark move to
|
||||
the next line, still on the right, rather than cut "Web Console" in two. */
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2) var(--s-5);
|
||||
font-size: var(--t-2);
|
||||
}
|
||||
|
||||
.name { color: var(--mute); white-space: nowrap; }
|
||||
|
||||
.ver {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.num { font-family: var(--font-mono); color: var(--ink); }
|
||||
|
||||
/*
|
||||
The mark rides on the version it is about, which is the whole point of putting
|
||||
it here: an update to the DNS server and an update to this console are not the
|
||||
same news, and a notice floating on its own could not say which one it meant.
|
||||
|
||||
Amber and not the red upstream uses: in this console red is what destroys
|
||||
(Delete Zones, Flush Cache) and an available update destroys nothing.
|
||||
*/
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px var(--s-4) 1px var(--s-3);
|
||||
border: 1px solid var(--acc-ink);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--acc-ink);
|
||||
font: inherit;
|
||||
font-size: var(--t-1);
|
||||
|
||||
line-height: 1.6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark:hover,
|
||||
.mark:focus-visible { background: color-mix(in srgb, var(--acc) 14%, transparent); }
|
||||
|
||||
.dot { width: 5px; height: 5px; border-radius: 50%; background: var(--acc-ink); }
|
||||
|
||||
/* Inside the dialog. */
|
||||
.jump {
|
||||
display: flex; align-items: baseline; gap: var(--s-5);
|
||||
margin-bottom: var(--s-7); font-family: var(--font-mono);
|
||||
}
|
||||
.from { color: var(--mute); }
|
||||
.arrow { color: var(--faint); }
|
||||
.to { color: var(--acc-ink); font-weight: 600; }
|
||||
.message { margin-bottom: var(--s-7); }
|
||||
.links { display: flex; flex-wrap: wrap; gap: var(--s-7); }
|
||||
|
||||
/* The console's update commands: one line each, read and copied whole. They
|
||||
are long, so they wrap between words rather than push the dialog wider. */
|
||||
/* The command and its Copy button on one line; the command takes the width. */
|
||||
.commandRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
margin: calc(-1 * var(--s-5)) 0 var(--s-7);
|
||||
}
|
||||
.commandRow > button { flex: none; }
|
||||
|
||||
.command {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: var(--s-4) var(--s-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--pan2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--t-1);
|
||||
overflow-wrap: break-word;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
/* Upstream's two `Note!`, after the links: they explain, they do not change the
|
||||
decision. Same rule as every other `Note!` in the console. */
|
||||
.notes { display: grid; gap: var(--s-5); margin-top: var(--s-9); }
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Versions } from './Versions'
|
||||
import * as userApi from '../api/user'
|
||||
import * as consoleUpdate from './console-update'
|
||||
import * as copyApi from '../lib/copy-text'
|
||||
import { wholeText } from '../test/whole-text'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function withUpdate(response: Partial<userApi.UpdateInfo> = {}) {
|
||||
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', response: { updateAvailable: true, ...response } },
|
||||
} as never)
|
||||
}
|
||||
|
||||
function withoutUpdate() {
|
||||
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', response: { updateAvailable: false } },
|
||||
} as never)
|
||||
}
|
||||
|
||||
const mark = { name: 'DNS Server update available' } as const
|
||||
|
||||
describe('Versions', () => {
|
||||
it('reports both versions and the server it is talking about', async () => {
|
||||
withoutUpdate()
|
||||
render(<Versions token="t" serverVersion="15.4" domain="dns.example.net" />)
|
||||
expect(screen.getByText('DNS Server')).toBeInTheDocument()
|
||||
expect(screen.getByText('15.4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Web Console')).toBeInTheDocument()
|
||||
expect(screen.getByText(wholeText('dns.example.net'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
/* The session may arrive without info; a missing version is said, not faked. */
|
||||
it('says nothing it does not know', async () => {
|
||||
withoutUpdate()
|
||||
render(<Versions token="t" />)
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not mark anything when the server is up to date', async () => {
|
||||
withoutUpdate()
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/* While the notice is silenced the endpoint is not even called and
|
||||
`checkForUpdate` answers `skipped`: nothing may be marked from that. */
|
||||
it('does not mark anything while the notice is silenced', async () => {
|
||||
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({ kind: 'skipped' })
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('a failed check is not an update', async () => {
|
||||
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({ kind: 'error', message: 'boom' } as never)
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the server version when there is an update', async () => {
|
||||
withUpdate()
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
expect(await screen.findByRole('button', mark)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the detail with versions, message and the three links', async () => {
|
||||
withUpdate({
|
||||
currentVersion: '15.4',
|
||||
updateVersion: '16.0',
|
||||
updateMessage: 'Fixes a resolver crash.',
|
||||
downloadLink: 'https://example.test/dl',
|
||||
instructionsLink: 'https://example.test/how',
|
||||
changeLogLink: 'https://example.test/log',
|
||||
})
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await userEvent.click(await screen.findByRole('button', mark))
|
||||
|
||||
expect(screen.getByText('16.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fixes a resolver crash.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Download Now!' })).toHaveAttribute('href', 'https://example.test/dl')
|
||||
expect(screen.getByRole('link', { name: 'Update Instructions' })).toHaveAttribute('href', 'https://example.test/how')
|
||||
expect(screen.getByRole('link', { name: 'Read Change Logs' })).toHaveAttribute('href', 'https://example.test/log')
|
||||
})
|
||||
|
||||
/*
|
||||
Three literals that were missing or reworded, found checking About against
|
||||
upstream on 2026-09-04: the update flow had never been walked against upstream
|
||||
item by item, because the earlier check covered the chrome and not this dialog.
|
||||
*/
|
||||
it('names each of the two versions, which the arrow only says to the eye', async () => {
|
||||
withUpdate({ currentVersion: '15.4', updateVersion: '16.0' })
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await userEvent.click(await screen.findByRole('button', mark))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('Current Version: 15.4')
|
||||
expect(dialog).toHaveTextContent('Update Version: 16.0')
|
||||
})
|
||||
|
||||
it('carries upstream two `Note!`, which were nowhere in the console', async () => {
|
||||
withUpdate({ updateMessage: 'x' })
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await userEvent.click(await screen.findByRole('button', mark))
|
||||
expect(
|
||||
screen.getByText('It is highly recommended to Backup Settings before installing the update.'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/You will have to refresh this web page manually after updating/),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
/* Upstream writes this title itself when the feed carries none. */
|
||||
it('falls back to upstream own wording when there is no title', async () => {
|
||||
withUpdate({ updateMessage: 'x' })
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await userEvent.click(await screen.findByRole('button', mark))
|
||||
expect(screen.getByRole('heading', { name: 'New Update Available!' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
/* A link to `null` would be a dead control, which is worse than no control. */
|
||||
it('hides each link the server does not send', async () => {
|
||||
withUpdate({ downloadLink: 'https://example.test/dl' })
|
||||
render(<Versions token="t" serverVersion="15.4" />)
|
||||
await userEvent.click(await screen.findByRole('button', mark))
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Download Now!' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Update Instructions' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Read Change Logs' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Versions: the console mark', () => {
|
||||
const consoleMark = { name: 'Web Console update available' } as const
|
||||
|
||||
function serverCheck(response: Partial<userApi.UpdateInfo>) {
|
||||
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: { status: 'ok', response: { updateAvailable: false, dnsServerEnableCheckForUpdate: true, ...response } },
|
||||
} as never)
|
||||
}
|
||||
|
||||
function consoleRelease(latest = '1.4.0') {
|
||||
return vi.spyOn(consoleUpdate, 'checkConsoleUpdate').mockResolvedValue({ latest, server: '15.5' })
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
it('shows the console mark when a newer console exists', async () => {
|
||||
serverCheck({})
|
||||
consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
expect(await screen.findByRole('button', consoleMark)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', mark)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the console dialog with the version jump and the commands', async () => {
|
||||
serverCheck({})
|
||||
consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
await userEvent.click(await screen.findByRole('button', consoleMark))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(screen.getByRole('heading', { name: 'New Web Console Version Available!' })).toBeInTheDocument()
|
||||
expect(dialog).toHaveTextContent(`Current Version: ${__CONSOLE_VERSION__}`)
|
||||
expect(dialog).toHaveTextContent('Update Version: 1.4.0')
|
||||
expect(screen.getByRole('link', { name: 'Release Notes' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/byGarcia/technitium-console/releases/tag/v1.4.0',
|
||||
)
|
||||
expect(dialog).toHaveTextContent(
|
||||
'curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh',
|
||||
)
|
||||
expect(dialog).toHaveTextContent(
|
||||
'docker compose pull technitium-console && docker compose up -d technitium-console',
|
||||
)
|
||||
})
|
||||
|
||||
it('each command has a Copy button that copies it whole and says so', async () => {
|
||||
serverCheck({})
|
||||
consoleRelease()
|
||||
const copy = vi.spyOn(copyApi, 'copyText').mockResolvedValue(true)
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
await userEvent.click(await screen.findByRole('button', consoleMark))
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Copy the installer command' }))
|
||||
expect(copy).toHaveBeenCalledWith(
|
||||
'curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh',
|
||||
expect.any(HTMLElement),
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Copy the installer command' })).toHaveTextContent('Copied')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Copy the Docker command' }))
|
||||
expect(copy).toHaveBeenLastCalledWith(
|
||||
'docker compose pull technitium-console && docker compose up -d technitium-console',
|
||||
expect.any(HTMLElement),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mark a console that is already the latest', async () => {
|
||||
serverCheck({})
|
||||
consoleRelease(__CONSOLE_VERSION__)
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
await settle()
|
||||
expect(screen.queryByRole('button', consoleMark)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not check without DNS Client permission', async () => {
|
||||
serverCheck({})
|
||||
const spy = consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" />)
|
||||
await settle()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not check when the server's check is off", async () => {
|
||||
serverCheck({ dnsServerEnableCheckForUpdate: false })
|
||||
const spy = consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
await settle()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not check when the notice is silenced', async () => {
|
||||
vi.spyOn(userApi, 'checkForUpdate').mockResolvedValue({ kind: 'skipped' })
|
||||
const spy = consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
await settle()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the console mark when the session silences the notice', async () => {
|
||||
serverCheck({})
|
||||
consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole markHidden />)
|
||||
await settle()
|
||||
expect(screen.queryByRole('button', consoleMark)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
/* The server's version reaches the Shell after the first render. The server's
|
||||
own check must not run again when it does: each run is a line in its log. */
|
||||
it("asks the server once when the server's version arrives later", async () => {
|
||||
serverCheck({})
|
||||
consoleRelease()
|
||||
const spy = vi.mocked(userApi.checkForUpdate)
|
||||
const { rerender } = render(<Versions token="t" canCheckConsole />)
|
||||
await settle()
|
||||
rerender(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
expect(await screen.findByRole('button', consoleMark)).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('names the two marks apart', async () => {
|
||||
serverCheck({ updateAvailable: true, updateVersion: '16.0' })
|
||||
consoleRelease()
|
||||
render(<Versions token="t" serverVersion="15.6.2" canCheckConsole />)
|
||||
expect(await screen.findByRole('button', consoleMark)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', mark)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { checkForUpdate, type UpdateInfo } from '../api/user'
|
||||
import { checkConsoleUpdate, newerConsole } from './console-update'
|
||||
import { Dialog } from '../ui/Dialog'
|
||||
import { External } from '../ui/External'
|
||||
import { Alert } from '../ui/Alert'
|
||||
import { DomainName } from '../ui/DomainName'
|
||||
import { Button } from '../ui/Button'
|
||||
import { copyText } from '../lib/copy-text'
|
||||
import styles from './Versions.module.css'
|
||||
import text from '../ui/text.module.css'
|
||||
|
||||
/*
|
||||
What is running, and whether there is anything newer.
|
||||
|
||||
Two facts that used to be scattered: the server's version lived only on the
|
||||
About screen, this console's version lived nowhere at all, and the update notice
|
||||
(which upstream shows from EVERY screen: `index.html:141`, in the panel heading,
|
||||
outside the tab panes) was not shown anywhere. The check existed in
|
||||
`api/user.ts` and the only thing it drove was one line inside About.
|
||||
|
||||
Putting the mark on the version it concerns is not decoration: the DNS server
|
||||
and this console update separately and by different means, so a notice floating
|
||||
on its own could not say which of the two it meant.
|
||||
|
||||
Nothing here decides anything the server has not already decided. Every field is
|
||||
shown, or hidden when the server sends it null, exactly as `main.js` does. And
|
||||
the silencing preference is not re-read: `checkForUpdate` refuses to call the
|
||||
endpoint while it is on, which is upstream's behaviour and not this component's
|
||||
decision.
|
||||
*/
|
||||
export function Versions({ token, serverVersion, domain, markHidden = false, canCheckConsole = false }: {
|
||||
token: string
|
||||
serverVersion?: string
|
||||
domain?: string
|
||||
/* The session may use DNS Client, which the console's own check goes through
|
||||
(`console-update.ts`). Without it the server would only log a denial. */
|
||||
canCheckConsole?: boolean
|
||||
/*
|
||||
The chrome silenced the notice in THIS session, so the mark goes.
|
||||
|
||||
Upstream hides its link the moment you disable the notification
|
||||
(`main.js:722`) and never shows it again, not even on re-enabling: you get it
|
||||
back by logging in again. Replicated as it stands.
|
||||
*/
|
||||
markHidden?: boolean
|
||||
}) {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [consoleLatest, setConsoleLatest] = useState<string | null>(null)
|
||||
const [consoleOpen, setConsoleOpen] = useState(false)
|
||||
/* The server's *Check for update* switch and a silenced notice (with which
|
||||
`checkForUpdate` answers `skipped`) turn both notices off. There is no
|
||||
setting of the console's own. */
|
||||
const [consoleCheckOn, setConsoleCheckOn] = useState(false)
|
||||
|
||||
/* Once per session token, as before: each call is a line in the server's log,
|
||||
so it does not depend on anything that arrives after the first render. */
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
void (async () => {
|
||||
const r = await checkForUpdate(token)
|
||||
if (!alive || r.kind !== 'ok') return
|
||||
const d = r.data as { response?: UpdateInfo }
|
||||
if (d.response?.updateAvailable) setInfo(d.response)
|
||||
setConsoleCheckOn(d.response?.dnsServerEnableCheckForUpdate === true)
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
/* Runs again when the server's version arrives, which costs nothing: the
|
||||
check keeps its answer for a day (`console-update.ts`). */
|
||||
useEffect(() => {
|
||||
if (!consoleCheckOn || !canCheckConsole) return
|
||||
let alive = true
|
||||
void (async () => {
|
||||
const release = await checkConsoleUpdate(token)
|
||||
if (alive) setConsoleLatest(newerConsole(release, __CONSOLE_VERSION__, serverVersion))
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [token, consoleCheckOn, canCheckConsole, serverVersion])
|
||||
|
||||
/* main.js: when the feed sends no title, upstream writes this one itself. */
|
||||
const title = info?.updateTitle ?? 'New Update Available!'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.block}>
|
||||
{domain && <div className={styles.host}><DomainName name={domain} /></div>}
|
||||
|
||||
<div className={styles.row}>
|
||||
<span className={styles.name}>DNS Server</span>
|
||||
{/* The mark goes BEFORE the number so that the two version numbers
|
||||
keep their right edges aligned in one column, which is the whole
|
||||
reason for reading them stacked. */}
|
||||
<span className={styles.ver}>
|
||||
{info && !markHidden && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mark}
|
||||
onClick={() => setOpen(true)}
|
||||
title={title}
|
||||
aria-label="DNS Server update available"
|
||||
>
|
||||
<span className={styles.dot} aria-hidden="true" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<span className={styles.num}>{serverVersion ?? '—'}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
This console's version, injected at build time from package.json.
|
||||
|
||||
The browser cannot ask GitHub for a newer one: the server's CSP is
|
||||
`default-src 'self'` with no `connect-src`. So the server asks instead,
|
||||
through DNS Client, the project's DNS-over-HTTPS endpoint
|
||||
(`console-update.ts`, `update-check/`), at most once a day.
|
||||
*/}
|
||||
<div className={styles.row}>
|
||||
<span className={styles.name}>Web Console</span>
|
||||
<span className={styles.ver}>
|
||||
{consoleLatest && !markHidden && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mark}
|
||||
onClick={() => setConsoleOpen(true)}
|
||||
title={CONSOLE_TITLE}
|
||||
aria-label="Web Console update available"
|
||||
>
|
||||
<span className={styles.dot} aria-hidden="true" />
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<span className={styles.num}>{__CONSOLE_VERSION__}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info && (
|
||||
<Dialog open={open} onOpenChange={setOpen} title={title} size="compact">
|
||||
{/*
|
||||
The two numbers keep the arrow, and get back the two names upstream
|
||||
writes beside them: `Current Version:` and `Update Version:`.
|
||||
|
||||
Found checking About against upstream, 2026-09-04: the arrow says which
|
||||
is which to the eye and to nobody else. Read aloud it was "15.4 → 16.0", two numbers
|
||||
with no way to tell the one you are running from the one you are being
|
||||
offered, and this is the dialog whose whole job is that distinction.
|
||||
The names go hidden rather than drawn so the comparison keeps the shape
|
||||
the chrome design decided.
|
||||
*/}
|
||||
{info.currentVersion && info.updateVersion && (
|
||||
<div className={styles.jump}>
|
||||
<span className={styles.from}>
|
||||
<span className={text.sr}>Current Version: </span>
|
||||
{info.currentVersion}
|
||||
</span>
|
||||
<span className={styles.arrow} aria-hidden="true">→</span>
|
||||
<span className={styles.to}>
|
||||
<span className={text.sr}>Update Version: </span>
|
||||
{info.updateVersion}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info.updateMessage && <p className={styles.message}>{info.updateMessage}</p>}
|
||||
|
||||
{/* Upstream hides each of the three on its own when it comes null. */}
|
||||
<div className={styles.links}>
|
||||
{info.downloadLink && <External href={info.downloadLink}>Download Now!</External>}
|
||||
{info.instructionsLink && <External href={info.instructionsLink}>Update Instructions</External>}
|
||||
{/* `Read Change Logs`, which is what upstream calls it. It said
|
||||
`Change Log`: a literal reworded, caught checking About against upstream. */}
|
||||
{info.changeLogLink && <External href={info.changeLogLink}>Read Change Logs</External>}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Upstream's two `Note!`, and they were nowhere in this console.
|
||||
|
||||
They are not decoration: one tells you to back up before updating and
|
||||
the other that the page has to be reloaded by hand afterwards, which is
|
||||
the difference between an update that goes well and one that leaves you
|
||||
looking at a console that is no longer the one running.
|
||||
*/}
|
||||
<div className={styles.notes}>
|
||||
<Alert type="info" title="Note!">
|
||||
It is highly recommended to Backup Settings before installing the update.
|
||||
</Alert>
|
||||
<Alert type="info" title="Note!">
|
||||
You will have to refresh this web page manually after updating the DNS Server so that
|
||||
new UI changes are loaded.
|
||||
</Alert>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/*
|
||||
OURS: the console's own notice. Same shape as the server's above; what to
|
||||
run is copied from the README, which is where the two commands live.
|
||||
*/}
|
||||
{consoleLatest && (
|
||||
<Dialog open={consoleOpen} onOpenChange={setConsoleOpen} title={CONSOLE_TITLE} size="compact">
|
||||
<div className={styles.jump}>
|
||||
<span className={styles.from}>
|
||||
<span className={text.sr}>Current Version: </span>
|
||||
{__CONSOLE_VERSION__}
|
||||
</span>
|
||||
<span className={styles.arrow} aria-hidden="true">→</span>
|
||||
<span className={styles.to}>
|
||||
<span className={text.sr}>Update Version: </span>
|
||||
{consoleLatest}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={styles.message}>To update, run the installer again:</p>
|
||||
<Command text={INSTALL_COMMAND} label="Copy the installer command" />
|
||||
<p className={styles.message}>On Docker:</p>
|
||||
<Command text={DOCKER_COMMAND} label="Copy the Docker command" />
|
||||
|
||||
<div className={styles.links}>
|
||||
<External href={`https://github.com/byGarcia/technitium-console/releases/tag/v${consoleLatest}`}>
|
||||
Release Notes
|
||||
</External>
|
||||
</div>
|
||||
|
||||
<div className={styles.notes}>
|
||||
<Alert type="info" title="Note!">
|
||||
You will have to refresh this web page manually after updating so that the new console is loaded.
|
||||
</Alert>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CONSOLE_TITLE = 'New Web Console Version Available!'
|
||||
const INSTALL_COMMAND =
|
||||
'curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh'
|
||||
const DOCKER_COMMAND = 'docker compose pull technitium-console && docker compose up -d technitium-console'
|
||||
|
||||
/*
|
||||
OURS: a command with its Copy button. The console cannot run it (it is a web page
|
||||
and the server offers no call that replaces its files), so the shortest way is
|
||||
one click to copy and a paste on the server. The button says "Copied" for two
|
||||
seconds; if the browser refuses, the line stays selectable as before.
|
||||
*/
|
||||
function Command({ text: command, label }: { text: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
// Inside the dialog, so its focus trap leaves the copy's selection alone.
|
||||
const row = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const t = window.setTimeout(() => setCopied(false), 2000)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [copied])
|
||||
|
||||
return (
|
||||
<div className={styles.commandRow} ref={row}>
|
||||
<code className={styles.command}>{command}</code>
|
||||
<Button size="sm" aria-label={label} onClick={() => void copyText(command, row.current ?? undefined).then(setCopied)}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Where the console hangs from.
|
||||
|
||||
It cannot be assumed to be `/`: the server honours `X-Forwarded-Prefix` by mounting
|
||||
a `PathBase` (`DnsWebService.cs:1943-1945`), so behind a proxy the console can live
|
||||
at `/dns/`. And ever since the routes became real (`/settings/logging/`), relative
|
||||
paths do not work either: `api/status` from there requests
|
||||
`/settings/logging/api/status`, which is a 404. Verified in the browser before
|
||||
fixing it.
|
||||
|
||||
Each generated `index.html` declares its own route in a `<meta>`, and the root
|
||||
comes from subtracting those segments from the `pathname`.
|
||||
*/
|
||||
|
||||
function compute(): string {
|
||||
const route = document.querySelector('meta[name="route"]')?.getAttribute('content')
|
||||
/*
|
||||
Repeated slashes are collapsed. The server serves the front page for `//`
|
||||
too, and a root of `//` would turn every API call into `//api/…`, a
|
||||
protocol-relative URL, i.e. the host `api`, with the session token in
|
||||
`Authorization`. Upstream never has this problem because it asks for `api/…`
|
||||
relative to the document (security audit 2026-09-30).
|
||||
*/
|
||||
const trail = window.location.pathname.replace(/\/{2,}/g, '/')
|
||||
if (route == null || route === '') return trail.endsWith('/') ? trail : trail + '/'
|
||||
|
||||
const spare = route.split('/').length
|
||||
const parts = trail.split('/').filter(Boolean)
|
||||
const prefix = parts.slice(0, Math.max(0, parts.length - spare))
|
||||
return prefix.length === 0 ? '/' : `/${prefix.join('/')}/`
|
||||
}
|
||||
|
||||
/*
|
||||
It is computed ONCE and frozen. The `<meta>` belongs to the document the server
|
||||
served and does not change on navigation: as soon as the application pushes
|
||||
`/settings/logging/` onto a document that said `dashboard`, subtracting its
|
||||
segments would give a different root on every hop. The root is a property of where
|
||||
the console is mounted, not of where the user is.
|
||||
*/
|
||||
let cache: string | null = null
|
||||
|
||||
export function appRoot(): string {
|
||||
cache ??= compute()
|
||||
return cache
|
||||
}
|
||||
|
||||
/** For the tests only, which change root between cases. */
|
||||
export function forgetRoot(): void {
|
||||
cache = null
|
||||
}
|
||||
|
||||
/** An endpoint's URL, hanging from the real root. */
|
||||
export function urlApi(trail: string): string {
|
||||
return appRoot() + trail
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for a file from `public/` (the logo, the `loader.gif`s). A relative
|
||||
* `src` also breaks from a two-level route: `img/logo.png` at
|
||||
* `/settings/logging/` requests `/settings/logging/img/logo.png`.
|
||||
*/
|
||||
export function publicUrl(trail: string): string {
|
||||
return appRoot() + trail
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as dnsclient from '../api/dnsclient'
|
||||
import {
|
||||
checkConsoleUpdate,
|
||||
compareVersions,
|
||||
CONSOLE_UPDATE_KEY,
|
||||
newerConsole,
|
||||
parseRecord,
|
||||
} from './console-update'
|
||||
|
||||
describe('parseRecord', () => {
|
||||
it('reads the record', () => {
|
||||
expect(parseRecord('v=1 latest=1.4.0 server=15.5')).toEqual({ latest: '1.4.0', server: '15.5' })
|
||||
})
|
||||
|
||||
it('ignores a record without its version', () => {
|
||||
expect(parseRecord('latest=1.4.0 server=15.5')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores keys it does not know', () => {
|
||||
expect(parseRecord('v=1 latest=1.4.0 server=15.5 future=x')).toEqual({ latest: '1.4.0', server: '15.5' })
|
||||
})
|
||||
|
||||
it('takes two-part versions', () => {
|
||||
expect(parseRecord('v=1 latest=1.4 server=15.5')).toEqual({ latest: '1.4', server: '15.5' })
|
||||
})
|
||||
|
||||
it('refuses anything that is not a version', () => {
|
||||
expect(parseRecord('v=1 latest=<b>1</b> server=15.5')).toBeNull()
|
||||
expect(parseRecord('v=1 latest=1.4.0')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it('compares field by field', () => {
|
||||
expect(compareVersions('1.4.0', '1.3.9')).toBeGreaterThan(0)
|
||||
expect(compareVersions('15.6.2', '15.5')).toBeGreaterThan(0)
|
||||
expect(compareVersions('15.5', '15.5.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('puts a prerelease before its release and after the one before', () => {
|
||||
expect(compareVersions('1.4.0-rc.1', '1.4.0')).toBeLessThan(0)
|
||||
expect(compareVersions('1.4.0-rc.1', '1.3.2')).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('newerConsole', () => {
|
||||
const release = { latest: '1.4.0', server: '15.5' }
|
||||
|
||||
it('notifies a newer release', () => {
|
||||
expect(newerConsole(release, '1.3.1', '15.6.2')).toBe('1.4.0')
|
||||
})
|
||||
|
||||
it('stays quiet for the same or an older release', () => {
|
||||
expect(newerConsole(release, '1.4.0', '15.6.2')).toBeNull()
|
||||
expect(newerConsole(release, '1.5.0', '15.6.2')).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet when the server is below the minimum', () => {
|
||||
expect(newerConsole(release, '1.3.1', '15.4')).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet without a server version or a record', () => {
|
||||
expect(newerConsole(release, '1.3.1', undefined)).toBeNull()
|
||||
expect(newerConsole(null, '1.3.1', '15.6.2')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkConsoleUpdate', () => {
|
||||
const NOW = 1_800_000_000_000
|
||||
const HOUR = 3_600_000
|
||||
|
||||
function answers(text: string | null) {
|
||||
return vi.spyOn(dnsclient, 'resolve').mockResolvedValue({
|
||||
kind: 'ok',
|
||||
data: {
|
||||
status: 'ok',
|
||||
response: {
|
||||
result: { Answer: text === null ? [] : [{ Type: 'TXT', RDATA: { Text: text } }] },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('asks the server with the DoH endpoint and no node', async () => {
|
||||
const spy = answers('v=1 latest=1.4.0 server=15.5')
|
||||
expect(await checkConsoleUpdate('t', NOW)).toEqual({ latest: '1.4.0', server: '15.5' })
|
||||
expect(spy).toHaveBeenCalledWith('t', {
|
||||
server: 'https://technitium-console.pages.dev/dns-query',
|
||||
domain: 'latest.technitium-console.pages.dev',
|
||||
type: 'TXT',
|
||||
protocol: 'HTTPS',
|
||||
dnssec: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses a fresh result', async () => {
|
||||
const spy = answers('v=1 latest=1.4.0 server=15.5')
|
||||
await checkConsoleUpdate('t', NOW)
|
||||
expect(await checkConsoleUpdate('t', NOW + HOUR)).toEqual({ latest: '1.4.0', server: '15.5' })
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('asks again after a day', async () => {
|
||||
const spy = answers('v=1 latest=1.4.0 server=15.5')
|
||||
await checkConsoleUpdate('t', NOW)
|
||||
await checkConsoleUpdate('t', NOW + 24 * HOUR)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('caches a failed check for a day', async () => {
|
||||
const spy = vi.spyOn(dnsclient, 'resolve').mockResolvedValue({ kind: 'error', message: 'Access was denied.' } as never)
|
||||
expect(await checkConsoleUpdate('t', NOW)).toBeNull()
|
||||
expect(await checkConsoleUpdate('t', NOW + HOUR)).toBeNull()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(localStorage.getItem(CONSOLE_UPDATE_KEY)!)).toEqual({ at: NOW, release: null })
|
||||
})
|
||||
|
||||
/* A server that cannot reach the endpoint holds the request for up to ten
|
||||
seconds: a reload or a second tab in that time must not ask again. */
|
||||
it('asks once while a check is still running', async () => {
|
||||
let finish: (v: unknown) => void = () => {}
|
||||
const spy = vi.spyOn(dnsclient, 'resolve').mockReturnValue(new Promise((r) => (finish = r)) as never)
|
||||
const first = checkConsoleUpdate('t', NOW)
|
||||
expect(await checkConsoleUpdate('t', NOW + 1000)).toBeNull()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
finish({ kind: 'error', message: 'timed out' })
|
||||
await first
|
||||
})
|
||||
|
||||
it('asks again when the stored release is not a valid one', async () => {
|
||||
const spy = answers('v=1 latest=1.4.0 server=15.5')
|
||||
localStorage.setItem(CONSOLE_UPDATE_KEY, JSON.stringify({ at: NOW, release: { latest: '<b>9</b>', server: '15.5' } }))
|
||||
expect(await checkConsoleUpdate('t', NOW + HOUR)).toEqual({ latest: '1.4.0', server: '15.5' })
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
/* A clock that ran ahead and was corrected must not silence the check. */
|
||||
it('asks again when the stored time is in the future', async () => {
|
||||
const spy = answers('v=1 latest=1.4.0 server=15.5')
|
||||
localStorage.setItem(CONSOLE_UPDATE_KEY, JSON.stringify({ at: NOW + 30 * 24 * HOUR, release: null }))
|
||||
await checkConsoleUpdate('t', NOW)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores an answer without TXT', async () => {
|
||||
answers(null)
|
||||
expect(await checkConsoleUpdate('t', NOW)).toBeNull()
|
||||
})
|
||||
|
||||
it('checks when storage cannot be read', async () => {
|
||||
const spy = answers('v=1 latest=1.4.0 server=15.5')
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
expect(await checkConsoleUpdate('t', NOW)).toEqual({ latest: '1.4.0', server: '15.5' })
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { resolve } from '../api/dnsclient'
|
||||
|
||||
/*
|
||||
OURS: whether a newer release of this console exists.
|
||||
|
||||
The browser cannot ask GitHub: the server serves the console with
|
||||
`default-src 'self'` and no `connect-src` (DnsWebService.cs, `OnPrepareResponse`).
|
||||
So the console asks its own server, with DNS Client, to resolve one TXT record
|
||||
against the project's DNS-over-HTTPS endpoint (`update-check/` in this repo),
|
||||
which answers `v=1 latest=X.Y.Z server=A.B`. It is an exception to "zero
|
||||
functionality", with its limits written down in CONVENTIONS.md.
|
||||
|
||||
At most once a day per browser, whatever the outcome: a failed check leaves a
|
||||
line in the server's log, and a day is the most that is acceptable.
|
||||
*/
|
||||
|
||||
export const CONSOLE_UPDATE_SERVER = 'https://technitium-console.pages.dev/dns-query'
|
||||
export const CONSOLE_UPDATE_NAME = 'latest.technitium-console.pages.dev'
|
||||
export const CONSOLE_UPDATE_KEY = 'consoleUpdateCheck'
|
||||
const DAY = 86_400_000
|
||||
const VERSION = /^\d+\.\d+(\.\d+)?$/
|
||||
|
||||
export interface ConsoleRelease {
|
||||
latest: string
|
||||
server: string
|
||||
}
|
||||
|
||||
/* The record's format is frozen: `v=1` and the keys this console knows. Others
|
||||
are ignored, so that the format can grow without silencing this one. */
|
||||
export function parseRecord(text: string): ConsoleRelease | null {
|
||||
const fields = new Map<string, string>()
|
||||
for (const pair of text.trim().split(/\s+/)) {
|
||||
const i = pair.indexOf('=')
|
||||
if (i > 0) fields.set(pair.slice(0, i), pair.slice(i + 1))
|
||||
}
|
||||
const latest = fields.get('latest')
|
||||
const server = fields.get('server')
|
||||
if (fields.get('v') !== '1' || !latest || !server) return null
|
||||
if (!VERSION.test(latest) || !VERSION.test(server)) return null
|
||||
return { latest, server }
|
||||
}
|
||||
|
||||
/* Numeric, field by field, missing fields counting as 0. A `-suffix` is a
|
||||
prerelease: before the same numbers without one. */
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const [aCore, aPre] = a.split('-', 2)
|
||||
const [bCore, bPre] = b.split('-', 2)
|
||||
const aParts = aCore.split('.').map(Number)
|
||||
const bParts = bCore.split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const d = (aParts[i] ?? 0) - (bParts[i] ?? 0)
|
||||
if (d !== 0) return d
|
||||
}
|
||||
if (aPre && !bPre) return -1
|
||||
if (!aPre && bPre) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
/* The version to announce, or null. Never one made for a newer server than the
|
||||
one running: the CHANGELOG says to update the console with the server, and
|
||||
the server's own notice already asks for that first. */
|
||||
export function newerConsole(
|
||||
release: ConsoleRelease | null,
|
||||
installed: string,
|
||||
serverVersion: string | undefined,
|
||||
): string | null {
|
||||
if (!release || !serverVersion) return null
|
||||
if (compareVersions(release.latest, installed) <= 0) return null
|
||||
if (compareVersions(serverVersion, release.server) < 0) return null
|
||||
return release.latest
|
||||
}
|
||||
|
||||
interface Stored {
|
||||
at: number
|
||||
release: ConsoleRelease | null
|
||||
}
|
||||
|
||||
/* What comes back from storage is checked like an answer: same-origin script
|
||||
or an older format can have written anything there. */
|
||||
function readStored(): Stored | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONSOLE_UPDATE_KEY)
|
||||
const stored = raw ? (JSON.parse(raw) as Partial<Stored>) : null
|
||||
if (!stored || typeof stored.at !== 'number') return null
|
||||
if (stored.release === null) return { at: stored.at, release: null }
|
||||
const r = stored.release
|
||||
if (!r || typeof r.latest !== 'string' || typeof r.server !== 'string') return null
|
||||
const release = parseRecord(`v=1 latest=${r.latest} server=${r.server}`)
|
||||
return release ? { at: stored.at, release } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function store(value: Stored): void {
|
||||
try {
|
||||
localStorage.setItem(CONSOLE_UPDATE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage refused: the next load checks again, which is all this costs.
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkConsoleUpdate(token: string | null, now = Date.now()): Promise<ConsoleRelease | null> {
|
||||
const stored = readStored()
|
||||
// A time ahead of now (a clock that was corrected) is not a fresh answer.
|
||||
if (stored && stored.at <= now && now - stored.at < DAY) return stored.release
|
||||
|
||||
// Taken before asking, not after: a server that cannot reach the endpoint
|
||||
// holds the request for up to ten seconds, and a reload or a second tab in
|
||||
// that time must not ask again. Until it answers, the last result stands.
|
||||
store({ at: now, release: stored?.release ?? null })
|
||||
|
||||
// No `node`: every node of a cluster serves its own console, so the one the
|
||||
// browser is connected to is the one that matters.
|
||||
const r = await resolve(token, {
|
||||
server: CONSOLE_UPDATE_SERVER,
|
||||
domain: CONSOLE_UPDATE_NAME,
|
||||
type: 'TXT',
|
||||
protocol: 'HTTPS',
|
||||
dnssec: false,
|
||||
})
|
||||
|
||||
let release: ConsoleRelease | null = null
|
||||
if (r.kind === 'ok') {
|
||||
const result = r.data.response?.result as { Answer?: { Type?: string; RDATA?: { Text?: unknown } }[] } | undefined
|
||||
const txt = result?.Answer?.find((a) => a.Type === 'TXT')?.RDATA?.Text
|
||||
if (typeof txt === 'string') release = parseRecord(txt)
|
||||
}
|
||||
store({ at: now, release })
|
||||
return release
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Upstream's footer links.
|
||||
|
||||
They live in a `div#footer` hanging directly off the `body` (not off `#pageLogin`
|
||||
nor `#pageMain`), so in upstream they are visible on EVERY screen: the login one
|
||||
and the whole console. Here they were missing entirely, and two of them
|
||||
(`technitium.com` and `dnsclient.net`) appeared nowhere else, so they had been lost
|
||||
from the product.
|
||||
|
||||
The list lives in a module of its own because two different places paint it (the
|
||||
sidebar and the login screen), and a repeated list of links is a list that ends up
|
||||
half-updated as soon as somebody touches one.
|
||||
|
||||
"About" is not there: in upstream it is the footer's sixth link and here it is a
|
||||
sidebar section, so it is already reachable.
|
||||
*/
|
||||
export interface FooterLink {
|
||||
text: string
|
||||
href: string
|
||||
/*
|
||||
An alternative accessible name, only when the visible one collides with another
|
||||
link on the same screen. It happens with "DNS Client": it is at once a sidebar
|
||||
section (`/dnsclient/`) and Technitium's other product (`dnsclient.net`), so
|
||||
anyone navigating by list of links heard the same thing twice pointing at
|
||||
different places. The name starts with the visible text, which is what the
|
||||
"label in name" criterion requires.
|
||||
*/
|
||||
name?: string
|
||||
}
|
||||
|
||||
export const FOOTER: FooterLink[] = [
|
||||
{ text: 'Technitium', href: 'https://technitium.com/' },
|
||||
{ text: 'Blog', href: 'https://blog.technitium.com/' },
|
||||
{ text: 'Donate', href: 'https://go.technitium.com/?id=35' },
|
||||
{ text: 'DNS Client', href: 'https://dnsclient.net/', name: 'DNS Client at dnsclient.net' },
|
||||
{ text: 'GitHub', href: 'https://github.com/TechnitiumSoftware/DnsServer' },
|
||||
]
|
||||
|
||||
/*
|
||||
The theme credit. It is NOT upstream's: it is the only thing in the footer this
|
||||
console adds, and that is why it lives outside `FOOTER` and outside the list
|
||||
`dev/check-parity-controls.mjs` compares. Whoever reads this has to be able to
|
||||
tell at a glance what is parity and what is our addition.
|
||||
|
||||
It goes on its own line and not as a sixth link: the five above are PRODUCT
|
||||
destinations and this is an authorship credit. Put in the same row, with the same
|
||||
pipes, it would read as one more Technitium site.
|
||||
*/
|
||||
export const THEME_CREDIT = {
|
||||
/*
|
||||
The handle, not the name: this is the identity the work is published under.
|
||||
|
||||
The label before it is "Theme:" and not "Theme by", which is what it said when
|
||||
the credit carried a person's name. With the handle it would have read "Theme
|
||||
by byGarcia", and the stutter is the handle's own "by" colliding with the
|
||||
label's.
|
||||
*/
|
||||
text: 'byGarcia',
|
||||
href: 'https://github.com/byGarcia',
|
||||
/* Starts with the visible text, which is what "label in name" requires. */
|
||||
name: 'byGarcia on GitHub',
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { AGGREGATE } from '../ui/ClusterNodeSelect'
|
||||
|
||||
/*
|
||||
Upstream's two jumps from one screen to another, which the row menus of the
|
||||
Dashboard, its Top Stats modal and Query Logs call:
|
||||
|
||||
· `queryDnsServer(domain, type, node)` (dnsclient.js:231-257) fills DNS Client
|
||||
(this server, the domain, the type or "A", UDP, no EDNS Client Subnet, DNSSEC
|
||||
validation unchecked), switches to it and RUNS the query.
|
||||
· `showQueryLogs(domain, clientIp, node, rcode, responseType)` (logs.js:629-663)
|
||||
resets the Query Logs form, fills the domain and/or the client, and since
|
||||
v15.6.2 the response code and/or the response type (the Dashboard's count
|
||||
cards, logs.js:21-24), switches to Logs › Query Logs and RUNS the query.
|
||||
|
||||
Both pick the target's cluster node only when they were given one and it is not
|
||||
the aggregate; otherwise the target keeps the node it had, which here is the one
|
||||
it starts on.
|
||||
|
||||
The screen that offers the jump asks through `useHandoff()`. The Shell owns the
|
||||
switch: it moves to the section and hands the request to the target screen as a
|
||||
prop, once. The target fills itself from it when it mounts and runs the query.
|
||||
*/
|
||||
|
||||
export interface DnsClientRequest {
|
||||
domain: string
|
||||
type: string
|
||||
/** Empty: keep the node selector where it starts. */
|
||||
node: string
|
||||
}
|
||||
|
||||
/*
|
||||
The form's fields a jump fills besides the domain and the client, each by its name in
|
||||
that form and written in the form's own format (`From`/`To` are `datetime-local`
|
||||
values in the browser's zone). `rcode` and `responseType` are upstream's, from the
|
||||
Dashboard's count cards. The rest is what the Dashboard's charts add (deviation 7 in
|
||||
CONVENTIONS.md, this console's own): the time window of a point of the Queries chart,
|
||||
and the value of a slice or a bar, over the period. Upstream's jump never carries a
|
||||
time window, a protocol or a query type.
|
||||
*/
|
||||
export interface QueryLogsFilters {
|
||||
start?: string
|
||||
end?: string
|
||||
responseType?: string
|
||||
rcode?: string
|
||||
protocol?: string
|
||||
qtype?: string
|
||||
}
|
||||
|
||||
export interface QueryLogsRequest {
|
||||
domain: string | null
|
||||
clientIp: string | null
|
||||
/** Empty: keep the node selector where it starts. */
|
||||
node: string
|
||||
filters?: QueryLogsFilters
|
||||
}
|
||||
|
||||
export interface Handoff {
|
||||
queryDnsServer: (domain: string, type: string | null, node: string | null) => void
|
||||
showQueryLogs: (
|
||||
domain: string | null,
|
||||
clientIp: string | null,
|
||||
node: string | null,
|
||||
filters?: QueryLogsFilters,
|
||||
) => void
|
||||
/** Whether Query Logs is there to land on. The row menus offer the jump anyway,
|
||||
* as upstream does, and it does nothing; a chart only invites the click when it
|
||||
* will go somewhere. Absent means it is. */
|
||||
canShowQueryLogs?: boolean
|
||||
}
|
||||
|
||||
export const HandoffContext = createContext<Handoff | null>(null)
|
||||
|
||||
/** `null` outside the Shell, where there is nowhere to go. */
|
||||
export function useHandoff(): Handoff | null {
|
||||
return useContext(HandoffContext)
|
||||
}
|
||||
|
||||
/* `if ((node != null) && (node != "cluster"))`, in both functions. */
|
||||
export function targetNode(node: string | null): string {
|
||||
return node != null && node !== AGGREGATE ? node : ''
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it, afterEach, vi } from 'vitest'
|
||||
import { SECTIONS } from './sections'
|
||||
import { STATIC_ROUTES } from './static-routes'
|
||||
import {
|
||||
toTrail,
|
||||
toSlug,
|
||||
writeRoute,
|
||||
readRoute,
|
||||
forgetRoot,
|
||||
appRoot,
|
||||
translateLegacyRoute,
|
||||
navigateTo,
|
||||
plainClick,
|
||||
} from './route'
|
||||
|
||||
/** Serves the document as the server would: in its folder and with its meta. */
|
||||
function servedAt(trail: string, route: string | null = null) {
|
||||
document.head.querySelector('meta[name="route"]')?.remove()
|
||||
if (route != null) {
|
||||
const m = document.createElement('meta')
|
||||
m.setAttribute('name', 'route')
|
||||
m.setAttribute('content', route)
|
||||
document.head.appendChild(m)
|
||||
}
|
||||
window.history.replaceState(null, '', trail)
|
||||
forgetRoot()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
servedAt('/')
|
||||
})
|
||||
|
||||
describe('toSlug', () => {
|
||||
it('it turns the upstream label into something that fits in a URL', () => {
|
||||
expect(toSlug('Leases')).toBe('leases')
|
||||
expect(toSlug('Web Service')).toBe('web-service')
|
||||
expect(toSlug('View Logs')).toBe('view-logs')
|
||||
// The `&` disappears instead of becoming a dash, so as not to leave `proxy--forwarders`
|
||||
expect(toSlug('Proxy & Forwarders')).toBe('proxy-forwarders')
|
||||
expect(toSlug('Optional Protocols')).toBe('optional-protocols')
|
||||
})
|
||||
|
||||
it('every sub-section yields a distinct slug within its section', () => {
|
||||
for (const s of SECTIONS) {
|
||||
if (s.subs == null) continue
|
||||
const slugs = s.subs.map(toSlug)
|
||||
expect(new Set(slugs).size, `slug collision in ${s.id}: ${slugs.join(', ')}`).toBe(slugs.length)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
The console can hang off any prefix: the server honours `X-Forwarded-Prefix` by
|
||||
mounting a `PathBase`. The root is deduced by subtracting from the `pathname` the
|
||||
segments the document itself declares in its `<meta>`.
|
||||
*/
|
||||
describe('appRoot', () => {
|
||||
it('on the front page, the root is the front page', () => {
|
||||
servedAt('/')
|
||||
expect(appRoot()).toBe('/')
|
||||
})
|
||||
|
||||
it('on a one-level path', () => {
|
||||
servedAt('/zones/', 'zones')
|
||||
expect(appRoot()).toBe('/')
|
||||
})
|
||||
|
||||
it('on a two-level path', () => {
|
||||
servedAt('/settings/logging/', 'settings/logging')
|
||||
expect(appRoot()).toBe('/')
|
||||
})
|
||||
|
||||
it('and behind a proxy with a prefix, which is what all this is about', () => {
|
||||
servedAt('/dns/settings/logging/', 'settings/logging')
|
||||
expect(appRoot()).toBe('/dns/')
|
||||
})
|
||||
|
||||
it('with a two-segment prefix', () => {
|
||||
servedAt('/home/dns/zones/', 'zones')
|
||||
expect(appRoot()).toBe('/home/dns/')
|
||||
})
|
||||
|
||||
it('a doubled slash on the front page stays on this origin', () => {
|
||||
// The server serves the front page for `//`. Taken as it stands, the root
|
||||
// would be `//` and every API call `//api/…`: a request to the host `api`
|
||||
// with the session token in `Authorization`.
|
||||
servedAt(window.location.origin + '//')
|
||||
expect(appRoot()).toBe('/')
|
||||
servedAt(window.location.origin + '//dns//')
|
||||
expect(appRoot()).toBe('/dns/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readRoute', () => {
|
||||
it('on the front page there is no route, and it starts from whatever the Shell says', () => {
|
||||
servedAt('/')
|
||||
expect(readRoute(SECTIONS)).toBeNull()
|
||||
})
|
||||
|
||||
it('it reads section and sub-section', () => {
|
||||
servedAt('/admin/cluster/', 'admin/cluster')
|
||||
expect(readRoute(SECTIONS)).toEqual({ section: 'admin', sub: 'Cluster' })
|
||||
})
|
||||
|
||||
it('it returns the original label, not the slug', () => {
|
||||
servedAt('/settings/proxy-forwarders/', 'settings/proxy-forwarders')
|
||||
expect(readRoute(SECTIONS)).toEqual({ section: 'settings', sub: 'Proxy & Forwarders' })
|
||||
})
|
||||
|
||||
it('it reads it the same behind a prefix', () => {
|
||||
servedAt('/dns/admin/cluster/', 'admin/cluster')
|
||||
expect(readRoute(SECTIONS)).toEqual({ section: 'admin', sub: 'Cluster' })
|
||||
})
|
||||
|
||||
it('an unknown section does not resolve', () => {
|
||||
servedAt('/nonexistent/', 'nonexistent')
|
||||
expect(readRoute(SECTIONS)).toBeNull()
|
||||
})
|
||||
|
||||
it('an unknown sub does NOT bring the section down: it falls to the first', () => {
|
||||
servedAt('/settings/nonexistent-either/', 'settings/nonexistent-either')
|
||||
expect(readRoute(SECTIONS)).toEqual({ section: 'settings', sub: null })
|
||||
})
|
||||
|
||||
it('a section hidden by permissions does not resolve, even though it exists', () => {
|
||||
servedAt('/admin/cluster/', 'admin/cluster')
|
||||
expect(readRoute(SECTIONS.filter((s) => s.id !== 'admin'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toTrail', () => {
|
||||
it('it omits the sub when there is none, and always ends in a slash', () => {
|
||||
servedAt('/')
|
||||
expect(toTrail({ section: 'zones', sub: null })).toBe('/zones/')
|
||||
})
|
||||
|
||||
it('and it slugs it when there is one', () => {
|
||||
servedAt('/')
|
||||
expect(toTrail({ section: 'logs', sub: 'Query Logs' })).toBe('/logs/query-logs/')
|
||||
})
|
||||
|
||||
it('it honours the proxy prefix', () => {
|
||||
servedAt('/dns/zones/', 'zones')
|
||||
expect(toTrail({ section: 'settings', sub: 'TSIG' })).toBe('/dns/settings/tsig/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy routes', () => {
|
||||
it('/allowed/ and /blocked/ still exist as folders', () => {
|
||||
expect(STATIC_ROUTES).toEqual(expect.arrayContaining(['allowed', 'blocked', 'blocking/rules']))
|
||||
})
|
||||
|
||||
it('/blocked/ becomes /blocking/rules/?rule=blocked, replacing the entry', () => {
|
||||
servedAt('/blocked/', 'blocked')
|
||||
const before = window.history.length
|
||||
expect(translateLegacyRoute()).toBe(true)
|
||||
expect(window.location.pathname + window.location.search).toBe('/blocking/rules/?rule=blocked')
|
||||
expect(window.history.length).toBe(before)
|
||||
})
|
||||
|
||||
it('behind a prefix', () => {
|
||||
servedAt('/dns/allowed/', 'allowed')
|
||||
expect(translateLegacyRoute()).toBe(true)
|
||||
expect(window.location.pathname + window.location.search).toBe('/dns/blocking/rules/?rule=allowed')
|
||||
})
|
||||
|
||||
it('leaves any other route alone', () => {
|
||||
servedAt('/zones/', 'zones')
|
||||
expect(translateLegacyRoute()).toBe(false)
|
||||
expect(window.location.pathname).toBe('/zones/')
|
||||
})
|
||||
|
||||
it('an unknown sub of blocking falls to its first tab', () => {
|
||||
servedAt('/blocking/zzz/', 'blocking/zzz')
|
||||
expect(readRoute(SECTIONS)).toEqual({ section: 'blocking', sub: null })
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
A link from inside a screen to another section. It used to be a bare `<a href>`
|
||||
and reloaded the whole console; `navigateTo` pushes the route and announces it
|
||||
with the same `popstate` the back button fires, which is what the Shell already
|
||||
follows.
|
||||
*/
|
||||
describe('navigateTo', () => {
|
||||
it('pushes the route and fires popstate', () => {
|
||||
servedAt('/blocking/overview/', 'blocking/overview')
|
||||
// Spied, not counted: `history.length` does not move in jsdom.
|
||||
const push = vi.spyOn(window.history, 'pushState')
|
||||
const heard = vi.fn()
|
||||
window.addEventListener('popstate', heard)
|
||||
navigateTo({ section: 'logs', sub: 'Query Logs' })
|
||||
window.removeEventListener('popstate', heard)
|
||||
expect(push).toHaveBeenCalledTimes(1)
|
||||
push.mockRestore()
|
||||
expect(window.location.pathname).toBe('/logs/query-logs/')
|
||||
expect(heard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('behind a prefix', () => {
|
||||
servedAt('/dns/blocking/lists/', 'blocking/lists')
|
||||
navigateTo({ section: 'settings', sub: 'Blocking' })
|
||||
expect(window.location.pathname).toBe('/dns/settings/blocking/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plainClick', () => {
|
||||
const click = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false }
|
||||
|
||||
it('a left click with no modifier is the application one', () => {
|
||||
expect(plainClick(click)).toBe(true)
|
||||
})
|
||||
|
||||
it('any modifier, another button or a click already handled is the browser one', () => {
|
||||
expect(plainClick({ ...click, ctrlKey: true })).toBe(false)
|
||||
expect(plainClick({ ...click, metaKey: true })).toBe(false)
|
||||
expect(plainClick({ ...click, shiftKey: true })).toBe(false)
|
||||
expect(plainClick({ ...click, altKey: true })).toBe(false)
|
||||
expect(plainClick({ ...click, button: 1 })).toBe(false)
|
||||
expect(plainClick({ ...click, defaultPrevented: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeRoute', () => {
|
||||
it('it leaves the address bar on the requested path', () => {
|
||||
servedAt('/')
|
||||
writeRoute({ section: 'dhcp', sub: 'Leases' }, true)
|
||||
expect(window.location.pathname).toBe('/dhcp/leases/')
|
||||
})
|
||||
|
||||
it('it does not touch the history if the path is already the current one', () => {
|
||||
servedAt('/zones/', 'zones')
|
||||
const before2 = window.history.length
|
||||
writeRoute({ section: 'zones', sub: null })
|
||||
expect(window.location.pathname).toBe('/zones/')
|
||||
expect(window.history.length).toBe(before2)
|
||||
})
|
||||
|
||||
/* 34 since Blocking (three tabs) replaced Allowed and Blocked (none): eleven
|
||||
sections and twenty-three sub-sections. The two legacy folders are not
|
||||
sections and are not in this walk. */
|
||||
it('what gets written reads back the same, across all 34 routes', () => {
|
||||
servedAt('/')
|
||||
for (const s of SECTIONS) {
|
||||
for (const sub of s.subs ?? [null]) {
|
||||
writeRoute({ section: s.id, sub }, true)
|
||||
expect(readRoute(SECTIONS)).toEqual({ section: s.id, sub })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
The name of the `<meta>` is written by the build and read by the application, and
|
||||
those are two different files. The cases above cannot see that joint because they
|
||||
mount the meta under the same name they then read it by: they always agree with
|
||||
themselves.
|
||||
|
||||
And that joint really did break. On renaming `ruta` to `route`, `vite.config.ts`
|
||||
started emitting `<meta name="route">` while `app/base.ts` kept looking for
|
||||
`meta[name="ruta"]`, inside a string, which is exactly where a rename does not
|
||||
reach. The result: the root fell back to the whole `pathname` and from
|
||||
`/dhcp/scopes/` the console asked for `/dhcp/scopes/api/status`. A 404 on 31 of
|
||||
the 32 routes, with typecheck, lint and 821 tests all green.
|
||||
|
||||
This case reads both files and checks they say the same name.
|
||||
*/
|
||||
describe('the `<meta>` name is the same on both sides of the build', () => {
|
||||
it('vite.config.ts writes the one app/base.ts reads', async () => {
|
||||
// `?raw` is Vite's own: it needs no extra dependency, and a dependency
|
||||
// would show up in the diff of the pull request.
|
||||
const config = (await import('../../vite.config.ts?raw')).default
|
||||
const reader = (await import('./base.ts?raw')).default
|
||||
|
||||
const written = /<meta name="([^"]+)" content=/.exec(config)?.[1]
|
||||
const read = /meta\[name="([^"]+)"\]/.exec(reader)?.[1]
|
||||
|
||||
expect(written, 'vite.config.ts no longer emits a route meta').toBeTruthy()
|
||||
expect(read, 'app/base.ts no longer reads a route meta').toBeTruthy()
|
||||
expect(read).toBe(written)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Section } from './sections'
|
||||
|
||||
/*
|
||||
The console's route, in the real address bar.
|
||||
|
||||
Upstream has none: its tabs are Bootstrap 3's, which call `preventDefault()` and
|
||||
never touch the URL, so reloading always returns to the Dashboard. This is
|
||||
therefore an ADDITION of ours and not recovered parity. It is stated here because
|
||||
the project's governing constraint is "design only, zero functionality" and whoever
|
||||
reads the diff should know which of the two they are looking at.
|
||||
|
||||
**And it needs no server change.** `www/` is served with `UseDefaultFiles()`, which
|
||||
resolves `/settings/logging/` to `/settings/logging/index.html` and redirects the
|
||||
slash-less version with a 301. The build (see `vite.config.ts`) emits one folder
|
||||
per route, so the URL genuinely exists: it can be copied, bookmarked and reloaded.
|
||||
|
||||
**The application root cannot be assumed.** The server honours
|
||||
`X-Forwarded-Prefix` by mounting a `PathBase` (`DnsWebService.cs:1943-1945`), so the
|
||||
console may be hanging off `/dns/` without knowing it. Each copy carries its own
|
||||
route in `<meta name="route">`, and the root comes from subtracting it from the
|
||||
`pathname`. That is the piece that makes all of this work behind a proxy.
|
||||
*/
|
||||
|
||||
export { toSlug } from './slug'
|
||||
import { toSlug } from './slug'
|
||||
export { forgetRoot, appRoot } from './base'
|
||||
import { appRoot } from './base'
|
||||
import { LEGACY_ROUTES } from './static-routes'
|
||||
|
||||
export interface Route {
|
||||
section: string
|
||||
sub: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the console hangs from, ending in `/`.
|
||||
*
|
||||
* On the front page it is the `pathname` itself. At `/dns/settings/logging/` it is
|
||||
* `/dns/`, and that is known because the document declares its route to be
|
||||
* `settings/logging`.
|
||||
*/
|
||||
/** What the address bar says, resolved against the visible sections. */
|
||||
export function readRoute(sections: Section[]): Route | null {
|
||||
const base = appRoot()
|
||||
const trail = window.location.pathname
|
||||
if (!trail.startsWith(base)) return null
|
||||
|
||||
const [sectionId, slugSub] = trail.slice(base.length).split('/').filter(Boolean)
|
||||
if (sectionId == null) return null
|
||||
|
||||
const section = sections.find((s) => s.id === sectionId)
|
||||
if (section == null) return null
|
||||
|
||||
// A sub that does not exist does not invalidate the section: it falls to the first.
|
||||
const sub = slugSub == null ? null : (section.subs?.find((t) => toSlug(t) === slugSub) ?? null)
|
||||
return { section: section.id, sub }
|
||||
}
|
||||
|
||||
export function toTrail({ section, sub }: Route): string {
|
||||
return appRoot() + (sub == null ? `${section}/` : `${section}/${toSlug(sub)}/`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the route. `replaceEntry` for the boot normalisation (which is not a
|
||||
* navigation and must not leave a history entry) and pushing for what the user
|
||||
* does, so the back button walks the sections instead of taking them out of the
|
||||
* console.
|
||||
*
|
||||
* The `search` is kept and the `hash` is NOT: the hash is only used by the SSO
|
||||
* return (`session/boot.ts`), which has already read it and wants it gone from the
|
||||
* bar.
|
||||
*/
|
||||
export function writeRoute(route: Route, replaceEntry = false): void {
|
||||
const blank = toTrail(route) + window.location.search
|
||||
if (window.location.pathname + window.location.search === blank) return
|
||||
if (replaceEntry) window.history.replaceState(null, '', blank)
|
||||
else window.history.pushState(null, '', blank)
|
||||
}
|
||||
|
||||
/*
|
||||
`/allowed/` and `/blocked/` are Blocking's Rules tab now. The bar is rewritten to
|
||||
`blocking/rules/?rule=…` BEFORE the route is read, with `replaceState` so the back
|
||||
button does not walk into the old address. The root comes from `appRoot()`, which
|
||||
the legacy folder's own `<meta name="route">` lets it compute behind a prefix.
|
||||
*/
|
||||
export function translateLegacyRoute(): boolean {
|
||||
const base = appRoot()
|
||||
const trail = window.location.pathname
|
||||
if (!trail.startsWith(base)) return false
|
||||
const parts = trail.slice(base.length).split('/').filter(Boolean)
|
||||
const legacy = parts.length === 1 ? LEGACY_ROUTES[parts[0]] : undefined
|
||||
if (legacy == null) return false
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.set('rule', legacy.rule)
|
||||
window.history.replaceState(null, '', `${base}${legacy.section}/${toSlug(legacy.sub)}/?${params.toString()}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A click the browser should handle itself: another button, or with a modifier
|
||||
* (open in a new tab, in a window, download), or one something else already took.
|
||||
* Intercepting them would turn a real link into a button in disguise. The sidebar,
|
||||
* the sub-tabs and `ui/RouteLink` all ask this same question.
|
||||
*/
|
||||
export function plainClick(e: {
|
||||
button: number
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
shiftKey: boolean
|
||||
altKey: boolean
|
||||
defaultPrevented?: boolean
|
||||
}): boolean {
|
||||
return e.defaultPrevented !== true && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to another section from inside a screen, without reloading the console.
|
||||
*
|
||||
* The route is pushed (it is something the user did, so the back button returns)
|
||||
* and then announced with the same `popstate` the back button fires. The Shell
|
||||
* already follows that event (it reads the bar and moves to what it says), so a
|
||||
* screen needs no handle on the Shell's state to send the user elsewhere.
|
||||
*/
|
||||
export function navigateTo(route: Route): void {
|
||||
writeRoute(route)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { visibleSections, type Section } from './sections'
|
||||
|
||||
const P = (canView: boolean) => ({ canView, canModify: false, canDelete: false })
|
||||
|
||||
const LIST: Section[] = [
|
||||
{ id: 'one', label: 'One', permission: 'One' },
|
||||
{ id: 'any', label: 'Any', permission: ['A', 'B'] },
|
||||
{ id: 'free', label: 'Free', permission: null },
|
||||
]
|
||||
|
||||
describe('visibleSections', () => {
|
||||
it('a list of permissions means any of them', () => {
|
||||
const ids = (p: Parameters<typeof visibleSections>[0]) => visibleSections(p, LIST).map((s) => s.id)
|
||||
expect(ids({ One: P(true), A: P(false), B: P(true) })).toEqual(['one', 'any', 'free'])
|
||||
expect(ids({ One: P(false), A: P(false), B: P(false) })).toEqual(['free'])
|
||||
expect(ids(undefined)).toEqual(['one', 'any', 'free'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
The console's 11 sections, in upstream's order, with one that is OURS: Blocking
|
||||
takes the place of upstream's Allowed and Blocked, and gathers them with the block
|
||||
lists into three tabs (CONVENTIONS.md, "Deliberate deviations from upstream
|
||||
behaviour", item 2). Their old addresses still land: see `LEGACY_ROUTES` in
|
||||
`static-routes.ts`.
|
||||
|
||||
`permission` is the key inside `sessionData.info.permissions`, and it does NOT
|
||||
always match the label: the "DNS Client" tab is governed by `DnsClient` and "DHCP"
|
||||
by `DhcpServer`. `About` has no permission: it is always visible.
|
||||
|
||||
Each section is hidden if its `canView` is false (main.js:119-250).
|
||||
*/
|
||||
export interface Section {
|
||||
id: string
|
||||
label: string
|
||||
/** The key inside `permissions`, or a LIST meaning "any of these" (Blocking:
|
||||
* visible with Blocked OR Allowed). */
|
||||
permission: string | readonly string[] | null
|
||||
/** Sub-sections, with upstream's literal labels. They are only shown when their
|
||||
* section is active, exactly as the sub-tabs are today. */
|
||||
subs?: string[]
|
||||
}
|
||||
|
||||
export const SECTIONS: Section[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', permission: 'Dashboard' },
|
||||
{ id: 'zones', label: 'Zones', permission: 'Zones' },
|
||||
{ id: 'cache', label: 'Cache', permission: 'Cache' },
|
||||
{ id: 'blocking', label: 'Blocking', permission: ['Blocked', 'Allowed'],
|
||||
subs: ['Overview', 'Rules', 'Lists'] },
|
||||
{ id: 'apps', label: 'Apps', permission: 'Apps' },
|
||||
{ id: 'dnsclient', label: 'DNS Client', permission: 'DnsClient' },
|
||||
{ id: 'settings', label: 'Settings', permission: 'Settings',
|
||||
subs: ['General','Web Service','Optional Protocols','TSIG','Recursion','Cache','Blocking','Proxy & Forwarders','Logging'] },
|
||||
{ id: 'dhcp', label: 'DHCP', permission: 'DhcpServer', subs: ['Leases','Scopes'] },
|
||||
{ id: 'admin', label: 'Administration', permission: 'Administration',
|
||||
subs: ['Sessions','Users','Groups','Permissions','SSO','LDAP','Cluster'] },
|
||||
{ id: 'logs', label: 'Logs', permission: 'Logs', subs: ['View Logs','Query Logs'] },
|
||||
{ id: 'about', label: 'About', permission: null },
|
||||
]
|
||||
|
||||
export interface Permission { canView: boolean; canModify: boolean; canDelete: boolean }
|
||||
|
||||
export function visibleSections(
|
||||
permissions: Record<string, Permission> | undefined,
|
||||
list: Section[] = SECTIONS,
|
||||
): Section[] {
|
||||
if (!permissions) return list
|
||||
return list.filter((s) => {
|
||||
if (s.permission == null) return true
|
||||
const keys = typeof s.permission === 'string' ? [s.permission] : s.permission
|
||||
return keys.some((k) => permissions[k]?.canView !== false)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
`Proxy & Forwarders` → `proxy-forwarders`.
|
||||
|
||||
It lives on its own and not inside `route.ts` because both sides need it: the
|
||||
browser to read the address bar, and `vite.config` to know which folders the build
|
||||
has to emit. `route.ts` touches `window` and `document`, and the config's TypeScript
|
||||
project has no DOM: a string function cannot drag that dependency along.
|
||||
*/
|
||||
export function toSlug(label: string): string {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.replace(/&/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* The `.js` extensions are deliberate: this file is also compiled by the
|
||||
`vite.config` TypeScript project, which uses `nodenext` resolution and requires
|
||||
them. The application's project resolves them just the same. */
|
||||
import { SECTIONS } from './sections.js'
|
||||
import { toSlug } from './slug.js'
|
||||
|
||||
/*
|
||||
The routes the build has to materialise as folders.
|
||||
|
||||
The console is served as static files: `UseDefaultFiles()` maps `/zones/` to
|
||||
`/zones/index.html` and redirects `/zones` with a 301, so that file merely existing
|
||||
is enough for `dns.example.net/settings/logging/` to work and survive an F5. It is
|
||||
the same technique any static site generator uses to publish a site without
|
||||
configuring the server.
|
||||
|
||||
It lives in `src/` and not in `vite.config` because the list comes from `SECTIONS`:
|
||||
if a new sub-section appears tomorrow, its folder is generated by itself.
|
||||
*/
|
||||
|
||||
/*
|
||||
The routes that no longer have a section of their own: `/allowed/` and `/blocked/`
|
||||
became the Rules tab of Blocking (CONVENTIONS.md, "Deliberate deviations from
|
||||
upstream behaviour", item 2). Their
|
||||
folders are still emitted so a bookmark keeps working; `route.ts` translates them
|
||||
before reading the route.
|
||||
*/
|
||||
export const LEGACY_ROUTES: Record<string, { section: string; sub: string; rule: 'allowed' | 'blocked' }> = {
|
||||
allowed: { section: 'blocking', sub: 'Rules', rule: 'allowed' },
|
||||
blocked: { section: 'blocking', sub: 'Rules', rule: 'blocked' },
|
||||
}
|
||||
|
||||
export const STATIC_ROUTES: string[] = [
|
||||
...SECTIONS.flatMap((s) => [s.id, ...(s.subs ?? []).map((t) => `${s.id}/${toSlug(t)}`)]),
|
||||
...Object.keys(LEGACY_ROUTES),
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
[
|
||||
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||
"rounded-lg",
|
||||
"px-3",
|
||||
"py-2.5",
|
||||
"has-[>svg]:gap-x-2.5",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||
invert:
|
||||
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn(
|
||||
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
outline: "border-border bg-transparent dark:bg-input/32",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
info: "bg-info text-white",
|
||||
success: "bg-success text-white",
|
||||
warning: "bg-warning text-white",
|
||||
destructive: "bg-destructive text-white",
|
||||
focus: "bg-focus text-focus-foreground",
|
||||
invert: "bg-invert text-invert-foreground",
|
||||
"primary-light":
|
||||
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
|
||||
"warning-light":
|
||||
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
|
||||
"success-light":
|
||||
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
|
||||
"info-light":
|
||||
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
|
||||
"destructive-light":
|
||||
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
|
||||
"invert-light":
|
||||
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
|
||||
"focus-light":
|
||||
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
|
||||
"primary-outline":
|
||||
"bg-background border-border text-primary dark:bg-input/30",
|
||||
"warning-outline":
|
||||
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
||||
"success-outline":
|
||||
"bg-background border-border text-success-foreground dark:bg-input/30",
|
||||
"info-outline":
|
||||
"bg-background border-border text-info-foreground dark:bg-input/30",
|
||||
"destructive-outline":
|
||||
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
||||
"invert-outline":
|
||||
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
||||
"focus-outline":
|
||||
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
||||
},
|
||||
size: {
|
||||
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
||||
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
||||
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||
},
|
||||
/** `default`: active style radius. `full`: pill radius. */
|
||||
radius: {
|
||||
default:
|
||||
"rounded-sm",
|
||||
full: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof badgeVariants>["variant"]
|
||||
size?: VariantProps<typeof badgeVariants>["size"]
|
||||
radius?: VariantProps<typeof badgeVariants>["radius"]
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
radius,
|
||||
render,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "badge",
|
||||
className: cn(badgeVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants, type BadgeProps }
|
||||
@@ -0,0 +1,928 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useCascaderState } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CASCADER_ROOT_KEY,
|
||||
isCascaderMoreNode,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type {
|
||||
CascaderIndex,
|
||||
CascaderLoadContext,
|
||||
CascaderLoadReason,
|
||||
CascaderLoadResult,
|
||||
CascaderLoadState,
|
||||
CascaderNode,
|
||||
CascaderSearchContext,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/**
|
||||
* Async data for the cascader. Loaded pages live in their own store and are
|
||||
* MERGED onto the index built from `items`, never folded into that build:
|
||||
* `items` changes identity on any parent re-render, and folding would discard
|
||||
* every level the user drilled into. Map membership is the load discriminator:
|
||||
* no `states` entry means never fetched, while an entry with no `loading`, no
|
||||
* `error` and no children means empty for real.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Fetches one level. `node` is `null` for the root level, and only
|
||||
* `{ value, label: value }`, no `data`, for a level on screen whose node has
|
||||
* not loaded: a deep link before the level above loads, or a page-loaded
|
||||
* level after a `loadKey` change.
|
||||
*/
|
||||
export type CascaderGetChildren<T = unknown> = (
|
||||
node: CascaderNode<T> | null,
|
||||
context: CascaderLoadContext
|
||||
) =>
|
||||
| CascaderNode<T>[]
|
||||
| CascaderLoadResult<T>
|
||||
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
|
||||
|
||||
/** Server-side search, replacing the local index scan while the query is set. */
|
||||
export type CascaderOnSearch<T = unknown> = (
|
||||
query: string,
|
||||
context: CascaderSearchContext
|
||||
) =>
|
||||
| CascaderNode<T>[]
|
||||
| CascaderLoadResult<T>
|
||||
| Promise<CascaderNode<T>[] | CascaderLoadResult<T>>
|
||||
|
||||
/** Resolves a selected value to its ancestor chain, root first, node last. */
|
||||
export type CascaderResolveValue<T = unknown> = (
|
||||
value: string,
|
||||
context: CascaderLoadContext
|
||||
) => CascaderNode<T>[] | Promise<CascaderNode<T>[]>
|
||||
|
||||
export interface CascaderLoaderStore<T = unknown> {
|
||||
/** Keyed by LEVEL: a parent's value, or `CASCADER_ROOT_KEY` for the root. */
|
||||
pages: Map<string, CascaderNode<T>[]>
|
||||
states: Map<string, CascaderLoadState>
|
||||
/** Keyed by node value: search hits and resolved selections, level-less. */
|
||||
detached: Map<string, CascaderNode<T>>
|
||||
/**
|
||||
* A resolved chain's parent links, node value to level key. Kept apart from
|
||||
* `pages`: a level's first real page replaced a chain stub there, and a node
|
||||
* that page left out lost its parent, and the trigger its path.
|
||||
*/
|
||||
links: Map<string, string>
|
||||
}
|
||||
|
||||
export interface UseCascaderLoaderOptions<T = unknown> {
|
||||
/** The index built from `items`, before any pages are merged in. */
|
||||
base: CascaderIndex<T>
|
||||
getChildren?: CascaderGetChildren<T>
|
||||
onSearch?: CascaderOnSearch<T>
|
||||
resolveValue?: CascaderResolveValue<T>
|
||||
/** Milliseconds of quiet before `onSearch` fires. */
|
||||
searchDebounce?: number
|
||||
/** Changing this drops every cached page, state and detached node. */
|
||||
loadKey?: unknown
|
||||
/** Speculatively fetch a branch's children when it is highlighted. */
|
||||
prefetch?: boolean
|
||||
/** Called when a request fails. Never for an aborted or superseded one. */
|
||||
onLoadError?: (
|
||||
error: unknown,
|
||||
context: { parent: string | null; reason: string }
|
||||
) => void
|
||||
/**
|
||||
* Whether the panel is live: the popup is open, or the cascader is inline.
|
||||
* Gates levels, paging and search. `resolveValue` runs regardless, and
|
||||
* retries a failed value when this turns true.
|
||||
*/
|
||||
enabled: boolean
|
||||
query: string
|
||||
/** Level keys that are currently on screen. Root is `CASCADER_ROOT_KEY`. */
|
||||
levels: string[]
|
||||
/** Handed to `onSearch` as its scope and read for nothing else: the
|
||||
* navigation path, or `[]` when the search covers the whole tree. */
|
||||
path: string[]
|
||||
/** Current selection, for `resolveValue`. */
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface CascaderLoader<T = unknown> {
|
||||
/** Whether a `getChildren` loader is configured at all. */
|
||||
active: boolean
|
||||
store: CascaderLoaderStore<T>
|
||||
states: ReadonlyMap<string, CascaderLoadState>
|
||||
/**
|
||||
* Async search hits, `null` when no `onSearch` is running. EMPTY while the
|
||||
* first request is in flight, so the level behind is not shown as the answer.
|
||||
*/
|
||||
searchResults: CascaderNode<T>[] | null
|
||||
searchState: CascaderLoadState | null
|
||||
/**
|
||||
* Fetches a level's FIRST page. No-ops on a `states` entry (in flight,
|
||||
* loaded or failed) or when `items` fills the level. The level effect fires
|
||||
* only for on-screen levels, so a branch merely PRESSED is asked for by hand.
|
||||
*/
|
||||
ensureLevel: (parentKey: string, reason: CascaderLoadReason) => void
|
||||
/** Fetches the next page of a level. No-ops unless one is available. */
|
||||
loadMore: (parentKey: string) => void
|
||||
/** Refires a failed level. No-ops unless that level is in an error state. */
|
||||
retryLevel: (parentKey: string) => void
|
||||
/** Refires the server search for the current query. No-ops without one. */
|
||||
retrySearch: () => void
|
||||
/** Schedules a speculative fetch. Safe to call on every highlight move. */
|
||||
prefetchNode: (node: CascaderNode<T> | null | undefined) => void
|
||||
/**
|
||||
* Evicts ONE level: aborts its request, drops its `states`/`pages` entries
|
||||
* and paging latch, so the level effect refetches it. `null` = root level.
|
||||
*/
|
||||
invalidateLevel: (value: string | null) => void
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Constants */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Highlight dwell before `prefetch` fetches: long enough that holding ArrowDown
|
||||
* does not fire a request per row, short enough to beat the ArrowRight press.
|
||||
*/
|
||||
const PREFETCH_DELAY = 150
|
||||
|
||||
/** Request keys for the two non-level requests. Never collide with a value. */
|
||||
const SEARCH_KEY = "\u0000search"
|
||||
const RESOLVE_PREFIX = "\u0000resolve:"
|
||||
|
||||
const NO_STATE: CascaderLoadState = {
|
||||
loading: false,
|
||||
error: false,
|
||||
hasMore: false,
|
||||
}
|
||||
|
||||
/** Stable empty result, so an idle search never churns the state context. */
|
||||
const NO_RESULTS: CascaderNode<never>[] = []
|
||||
|
||||
function createStore<T>(): CascaderLoaderStore<T> {
|
||||
return {
|
||||
pages: new Map(),
|
||||
states: new Map(),
|
||||
detached: new Map(),
|
||||
links: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function sameLoadState(a: CascaderLoadState, b: CascaderLoadState): boolean {
|
||||
return (
|
||||
a.loading === b.loading &&
|
||||
a.error === b.error &&
|
||||
a.hasMore === b.hasMore &&
|
||||
a.cursor === b.cursor
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store transitions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Copy-on-write, and a NO-OP when nothing changed: the merged index is memoised
|
||||
* on store identity, so a fresh object for an unchanged state would rebuild it.
|
||||
*/
|
||||
function withLoadState<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
key: string,
|
||||
update: (state: CascaderLoadState) => CascaderLoadState
|
||||
): CascaderLoaderStore<T> {
|
||||
const current = store.states.get(key) ?? NO_STATE
|
||||
const next = update(current)
|
||||
if (store.states.has(key) && sameLoadState(current, next)) return store
|
||||
const states = new Map(store.states)
|
||||
states.set(key, next)
|
||||
return { ...store, states }
|
||||
}
|
||||
|
||||
function withPage<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
key: string,
|
||||
items: readonly CascaderNode<T>[],
|
||||
options: { append: boolean; hasMore: boolean; cursor?: string }
|
||||
): CascaderLoaderStore<T> {
|
||||
const previous = options.append ? (store.pages.get(key) ?? []) : []
|
||||
const seen = new Set(previous.map((node) => node.value))
|
||||
const merged = previous.slice()
|
||||
for (const item of items) {
|
||||
if (seen.has(item.value)) continue
|
||||
seen.add(item.value)
|
||||
merged.push(item)
|
||||
}
|
||||
|
||||
const pages = new Map(store.pages)
|
||||
pages.set(key, merged)
|
||||
const states = new Map(store.states)
|
||||
states.set(key, {
|
||||
loading: false,
|
||||
error: false,
|
||||
hasMore: options.hasMore,
|
||||
cursor: options.cursor,
|
||||
})
|
||||
return { ...store, pages, states }
|
||||
}
|
||||
|
||||
function withDetached<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
items: readonly CascaderNode<T>[]
|
||||
): CascaderLoaderStore<T> {
|
||||
let detached: Map<string, CascaderNode<T>> | null = null
|
||||
for (const item of items) {
|
||||
if (store.detached.get(item.value) === item) continue
|
||||
detached = detached ?? new Map(store.detached)
|
||||
detached.set(item.value, item)
|
||||
}
|
||||
if (!detached) return store
|
||||
return { ...store, detached }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a resolved ancestor chain, root first, as `detached` nodes plus
|
||||
* `links`, never as rows in `pages`: a stub there made an unloaded level read
|
||||
* as loaded. Writes no `states`, so every level on it still fetches for real.
|
||||
*/
|
||||
function withChain<T>(
|
||||
store: CascaderLoaderStore<T>,
|
||||
chain: readonly CascaderNode<T>[]
|
||||
): CascaderLoaderStore<T> {
|
||||
if (chain.length === 0) return store
|
||||
const detached = new Map(store.detached)
|
||||
const links = new Map(store.links)
|
||||
let parentKey = CASCADER_ROOT_KEY
|
||||
|
||||
for (const node of chain) {
|
||||
detached.set(node.value, node)
|
||||
links.set(node.value, parentKey)
|
||||
parentKey = node.value
|
||||
}
|
||||
|
||||
return { ...store, detached, links }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hook */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderLoaderLatest<T> {
|
||||
base: CascaderIndex<T>
|
||||
store: CascaderLoaderStore<T>
|
||||
getChildren?: CascaderGetChildren<T>
|
||||
onSearch?: CascaderOnSearch<T>
|
||||
resolveValue?: CascaderResolveValue<T>
|
||||
onLoadError?: (
|
||||
error: unknown,
|
||||
context: { parent: string | null; reason: string }
|
||||
) => void
|
||||
prefetch: boolean
|
||||
path: string[]
|
||||
query: string
|
||||
}
|
||||
|
||||
interface CascaderSearchSlice<T> {
|
||||
query: string
|
||||
results: CascaderNode<T>[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader. A SIBLING of the `buildCascaderIndex` memo, never inside it: the
|
||||
* build stays pure in `items`, the merge pure in that build plus this store.
|
||||
*/
|
||||
export function useCascaderLoader<T = unknown>({
|
||||
base,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
searchDebounce = 250,
|
||||
loadKey,
|
||||
prefetch = false,
|
||||
onLoadError,
|
||||
enabled,
|
||||
query,
|
||||
levels,
|
||||
path,
|
||||
values,
|
||||
}: UseCascaderLoaderOptions<T>): CascaderLoader<T> {
|
||||
const [store, setStore] = React.useState<CascaderLoaderStore<T>>(createStore)
|
||||
const [search, setSearch] = React.useState<CascaderSearchSlice<T> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
/**
|
||||
* Latest callbacks, WRITTEN IN AN EFFECT: `getChildren` is inline in most
|
||||
* consumers, so closing over it would refire every in-flight request per
|
||||
* re-render. The ref is what keeps the request machinery `[]`-dep. Declared
|
||||
* FIRST, since effects run in declaration order, so the level effect below
|
||||
* already sees the current commit.
|
||||
*/
|
||||
const latest = React.useRef<CascaderLoaderLatest<T>>({
|
||||
base,
|
||||
store,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
onLoadError,
|
||||
prefetch,
|
||||
path,
|
||||
query,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
latest.current = {
|
||||
base,
|
||||
store,
|
||||
getChildren,
|
||||
onSearch,
|
||||
resolveValue,
|
||||
onLoadError,
|
||||
prefetch,
|
||||
path,
|
||||
query,
|
||||
}
|
||||
})
|
||||
|
||||
/** One AbortController PER KEY: columns mode runs several levels at once. */
|
||||
const controllers = React.useRef(new Map<string, AbortController>())
|
||||
/** Monotonic per key. The stale guard for out-of-order responses. */
|
||||
const requestIds = React.useRef(new Map<string, number>())
|
||||
/** In-flight `(level, cursor)` signatures, so a duplicate ask is free. */
|
||||
const inflight = React.useRef(new Map<string, string>())
|
||||
/**
|
||||
* The `(child count, cursor)` signature at the last paging fire, per level.
|
||||
* Guards what `hasMore` cannot: a page of zero new items while the server
|
||||
* still says `hasMore`. Cursor is IN the signature because an all-duplicates
|
||||
* page advances it while the count stands still - real progress, which a
|
||||
* count-only latch would brick forever.
|
||||
*/
|
||||
const moreLatch = React.useRef(new Map<string, string>())
|
||||
/** Values `resolveValue` has already been asked about, so it asks once. */
|
||||
const attempted = React.useRef(new Set<string>())
|
||||
/**
|
||||
* Values whose resolve failed. They stay `attempted` until the next open
|
||||
* rather than the next values pass: with inline `items` every parent render
|
||||
* is one, and an `onLoadError` that set parent state looped at network speed.
|
||||
*/
|
||||
const failed = React.useRef(new Set<string>())
|
||||
/** Every node the loader has seen, so a level key can name its own node. */
|
||||
const known = React.useRef(new Map<string, CascaderNode<T>>())
|
||||
const timers = React.useRef<{
|
||||
prefetch: ReturnType<typeof setTimeout> | null
|
||||
}>({ prefetch: null })
|
||||
/** Bumped by a `loadKey` change, so responses from before it are dropped. */
|
||||
const epoch = React.useRef(0)
|
||||
|
||||
const remember = React.useCallback((nodes: readonly CascaderNode<T>[]) => {
|
||||
const map = known.current
|
||||
const walk = (list: readonly CascaderNode<T>[]) => {
|
||||
for (const node of list) {
|
||||
map.set(node.value, node)
|
||||
if (node.children?.length) walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
}, [])
|
||||
|
||||
const abortKey = React.useCallback((key: string) => {
|
||||
const controller = controllers.current.get(key)
|
||||
if (!controller) return
|
||||
controllers.current.delete(key)
|
||||
inflight.current.delete(key)
|
||||
controller.abort()
|
||||
}, [])
|
||||
|
||||
/* ------------------------------- level load ------------------------------ */
|
||||
|
||||
const runLoad = React.useCallback(
|
||||
(
|
||||
key: string,
|
||||
reason: CascaderLoadReason,
|
||||
cursor: string | undefined,
|
||||
append: boolean
|
||||
) => {
|
||||
const { getChildren: loader, base: currentBase } = latest.current
|
||||
if (!loader) return
|
||||
|
||||
const signature = `${append ? "1" : "0"}:${cursor ?? ""}`
|
||||
if (inflight.current.get(key) === signature) return
|
||||
|
||||
abortKey(key)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(key, controller)
|
||||
inflight.current.set(key, signature)
|
||||
|
||||
const requestId = (requestIds.current.get(key) ?? 0) + 1
|
||||
requestIds.current.set(key, requestId)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
// A deep link (`path`, `expanded`) can put a level on screen before the
|
||||
// level above it loads, and a `loadKey` reset forgets every node. `null`
|
||||
// is the ROOT's argument, and a loader answered it with the root rows,
|
||||
// so the level gets its value instead.
|
||||
const node =
|
||||
key === CASCADER_ROOT_KEY
|
||||
? null
|
||||
: (currentBase.byValue.get(key) ??
|
||||
known.current.get(key) ?? { value: key, label: key })
|
||||
|
||||
setStore((prev) =>
|
||||
withLoadState(prev, key, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: false,
|
||||
}))
|
||||
)
|
||||
|
||||
const settle = () => {
|
||||
if (inflight.current.get(key) === signature)
|
||||
inflight.current.delete(key)
|
||||
if (controllers.current.get(key) === controller) {
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const stale = () =>
|
||||
controller.signal.aborted ||
|
||||
startEpoch !== epoch.current ||
|
||||
requestId !== requestIds.current.get(key)
|
||||
|
||||
// `Promise.resolve().then(...)`, not a direct call: it normalises a
|
||||
// SYNCHRONOUS throw into a rejection instead of taking the render down.
|
||||
Promise.resolve()
|
||||
.then(() => loader(node, { signal: controller.signal, cursor, reason }))
|
||||
.then((result) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
const items = Array.isArray(result) ? result : result.items
|
||||
const nextCursor = Array.isArray(result)
|
||||
? undefined
|
||||
: result.nextCursor
|
||||
const hasMore = Array.isArray(result)
|
||||
? false
|
||||
: (result.hasMore ?? nextCursor != null)
|
||||
|
||||
if (!append) moreLatch.current.delete(key)
|
||||
remember(items)
|
||||
setStore((prev) =>
|
||||
withPage(prev, key, items, { append, hasMore, cursor: nextCursor })
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
setStore((prev) =>
|
||||
withLoadState(prev, key, (state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: true,
|
||||
}))
|
||||
)
|
||||
// Behind the stale guard: an abort is navigation, not a failure.
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: key === CASCADER_ROOT_KEY ? null : key,
|
||||
reason,
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
const ensureLevel = React.useCallback(
|
||||
(key: string, reason: CascaderLoadReason) => {
|
||||
const {
|
||||
getChildren: loader,
|
||||
store: current,
|
||||
base: index,
|
||||
} = latest.current
|
||||
if (!loader) return
|
||||
if (current.states.has(key)) return
|
||||
// A level `items` already fills is not the loader's business: that is how
|
||||
// a static root plus `getChildren` for the branches works with no flag.
|
||||
if (index.childrenOf.has(key)) return
|
||||
runLoad(key, reason, undefined, false)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
const loadMore = React.useCallback(
|
||||
(key: string) => {
|
||||
const { getChildren: loader, store: current } = latest.current
|
||||
if (!loader) return
|
||||
const state = current.states.get(key)
|
||||
if (!state || state.loading || !state.hasMore) return
|
||||
|
||||
const loaded = current.pages.get(key)?.length ?? 0
|
||||
const signature = `${loaded}:${state.cursor ?? ""}`
|
||||
if (moreLatch.current.get(key) === signature) return
|
||||
moreLatch.current.set(key, signature)
|
||||
|
||||
runLoad(key, "more", state.cursor, true)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
const retryLevel = React.useCallback(
|
||||
(key: string) => {
|
||||
const { getChildren: loader, store: current } = latest.current
|
||||
if (!loader) return
|
||||
const state = current.states.get(key)
|
||||
if (!state?.error) return
|
||||
|
||||
const loaded = current.pages.get(key)?.length ?? 0
|
||||
// A retry must be able to re-fire the page the latch just blocked.
|
||||
moreLatch.current.delete(key)
|
||||
runLoad(key, "retry", loaded > 0 ? state.cursor : undefined, loaded > 0)
|
||||
},
|
||||
[runLoad]
|
||||
)
|
||||
|
||||
/**
|
||||
* `detached` and `links` are deliberately untouched: chains and search hits
|
||||
* belong to no level, and the trigger needs their path while the new page is
|
||||
* in flight.
|
||||
*/
|
||||
const invalidateLevel = React.useCallback(
|
||||
(value: string | null) => {
|
||||
const key = value ?? CASCADER_ROOT_KEY
|
||||
abortKey(key)
|
||||
// Bump the id too: a response past its signal check must still be stale.
|
||||
requestIds.current.set(key, (requestIds.current.get(key) ?? 0) + 1)
|
||||
moreLatch.current.delete(key)
|
||||
setStore((prev) => {
|
||||
if (!prev.states.has(key) && !prev.pages.has(key)) return prev
|
||||
const states = new Map(prev.states)
|
||||
states.delete(key)
|
||||
const pages = new Map(prev.pages)
|
||||
pages.delete(key)
|
||||
return { ...prev, pages, states }
|
||||
})
|
||||
},
|
||||
[abortKey]
|
||||
)
|
||||
|
||||
const prefetchNode = React.useCallback(
|
||||
(node: CascaderNode<T> | null | undefined) => {
|
||||
const {
|
||||
prefetch: on,
|
||||
getChildren: loader,
|
||||
store: current,
|
||||
} = latest.current
|
||||
if (!on || !loader || !node) return
|
||||
if (isCascaderMoreNode(node)) return
|
||||
if (!node.hasChildren) return
|
||||
if (current.states.has(node.value)) return
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) clearTimeout(holder.prefetch)
|
||||
// A TIMEOUT, not a direct call: `onItemHighlighted` fires from a layout
|
||||
// effect, where a synchronous setState is a render-phase cascade.
|
||||
holder.prefetch = setTimeout(() => {
|
||||
holder.prefetch = null
|
||||
ensureLevel(node.value, "prefetch")
|
||||
}, PREFETCH_DELAY)
|
||||
},
|
||||
[ensureLevel]
|
||||
)
|
||||
|
||||
/* --------------------------------- search -------------------------------- */
|
||||
|
||||
const runSearch = React.useCallback(
|
||||
(text: string) => {
|
||||
const { onSearch: searcher, path: currentPath } = latest.current
|
||||
if (!searcher) return
|
||||
|
||||
abortKey(SEARCH_KEY)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(SEARCH_KEY, controller)
|
||||
const requestId = (requestIds.current.get(SEARCH_KEY) ?? 0) + 1
|
||||
requestIds.current.set(SEARCH_KEY, requestId)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
setSearch((prev) => ({
|
||||
query: text,
|
||||
results: prev?.results ?? [],
|
||||
loading: true,
|
||||
error: false,
|
||||
}))
|
||||
|
||||
const settle = () => {
|
||||
if (controllers.current.get(SEARCH_KEY) === controller) {
|
||||
controllers.current.delete(SEARCH_KEY)
|
||||
}
|
||||
}
|
||||
const stale = () =>
|
||||
controller.signal.aborted ||
|
||||
startEpoch !== epoch.current ||
|
||||
requestId !== requestIds.current.get(SEARCH_KEY)
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
searcher(text, { signal: controller.signal, path: currentPath })
|
||||
)
|
||||
.then((result) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
const items = Array.isArray(result) ? result : result.items
|
||||
remember(items)
|
||||
// Search hits go to `detached`, never a level: a hit lives anywhere in
|
||||
// the tree, and filing it under the open level would misplace it.
|
||||
setStore((prev) => withDetached(prev, items))
|
||||
setSearch({
|
||||
query: text,
|
||||
results: items,
|
||||
loading: false,
|
||||
error: false,
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
settle()
|
||||
if (stale()) return
|
||||
setSearch((prev) => ({
|
||||
query: text,
|
||||
results: prev?.results ?? [],
|
||||
loading: false,
|
||||
error: true,
|
||||
}))
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: null,
|
||||
reason: "search",
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
const retrySearch = React.useCallback(() => {
|
||||
const text = latest.current.query.trim()
|
||||
if (text) runSearch(text)
|
||||
}, [runSearch])
|
||||
|
||||
/* --------------------------------- resolve ------------------------------- */
|
||||
|
||||
const runResolve = React.useCallback(
|
||||
(value: string) => {
|
||||
const { resolveValue: resolver } = latest.current
|
||||
if (!resolver) return
|
||||
|
||||
const key = `${RESOLVE_PREFIX}${value}`
|
||||
abortKey(key)
|
||||
const controller = new AbortController()
|
||||
controllers.current.set(key, controller)
|
||||
const startEpoch = epoch.current
|
||||
|
||||
const settle = () => {
|
||||
if (controllers.current.get(key) === controller) {
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
resolver(value, { signal: controller.signal, reason: "resolve" })
|
||||
)
|
||||
.then((chain) => {
|
||||
settle()
|
||||
if (controller.signal.aborted || startEpoch !== epoch.current) return
|
||||
if (!chain?.length) return
|
||||
remember(chain)
|
||||
setStore((prev) => withChain(prev, chain))
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Read BEFORE `settle`. A newer ask or a reset that replaced this
|
||||
// request owns the value's retry, so only a live failure is parked.
|
||||
const live = controllers.current.get(key) === controller
|
||||
settle()
|
||||
if (live) failed.current.add(value)
|
||||
if (controller.signal.aborted || startEpoch !== epoch.current) return
|
||||
latest.current.onLoadError?.(error, {
|
||||
parent: null,
|
||||
reason: "resolve",
|
||||
})
|
||||
})
|
||||
},
|
||||
[abortKey, remember]
|
||||
)
|
||||
|
||||
/* --------------------------------- resets -------------------------------- */
|
||||
|
||||
// Aborts what the PANEL asked for. A resolve keeps running: it feeds the
|
||||
// trigger, which shows with the popup closed.
|
||||
const cancelPanel = React.useCallback(() => {
|
||||
const keys = Array.from(controllers.current.keys()).filter(
|
||||
(key) => !key.startsWith(RESOLVE_PREFIX)
|
||||
)
|
||||
for (const key of keys) {
|
||||
controllers.current.get(key)?.abort()
|
||||
controllers.current.delete(key)
|
||||
}
|
||||
inflight.current.clear()
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) {
|
||||
clearTimeout(holder.prefetch)
|
||||
holder.prefetch = null
|
||||
}
|
||||
if (keys.length === 0) return
|
||||
|
||||
setStore((prev) => {
|
||||
let states: Map<string, CascaderLoadState> | null = null
|
||||
for (const key of keys) {
|
||||
const state = prev.states.get(key)
|
||||
if (!state?.loading) continue
|
||||
states = states ?? new Map(prev.states)
|
||||
if ((prev.pages.get(key)?.length ?? 0) > 0) {
|
||||
states.set(key, { ...state, loading: false })
|
||||
} else {
|
||||
// No entry AT ALL: membership is what says "loaded", so a stranded
|
||||
// `loading: true` would read as loaded-and-empty forever.
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
return states ? { ...prev, states } : prev
|
||||
})
|
||||
setSearch((prev) => (prev?.loading ? { ...prev, loading: false } : prev))
|
||||
}, [])
|
||||
|
||||
// A `loadKey` change drops everything. Declared BEFORE the level effect, so
|
||||
// a reset always lands before the levels are asked for again.
|
||||
const loadKeyRef = React.useRef<{ key: unknown } | null>(null)
|
||||
React.useEffect(() => {
|
||||
const previous = loadKeyRef.current
|
||||
loadKeyRef.current = { key: loadKey }
|
||||
if (!previous || Object.is(previous.key, loadKey)) return
|
||||
|
||||
epoch.current += 1
|
||||
for (const controller of controllers.current.values()) controller.abort()
|
||||
controllers.current.clear()
|
||||
inflight.current.clear()
|
||||
// `requestIds` is deliberately NOT cleared: the ids are stale-response
|
||||
// guards, not cache. Reusing an aborted predecessor's id would leave only
|
||||
// the epoch bump and the abort to reject the old response; monotonic per
|
||||
// key keeps all three guards independent. The unmount cleanup clears them.
|
||||
moreLatch.current.clear()
|
||||
attempted.current.clear()
|
||||
failed.current.clear()
|
||||
known.current.clear()
|
||||
|
||||
const holder = timers.current
|
||||
if (holder.prefetch) {
|
||||
clearTimeout(holder.prefetch)
|
||||
holder.prefetch = null
|
||||
}
|
||||
|
||||
setStore(createStore)
|
||||
setSearch(null)
|
||||
}, [loadKey])
|
||||
|
||||
// Closing the popup aborts the panel's requests but does NOT drop the cache:
|
||||
// reopening onto an already-loaded level is the point of keeping it.
|
||||
React.useEffect(() => {
|
||||
if (enabled) return
|
||||
if (controllers.current.size === 0) return
|
||||
cancelPanel()
|
||||
}, [enabled, cancelPanel])
|
||||
|
||||
React.useEffect(() => {
|
||||
const active = controllers.current
|
||||
const holder = timers.current
|
||||
const pending = inflight.current
|
||||
const ids = requestIds.current
|
||||
const tried = attempted.current
|
||||
const parked = failed.current
|
||||
return () => {
|
||||
for (const controller of active.values()) controller.abort()
|
||||
active.clear()
|
||||
// Cleared WITH the controllers, for StrictMode's dev remount: a stale
|
||||
// inflight signature would make `runLoad` skip the refetch forever, and
|
||||
// a value still marked as asked would never be resolved again. The
|
||||
// aborted promises are stale-guarded, so emptying the ids is safe.
|
||||
pending.clear()
|
||||
ids.clear()
|
||||
tried.clear()
|
||||
parked.clear()
|
||||
if (holder.prefetch) clearTimeout(holder.prefetch)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ------------------------------- the effects ----------------------------- */
|
||||
|
||||
const hasLoader = typeof getChildren === "function"
|
||||
const hasSearch = typeof onSearch === "function"
|
||||
const hasResolve = typeof resolveValue === "function"
|
||||
const trimmed = query.trim()
|
||||
|
||||
// Serialised so the effects key on CONTENT, not on the array identity.
|
||||
const levelsKey = JSON.stringify(levels)
|
||||
const valuesKey = JSON.stringify(values)
|
||||
|
||||
// THE load trigger: one declarative effect on the levels on screen, not a
|
||||
// call from `pushLevel`/`navigate`, which a controlled `path` never touches.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasLoader) return
|
||||
for (const key of levels) ensureLevel(key, "level")
|
||||
// `levels` enters through `levelsKey`; `store` re-runs after a load lands.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, hasLoader, levelsKey, store, ensureLevel])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasSearch) return undefined
|
||||
if (!enabled || !trimmed) {
|
||||
abortKey(SEARCH_KEY)
|
||||
setSearch((prev) => (prev === null ? prev : null))
|
||||
return undefined
|
||||
}
|
||||
const timer = setTimeout(() => runSearch(trimmed), searchDebounce)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
abortKey(SEARCH_KEY)
|
||||
}
|
||||
}, [hasSearch, enabled, trimmed, searchDebounce, abortKey, runSearch])
|
||||
|
||||
// Opening is the retry point for a failed resolve: un-ask it here, and the
|
||||
// values pass below, which also re-runs on `enabled`, asks again.
|
||||
React.useEffect(() => {
|
||||
if (!enabled || failed.current.size === 0) return
|
||||
for (const value of failed.current) attempted.current.delete(value)
|
||||
failed.current.clear()
|
||||
}, [enabled])
|
||||
|
||||
// NOT gated on `enabled`: the trigger shows the selection with the popup
|
||||
// closed, so a saved value is pathed on load, not on the first open.
|
||||
// `loadKey` re-runs it, because the reset dropped every resolved chain.
|
||||
React.useEffect(() => {
|
||||
if (!hasResolve) return
|
||||
for (const value of values) {
|
||||
if (!value) continue
|
||||
if (attempted.current.has(value)) continue
|
||||
if (base.byValue.has(value) || known.current.has(value)) continue
|
||||
attempted.current.add(value)
|
||||
runResolve(value)
|
||||
}
|
||||
// `values` is depended on through `valuesKey`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, hasResolve, valuesKey, base, runResolve, loadKey])
|
||||
|
||||
/* -------------------------------- the value ------------------------------ */
|
||||
|
||||
const searchResults = React.useMemo(() => {
|
||||
if (!hasSearch || !trimmed) return null
|
||||
if (!search || search.query !== trimmed) {
|
||||
return NO_RESULTS as CascaderNode<T>[]
|
||||
}
|
||||
return search.results
|
||||
}, [hasSearch, trimmed, search])
|
||||
|
||||
const searchState = React.useMemo<CascaderLoadState | null>(() => {
|
||||
if (!hasSearch || !trimmed) return null
|
||||
const settled = search?.query === trimmed
|
||||
return {
|
||||
loading: !settled || !!search?.loading,
|
||||
error: settled && !!search?.error,
|
||||
hasMore: false,
|
||||
}
|
||||
}, [hasSearch, trimmed, search])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
active: hasLoader,
|
||||
store,
|
||||
states: store.states,
|
||||
searchResults,
|
||||
searchState,
|
||||
ensureLevel,
|
||||
loadMore,
|
||||
retryLevel,
|
||||
retrySearch,
|
||||
invalidateLevel,
|
||||
prefetchNode,
|
||||
}),
|
||||
[
|
||||
hasLoader,
|
||||
store,
|
||||
searchResults,
|
||||
searchState,
|
||||
ensureLevel,
|
||||
loadMore,
|
||||
retryLevel,
|
||||
retrySearch,
|
||||
invalidateLevel,
|
||||
prefetchNode,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Consumers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** One level's load state, `null` when never fetched. Omit for the root. */
|
||||
export function useCascaderLoadState(
|
||||
parent?: string | null
|
||||
): CascaderLoadState | null {
|
||||
const { loadStates } = useCascaderState()
|
||||
return loadStates.get(parent ?? CASCADER_ROOT_KEY) ?? null
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CascaderItem,
|
||||
getCascaderMoreProps,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
CASCADER_LIST_HEIGHT_CLASS,
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
CASCADER_ROOT_KEY,
|
||||
CASCADER_ROWS_CLASS,
|
||||
CASCADER_SCROLL_CLASS,
|
||||
focusCascaderHome,
|
||||
warnCascaderOnce,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
export interface CascaderColumnsProps extends Omit<
|
||||
React.ComponentProps<"div">,
|
||||
"children"
|
||||
> {
|
||||
/** Width of each column. */
|
||||
columnWidth?: number | string
|
||||
/** Height CAP per column. Falls back to the root `maxHeight`, then 24rem. */
|
||||
maxHeight?: number | string
|
||||
/** Replaces the default panel; the seam a windowed column plugs into. */
|
||||
children?: (column: CascaderColumn) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Miller columns: the open trail side by side, one panel per level. Only the
|
||||
* DEEPEST column is a real listbox (Base UI owns exactly one list); the trail
|
||||
* behind is plain buttons, which keeps one state machine instead of a second,
|
||||
* 2D one. `CascaderInput` moves between columns with ArrowLeft/ArrowRight.
|
||||
*/
|
||||
function CascaderColumns({
|
||||
className,
|
||||
columnWidth = 220,
|
||||
maxHeight: maxHeightProp,
|
||||
children,
|
||||
...props
|
||||
}: CascaderColumnsProps) {
|
||||
const { maxHeight, mode, labels } = useCascaderActions()
|
||||
const { columns } = useCascaderState()
|
||||
|
||||
// Before the early return, so the hook count is the same in both modes.
|
||||
React.useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production") return
|
||||
if (mode === "columns") return
|
||||
warnCascaderOnce(
|
||||
`columns-outside-columns-mode:${mode}`,
|
||||
`\`CascaderColumns\` renders nothing in \`mode="${mode}"\`, so \`columnWidth\` and everything else on it does nothing. Set \`mode="columns"\` on the root, or render \`CascaderList\` instead.`
|
||||
)
|
||||
}, [mode])
|
||||
|
||||
if (mode !== "columns") return null
|
||||
|
||||
const height = maxHeightProp ?? maxHeight
|
||||
const toCss = (value: number | string) =>
|
||||
typeof value === "number" ? `${value}px` : value
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-columns"
|
||||
role="group"
|
||||
aria-label={labels.columnsLabel}
|
||||
style={
|
||||
{
|
||||
"--cascader-column-width": toCss(columnWidth),
|
||||
/* Set only from an EXPLICIT cap: unset means "24rem or what the
|
||||
viewport leaves", via the `min()` fallback on the panel. A `?? 280`
|
||||
default here ignored short viewports and wasted tall ones. */
|
||||
...(height != null
|
||||
? { "--cascader-max-height": toCss(height) }
|
||||
: null),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
/* `max-h-full` with `min-h-0`: the trail is the panel's shrinking
|
||||
child, and the columns inside it size against this box. */
|
||||
"flex max-h-full min-h-0 items-stretch overflow-x-auto overscroll-x-contain",
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{columns.map((column) =>
|
||||
children ? (
|
||||
<React.Fragment key={column.depth}>{children(column)}</React.Fragment>
|
||||
) : (
|
||||
<CascaderColumnPanel key={column.depth} column={column} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One column's box. `columnWidth` is the width of the LIST, not the box: under
|
||||
* `border-box` the 1px inline-start divider on every column but the first would
|
||||
* come out of the rows, so bordered columns are widened by that pixel. The box
|
||||
* is also the BOUND, the same `min(--available-height, cap)` the single list
|
||||
* uses, and the `ScrollArea` inside scrolls, so every column shows a thumb.
|
||||
*/
|
||||
const PANEL_CLASS = `flex w-(--cascader-column-width) shrink-0 flex-col overscroll-contain not-first:w-[calc(var(--cascader-column-width)_+_1px)] not-first:border-border/60 not-first:border-s ${CASCADER_LIST_HEIGHT_CLASS}`
|
||||
|
||||
export interface CascaderColumnPanelProps {
|
||||
column: CascaderColumn
|
||||
/** Replaces the panel's rows; the empty state still wins on an empty column. */
|
||||
children?: React.ReactNode
|
||||
/** Containing block for the windowed column's absolutely positioned rows. */
|
||||
virtualized?: boolean
|
||||
}
|
||||
|
||||
function CascaderColumnPanel({
|
||||
column,
|
||||
children,
|
||||
virtualized,
|
||||
}: CascaderColumnPanelProps) {
|
||||
const {
|
||||
labels,
|
||||
baseId,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
retryLevel,
|
||||
searchScope,
|
||||
} = useCascaderActions()
|
||||
const { loadStates, deepResults } = useCascaderState()
|
||||
|
||||
// Keyed per level, not one global flag: columns load and land independently.
|
||||
const columnKey = column.parent?.value ?? CASCADER_ROOT_KEY
|
||||
const loadState = loadStates.get(columnKey)
|
||||
|
||||
let emptyBody: React.ReactNode = labels.empty
|
||||
if (loadState?.error) {
|
||||
emptyBody = (
|
||||
<>
|
||||
{labels.error}{" "}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-retry"
|
||||
onClick={(event) => {
|
||||
// Same hand-off as the panel's Retry: a trail column, or an
|
||||
// inline one, can hold focus, and the loading state unmounts this.
|
||||
focusCascaderHome(event.currentTarget)
|
||||
retryLevel(columnKey)
|
||||
}}
|
||||
className="text-foreground hover:bg-accent focus-visible:ring-ring/50 rounded-md px-1 font-medium outline-hidden transition-colors focus-visible:ring-2"
|
||||
>
|
||||
{labels.retry}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
} else if (loadState?.loading) {
|
||||
emptyBody = (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" aria-hidden />
|
||||
{labels.loading}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const rows =
|
||||
column.items.length === 0 ? (
|
||||
<p
|
||||
data-slot="cascader-column-empty"
|
||||
data-state={
|
||||
loadState?.error ? "error" : loadState?.loading ? "loading" : "empty"
|
||||
}
|
||||
className="text-muted-foreground px-2 py-1.5 text-sm"
|
||||
>
|
||||
{emptyBody}
|
||||
</p>
|
||||
) : (
|
||||
(children ??
|
||||
column.items.map((node, i) => {
|
||||
const open = node.value === column.activeValue
|
||||
return (
|
||||
<CascaderItem
|
||||
key={node.value}
|
||||
node={node}
|
||||
/* A trail row must not compete for `aria-activedescendant`. */
|
||||
as={column.active ? "option" : "button"}
|
||||
depth={column.depth}
|
||||
/* Answered here, not in the row: the trail rows are memoised too. */
|
||||
branch={isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
data-open={open || undefined}
|
||||
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
|
||||
/* Set metadata is option-only; a trail row is a `role="button"`. */
|
||||
{...(column.active
|
||||
? {
|
||||
"aria-setsize": column.items.length,
|
||||
"aria-posinset": i + 1,
|
||||
}
|
||||
: null)}
|
||||
{...(!column.active && open
|
||||
? {
|
||||
"aria-expanded": true,
|
||||
"aria-controls": `${baseId}-column-${column.depth + 1}`,
|
||||
}
|
||||
: null)}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
)
|
||||
|
||||
const shared = {
|
||||
"data-slot": "cascader-column",
|
||||
"data-active": column.active || undefined,
|
||||
"data-depth": column.depth,
|
||||
// Addressable so the opening trail row can point `aria-controls` here, and
|
||||
// named even at the root, which has no parent label to borrow.
|
||||
id: `${baseId}-column-${column.depth}`,
|
||||
// Global hits come from the whole tree, so the parent would understate them.
|
||||
"aria-label":
|
||||
column.active && searchScope === "global" && deepResults
|
||||
? labels.rootLevel
|
||||
: (column.parent?.label ?? labels.rootLevel),
|
||||
// Conditional spread, never an explicit `undefined`: the active column is a
|
||||
// Base UI element, and its `mergeProps` iterates own keys.
|
||||
...(virtualized ? { "data-virtualized": true } : null),
|
||||
}
|
||||
|
||||
// A windowed row is absolutely positioned, so the ROWS' box is the containing
|
||||
// block, not the scrollport: it carries the padding the geometry is measured
|
||||
// against.
|
||||
const rowsClass = cn(CASCADER_ROWS_CLASS, virtualized && "relative")
|
||||
|
||||
// The active column IS the Combobox list: only rows inside `Combobox.List`
|
||||
// reach the CompositeList, arrow-key navigation and `aria-activedescendant`.
|
||||
const body = column.active ? (
|
||||
<ComboboxPrimitive.List {...shared} className={rowsClass}>
|
||||
{rows}
|
||||
</ComboboxPrimitive.List>
|
||||
) : (
|
||||
// A named `group`, not a second listbox competing with the active column.
|
||||
<div {...shared} role="group" className={rowsClass}>
|
||||
{rows}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-column-bounds"
|
||||
/* Repeated from `shared`: this box, not the semantic element, owns the
|
||||
width, the divider and the height, so style hooks must reach it, and
|
||||
`:first-child` on the semantic element no longer means "first column"
|
||||
(it is its own scrollport's only child). */
|
||||
data-active={column.active || undefined}
|
||||
data-depth={column.depth}
|
||||
className={PANEL_CLASS}
|
||||
>
|
||||
<ScrollArea className={CASCADER_SCROLL_CLASS}>{body}</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CascaderColumnPanel, CascaderColumns }
|
||||
@@ -0,0 +1,324 @@
|
||||
import * as React from "react"
|
||||
import type {
|
||||
CascaderActionItem,
|
||||
CascaderChangeReason,
|
||||
CascaderFlatNode,
|
||||
CascaderIndex,
|
||||
CascaderLabels,
|
||||
CascaderLoadState,
|
||||
CascaderMode,
|
||||
CascaderNode,
|
||||
CascaderSearchScope,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/**
|
||||
* Four contexts, not one: actions (config and callbacks, near-stable), state
|
||||
* (every keystroke), render (`renderItem` identity) and highlight (every arrow
|
||||
* key and pointer move). One combined context re-rendered every row on every
|
||||
* keystroke, which is what made `React.memo` on the row worth nothing.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Shared types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderColumn<T = unknown> {
|
||||
parent: CascaderNode<T> | null
|
||||
items: CascaderNode<T>[]
|
||||
depth: number
|
||||
/** The node in this column that is drilled into, if any. */
|
||||
activeValue: string | null
|
||||
/** Whether this is the deepest column, the one Base UI owns. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface CascaderItemState<T = unknown> {
|
||||
branch: boolean
|
||||
selected: boolean
|
||||
/** The node's own flag or a disabled ancestor's, as `isCascaderDisabled`. */
|
||||
disabled: boolean
|
||||
depth: number
|
||||
count: number
|
||||
/** Ancestor chain, root first. Populated for deep and global search rows. */
|
||||
path: CascaderNode<T>[]
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* State */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Everything derived from the query, the path and the selection. One keystroke
|
||||
* rebuilds most of it, so never subscribe to it from a row.
|
||||
*/
|
||||
export interface CascaderStateContextValue<T = unknown> {
|
||||
/** Same `useMemo` identity as the actions context's `index`. */
|
||||
index: CascaderIndex<T>
|
||||
path: string[]
|
||||
expanded: ReadonlySet<string>
|
||||
query: string
|
||||
currentParent: CascaderNode<T> | null
|
||||
/** Rows for the current level, already filtered. */
|
||||
levelItems: CascaderNode<T>[]
|
||||
deepResults: CascaderNode<T>[] | null
|
||||
/** What `Combobox.Root` is currently rendering, in render order. */
|
||||
renderedItems: CascaderNode<T>[]
|
||||
columns: CascaderColumn<T>[]
|
||||
treeRows: CascaderFlatNode<T>[]
|
||||
selectedValues: string[]
|
||||
/** Selected nodes below each value, at any depth. Absent means zero. */
|
||||
selectedDescendants: ReadonlyMap<string, number>
|
||||
/**
|
||||
* Per level, keyed by parent value or `CASCADER_ROOT_KEY`. MEMBERSHIP is the
|
||||
* discriminator: no entry means never fetched, an entry with no `loading`, no
|
||||
* `error` and no children means fetched and genuinely empty.
|
||||
*/
|
||||
loadStates: ReadonlyMap<string, CascaderLoadState>
|
||||
/** Async search, `null` when idle. Separate: a search belongs to no level. */
|
||||
searchState: CascaderLoadState | null
|
||||
announcement: string
|
||||
}
|
||||
|
||||
const CascaderStateContext = React.createContext<
|
||||
CascaderStateContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
/**
|
||||
* One provider serves every `T`, so the context holds an erased `unknown` value
|
||||
* and this cast restores it. The primitive never inspects the payload.
|
||||
*/
|
||||
export function useCascaderState<T = unknown>(): CascaderStateContextValue<T> {
|
||||
const context = React.useContext(CascaderStateContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderState must be used within a Cascader")
|
||||
}
|
||||
return context as unknown as CascaderStateContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Config and callbacks, slow enough that a memoised row can subscribe: the
|
||||
* mutators are `[]`-dep callbacks over a latest-props ref. The predicates are
|
||||
* the exception, read DURING RENDER where a ref written in an effect would
|
||||
* return the previous commit's answer, so each is memoised on its own input.
|
||||
*/
|
||||
export interface CascaderActionsContextValue<T = unknown> {
|
||||
index: CascaderIndex<T>
|
||||
mode: CascaderMode
|
||||
multiple: boolean
|
||||
/** Multi-select only: a commit propagates over the LOADED subtree. */
|
||||
cascade: boolean
|
||||
/** Whether a BRANCH is committable. Per-LIST: the check gutter is a COLUMN. */
|
||||
branchesSelectable: boolean
|
||||
/** Draws the SINGLE-SELECT check and its gutter. Ignored in multi-select. */
|
||||
indicator: boolean
|
||||
expandTrigger?: "click" | "hover"
|
||||
actions: CascaderActionItem[]
|
||||
searchScope: CascaderSearchScope
|
||||
maxHeight?: number | string
|
||||
inline: boolean
|
||||
invalid: boolean
|
||||
/**
|
||||
* Id prefix; columns are `${baseId}-column-${depth}`. The SCHEME is contract:
|
||||
* the filters primitive's `FilterMenuPinKeeper` restores its highlight across
|
||||
* a live re-pin through `${baseId}-column-0` and the `cascader-item` slot.
|
||||
*/
|
||||
baseId: string
|
||||
labels: CascaderLabels
|
||||
|
||||
/**
|
||||
* Whether rows are WINDOWED. Also handed to `Combobox.Root`, which is what
|
||||
* makes an explicit row `index` legal: forwarding one while this is false
|
||||
* makes `aria-activedescendant` resolve to nothing. Latched per level.
|
||||
*/
|
||||
virtualized: boolean
|
||||
/** Mounts a windowing renderer, returns its unregister. LAYOUT effect only. */
|
||||
registerVirtualRenderer: () => () => void
|
||||
/** The root's `virtualize` prop. `undefined` means "decide by count". */
|
||||
virtualize?: boolean
|
||||
virtualizeThreshold: number
|
||||
estimateRowSize: number
|
||||
overscan: number
|
||||
|
||||
/** Tells "this level is empty" from "not fetched yet" before a load state. */
|
||||
hasLoader: boolean
|
||||
/** Next page of a level. Latched: a page with nothing new is not re-asked. */
|
||||
loadMore: (parentKey: string) => void
|
||||
retryLevel: (parentKey: string) => void
|
||||
/** Re-runs a failed server search (`onSearch`) for the current query. */
|
||||
retrySearch: () => void
|
||||
/**
|
||||
* Evicts one level's async cache, `null` for the root, so membership reads
|
||||
* never-loaded. A level that is on screen when evicted refetches at once.
|
||||
*/
|
||||
invalidateLevel: (value: string | null) => void
|
||||
|
||||
/** Index as of the last commit. Use in the stable callbacks, not `index`. */
|
||||
getIndex: () => CascaderIndex<T>
|
||||
getState: () => CascaderStateContextValue<T>
|
||||
/** Highlighted row or null. A getter: the highlight moves per arrow key. */
|
||||
getHighlighted: () => CascaderNode<T> | null
|
||||
|
||||
setPath: (next: string[] | ((prev: string[]) => string[])) => void
|
||||
pushLevel: (value: string) => void
|
||||
popLevel: () => void
|
||||
goToDepth: (depth: number) => void
|
||||
toggleExpanded: (value: string) => void
|
||||
/**
|
||||
* Registers a footer submenu as open or closed. `Combobox` has no
|
||||
* `FloatingTree`, so one Escape would dismiss the flyout AND the cascader;
|
||||
* the root's `onOpenChange` guard cancels the close while any is open, from a
|
||||
* ref so it reads as of that event without re-rendering the root.
|
||||
*/
|
||||
setFlyoutOpen: (key: string, open: boolean) => void
|
||||
hasOpenFlyout: () => boolean
|
||||
setQuery: (next: string) => void
|
||||
/**
|
||||
* Replaces the selection. `onValueChange` diffs it against the current one
|
||||
* for its node and reason; pass `reason` only when the caller knows better.
|
||||
*/
|
||||
setSelection: (values: string[], reason?: CascaderChangeReason) => void
|
||||
/** Commits a node, for rows outside the listbox such as ancestor columns. */
|
||||
commit: (node: CascaderNode<T>) => void
|
||||
navigate: (node: CascaderNode<T>) => void
|
||||
/** Into `node` as a child of `depth`, replacing anything deeper. */
|
||||
navigateAt: (node: CascaderNode<T>, depth: number) => void
|
||||
/** Never undefined: falls back to a remembered label, then a synthetic node. */
|
||||
resolveNode: (value: string) => CascaderNode<T>
|
||||
isBranch: (node: CascaderNode<T>) => boolean
|
||||
isSelectable: (node: CascaderNode<T>) => boolean
|
||||
/**
|
||||
* The node or an ancestor is `disabled`. Rows render it disabled; row
|
||||
* presses, keys, `commit`, `navigate`, `navigateAt` and a held navigation
|
||||
* refuse it. `pushLevel`, `setPath`, `toggleExpanded` and `setSelection` are
|
||||
* raw setters and do not ask.
|
||||
*/
|
||||
isDisabled: (node: CascaderNode<T>) => boolean
|
||||
isSelected: (node: CascaderNode<T>) => boolean
|
||||
/** Always `false` without `cascade`: partial selection needs propagation. */
|
||||
isIndeterminate: (node: CascaderNode<T>) => boolean
|
||||
/**
|
||||
* O(1) read of `selectedDescendants`; a memoised row may not subscribe.
|
||||
* Unlike `isIndeterminate` this answers in every mode.
|
||||
*/
|
||||
selectedDescendantCount: (node: CascaderNode<T>) => number
|
||||
}
|
||||
|
||||
const CascaderActionsContext = React.createContext<
|
||||
CascaderActionsContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
export function useCascaderActions<
|
||||
T = unknown,
|
||||
>(): CascaderActionsContextValue<T> {
|
||||
const context = React.useContext(CascaderActionsContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderActions must be used within a Cascader")
|
||||
}
|
||||
return context as unknown as CascaderActionsContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Render props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Their own context, republished every render. They cannot ride on the actions
|
||||
* context: an inline closure read off a memoised object is whichever closure
|
||||
* that object captured, so the row would call a stale prop over stale state.
|
||||
*/
|
||||
export interface CascaderRenderContextValue<T = unknown> {
|
||||
renderItem?: (
|
||||
node: CascaderNode<T>,
|
||||
state: CascaderItemState<T>
|
||||
) => React.ReactNode
|
||||
renderLabel?: (
|
||||
node: CascaderNode<T>,
|
||||
state: CascaderItemState<T>
|
||||
) => React.ReactNode
|
||||
}
|
||||
|
||||
const CascaderRenderContext = React.createContext<CascaderRenderContextValue>(
|
||||
{}
|
||||
)
|
||||
|
||||
export function useCascaderRender<
|
||||
T = unknown,
|
||||
>(): CascaderRenderContextValue<T> {
|
||||
return React.useContext(
|
||||
CascaderRenderContext
|
||||
) as CascaderRenderContextValue<T>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Highlight store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderHighlight {
|
||||
index: number
|
||||
value: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* An external store, deliberately NOT React state: `onItemHighlighted` fires on
|
||||
* every arrow key AND every pointer move over the list, so `setState` would
|
||||
* re-render the whole root at mousemove rate. A store re-renders subscribers
|
||||
* only, which inside the primitive is the virtualizer.
|
||||
*/
|
||||
export interface CascaderHighlightStore {
|
||||
subscribe: (onStoreChange: () => void) => () => void
|
||||
getSnapshot: () => CascaderHighlight
|
||||
set: (next: CascaderHighlight) => void
|
||||
}
|
||||
|
||||
const NO_HIGHLIGHT: CascaderHighlight = { index: -1, value: null }
|
||||
|
||||
export function createCascaderHighlightStore(): CascaderHighlightStore {
|
||||
let snapshot: CascaderHighlight = NO_HIGHLIGHT
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
subscribe(onStoreChange) {
|
||||
listeners.add(onStoreChange)
|
||||
return () => {
|
||||
listeners.delete(onStoreChange)
|
||||
}
|
||||
},
|
||||
// The SAME object until something changes; `useSyncExternalStore` needs it.
|
||||
getSnapshot() {
|
||||
return snapshot
|
||||
},
|
||||
set(next) {
|
||||
if (next.index === snapshot.index && next.value === snapshot.value) return
|
||||
snapshot = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared and permanently empty, so the hook degrades outside a `Cascader`. */
|
||||
const FALLBACK_HIGHLIGHT_STORE = createCascaderHighlightStore()
|
||||
|
||||
const CascaderHighlightContext = React.createContext<CascaderHighlightStore>(
|
||||
FALLBACK_HIGHLIGHT_STORE
|
||||
)
|
||||
|
||||
/** Subscribes to the highlight. Re-renders ONLY the calling component. */
|
||||
export function useCascaderHighlight(): CascaderHighlight {
|
||||
const store = React.useContext(CascaderHighlightContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderActionsContext,
|
||||
CascaderHighlightContext,
|
||||
CascaderRenderContext,
|
||||
CascaderStateContext,
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useCascaderActions } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CASCADER_ACTION_CLASS,
|
||||
CascaderGroup,
|
||||
CascaderLabel,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
getCascaderFooterStops,
|
||||
isCascaderRtl,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type { CascaderActionItem } from "@/components/reui/cascader/cascader-types"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react"
|
||||
import { useDirection } from "@base-ui/react/direction-provider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* The pinned footer, and the side-anchored flyout a footer row can open. These
|
||||
* are COMMANDS: nothing here joins the selection, the filter set or the
|
||||
* highlight. The flyout is a Base UI `Popover` rendered as a REACT CHILD of
|
||||
* `Combobox.Popup` with its OWN `Portal` and NO `container`: a nested portal
|
||||
* resolves to the parent portal node, so it is a DOM sibling of the combobox
|
||||
* popup (not clipped, not `aria-hidden`) but a React descendant, which is what
|
||||
* the outside-press and focus-out whitelists read. `Combobox` builds no
|
||||
* `FloatingTree`, so the flyout is not consulted first and one Escape would
|
||||
* dismiss both; hence `CascaderSubmenu` registering with the root to turn one
|
||||
* Escape into two. `Combobox.List` clicks its highlighted row on Enter, hence
|
||||
* the footer sitting outside `CascaderList`. And a `Positioner` throws without
|
||||
* its `Portal`, while `modal` stays `false` on the `Root` so the combobox
|
||||
* keeps its own dismissal behaviour.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Footer */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Keys the option list acts on, swallowed at the footer boundary. Escape and
|
||||
* Tab are absent on purpose: Escape must reach the root, Tab must keep moving.
|
||||
*/
|
||||
const FOOTER_SWALLOWED_KEYS = new Set([
|
||||
"Enter",
|
||||
" ",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
])
|
||||
|
||||
export type CascaderFooterProps = React.ComponentProps<"div">
|
||||
|
||||
/**
|
||||
* Actions pinned below the list, a SIBLING of `CascaderList`. Children win
|
||||
* over the root's `actions` prop; with neither it renders nothing.
|
||||
*/
|
||||
function CascaderFooter({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: CascaderFooterProps) {
|
||||
const { actions, labels } = useCascaderActions()
|
||||
const hasChildren = React.Children.count(children) > 0
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
if (!FOOTER_SWALLOWED_KEYS.has(event.key)) return
|
||||
event.stopPropagation()
|
||||
|
||||
// The strip's own vertical movement, and the way back from the list's
|
||||
// hand-off: either end returns focus to the search field, from which
|
||||
// Base UI's empty highlight resumes the list. Down wraps to the FIELD,
|
||||
// not a command (traps the arrows) or a row (no imperative highlight).
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
||||
const footer = event.currentTarget
|
||||
const stops = getCascaderFooterStops(footer)
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const index = active ? stops.indexOf(active) : -1
|
||||
if (index === -1) return
|
||||
event.preventDefault()
|
||||
const next =
|
||||
event.key === "ArrowDown" ? stops[index + 1] : stops[index - 1]
|
||||
if (next) {
|
||||
next.focus()
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp" && index > 0) return
|
||||
footer
|
||||
.closest<HTMLElement>('[data-slot="cascader-panel"]')
|
||||
?.querySelector<HTMLElement>('[data-slot="cascader-input"]')
|
||||
?.focus()
|
||||
},
|
||||
[onKeyDown]
|
||||
)
|
||||
|
||||
if (!hasChildren && actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-footer"
|
||||
/* Named, not a bare div: without it a screen reader reaches the actions
|
||||
with nothing to say they are not more options. */
|
||||
role="group"
|
||||
aria-label={labels.actionsLabel}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"border-border/60 flex shrink-0 flex-col gap-0.5 border-t",
|
||||
/* The LIST's padding, not a flat `p-1`: with a padding of its own the
|
||||
two columns of text were 2px out in luma and sera and 4px out in
|
||||
lyra. It also gives a separator in here a number to cancel. */
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
"p-(--cascader-list-pad,4px)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hasChildren ? children : <CascaderFooterActions actions={actions} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CascaderFooterActions({ actions }: { actions: CascaderActionItem[] }) {
|
||||
return (
|
||||
<>
|
||||
{actions.map((action, i) =>
|
||||
action.items?.length ? (
|
||||
<CascaderSubmenu key={actionKey(action, i)}>
|
||||
<CascaderSubmenuTrigger
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</CascaderSubmenuTrigger>
|
||||
<CascaderSubmenuContent>
|
||||
<CascaderActionList items={action.items} />
|
||||
</CascaderSubmenuContent>
|
||||
</CascaderSubmenu>
|
||||
) : (
|
||||
<CascaderAction
|
||||
key={actionKey(action, i)}
|
||||
icon={action.icon}
|
||||
disabled={action.disabled}
|
||||
onSelect={action.onSelect}
|
||||
>
|
||||
{action.label}
|
||||
</CascaderAction>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function actionKey(action: CascaderActionItem, index: number): string {
|
||||
if (action.value != null) return action.value
|
||||
if (typeof action.label === "string") return action.label
|
||||
return String(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consecutive entries sharing a `group`, as runs not buckets: two separated
|
||||
* runs with the same name stay two, so the author's order survives.
|
||||
*/
|
||||
function groupActionRuns(
|
||||
items: CascaderActionItem[]
|
||||
): { group?: string; items: CascaderActionItem[] }[] {
|
||||
const runs: { group?: string; items: CascaderActionItem[] }[] = []
|
||||
for (const item of items) {
|
||||
const last = runs[runs.length - 1]
|
||||
if (last && last.group === item.group) last.items.push(item)
|
||||
else runs.push({ group: item.group, items: [item] })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* Flyout body for a data-driven submenu. A named run becomes a real
|
||||
* `CascaderGroup`; unnamed runs stay unwrapped, as an unnamed group is noise.
|
||||
*/
|
||||
function CascaderActionList({ items }: { items: CascaderActionItem[] }) {
|
||||
const { close } = useCascaderSubmenu()
|
||||
const runs = React.useMemo(() => groupActionRuns(items), [items])
|
||||
|
||||
const renderAction = (item: CascaderActionItem, i: number) => (
|
||||
<CascaderAction
|
||||
key={actionKey(item, i)}
|
||||
icon={item.icon}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => {
|
||||
item.onSelect?.()
|
||||
/* Closes behind the command, or the entries would read as toggles. */
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</CascaderAction>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, runIndex) =>
|
||||
run.group ? (
|
||||
<CascaderGroup key={`${run.group}-${runIndex}`} className="gap-0.5">
|
||||
<CascaderLabel>{run.group}</CascaderLabel>
|
||||
{run.items.map(renderAction)}
|
||||
</CascaderGroup>
|
||||
) : (
|
||||
<React.Fragment key={`run-${runIndex}`}>
|
||||
{run.items.map(renderAction)}
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderActionProps extends Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"onSelect"
|
||||
> {
|
||||
icon?: React.ReactNode
|
||||
/** Fires on press, after `onClick`, and not at all when disabled. */
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The cascader popup's panel per style, spelled with `style-<name>:` variants
|
||||
* rather than ReUI theme CSS so an installed footer needs only Tailwind.
|
||||
*/
|
||||
const FLYOUT_SURFACE_CLASS =
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-72 overflow-hidden ring-1 duration-100 ring-foreground/10 shadow-md rounded-lg"
|
||||
|
||||
/**
|
||||
* One footer command, shaped like a row and deliberately NOT one. A real
|
||||
* `<button>`, never a `Combobox.Item`: an item would join the arrow-key ring,
|
||||
* appear in `filteredItems`, and vanish the moment a query matched nothing -
|
||||
* exactly when "Create new attribute" is most useful. Disabled is
|
||||
* `aria-disabled`, never the native attribute: that one is not a tab stop and
|
||||
* the panel's Tab order reads `button:not([disabled])`, so a footer whose ONLY
|
||||
* row is a disabled command had no stop after the search field (measured on
|
||||
* `c-cascader-8`). Staying focusable costs the guards below, plus the
|
||||
* greyed-out look: `CASCADER_ACTION_CLASS` keys that off `aria-disabled`
|
||||
* instead of `:disabled`.
|
||||
*/
|
||||
function CascaderAction({
|
||||
className,
|
||||
icon,
|
||||
children,
|
||||
onSelect,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
...props
|
||||
}: CascaderActionProps) {
|
||||
// Set by `CascaderSubmenuContent`: a plain button in the footer's Tab ring,
|
||||
// a roving-focus `menuitem` inside a flyout, never both.
|
||||
const inMenu = React.useContext(CascaderMenuContext)
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Before the consumer's handler: a disabled command runs none of them.
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
onSelect?.()
|
||||
},
|
||||
[disabled, onClick, onSelect]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
// The two keys a `<button>` activates on, and only those.
|
||||
if (event.key === "Enter" || event.key === " ") event.preventDefault()
|
||||
return
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
},
|
||||
[disabled, onKeyDown]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-action"
|
||||
/* Conditional spread: `false` would publish `aria-disabled="false"`. */
|
||||
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Conditional spread, so a consumer's own `role` or `tabIndex` wins. */
|
||||
{...(inMenu ? { role: "menuitem" as const, tabIndex: -1 } : null)}
|
||||
className={cn(CASCADER_ACTION_CLASS, className)}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="cascader-action-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Submenu */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderSubmenuContextValue {
|
||||
rowRef: React.RefObject<HTMLButtonElement | null>
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
close: () => void
|
||||
/** Names the flyout: a menu is labelled by the control that opens it. */
|
||||
triggerId: string
|
||||
/**
|
||||
* Whether the pending open came from the KEYBOARD. Base UI's own `openType`
|
||||
* calls the opening arrow a POINTER open, because the trigger intercepts it
|
||||
* and calls `setOpen` (measured: focus landed on the popup, not the first
|
||||
* entry). A ref, so reading it in the focus phase cannot render.
|
||||
*/
|
||||
keyboardRef: React.RefObject<boolean>
|
||||
}
|
||||
|
||||
/** Marks the subtree INSIDE a flyout. See `inMenu` in `CascaderAction`. */
|
||||
const CascaderMenuContext = React.createContext(false)
|
||||
|
||||
/**
|
||||
* Every entry a menu's roving focus may land on, in DOM order, read from the
|
||||
* DOM because the entries are whatever the consumer composed. A disabled
|
||||
* `CascaderAction` is INCLUDED: it carries `aria-disabled`, not the native
|
||||
* attribute, so `:not([disabled])` excludes only a consumer's own natively
|
||||
* disabled `menuitem`, which cannot take focus.
|
||||
*/
|
||||
function menuItems(popup: HTMLElement | null): HTMLElement[] {
|
||||
if (!popup) return []
|
||||
return Array.from(
|
||||
popup.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])')
|
||||
)
|
||||
}
|
||||
|
||||
const CascaderSubmenuContext = React.createContext<
|
||||
CascaderSubmenuContextValue | undefined
|
||||
>(undefined)
|
||||
|
||||
/** The flyout's own state. `close()` is the one a custom entry usually wants. */
|
||||
export function useCascaderSubmenu(): CascaderSubmenuContextValue {
|
||||
const context = React.useContext(CascaderSubmenuContext)
|
||||
if (!context) {
|
||||
throw new Error("useCascaderSubmenu must be used within a CascaderSubmenu")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuProps {
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A footer row plus the flyout it opens. Registers with the cascader root
|
||||
* while open, which turns one Escape into two. Cleared in an EFFECT, so the
|
||||
* flyout still reads as open during the event that closed it.
|
||||
*/
|
||||
function CascaderSubmenu({
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: CascaderSubmenuProps) {
|
||||
const { setFlyoutOpen } = useCascaderActions()
|
||||
const key = React.useId()
|
||||
const triggerId = React.useId()
|
||||
const rowRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const keyboardRef = React.useRef(false)
|
||||
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen)
|
||||
const open = openProp ?? uncontrolled
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(next: boolean) => {
|
||||
if (openProp == null) setUncontrolled(next)
|
||||
onOpenChange?.(next)
|
||||
},
|
||||
[openProp, onOpenChange]
|
||||
)
|
||||
|
||||
const close = React.useCallback(() => setOpen(false), [setOpen])
|
||||
|
||||
React.useEffect(() => {
|
||||
setFlyoutOpen(key, open)
|
||||
return () => setFlyoutOpen(key, false)
|
||||
}, [setFlyoutOpen, key, open])
|
||||
|
||||
const context = React.useMemo<CascaderSubmenuContextValue>(
|
||||
() => ({ rowRef, open, setOpen, close, triggerId, keyboardRef }),
|
||||
[open, setOpen, close, triggerId]
|
||||
)
|
||||
|
||||
return (
|
||||
<CascaderSubmenuContext.Provider value={context}>
|
||||
<PopoverPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/* NEVER modal: it would disable the combobox that owns it. */
|
||||
modal={false}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Root>
|
||||
</CascaderSubmenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuTriggerProps extends Omit<
|
||||
React.ComponentProps<"button">,
|
||||
"onSelect"
|
||||
> {
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
/** Carries the handler-veto hook, derived so a Base UI bump cannot drift. */
|
||||
type CascaderSubmenuTriggerClickEvent = Parameters<
|
||||
NonNullable<PopoverPrimitive.Trigger.Props["onClick"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* The footer row that opens the flyout, and its anchor. `aria-haspopup="menu"`
|
||||
* rather than the `dialog` Base UI would announce: what opens is a list of
|
||||
* commands with roving focus. `disabled` is intercepted, not forwarded:
|
||||
* `Popover.Trigger` runs it through `useButton`, which writes the NATIVE
|
||||
* attribute for a native `<button>` and takes the row out of the panel's Tab
|
||||
* ring, the defect `CascaderAction` documents, and this component exposes no
|
||||
* `focusableWhenDisabled` to opt out. Published as `aria-disabled`, with the
|
||||
* arrow keys, Enter and Space, and the click closed by hand (`useClick`
|
||||
* ignores `defaultPrevented`).
|
||||
*/
|
||||
function CascaderSubmenuTrigger({
|
||||
className,
|
||||
icon,
|
||||
children,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: CascaderSubmenuTriggerProps) {
|
||||
const { labels } = useCascaderActions()
|
||||
const { rowRef, setOpen, triggerId, keyboardRef } = useCascaderSubmenu()
|
||||
const direction = useDirection()
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: CascaderSubmenuTriggerClickEvent) => {
|
||||
if (disabled) {
|
||||
// `mergeProps` runs right to left, so this drops Base UI's own handler.
|
||||
event.preventDefault()
|
||||
event.preventBaseUIHandler()
|
||||
return
|
||||
}
|
||||
onClick?.(event)
|
||||
},
|
||||
[disabled, onClick]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
if (event.key === "Enter" || event.key === " ") event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
// The opening key points AT the flyout, so it flips with the writing
|
||||
// direction; ArrowDown stays "next command". Resolved per keydown, never
|
||||
// `useDirection()` alone: with no provider that answers "ltr" in RTL.
|
||||
const openKey = isCascaderRtl(event.currentTarget, direction)
|
||||
? "ArrowLeft"
|
||||
: "ArrowRight"
|
||||
|
||||
// Enter and Space open through the button's own click, so only FLAGGED.
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
keyboardRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== openKey) return
|
||||
|
||||
event.preventDefault()
|
||||
keyboardRef.current = true
|
||||
setOpen(true)
|
||||
},
|
||||
[disabled, onKeyDown, direction, setOpen, keyboardRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Trigger
|
||||
ref={rowRef}
|
||||
id={triggerId}
|
||||
data-slot="cascader-submenu-trigger"
|
||||
aria-haspopup="menu"
|
||||
/* NOT `disabled={disabled}`: Base UI writes the native attribute. */
|
||||
{...(disabled ? { "aria-disabled": true, "data-disabled": "" } : null)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
CASCADER_ACTION_CLASS,
|
||||
/* Painted while the flyout is open, as shadcn paints a SubTrigger.
|
||||
Keyed off `aria-expanded`, not shadcn's `data-[state=open]`: the
|
||||
popover trigger carries `aria-expanded` in both states. */
|
||||
"aria-expanded:bg-accent aria-expanded:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
data-slot="cascader-action-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center justify-center"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{children}</span>
|
||||
{/* Nothing else says the row opens a MENU, not another tree level. */}
|
||||
<span className="sr-only">, {labels.submenuAffordance}</span>
|
||||
<ChevronRightIcon aria-hidden="true" className="text-muted-foreground -me-0.5 size-4 shrink-0 rtl:-scale-x-100" />
|
||||
</PopoverPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CascaderSubmenuContentProps
|
||||
extends
|
||||
PopoverPrimitive.Popup.Props,
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset"
|
||||
> {}
|
||||
|
||||
/** Carries Base UI's handler-veto hook, so a plain React event will not do. */
|
||||
type CascaderSubmenuKeyEvent = Parameters<
|
||||
NonNullable<PopoverPrimitive.Popup.Props["onKeyDown"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* The flyout itself. `Portal` with NO `container`: a nested portal resolves to
|
||||
* the parent portal node, the mechanism the file header describes.
|
||||
*/
|
||||
function CascaderSubmenuContent({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
side = "inline-end",
|
||||
align = "end",
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
...props
|
||||
}: CascaderSubmenuContentProps) {
|
||||
const { rowRef, close, triggerId, keyboardRef } = useCascaderSubmenu()
|
||||
const direction = useDirection()
|
||||
const popupRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const typeaheadRef = React.useRef({ buffer: "", at: 0 })
|
||||
|
||||
/**
|
||||
* Keyboard opens land on the first entry; a pointer open falls through to
|
||||
* Base UI's default, the popup, so a click paints no focus ring. Not an
|
||||
* effect of our own: Base UI's focus manager runs on open and wins the race.
|
||||
*/
|
||||
const initialFocus = React.useCallback(() => {
|
||||
const byKeyboard = keyboardRef.current
|
||||
keyboardRef.current = false
|
||||
if (!byKeyboard) return true
|
||||
return menuItems(popupRef.current)[0] ?? true
|
||||
}, [keyboardRef])
|
||||
|
||||
const closeAndReturn = React.useCallback(() => {
|
||||
close()
|
||||
rowRef.current?.focus()
|
||||
}, [close, rowRef])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: CascaderSubmenuKeyEvent) => {
|
||||
onKeyDown?.(event)
|
||||
// A DOM sibling of the combobox popup but a REACT descendant, so keys in
|
||||
// here reach its handlers unless stopped. Enter would commit a row.
|
||||
event.stopPropagation()
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
const popup = popupRef.current
|
||||
const items = menuItems(popup)
|
||||
if (items.length === 0) return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const index = active ? items.indexOf(active) : -1
|
||||
const move = (next: number) => {
|
||||
event.preventDefault()
|
||||
items[(next + items.length) % items.length]?.focus()
|
||||
}
|
||||
|
||||
// Mirrors the open key on the SAME per-keydown answer as the trigger.
|
||||
const closeKey = isCascaderRtl(event.currentTarget, direction)
|
||||
? "ArrowRight"
|
||||
: "ArrowLeft"
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
return move(index + 1)
|
||||
case "ArrowUp":
|
||||
// From the popup itself (a pointer open) Up means the LAST entry.
|
||||
return move(index === -1 ? items.length - 1 : index - 1)
|
||||
case "Home":
|
||||
return move(0)
|
||||
case "End":
|
||||
return move(items.length - 1)
|
||||
case closeKey:
|
||||
event.preventDefault()
|
||||
return closeAndReturn()
|
||||
case "Tab":
|
||||
// A menu never holds Tab: it closes and focus carries on from its row.
|
||||
close()
|
||||
rowRef.current?.focus()
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Typeahead, after the switch so it can never swallow a navigation key.
|
||||
if (
|
||||
event.key.length !== 1 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
)
|
||||
return
|
||||
const now = event.timeStamp
|
||||
const state = typeaheadRef.current
|
||||
state.buffer = now - state.at > 500 ? event.key : state.buffer + event.key
|
||||
state.at = now
|
||||
const prefix = state.buffer.toLowerCase()
|
||||
const from = index === -1 ? 0 : index
|
||||
// Starts AFTER the current entry so one letter cycles rather than sticks.
|
||||
const ordered = [
|
||||
...items.slice(state.buffer.length > 1 ? from : from + 1),
|
||||
...items.slice(0, state.buffer.length > 1 ? from : from + 1),
|
||||
]
|
||||
const hit = ordered.find((item) =>
|
||||
(item.textContent ?? "").trim().toLowerCase().startsWith(prefix)
|
||||
)
|
||||
if (hit) {
|
||||
event.preventDefault()
|
||||
hit.focus()
|
||||
}
|
||||
},
|
||||
[onKeyDown, direction, close, closeAndReturn, rowRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
/* The ROW, not whatever Base UI last treated as the trigger. */
|
||||
anchor={rowRef}
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
ref={popupRef}
|
||||
initialFocus={initialFocus}
|
||||
data-slot="cascader-submenu-content"
|
||||
/* Roving focus, named by the row that opens it. `tabIndex={-1}` lets
|
||||
a pointer open park focus here without adding a Tab stop. */
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby={triggerId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Marks a menu surface for the docs design-system picker, which
|
||||
repaints menus by walking the DOM. An attribute, not a class. */
|
||||
data-menu-target=""
|
||||
className={cn(
|
||||
FLYOUT_SURFACE_CLASS,
|
||||
"flex max-w-(--available-width) min-w-48 flex-col gap-0.5 outline-hidden",
|
||||
CASCADER_LIST_PAD_CLASS,
|
||||
"p-(--cascader-list-pad,4px)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CascaderMenuContext.Provider value={true}>
|
||||
{children}
|
||||
</CascaderMenuContext.Provider>
|
||||
</PopoverPrimitive.Popup>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the footer would render anything, for a wrapper (a separator, a grid
|
||||
* row) that has to make the same call `CascaderFooter` already makes.
|
||||
*/
|
||||
export function useCascaderHasActions(): boolean {
|
||||
const { actions } = useCascaderActions()
|
||||
return actions.length > 0
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderAction,
|
||||
CascaderFooter,
|
||||
CascaderSubmenu,
|
||||
CascaderSubmenuContent,
|
||||
CascaderSubmenuTrigger,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CascaderLabels,
|
||||
CascaderMode,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
|
||||
/** Name of the root level. Hoisted so the root announcement can reuse it. */
|
||||
const ROOT_LEVEL = "Top level"
|
||||
|
||||
/** Hoisted for the same reason: several defaults end in an item count. */
|
||||
const itemCount = (count: number) =>
|
||||
`${count} ${count === 1 ? "item" : "items"}`
|
||||
|
||||
/**
|
||||
* English defaults. Every string the primitive can render lives here, so a
|
||||
* consumer can translate the whole surface by passing `labels`.
|
||||
*/
|
||||
export const CASCADER_LABELS: CascaderLabels = {
|
||||
// Deliberately NOT lowercased. `toLowerCase()` is locale-hostile - it maps
|
||||
// Turkish "İ" to a two-code-point sequence and German "İstanbul" style
|
||||
// proper nouns lose their casing - and a label is already written the way
|
||||
// its author wants it read.
|
||||
search: (parentLabel) =>
|
||||
parentLabel ? `Search ${parentLabel}...` : "Search...",
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
loadingMore: "Loading more...",
|
||||
loadMore: "Load more",
|
||||
error: "Could not load items.",
|
||||
retry: "Retry",
|
||||
empty: "No results found.",
|
||||
selectedCount: (count) => `${count} selected`,
|
||||
breadcrumbLabel: "Breadcrumb",
|
||||
chipsLabel: "Selected items",
|
||||
removeChip: (label) => `Remove ${label}`,
|
||||
pathSeparator: "/",
|
||||
rootLevel: ROOT_LEVEL,
|
||||
itemCount,
|
||||
branchAffordance: "submenu",
|
||||
selectedState: "selected",
|
||||
partiallySelectedState: "partially selected",
|
||||
columnsLabel: "Levels",
|
||||
actionsLabel: "Actions",
|
||||
submenuAffordance: "opens a menu",
|
||||
panelLabel: "Options",
|
||||
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => {
|
||||
// "Deeper" is the direction the text runs, so the level keys mirror in
|
||||
// RTL and the hint has to name the mirrored pair there - an LTR-worded
|
||||
// hint would teach exactly the wrong keys.
|
||||
const open = dir === "rtl" ? "Left" : "Right"
|
||||
const back = dir === "rtl" ? "Right" : "Left"
|
||||
if (mode === "tree") {
|
||||
return `Use the ${open} Arrow key to expand and the ${back} Arrow key to collapse.`
|
||||
}
|
||||
if (mode === "columns") {
|
||||
return `Use the ${open} Arrow key to open the next column and the ${back} Arrow key to go back.`
|
||||
}
|
||||
return `Use the ${open} Arrow key to open a branch and the ${back} Arrow key to go back.`
|
||||
},
|
||||
rootAnnouncement: (count) => `${ROOT_LEVEL}, ${itemCount(count)}`,
|
||||
expandedAnnouncement: (label, count) =>
|
||||
`${label} expanded, ${itemCount(count)}`,
|
||||
collapsedAnnouncement: (label) => `${label} collapsed`,
|
||||
levelAnnouncement: (parentLabel, depth, count) =>
|
||||
`${parentLabel}, level ${depth}, ${itemCount(count)}`,
|
||||
resultsAnnouncement: (count) =>
|
||||
count === 1 ? "1 result" : `${count} results`,
|
||||
maxReachedAnnouncement: (max) => `Selection limit of ${max} reached`,
|
||||
cascadeAnnouncement: (label, count, selecting) =>
|
||||
`${label} ${selecting ? "selected" : "deselected"}, ${itemCount(count)} followed`,
|
||||
searchingAnnouncement: "Searching...",
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-merges consumer overrides over the defaults, so `labels` can carry a
|
||||
* single key without restating the rest.
|
||||
*/
|
||||
export function resolveCascaderLabels(
|
||||
labels?: Partial<CascaderLabels>
|
||||
): CascaderLabels {
|
||||
if (!labels) return CASCADER_LABELS
|
||||
return { ...CASCADER_LABELS, ...labels }
|
||||
}
|
||||
|
||||
/** Resolves the search placeholder, which may be a string or a function. */
|
||||
export function resolveCascaderSearchLabel(
|
||||
labels: CascaderLabels,
|
||||
parentLabel?: string
|
||||
): string {
|
||||
return typeof labels.search === "function"
|
||||
? labels.search(parentLabel)
|
||||
: labels.search
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,651 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import { resolveCascaderSearchLabel } from "@/components/reui/cascader/cascader-i18n"
|
||||
import {
|
||||
collapseCascaderPath,
|
||||
getCascaderFooterStops,
|
||||
getCascaderPath,
|
||||
isCascaderRtl,
|
||||
} from "@/components/reui/cascader/cascader-lib"
|
||||
import type {
|
||||
CascaderCollapse,
|
||||
CascaderNode,
|
||||
CascaderValueDisplay,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
import { useDirection } from "@base-ui/react/direction-provider"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Separator */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function PathChevron() {
|
||||
return (
|
||||
<ChevronRightIcon className="size-3 shrink-0 opacity-50 rtl:-scale-x-100" />
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Nav */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type CascaderNavProps = useRender.ComponentProps<"div">
|
||||
|
||||
/**
|
||||
* Header: back control, search input, and the separator under them. The
|
||||
* breadcrumb belongs with the list below: it describes the rows, not the field.
|
||||
*/
|
||||
function CascaderNav({ className, ...props }: CascaderNavProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-nav",
|
||||
className: cn(
|
||||
// `py-1`, not `py-1.5`: the row inside sets its own per-style height, so
|
||||
// even padding read too tall. 6px beside the field, 4px above and below.
|
||||
"border-border/60 flex shrink-0 flex-col gap-1 border-b px-1.5 py-1",
|
||||
className
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render: props.render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Back */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderBackProps extends Omit<
|
||||
useRender.ComponentProps<"button">,
|
||||
"children"
|
||||
> {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/** Pops one level. Renders nothing at the root, so no dead header space. */
|
||||
function CascaderBack({ className, children, ...props }: CascaderBackProps) {
|
||||
const { popLevel, labels, mode } = useCascaderActions()
|
||||
const { path } = useCascaderState()
|
||||
|
||||
// Checked after `useRender`, never before it: an early return would change
|
||||
// the hook count between the root level and any deeper one.
|
||||
const hidden = mode !== "drill" || path.length === 0
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-back",
|
||||
type: "button" as const,
|
||||
"aria-label": labels.back,
|
||||
onClick: () => popLevel(),
|
||||
className: cn(
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex shrink-0 items-center justify-center rounded-md outline-hidden transition-colors focus-visible:ring-2",
|
||||
// A notch under the row height: an affordance, not a second field.
|
||||
"size-6",
|
||||
className
|
||||
),
|
||||
children: children ?? (
|
||||
<ChevronLeftIcon className="size-4 rtl:-scale-x-100" />
|
||||
),
|
||||
}
|
||||
|
||||
const element = useRender({
|
||||
defaultTagName: "button",
|
||||
render: props.render,
|
||||
props: mergeProps<"button">(defaultProps, props),
|
||||
})
|
||||
|
||||
return hidden ? null : element
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Breadcrumb */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderBreadcrumbProps extends Omit<
|
||||
useRender.ComponentProps<"nav">,
|
||||
"children"
|
||||
> {
|
||||
/** Maximum visible node segments before the middle collapses. */
|
||||
maxSegments?: number
|
||||
collapse?: CascaderCollapse
|
||||
/** Clicking a segment navigates back to that level. Defaults to true. */
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact trail of the current level AND its ancestors, collapsed by the same
|
||||
* helper as `CascaderValue` so panel and trigger cannot disagree. The current
|
||||
* level IS the last crumb: dropping it read as a rendering bug, a trail naming
|
||||
* only the places you are not.
|
||||
*/
|
||||
function CascaderBreadcrumb({
|
||||
className,
|
||||
maxSegments = 3,
|
||||
collapse = "middle",
|
||||
interactive = true,
|
||||
...props
|
||||
}: CascaderBreadcrumbProps) {
|
||||
const { goToDepth, mode, labels } = useCascaderActions()
|
||||
const { path, index } = useCascaderState()
|
||||
|
||||
// The WHOLE path, current level included; unresolvable entries drop out.
|
||||
const nodes = path
|
||||
.map((value) => index.byValue.get(value))
|
||||
.filter(Boolean) as CascaderNode[]
|
||||
const segments = collapseCascaderPath(nodes, { maxSegments, collapse })
|
||||
// `collapseCascaderPath` never collapses the last node away, so this is
|
||||
// always the final segment - derived rather than assumed, so a `collapse`
|
||||
// mode that ever changes that cannot mark an ancestor as the current page.
|
||||
const currentValue = nodes.length ? nodes[nodes.length - 1].value : null
|
||||
|
||||
// See `CascaderBack`: the hide check must not short-circuit past `useRender`.
|
||||
const hidden = mode !== "drill" || nodes.length === 0
|
||||
|
||||
const goTo = (node: CascaderNode) => {
|
||||
const depth = path.indexOf(node.value)
|
||||
if (depth < 0) return
|
||||
// `goToDepth`, not a bare `setPath`: it reports `reason: "breadcrumb"`,
|
||||
// clears the query and drops any pending navigation.
|
||||
goToDepth(depth + 1)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-breadcrumb",
|
||||
"aria-label": labels.breadcrumbLabel,
|
||||
className: cn(
|
||||
"text-muted-foreground flex min-w-0 shrink-0 items-center gap-0.5 pt-1.5 pb-0.5 text-xs",
|
||||
// Lines up with the ROWS, not the header: each style's list padding plus
|
||||
// its row inset. vega/mira/rhea `p-1`+`pl-2`, nova `p-1`+`pl-1.5`, maia
|
||||
// `p-1`+`pl-3`, lyra `pl-2`, luma/sera `p-1.5`+`pl-3`, per style-*.css.
|
||||
"px-2.5",
|
||||
className
|
||||
),
|
||||
children: segments.map((segment, i) => (
|
||||
<React.Fragment
|
||||
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
|
||||
>
|
||||
{i > 0 ? <PathChevron /> : null}
|
||||
{segment.type === "ellipsis" ? (
|
||||
<span
|
||||
data-slot="cascader-breadcrumb-ellipsis"
|
||||
title={segment.hidden
|
||||
.map((n) => n.label)
|
||||
.join(` ${labels.pathSeparator} `)}
|
||||
className="shrink-0"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : interactive && segment.node.value !== currentValue ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="cascader-breadcrumb-item"
|
||||
onClick={() => goTo(segment.node)}
|
||||
className="hover:text-foreground focus-visible:ring-ring/50 max-w-32 truncate rounded-sm outline-hidden transition-colors focus-visible:ring-2"
|
||||
>
|
||||
{segment.node.label}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
data-slot="cascader-breadcrumb-item"
|
||||
/* The level on screen: a span because it goes nowhere, plus
|
||||
`aria-current="page"` so it is not read as one of a flat list. */
|
||||
{...(segment.node.value === currentValue
|
||||
? { "aria-current": "page" as const }
|
||||
: null)}
|
||||
className={cn(
|
||||
"max-w-32 truncate",
|
||||
segment.node.value === currentValue &&
|
||||
"text-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{segment.node.label}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)),
|
||||
}
|
||||
|
||||
const element = useRender({
|
||||
defaultTagName: "nav",
|
||||
render: props.render,
|
||||
props: mergeProps<"nav">(defaultProps, props),
|
||||
})
|
||||
|
||||
return hidden ? null : element
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Input */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderInputProps extends ComboboxPrimitive.Input.Props {
|
||||
/** Renders the back control inline, before the field. Defaults to true. */
|
||||
showBack?: boolean
|
||||
}
|
||||
|
||||
/** Base UI hands its input handlers an event carrying the veto hook. */
|
||||
type CascaderInputKeyEvent = Parameters<
|
||||
NonNullable<ComboboxPrimitive.Input.Props["onKeyDown"]>
|
||||
>[0]
|
||||
|
||||
/**
|
||||
* Search field. MUST render inside the positioner: only there does Base UI skip
|
||||
* the refill from the committed selection that would fight every level swap.
|
||||
*/
|
||||
function CascaderInput({
|
||||
className,
|
||||
showBack = true,
|
||||
placeholder,
|
||||
onKeyDown,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
...props
|
||||
}: CascaderInputProps) {
|
||||
const {
|
||||
labels,
|
||||
popLevel,
|
||||
mode,
|
||||
getHighlighted,
|
||||
isBranch,
|
||||
isDisabled,
|
||||
navigate,
|
||||
index,
|
||||
toggleExpanded,
|
||||
inline,
|
||||
invalid,
|
||||
baseId,
|
||||
searchScope,
|
||||
} = useCascaderActions()
|
||||
const direction = useDirection()
|
||||
const { currentParent, query, path, expanded, renderedItems, treeRows } =
|
||||
useCascaderState()
|
||||
|
||||
// Root wording under a global search: naming the level on screen would
|
||||
// promise a narrower search than the one that runs.
|
||||
const resolvedPlaceholder =
|
||||
placeholder ??
|
||||
resolveCascaderSearchLabel(
|
||||
labels,
|
||||
searchScope === "global" ? undefined : currentParent?.label
|
||||
)
|
||||
|
||||
// Not `showBack`: `CascaderBack` renders nothing at the root or outside
|
||||
// drill mode, where the field's own leading `px-1.5` is wanted. Beside the
|
||||
// button it stacks with the row's `gap-1` into a 10px hole, hence `ps-0`.
|
||||
const backVisible = showBack && mode === "drill" && path.length > 0
|
||||
|
||||
const hintId = `${baseId}-hint`
|
||||
// Base UI names no list while inline; columns mode moves the real listbox to
|
||||
// the deepest panel, which is the `path.length` one.
|
||||
const listId = `${baseId}-column-${mode === "columns" ? path.length : 0}`
|
||||
|
||||
// The direction the HINT is worded for. `handleKeyDown` re-reads
|
||||
// `isCascaderRtl` per keystroke; this is text on screen before any key, so
|
||||
// the same check runs once against the mounted DOM (on the hint span, so no
|
||||
// second ref into Base UI's input). SSR renders the "ltr" default and
|
||||
// corrects on hydration. `direction` is the only dep: a `dir` attribute and
|
||||
// the stylesheet are declarations, not state, so nothing to resubscribe to.
|
||||
const hintRef = React.useRef<HTMLSpanElement>(null)
|
||||
const [hintDir, setHintDir] = React.useState<"ltr" | "rtl">("ltr")
|
||||
React.useLayoutEffect(() => {
|
||||
const hint = hintRef.current
|
||||
if (!hint) return
|
||||
setHintDir(isCascaderRtl(hint, direction) ? "rtl" : "ltr")
|
||||
}, [direction])
|
||||
|
||||
/**
|
||||
* Moves the highlight to `targetIndex` in `treeRows`. Base UI has no
|
||||
* imperative setter (`actionsRef` is `{ unmount }`), so the move is arrow
|
||||
* presses, sound only because `useListNavigation` fires `onItemHighlighted`
|
||||
* SYNCHRONOUSLY per keydown. A press that fails to close the gap ends it.
|
||||
*/
|
||||
const moveHighlightTo = (field: HTMLInputElement, targetIndex: number) => {
|
||||
const rowIndex = () => {
|
||||
const highlighted = getHighlighted()
|
||||
if (!highlighted) return -1
|
||||
return treeRows.findIndex((row) => row.node.value === highlighted.value)
|
||||
}
|
||||
|
||||
let current = rowIndex()
|
||||
for (let step = 0; step < treeRows.length; step += 1) {
|
||||
if (current === -1 || current === targetIndex) return
|
||||
const key = current > targetIndex ? "ArrowUp" : "ArrowDown"
|
||||
field.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
||||
)
|
||||
const next = rowIndex()
|
||||
const progressed =
|
||||
key === "ArrowUp"
|
||||
? next < current && next >= targetIndex
|
||||
: next > current && next <= targetIndex
|
||||
if (next === -1 || !progressed) return
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree pattern's two level keys; Base UI leaves both alone (list
|
||||
* navigation is vertical-only, the chip handler no-ops without
|
||||
* `Combobox.Chips`). Passed in rather than hardcoded because RTL swaps them:
|
||||
* APG defines them as "toward the children", not as a physical arrow.
|
||||
*/
|
||||
const handleTreeKeyDown = (
|
||||
event: CascaderInputKeyEvent,
|
||||
field: HTMLInputElement,
|
||||
caretAtStart: boolean,
|
||||
caretAtEnd: boolean,
|
||||
forwardKey: "ArrowLeft" | "ArrowRight",
|
||||
backKey: "ArrowLeft" | "ArrowRight"
|
||||
) => {
|
||||
const forward = event.key === forwardKey
|
||||
if (!forward && event.key !== backKey) return
|
||||
if (forward ? !caretAtEnd : !caretAtStart) return
|
||||
|
||||
const highlighted = getHighlighted()
|
||||
if (!highlighted) return
|
||||
const rowIndex = treeRows.findIndex(
|
||||
(row) => row.node.value === highlighted.value
|
||||
)
|
||||
if (rowIndex < 0) return
|
||||
const row = treeRows[rowIndex]
|
||||
|
||||
if (forward) {
|
||||
if (!row.branch) return
|
||||
event.preventDefault()
|
||||
if (!row.expanded) {
|
||||
// `navigate`, not `toggleExpanded`: it waits for an unloaded branch's
|
||||
// children, so the keyboard path in matches the expander.
|
||||
navigate(row.node)
|
||||
return
|
||||
}
|
||||
// `flattenCascaderTree` emits children right after their parent, so the
|
||||
// first child is the next row. The depth check covers an empty branch.
|
||||
const child = treeRows[rowIndex + 1]
|
||||
if (child && child.depth === row.depth + 1) {
|
||||
moveHighlightTo(field, rowIndex + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A query holds every hit's ancestors open without adding them to
|
||||
// `expanded`, so the toggle would ADD such a row: it would open a disabled
|
||||
// one for good. That row moves to its parent instead, as a closed one does.
|
||||
const heldByQuery = isDisabled(row.node) && !expanded.has(row.node.value)
|
||||
if (row.branch && row.expanded && !heldByQuery) {
|
||||
event.preventDefault()
|
||||
toggleExpanded(row.node.value)
|
||||
return
|
||||
}
|
||||
|
||||
const parentValue = index.parentOf.get(row.node.value)
|
||||
if (!parentValue) return
|
||||
const parentIndex = treeRows.findIndex(
|
||||
(entry) => entry.node.value === parentValue
|
||||
)
|
||||
if (parentIndex < 0) return
|
||||
event.preventDefault()
|
||||
moveHighlightTo(field, parentIndex)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: CascaderInputKeyEvent) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
const field = event.currentTarget
|
||||
const caretAtStart = field.selectionStart === 0 && field.selectionEnd === 0
|
||||
const caretAtEnd =
|
||||
field.selectionStart === query.length &&
|
||||
field.selectionEnd === query.length
|
||||
|
||||
// ArrowDown at the END of the list hands real focus to the pinned footer,
|
||||
// the key a combobox is actually navigated with; Tab still works and still
|
||||
// skips the scroll area. An EMPTY list hands off at once, and the footer
|
||||
// owns the way back. Counted from state, so a windowed list still answers.
|
||||
if (event.key === "ArrowDown" && !event.altKey) {
|
||||
const rowCount = mode === "tree" ? treeRows.length : renderedItems.length
|
||||
const lastValue =
|
||||
mode === "tree"
|
||||
? treeRows[rowCount - 1]?.node.value
|
||||
: renderedItems[rowCount - 1]?.value
|
||||
const highlighted = getHighlighted()
|
||||
const atEnd =
|
||||
rowCount === 0 ||
|
||||
(highlighted != null && highlighted.value === lastValue)
|
||||
if (atEnd) {
|
||||
const panel = field.closest<HTMLElement>('[data-slot="cascader-panel"]')
|
||||
const stop = panel ? getCascaderFooterStops(panel)[0] : undefined
|
||||
if (stop) {
|
||||
event.preventDefault()
|
||||
// Base UI's handler is deliberately NOT vetoed: its wrap's first
|
||||
// press CLEARS the highlight, so no stale row stays active behind the
|
||||
// focused command, and the emptied highlight completes the ring.
|
||||
stop.focus()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The level keys are LOGICAL, not physical: in RTL, ArrowLeft opens a
|
||||
// branch and ArrowRight goes back. They act only at the caret edge, and
|
||||
// those guards do NOT mirror: `selectionStart === 0` is the logical start.
|
||||
const rtl = isCascaderRtl(field, direction)
|
||||
const forwardKey = rtl ? "ArrowLeft" : "ArrowRight"
|
||||
const backKey = rtl ? "ArrowRight" : "ArrowLeft"
|
||||
|
||||
if (mode === "tree") {
|
||||
handleTreeKeyDown(
|
||||
event,
|
||||
field,
|
||||
caretAtStart,
|
||||
caretAtEnd,
|
||||
forwardKey,
|
||||
backKey
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === forwardKey && caretAtEnd) {
|
||||
const highlighted = getHighlighted()
|
||||
if (highlighted && isBranch(highlighted)) {
|
||||
event.preventDefault()
|
||||
navigate(highlighted)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace on an empty query pops a level too, so the keyboard way out of
|
||||
// a level is symmetric with typing into it.
|
||||
if (
|
||||
(event.key === backKey && caretAtStart) ||
|
||||
(event.key === "Backspace" && query.length === 0)
|
||||
) {
|
||||
if (path.length > 0) {
|
||||
event.preventDefault()
|
||||
popLevel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="cascader-input-row"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
/* Each style sizes a combobox search field only as a DIRECT child of
|
||||
the popup; this row is nested, so the ladder is mirrored here rather
|
||||
than hardcoding one height for all eight styles. */
|
||||
"h-8"
|
||||
)}
|
||||
>
|
||||
{showBack ? <CascaderBack /> : null}
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="cascader-input"
|
||||
placeholder={resolvedPlaceholder}
|
||||
onKeyDown={handleKeyDown}
|
||||
/* Nothing on screen says the level keys exist. */
|
||||
aria-describedby={[ariaDescribedBy, hintId].filter(Boolean).join(" ")}
|
||||
/* Conditional: an explicit `undefined` deletes Base UI's own value. */
|
||||
{...(mode === "tree" ? { "aria-haspopup": "tree" as const } : null)}
|
||||
/* Base UI omits BOTH `aria-expanded` and `aria-controls` while inline,
|
||||
and an embedded panel is permanently expanded, so supply both. */
|
||||
{...(inline
|
||||
? { "aria-expanded": true, "aria-controls": listId }
|
||||
: null)}
|
||||
/* No trigger to carry the invalid state in the `inline` case. */
|
||||
{...(invalid ? { "aria-invalid": true, "data-invalid": "" } : null)}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent px-1.5 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
backVisible && "ps-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<span id={hintId} ref={hintRef} className="sr-only">
|
||||
{/* `hintDir`, not `direction`: the context alone misses an RTL app that
|
||||
uses a `dir` attribute instead of `DirectionProvider`. */}
|
||||
{labels.keyboardHint(mode, hintDir)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Value */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderValueProps extends Omit<
|
||||
useRender.ComponentProps<"span">,
|
||||
"children"
|
||||
> {
|
||||
/** `path` shows the full trail, `leaf` only the node, `count` only a total. */
|
||||
display?: CascaderValueDisplay
|
||||
maxSegments?: number
|
||||
collapse?: CascaderCollapse
|
||||
separator?: React.ReactNode
|
||||
/** Renders the selected node's icon before the trail. */
|
||||
showIcon?: boolean
|
||||
placeholder?: React.ReactNode
|
||||
/** Replaces the whole rendering. Receives the resolved selection and path. */
|
||||
children?: (selected: CascaderNode[], path: CascaderNode[]) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger display. Defaults to the collapsed selection path rather than a bare
|
||||
* leaf label, which in a nested picker is frequently ambiguous.
|
||||
*/
|
||||
function CascaderValue({
|
||||
className,
|
||||
display = "path",
|
||||
maxSegments = 3,
|
||||
collapse = "middle",
|
||||
separator,
|
||||
showIcon = true,
|
||||
placeholder,
|
||||
children,
|
||||
...props
|
||||
}: CascaderValueProps) {
|
||||
const { labels, multiple, resolveNode } = useCascaderActions()
|
||||
const { index, selectedValues } = useCascaderState()
|
||||
|
||||
// `resolveNode`, not `index.byValue`: a selection missing from `items` (async
|
||||
// children, a removed item) must still render a label, not an empty trigger.
|
||||
const selected = selectedValues.map(resolveNode)
|
||||
|
||||
const resolvedPath =
|
||||
selectedValues.length === 1 ? getCascaderPath(index, selectedValues[0]) : []
|
||||
// No ancestor chain: fall back to the node so `display="path"` shows a leaf.
|
||||
const path =
|
||||
resolvedPath.length > 0
|
||||
? resolvedPath
|
||||
: selected.length === 1
|
||||
? selected
|
||||
: []
|
||||
|
||||
let content: React.ReactNode
|
||||
|
||||
if (children) {
|
||||
content = children(selected, path)
|
||||
} else if (selectedValues.length === 0) {
|
||||
content = (
|
||||
<span className="text-muted-foreground truncate">{placeholder}</span>
|
||||
)
|
||||
} else if (display === "count" || (multiple && selectedValues.length > 1)) {
|
||||
content = labels.selectedCount(selectedValues.length)
|
||||
} else {
|
||||
const leaf = path[path.length - 1] ?? selected[0]
|
||||
const segments =
|
||||
display === "leaf"
|
||||
? [{ type: "node" as const, node: leaf }]
|
||||
: collapseCascaderPath(path, { maxSegments, collapse })
|
||||
|
||||
content = (
|
||||
<>
|
||||
{showIcon && leaf?.icon ? (
|
||||
<span
|
||||
data-slot="cascader-value-icon"
|
||||
className="text-muted-foreground flex shrink-0 items-center"
|
||||
>
|
||||
{leaf.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{segments.map((segment, i) => (
|
||||
<React.Fragment
|
||||
key={segment.type === "node" ? segment.node.value : `gap-${i}`}
|
||||
>
|
||||
{i > 0 ? (separator ?? <PathChevron />) : null}
|
||||
{segment.type === "ellipsis" ? (
|
||||
<span
|
||||
data-slot="cascader-value-ellipsis"
|
||||
title={segment.hidden
|
||||
.map((n) => n.label)
|
||||
.join(` ${labels.pathSeparator} `)}
|
||||
className="text-muted-foreground shrink-0"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
i < segments.length - 1 && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{segment.node?.label}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "cascader-value",
|
||||
className: cn("flex min-w-0 items-center gap-1 truncate", className),
|
||||
children: content,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render: props.render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
CascaderNav,
|
||||
CascaderBack,
|
||||
CascaderBreadcrumb,
|
||||
CascaderInput,
|
||||
CascaderValue,
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import type * as React from "react"
|
||||
|
||||
/**
|
||||
* A node in the cascader tree. Two input shapes normalize to one internal
|
||||
* index: nested (nodes carry `children`), or flat adjacency plus the root's
|
||||
* `getParent`, which skips the re-nesting step for large datasets.
|
||||
*/
|
||||
export interface CascaderNode<T = unknown> {
|
||||
/** Stable id. Also the committed selection value. */
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
children?: CascaderNode<T>[]
|
||||
/** Declares a branch before load; async nodes read as leaves without it. */
|
||||
hasChildren?: boolean
|
||||
/** Trailing count. Defaults to known children; set it for async nodes. */
|
||||
count?: number
|
||||
/** Listed, never selected or opened; on a branch, its whole subtree too. */
|
||||
disabled?: boolean
|
||||
keywords?: string[]
|
||||
/**
|
||||
* Only read on a node nested inside a `getChildren` result. For the level a
|
||||
* node OWNS the authoritative signal is that level's `CascaderLoadResult`.
|
||||
*/
|
||||
hasMore?: boolean
|
||||
data?: T
|
||||
}
|
||||
|
||||
/**
|
||||
* One footer ACTION. Deliberately not a `CascaderNode`, so a command can never
|
||||
* be passed where an option is expected and join the ring, filter or selection.
|
||||
*/
|
||||
export interface CascaderActionItem {
|
||||
/** Stable key. Falls back to the label when it is a string, then the index. */
|
||||
value?: string
|
||||
label: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
/** Ignored when `items` is present - a flyout opens instead. */
|
||||
onSelect?: () => void
|
||||
/** Turns the row into a submenu trigger. One level deep on purpose. */
|
||||
items?: CascaderActionItem[]
|
||||
/** Consecutive entries sharing a heading are drawn under one. */
|
||||
group?: string
|
||||
}
|
||||
|
||||
/** Panel layout. See the docs for the keyboard map of each. */
|
||||
export type CascaderMode = "drill" | "columns" | "tree"
|
||||
|
||||
export type CascaderSearchScope = "level" | "deep" | "global"
|
||||
|
||||
/**
|
||||
* Which nodes may be committed. The predicate arm is generic over the payload
|
||||
* rather than quantified per call: a per-call `<T>` would force every predicate
|
||||
* to accept EVERY payload, so `(node: CascaderNode<Member>) => boolean` could
|
||||
* never satisfy it.
|
||||
*/
|
||||
export type CascaderSelectable<T = unknown> =
|
||||
| "leaf"
|
||||
| "any"
|
||||
| ((node: CascaderNode<T>) => boolean)
|
||||
|
||||
export type CascaderCollapse = "middle" | "start" | "none"
|
||||
|
||||
export type CascaderValueDisplay = "path" | "leaf" | "count"
|
||||
|
||||
export type CascaderChangeReason = "select" | "deselect" | "clear"
|
||||
|
||||
/** Second argument to `onValueChange`: resolved nodes, so no re-lookup. */
|
||||
export interface CascaderChangeDetails<T = unknown> {
|
||||
/** The node committed or toggled. Null when the selection was cleared. */
|
||||
node: CascaderNode<T> | null
|
||||
/** Ancestor chain of `node`, root first, node last. */
|
||||
path: CascaderNode<T>[]
|
||||
nodes: CascaderNode<T>[]
|
||||
reason: CascaderChangeReason
|
||||
}
|
||||
|
||||
/** Normalized tree, built once per `items` identity, shared by every mode. */
|
||||
export interface CascaderIndex<T = unknown> {
|
||||
byValue: Map<string, CascaderNode<T>>
|
||||
/** Children by parent value. Root children are keyed by `ROOT_KEY`. */
|
||||
childrenOf: Map<string, CascaderNode<T>[]>
|
||||
parentOf: Map<string, string | null>
|
||||
/** Zero-based depth by node value. */
|
||||
depthOf: Map<string, number>
|
||||
/** Top level nodes, in input order. */
|
||||
roots: CascaderNode<T>[]
|
||||
/** Every node in a stable, depth-first order. Used by deep search. */
|
||||
all: CascaderNode<T>[]
|
||||
/** Resolved chain nodes listed last in a level that may still return them. */
|
||||
pinned?: ReadonlySet<string>
|
||||
/** Resolved chain nodes whose level loaded completely without them: they
|
||||
* keep their path for the trigger, and are no row, hit or cascade child. */
|
||||
hidden?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export interface CascaderFlatNode<T = unknown> {
|
||||
node: CascaderNode<T>
|
||||
depth: number
|
||||
/** Known children, or a declared `hasChildren`. */
|
||||
branch: boolean
|
||||
expanded: boolean
|
||||
/** Sibling count, plus one when the level has a paging row. */
|
||||
setSize: number
|
||||
/** One-based index among siblings. */
|
||||
posInSet: number
|
||||
}
|
||||
|
||||
/** One segment of a rendered path, after collapsing. */
|
||||
export type CascaderPathSegment<T = unknown> =
|
||||
| { type: "node"; node: CascaderNode<T> }
|
||||
| { type: "ellipsis"; hidden: CascaderNode<T>[] }
|
||||
|
||||
/**
|
||||
* Async load state for one node's children. Deliberately WITHOUT a `status`
|
||||
* field: "declared a branch, never fetched" and "fetched and genuinely empty"
|
||||
* are told apart by MAP MEMBERSHIP, and a second source of that truth would
|
||||
* eventually disagree with the first.
|
||||
*/
|
||||
export interface CascaderLoadState {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
hasMore: boolean
|
||||
/** Opaque cursor handed back to `getChildren` for the next page. */
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/** Why a level was fetched. `resolve`: `resolveValue` hit an unloaded node. */
|
||||
export type CascaderLoadReason =
|
||||
| "level"
|
||||
| "more"
|
||||
| "prefetch"
|
||||
| "retry"
|
||||
| "resolve"
|
||||
|
||||
/** Argument handed to `getChildren` and `resolveValue`. */
|
||||
export interface CascaderLoadContext {
|
||||
/**
|
||||
* Aborted when the request is superseded, `loadKey` changes or the cascader
|
||||
* unmounts, and for a `getChildren` load also when the popup closes.
|
||||
*/
|
||||
signal: AbortSignal
|
||||
cursor?: string
|
||||
reason?: CascaderLoadReason
|
||||
}
|
||||
|
||||
/** Value returned by `getChildren`. A bare array is also accepted. */
|
||||
export interface CascaderLoadResult<T = unknown> {
|
||||
items: CascaderNode<T>[]
|
||||
nextCursor?: string
|
||||
/** Defaults to whether `nextCursor` was supplied. */
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
/** Argument handed to `onSearch`. */
|
||||
export interface CascaderSearchContext {
|
||||
signal: AbortSignal
|
||||
/** The path the user is searching within, deepest last. Empty under a
|
||||
* global search, whose scope is the whole tree. */
|
||||
path: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every user facing string, so the primitive ships no hardcoded copy. The
|
||||
* callbacks take plain labels, not nodes: a `CascaderNode<T>` parameter would
|
||||
* force this object to carry the item generic. `*Announcement` is live-region.
|
||||
*/
|
||||
export interface CascaderLabels {
|
||||
/** `parentLabel` is absent at the root and under a global search. */
|
||||
search: string | ((parentLabel?: string) => string)
|
||||
back: string
|
||||
/** A level's FIRST page. `loadingMore` is the next, `loadMore` its idle row. */
|
||||
loading: string
|
||||
loadingMore: string
|
||||
loadMore: string
|
||||
error: string
|
||||
retry: string
|
||||
empty: string
|
||||
/** Rendered by `CascaderValue` when `display="count"`. */
|
||||
selectedCount: (count: number) => string
|
||||
breadcrumbLabel: string
|
||||
chipsLabel: string
|
||||
removeChip: (label: string) => string
|
||||
/** Trail separator. Not every locale writes one with a slash. */
|
||||
pathSeparator: string
|
||||
/** Names the root level wherever there is no parent node to name it. */
|
||||
rootLevel: string
|
||||
itemCount: (count: number) => string
|
||||
/** Appended to a branch row's name outside tree mode: it opens another list. */
|
||||
branchAffordance: string
|
||||
/** Columns-trail rows are plain buttons, so they carry no `aria-selected`. */
|
||||
selectedState: string
|
||||
/** Same for the mixed state, which a `role="button"` row may not carry. */
|
||||
partiallySelectedState: string
|
||||
columnsLabel: string
|
||||
actionsLabel: string
|
||||
submenuAffordance: string
|
||||
panelLabel: string
|
||||
/** Read per mode AND per direction: the level keys mirror in RTL. */
|
||||
keyboardHint: (mode: CascaderMode, dir: "ltr" | "rtl") => string
|
||||
rootAnnouncement: (count: number) => string
|
||||
expandedAnnouncement: (label: string, count: number) => string
|
||||
collapsedAnnouncement: (label: string) => string
|
||||
levelAnnouncement: (
|
||||
parentLabel: string,
|
||||
depth: number,
|
||||
count: number
|
||||
) => string
|
||||
resultsAnnouncement: (count: number) => string
|
||||
maxReachedAnnouncement: (max: number) => string
|
||||
/** `count` is the descendants swept along; `selecting` is the direction. */
|
||||
cascadeAnnouncement: (
|
||||
label: string,
|
||||
count: number,
|
||||
selecting: boolean
|
||||
) => string
|
||||
searchingAnnouncement: string
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import * as React from "react"
|
||||
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||
import {
|
||||
useCascaderActions,
|
||||
useCascaderHighlight,
|
||||
useCascaderState,
|
||||
} from "@/components/reui/cascader/cascader-context"
|
||||
import type { CascaderColumn } from "@/components/reui/cascader/cascader-context"
|
||||
import {
|
||||
CascaderItem,
|
||||
CascaderItems,
|
||||
getCascaderMoreProps,
|
||||
} from "@/components/reui/cascader/cascader-item"
|
||||
import {
|
||||
defaultRangeExtractor,
|
||||
useVirtualizer,
|
||||
type Range,
|
||||
type Virtualizer,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
/**
|
||||
* Windowing for the cascader, in its own file so the primitive's own install
|
||||
* never pulls `@tanstack/react-virtual` in. Base UI forces three rules:
|
||||
*
|
||||
* 1. A windowed row MUST carry an explicit `index`; the fallback
|
||||
* `findItemIndex` is O(n) per row and returns `-1` on the frame a level swap
|
||||
* renders empty, dangling `aria-activedescendant`.
|
||||
* 2. That index is DESTRUCTIVE while unvirtualized (`useCompositeListItem`
|
||||
* skips registration, `CompositeList` truncates `elementsRef` to zero and
|
||||
* the first arrow key highlights nothing), so rows stay plain until the root
|
||||
* flips `virtualized`, gated inside `CascaderItem`.
|
||||
* 3. No `Combobox.Collection` and no FUNCTION CHILD on `Combobox.List`, which
|
||||
* implicitly wraps one in a Collection: a Collection renders every filtered
|
||||
* item.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Virtualizer */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface UseCascaderVirtualizerOptions {
|
||||
count: number
|
||||
getScrollElement: () => HTMLElement | null
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
/** Key rows by node value, never by index: a tree expand shifts every index. */
|
||||
getItemKey: (index: number) => string | number
|
||||
/** Render index of the highlighted row, or `-1`. Pinned into the window. */
|
||||
activeIndex?: number
|
||||
}
|
||||
|
||||
export type CascaderVirtualizer = Virtualizer<HTMLElement, HTMLElement>
|
||||
|
||||
/**
|
||||
* The cascader's virtualizer: TanStack plus the highlight pinning and
|
||||
* scroll-into-view a combobox list needs. `activeIndex` is clamped here, not by
|
||||
* the caller: emitted from a layout effect, it can be one commit ahead of a
|
||||
* level that just shrank and would otherwise scroll to a row that is gone.
|
||||
*/
|
||||
export function useCascaderVirtualizer({
|
||||
count,
|
||||
getScrollElement,
|
||||
estimateSize = 32,
|
||||
overscan = 8,
|
||||
getItemKey,
|
||||
activeIndex = -1,
|
||||
}: UseCascaderVirtualizerOptions): CascaderVirtualizer {
|
||||
const active = activeIndex >= 0 && activeIndex < count ? activeIndex : -1
|
||||
|
||||
// Keeps the highlighted row mounted so `aria-activedescendant` never dangles.
|
||||
const rangeExtractor = React.useCallback(
|
||||
(range: Range) => {
|
||||
const indices = new Set(defaultRangeExtractor(range))
|
||||
if (active !== -1) indices.add(active)
|
||||
return Array.from(indices).sort((a, b) => a - b)
|
||||
},
|
||||
[active]
|
||||
)
|
||||
|
||||
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||
|
||||
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
|
||||
// propagate to the components that call this hook. They opt out themselves.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||
count,
|
||||
getScrollElement,
|
||||
estimateSize: measureEstimate,
|
||||
overscan,
|
||||
getItemKey,
|
||||
rangeExtractor,
|
||||
})
|
||||
|
||||
// Base UI's scroll-into-view no-ops on a windowed-out row (empty `listRef`).
|
||||
React.useEffect(() => {
|
||||
if (active === -1) return
|
||||
virtualizer.scrollToIndex(active, { align: "auto" })
|
||||
}, [active, count, virtualizer])
|
||||
|
||||
return virtualizer
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Geometry */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface CascaderVirtualGutter {
|
||||
block: number
|
||||
inline: number
|
||||
}
|
||||
|
||||
const NO_GUTTER: CascaderVirtualGutter = { block: 0, inline: 0 }
|
||||
|
||||
/**
|
||||
* The ROWS' BOX's own padding, never the scrollport's (separate element, reads
|
||||
* a flat zero). Measured because every style sets its own: a windowed row is
|
||||
* positioned against this box's PADDING box while the scroll-height spacer is
|
||||
* an in-flow child of its CONTENT box, so without it rows sit flush.
|
||||
*/
|
||||
function useCascaderVirtualGutter(
|
||||
contentElement: HTMLElement | null
|
||||
): CascaderVirtualGutter {
|
||||
const [gutter, setGutter] = React.useState<CascaderVirtualGutter>(NO_GUTTER)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!contentElement) return
|
||||
const styles = getComputedStyle(contentElement)
|
||||
const block = Number.parseFloat(styles.paddingTop) || 0
|
||||
const inline =
|
||||
Number.parseFloat(styles.paddingInlineStart || styles.paddingLeft) || 0
|
||||
setGutter((previous) =>
|
||||
previous.block === block && previous.inline === inline
|
||||
? previous
|
||||
: { block, inline }
|
||||
)
|
||||
}, [contentElement])
|
||||
|
||||
return gutter
|
||||
}
|
||||
|
||||
/** Logical insets, not `left` / `width`, so RTL needs no second code path. */
|
||||
function cascaderVirtualRowStyle(
|
||||
start: number,
|
||||
gutter: CascaderVirtualGutter
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
insetInlineStart: gutter.inline,
|
||||
insetInlineEnd: gutter.inline,
|
||||
transform: `translateY(${start + gutter.block}px)`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that actually SCROLLS: the `ScrollArea` viewport above the rows'
|
||||
* box, not the rows' box itself, which never overflows, so a virtualizer aimed
|
||||
* at it would read a full `clientHeight` and render every row. `closest` rather
|
||||
* than `parentElement`, plus a fallback to the content element, keeps a
|
||||
* hand-written `overflow-y-auto` container working.
|
||||
*/
|
||||
function cascaderScrollElement(
|
||||
content: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
if (!content) return null
|
||||
return (
|
||||
content.closest<HTMLElement>('[data-slot="scroll-area-viewport"]') ??
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `role="presentation"` keeps the listbox owning nothing but options, and this
|
||||
* node's parent IS the rows' box, saving a ref threaded through `CascaderList`.
|
||||
*/
|
||||
function CascaderVirtualSpacer({
|
||||
height,
|
||||
onContentElement,
|
||||
}: {
|
||||
height: number
|
||||
onContentElement: (element: HTMLElement | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={(node) => onContentElement(node?.parentElement ?? null)}
|
||||
role="presentation"
|
||||
data-slot="cascader-virtual-spacer"
|
||||
style={{ height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Rows */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderVirtualItemsProps {
|
||||
/** Row height before measurement. Defaults to the root `estimateRowSize`. */
|
||||
estimateSize?: number
|
||||
/** Rows rendered beyond each edge. Defaults to the root `overscan`. */
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed replacement for `CascaderItems`, dropped inside `CascaderList`. The
|
||||
* registration is a LAYOUT effect because the root derives `virtualized` from
|
||||
* it and the flip has to land before the browser paints. Until the root has
|
||||
* seen it, this renders exactly what `CascaderItems` does, so no frame carries
|
||||
* indexed rows while Base UI still owns the composite list.
|
||||
*/
|
||||
function CascaderVirtualItems(props: CascaderVirtualItemsProps) {
|
||||
const { virtualized, registerVirtualRenderer } = useCascaderActions()
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerVirtualRenderer(),
|
||||
[registerVirtualRenderer]
|
||||
)
|
||||
|
||||
if (!virtualized) return <CascaderItems />
|
||||
|
||||
return <CascaderVirtualRows {...props} />
|
||||
}
|
||||
|
||||
function CascaderVirtualRows({
|
||||
estimateSize,
|
||||
overscan,
|
||||
}: CascaderVirtualItemsProps) {
|
||||
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||
window freezes on scroll. Inert where no compiler runs. */
|
||||
"use no memo"
|
||||
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
mode,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
} = useCascaderActions()
|
||||
const { renderedItems, treeRows, deepResults, loadStates } =
|
||||
useCascaderState()
|
||||
// Re-renders on every highlight change, and has to: the pinned row and the
|
||||
// scroll target are both derived from it.
|
||||
const highlight = useCascaderHighlight()
|
||||
|
||||
const [contentElement, setContentElement] =
|
||||
React.useState<HTMLElement | null>(null)
|
||||
const gutter = useCascaderVirtualGutter(contentElement)
|
||||
|
||||
const tree = mode === "tree"
|
||||
const count = tree ? treeRows.length : renderedItems.length
|
||||
const showPath = !tree && deepResults !== null
|
||||
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) =>
|
||||
(tree ? treeRows[index]?.node.value : renderedItems[index]?.value) ??
|
||||
index,
|
||||
[tree, treeRows, renderedItems]
|
||||
)
|
||||
|
||||
const virtualizer = useCascaderVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => cascaderScrollElement(contentElement),
|
||||
estimateSize: estimateSize ?? estimateRowSize,
|
||||
overscan: overscan ?? rootOverscan,
|
||||
getItemKey,
|
||||
activeIndex: highlight.index,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<CascaderVirtualSpacer
|
||||
height={virtualizer.getTotalSize()}
|
||||
onContentElement={setContentElement}
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const flat = tree ? treeRows[row.index] : undefined
|
||||
const node = tree ? flat?.node : renderedItems[row.index]
|
||||
// For one commit after a query narrows the level, the window can name
|
||||
// a row it no longer has; the count recomputes on the same tick.
|
||||
if (!node) return null
|
||||
|
||||
return (
|
||||
<CascaderItem
|
||||
key={row.key}
|
||||
/* Measured, not estimated: rows are two lines with a `description`
|
||||
and three in deep or global search, and row height is per
|
||||
style. An estimate alone would make `scrollToIndex` land on the
|
||||
wrong row. */
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
style={cascaderVirtualRowStyle(row.start, gutter)}
|
||||
node={node}
|
||||
index={row.index}
|
||||
depth={flat?.depth}
|
||||
expanded={flat?.expanded}
|
||||
branch={flat ? flat.branch : isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
showPath={showPath}
|
||||
aria-setsize={flat ? flat.setSize : count}
|
||||
aria-posinset={flat ? flat.posInSet : row.index + 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Column */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CascaderVirtualColumnProps extends CascaderVirtualItemsProps {
|
||||
column: CascaderColumn
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed replacement for one Miller column, passed through the
|
||||
* `CascaderColumns` render slot. One virtualizer PER COLUMN, since columns
|
||||
* scroll independently, and only the deepest is Base UI's listbox: the trail
|
||||
* behind is `as="button"` rows outside Base UI, so it windows on its own row
|
||||
* count rather than waiting for the root to flip.
|
||||
*/
|
||||
function CascaderVirtualColumn({
|
||||
column,
|
||||
estimateSize,
|
||||
overscan,
|
||||
}: CascaderVirtualColumnProps) {
|
||||
const {
|
||||
virtualized,
|
||||
registerVirtualRenderer,
|
||||
virtualize,
|
||||
virtualizeThreshold,
|
||||
} = useCascaderActions()
|
||||
|
||||
React.useLayoutEffect(
|
||||
() => registerVirtualRenderer(),
|
||||
[registerVirtualRenderer]
|
||||
)
|
||||
|
||||
const windowed = column.active
|
||||
? virtualized
|
||||
: (virtualize ?? column.items.length >= virtualizeThreshold)
|
||||
|
||||
if (!windowed) return <CascaderColumnPanel column={column} />
|
||||
|
||||
return (
|
||||
<CascaderColumnPanel column={column} virtualized>
|
||||
{column.active ? (
|
||||
<CascaderVirtualActiveColumnRows
|
||||
column={column}
|
||||
estimateSize={estimateSize}
|
||||
overscan={overscan}
|
||||
/>
|
||||
) : (
|
||||
<CascaderVirtualColumnRows
|
||||
column={column}
|
||||
estimateSize={estimateSize}
|
||||
overscan={overscan}
|
||||
activeIndex={-1}
|
||||
/>
|
||||
)}
|
||||
</CascaderColumnPanel>
|
||||
)
|
||||
}
|
||||
|
||||
/** Only the active column subscribes to the highlight; a trail column must not. */
|
||||
function CascaderVirtualActiveColumnRows(props: CascaderVirtualColumnProps) {
|
||||
const highlight = useCascaderHighlight()
|
||||
return <CascaderVirtualColumnRows {...props} activeIndex={highlight.index} />
|
||||
}
|
||||
|
||||
function CascaderVirtualColumnRows({
|
||||
column,
|
||||
estimateSize,
|
||||
overscan,
|
||||
activeIndex,
|
||||
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||
window freezes on scroll. Inert where no compiler runs. */
|
||||
"use no memo"
|
||||
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
baseId,
|
||||
isBranch,
|
||||
isSelectable,
|
||||
isSelected,
|
||||
isIndeterminate,
|
||||
} = useCascaderActions()
|
||||
const { loadStates } = useCascaderState()
|
||||
|
||||
const [contentElement, setContentElement] =
|
||||
React.useState<HTMLElement | null>(null)
|
||||
const gutter = useCascaderVirtualGutter(contentElement)
|
||||
|
||||
const items = column.items
|
||||
const getItemKey = React.useCallback(
|
||||
(index: number) => items[index]?.value ?? index,
|
||||
[items]
|
||||
)
|
||||
|
||||
const virtualizer = useCascaderVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => cascaderScrollElement(contentElement),
|
||||
estimateSize: estimateSize ?? estimateRowSize,
|
||||
overscan: overscan ?? rootOverscan,
|
||||
getItemKey,
|
||||
activeIndex,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<CascaderVirtualSpacer
|
||||
height={virtualizer.getTotalSize()}
|
||||
onContentElement={setContentElement}
|
||||
/>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const node = items[row.index]
|
||||
if (!node) return null
|
||||
const open = node.value === column.activeValue
|
||||
|
||||
return (
|
||||
<CascaderItem
|
||||
key={row.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
style={cascaderVirtualRowStyle(row.start, gutter)}
|
||||
node={node}
|
||||
as={column.active ? "option" : "button"}
|
||||
depth={column.depth}
|
||||
branch={isBranch(node)}
|
||||
selectable={isSelectable(node)}
|
||||
selected={isSelected(node)}
|
||||
indeterminate={isIndeterminate(node)}
|
||||
{...getCascaderMoreProps(node, loadStates)}
|
||||
data-open={open || undefined}
|
||||
className={open ? "bg-accent/60 text-accent-foreground" : undefined}
|
||||
/* Only the listbox column may carry an index or listbox metadata.
|
||||
A trail row is a `role="button"`, which allows neither. */
|
||||
{...(column.active
|
||||
? {
|
||||
index: row.index,
|
||||
"aria-setsize": items.length,
|
||||
"aria-posinset": row.index + 1,
|
||||
}
|
||||
: null)}
|
||||
{...(!column.active && open
|
||||
? {
|
||||
"aria-expanded": true,
|
||||
"aria-controls": `${baseId}-column-${column.depth + 1}`,
|
||||
}
|
||||
: null)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { CascaderVirtualColumn, CascaderVirtualItems }
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
|
||||
"use no memo"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData extends object, TValue> {
|
||||
column?: Column<DataGridFeatures, TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData extends object, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const { i18n } = useDataGrid()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
Array.isArray(filterValue) ? (filterValue as string[]) : []
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery) return options
|
||||
return options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [options, searchQuery])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden gap-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{i18n.labels.filterSelectedCount(selectedValues.size)}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
{i18n.labels.filterNoResults}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
const facetCount = facets?.get(option.value)
|
||||
const toggleOption = () => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={toggleOption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
toggleOption()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facetCount !== undefined && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
column?.setFilterValue(undefined)
|
||||
}
|
||||
}}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
{i18n.labels.filterClear}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -0,0 +1,506 @@
|
||||
"use no memo"
|
||||
|
||||
import { memo, useLayoutEffect, useMemo, useRef } from "react"
|
||||
import type { HTMLAttributes, ReactNode } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
getDataGridHeaderSpans,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import { Subscribe } from "@tanstack/react-table"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData extends object,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<DataGridFeatures, TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
// Unpinning moves a column into another header list (pinned, windowed center),
|
||||
// which remounts its header, so the focus request outlives the button: the next
|
||||
// header rendered for that column takes it. `column` is a stable reference.
|
||||
const pendingUnpinFocus = new WeakSet<object>()
|
||||
|
||||
function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { i18n, isLoading, table, props } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
|
||||
// to []), then every leaf it leaves out, in definition order - the same
|
||||
// completion TanStack applies when rendering, so a rendered neighbour is
|
||||
// always present to re-seat beside, even after columns are added later.
|
||||
const columnOrderState = table.state.columnOrder
|
||||
const definitionOrder = table
|
||||
.getAllColumns()
|
||||
.flatMap((topColumn) => topColumn.getLeafColumns())
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
const columnOrder = [
|
||||
...columnOrderState,
|
||||
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
|
||||
]
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.state.columnVisibility)
|
||||
: ""
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (pendingUnpinFocus.delete(column)) menuTriggerRef.current?.focus()
|
||||
})
|
||||
const canSort = column.getCanSort()
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
// Move neighbours come from what is RENDERED: the column's own pin bucket,
|
||||
// visible columns only. Stepping through the raw columnOrder would trade
|
||||
// places with a hidden or pinned column - an enabled click that moves nothing.
|
||||
// With grouping in TanStack's default "reorder" mode, grouped columns render
|
||||
// first whatever columnOrder says: they neither move nor serve as a target.
|
||||
const groupedColumnMode = (
|
||||
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
|
||||
).groupedColumnMode
|
||||
const isHoistedByGrouping = (target: object) =>
|
||||
groupedColumnMode !== false &&
|
||||
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
|
||||
(target as { getIsGrouped: () => boolean }).getIsGrouped()
|
||||
const renderedLeaves = (
|
||||
isPinned === "start"
|
||||
? table.getStartVisibleLeafColumns()
|
||||
: isPinned === "end"
|
||||
? table.getEndVisibleLeafColumns()
|
||||
: table.getCenterVisibleLeafColumns()
|
||||
)
|
||||
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
const renderedOrder = new Map(renderedLeaves.map((id, index) => [id, index]))
|
||||
const byRenderedOrder = (a: string, b: string) =>
|
||||
renderedOrder.get(a)! - renderedOrder.get(b)!
|
||||
// A peer is a sibling at this column's header level with its rendered
|
||||
// leaves, so a group or a meta.headerColSpan span moves as one.
|
||||
const headerSpans = getDataGridHeaderSpans(table)
|
||||
const coveredIds = new Set(
|
||||
[...headerSpans.values()].flat().map((leafColumn) => leafColumn.id)
|
||||
)
|
||||
// Siblings only: a leaf stepping out of its group would split its header.
|
||||
const renderedPeers = (column.parent?.columns ?? table.getAllColumns())
|
||||
.filter((sibling) => !coveredIds.has(sibling.id))
|
||||
.map((sibling) =>
|
||||
[...sibling.getLeafColumns(), ...(headerSpans.get(sibling.id) ?? [])]
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
.filter((id) => renderedOrder.has(id))
|
||||
.sort(byRenderedOrder)
|
||||
)
|
||||
.filter((leaves) => leaves.length > 0)
|
||||
.sort((a, b) => byRenderedOrder(a[0]!, b[0]!))
|
||||
const renderedIndex = column.columns.length
|
||||
? -1
|
||||
: renderedPeers.findIndex((leaves) => leaves[0] === column.id)
|
||||
const leftNeighbour =
|
||||
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
|
||||
const rightNeighbour =
|
||||
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
|
||||
? renderedPeers[renderedIndex + 1]
|
||||
: undefined
|
||||
const canMoveLeft = leftNeighbour !== undefined
|
||||
const canMoveRight = rightNeighbour !== undefined
|
||||
|
||||
/** Re-seats this column, with the leaves its header spans, beside a rendered
|
||||
* neighbour; every other column, hidden or pinned, keeps its place. */
|
||||
const moveBeside = (neighbour: string[], side: "before" | "after") => {
|
||||
const moving = renderedPeers[renderedIndex] ?? [column.id]
|
||||
const newOrder = columnOrder.filter((id) => !moving.includes(id))
|
||||
const anchor =
|
||||
side === "before" ? neighbour[0] : neighbour[neighbour.length - 1]
|
||||
const at = anchor === undefined ? -1 : newOrder.indexOf(anchor)
|
||||
if (at === -1) return
|
||||
newOrder.splice(side === "before" ? at : at + 1, 0, ...moving)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
|
||||
// column.toggleSorting has no getCanSort() guard of its own, and the
|
||||
// direct button also renders for columns that are only resizable.
|
||||
const handleSort = () => {
|
||||
if (!canSort) return
|
||||
if (isSorted === "asc") {
|
||||
column.toggleSorting(true)
|
||||
} else if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headerLabelClassName = cn(
|
||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||
className
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
|
||||
))
|
||||
|
||||
// A start-pinned column's resize handle covers the last 20px of its cell
|
||||
// (the column that renders last already reserves it with pe-8, and a
|
||||
// start-pinned one renders last only when nothing follows its group), so
|
||||
// the unpin button steps in until it clears the handle in both densities.
|
||||
// The step is padding on a wrapper that shrinks first: a column too narrow
|
||||
// for label, button and clearance gives the clearance back, never the label.
|
||||
const unpinClearsResizeHandle =
|
||||
props.tableLayout?.columnsResizable &&
|
||||
canResize &&
|
||||
isPinned === "start" &&
|
||||
!(
|
||||
column.getIsLastColumn("start") &&
|
||||
table.getCenterVisibleLeafColumns().length === 0 &&
|
||||
table.getEndVisibleLeafColumns().length === 0
|
||||
)
|
||||
|
||||
const hasControls =
|
||||
props.tableLayout?.columnsMovable ||
|
||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||
filter
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: ReactNode[] = []
|
||||
let hasPreviousSection = false
|
||||
|
||||
// Filter section
|
||||
if (filter) {
|
||||
items.push(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">{i18n.labels.sortAscending}</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">{i18n.labels.sortDescending}</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">{i18n.labels.pinColumnStart}</span>
|
||||
{isPinned === "start" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">{i18n.labels.pinColumnEnd}</span>
|
||||
{isPinned === "end" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (leftNeighbour) moveBeside(leftNeighbour, "before")
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>{i18n.labels.moveColumnStart}</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (rightNeighbour) moveBeside(rightNeighbour, "after")
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>{i18n.labels.moveColumnEnd}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>{i18n.labels.columnsMenu}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className={
|
||||
getColumnHeaderLabel(col) === col.id
|
||||
? "capitalize"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filter,
|
||||
canSort,
|
||||
isSorted,
|
||||
column,
|
||||
props.tableLayout?.columnsPinnable,
|
||||
props.tableLayout?.columnsMovable,
|
||||
props.tableLayout?.columnsVisibility,
|
||||
canPin,
|
||||
isPinned,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
leftNeighbour,
|
||||
rightNeighbour,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
ref={menuTriggerRef}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<span
|
||||
className={cn(
|
||||
"-me-1 flex min-w-7 shrink-[999]",
|
||||
unpinClearsResizeHandle && "pe-4"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg size-7"
|
||||
onClick={(event) => {
|
||||
const scrollArea = event.currentTarget.closest<HTMLElement>(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
)
|
||||
pendingUnpinFocus.add(column)
|
||||
column.pin(false)
|
||||
// A column the virtual window leaves unmounted claims nothing,
|
||||
// so the grid's scroll area takes focus instead of the page.
|
||||
requestAnimationFrame(() => {
|
||||
if (pendingUnpinFocus.delete(column)) scrollArea?.focus()
|
||||
})
|
||||
}}
|
||||
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
title={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="-ms-2 flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
|
||||
TData extends object,
|
||||
TValue,
|
||||
>(
|
||||
props: DataGridColumnHeaderProps<TData, TValue> & {
|
||||
/** Internal: the state slices the header re-renders on. Not part of the public API. */
|
||||
subscribedState?: unknown
|
||||
}
|
||||
) => ReactNode
|
||||
|
||||
/**
|
||||
* Sort and pin state reaches this header through builder calls on `column`
|
||||
* (`getIsSorted()`, `getIsPinned()`), and `column` keeps its identity across
|
||||
* renders, so anything memoized against it (the `memo` below, or a consumer's
|
||||
* compiled header template) never re-runs those reads: frozen sort arrows and
|
||||
* pin controls. The `Subscribe` below turns the slices this header actually
|
||||
* reads into a real reactive dependency, and threading the selection through
|
||||
* as a prop is what lets it past the `memo`.
|
||||
*/
|
||||
function DataGridColumnHeader<TData extends object, TValue>(
|
||||
props: DataGridColumnHeaderProps<TData, TValue>
|
||||
) {
|
||||
const { table } = useDataGrid()
|
||||
|
||||
return (
|
||||
<Subscribe
|
||||
source={table.store}
|
||||
selector={(state) => ({
|
||||
sorting: state.sorting,
|
||||
columnPinning: state.columnPinning,
|
||||
columnOrder: state.columnOrder,
|
||||
columnVisibility: state.columnVisibility,
|
||||
})}
|
||||
>
|
||||
{(subscribed) => (
|
||||
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
|
||||
)}
|
||||
</Subscribe>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
|
||||
"use no memo"
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData extends object>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<DataGridFeatures, TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
{i18n.labels.toggleColumns}
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
// Title-casing is for a raw column id only; an authored
|
||||
// label keeps its own casing.
|
||||
className={
|
||||
getColumnHeaderLabel(column) === column.id
|
||||
? "capitalize"
|
||||
: undefined
|
||||
}
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{getColumnHeaderLabel(column)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnVisibility }
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface DataGridI18nLabels {
|
||||
/* The column header menu. */
|
||||
sortAscending: string
|
||||
sortDescending: string
|
||||
pinColumnStart: string
|
||||
pinColumnEnd: string
|
||||
moveColumnStart: string
|
||||
moveColumnEnd: string
|
||||
columnsMenu: string
|
||||
unpinColumn: (title: string) => string
|
||||
toggleColumns: string
|
||||
/* Row and cell affordances. */
|
||||
rowCreate: string
|
||||
pinRow: string
|
||||
unpinRow: string
|
||||
selectRow: string
|
||||
selectAll: string
|
||||
expandRow: string
|
||||
collapseRow: string
|
||||
dragToReorder: string
|
||||
dragToReorderRow: string
|
||||
reorderingUnavailable: string
|
||||
/* Grid states. */
|
||||
loading: string
|
||||
empty: string
|
||||
allRowsLoaded: string
|
||||
/* Pagination. */
|
||||
rowsPerPage: string
|
||||
paginationInfo: (info: { from: number; to: number; count: number }) => string
|
||||
previousPage: string
|
||||
nextPage: string
|
||||
goToPage: (page: number) => string
|
||||
paginationEllipsis: string
|
||||
/* The faceted column filter. */
|
||||
filterSelectedCount: (count: number) => string
|
||||
filterNoResults: string
|
||||
filterClear: string
|
||||
}
|
||||
|
||||
export interface DataGridI18nConfig {
|
||||
labels: DataGridI18nLabels
|
||||
}
|
||||
|
||||
export type DataGridI18nOverrides = {
|
||||
labels?: Partial<DataGridI18nLabels>
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
|
||||
sortAscending: "Asc",
|
||||
sortDescending: "Desc",
|
||||
pinColumnStart: "Pin to left",
|
||||
pinColumnEnd: "Pin to right",
|
||||
moveColumnStart: "Move to left",
|
||||
moveColumnEnd: "Move to right",
|
||||
columnsMenu: "Columns",
|
||||
unpinColumn: (title) => `Unpin ${title} column`,
|
||||
toggleColumns: "Toggle Columns",
|
||||
rowCreate: "Add row",
|
||||
pinRow: "Pin row",
|
||||
unpinRow: "Unpin row",
|
||||
selectRow: "Select row",
|
||||
selectAll: "Select all",
|
||||
expandRow: "Expand row",
|
||||
collapseRow: "Collapse row",
|
||||
dragToReorder: "Drag to reorder",
|
||||
dragToReorderRow: "Drag to reorder row",
|
||||
reorderingUnavailable: "Reordering unavailable",
|
||||
loading: "Loading...",
|
||||
empty: "No data available",
|
||||
allRowsLoaded: "All records loaded",
|
||||
rowsPerPage: "Rows per page",
|
||||
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
|
||||
previousPage: "Go to previous page",
|
||||
nextPage: "Go to next page",
|
||||
goToPage: (page) => `Go to page ${page}`,
|
||||
paginationEllipsis: "...",
|
||||
filterSelectedCount: (count) => `${count} selected`,
|
||||
filterNoResults: "No results found.",
|
||||
filterClear: "Clear filters",
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
|
||||
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
|
||||
})
|
||||
|
||||
/**
|
||||
* A shallow merge per section, deliberately: a deep merge would leak a
|
||||
* default back into a function-valued label the consumer replaced. With no
|
||||
* overrides the frozen default is returned as-is, so the merge is free to
|
||||
* run on every render without producing a new identity.
|
||||
*/
|
||||
export function mergeDataGridI18n(
|
||||
overrides?: DataGridI18nOverrides
|
||||
): DataGridI18nConfig {
|
||||
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
|
||||
return {
|
||||
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use no memo"
|
||||
|
||||
import type { JSX, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface DataGridPaginationProps {
|
||||
sizes?: number[]
|
||||
sizesInfo?: string
|
||||
sizesLabel?: string
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
/** Page buttons the truncated middle shows; the row adds first, last and
|
||||
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
className?: string
|
||||
rowsPerPageLabel?: string
|
||||
previousPageLabel?: string
|
||||
nextPageLabel?: string
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One slot in the page row: a page button, or an ellipsis that jumps over the
|
||||
* pages it stands for.
|
||||
*/
|
||||
type DataGridPaginationItem =
|
||||
| { type: "page"; index: number }
|
||||
| { type: "ellipsis"; direction: "previous"; target: number }
|
||||
| { type: "ellipsis"; direction: "next"; target: number }
|
||||
|
||||
/**
|
||||
* The adaptive window: first page, the run around the current one, last page,
|
||||
* with an ellipsis standing in for each hidden stretch. It replaced fixed
|
||||
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
|
||||
* 50-page grid there was no way to reach the end - the primitive wires only
|
||||
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
|
||||
*
|
||||
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
|
||||
* At the default of 5 this emits exactly the same rows as the block version
|
||||
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
|
||||
* An ellipsis never stands for a single page, because the early return covers
|
||||
* every count a full row could hold.
|
||||
*/
|
||||
function getDataGridPaginationItems(
|
||||
pageIndex: number,
|
||||
pageCount: number,
|
||||
limit: number
|
||||
): DataGridPaginationItem[] {
|
||||
// Pages flanking the current one, per side. The run is rebuilt from it so
|
||||
// the count is always odd: an even `limit` would make the head and tail
|
||||
// rows one slot wider than the middle, and the footer would twitch as the
|
||||
// user pages through.
|
||||
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
|
||||
const pages = sibling * 2 + 3
|
||||
|
||||
// Two ellipses cost the width of two pages, so below this there is nothing
|
||||
// to gain by hiding any.
|
||||
if (pageCount <= pages + 2) {
|
||||
return Array.from({ length: pageCount }, (_, index) => ({
|
||||
type: "page" as const,
|
||||
index,
|
||||
}))
|
||||
}
|
||||
|
||||
if (pageIndex <= sibling + 2) {
|
||||
return [
|
||||
...Array.from({ length: pages }, (_, index) => ({
|
||||
type: "page" as const,
|
||||
index,
|
||||
})),
|
||||
{ type: "ellipsis", direction: "next", target: pages },
|
||||
{ type: "page", index: pageCount - 1 },
|
||||
]
|
||||
}
|
||||
|
||||
if (pageIndex >= pageCount - sibling - 3) {
|
||||
return [
|
||||
{ type: "page", index: 0 },
|
||||
{
|
||||
type: "ellipsis",
|
||||
direction: "previous",
|
||||
target: pageCount - pages - 1,
|
||||
},
|
||||
...Array.from({ length: pages }, (_, offset) => ({
|
||||
type: "page" as const,
|
||||
index: pageCount - pages + offset,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "page", index: 0 },
|
||||
{
|
||||
type: "ellipsis",
|
||||
direction: "previous",
|
||||
target: pageIndex - sibling - 1,
|
||||
},
|
||||
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
|
||||
type: "page" as const,
|
||||
index: pageIndex - sibling + offset,
|
||||
})),
|
||||
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
|
||||
{ type: "page", index: pageCount - 1 },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A page button is square at one digit and grows from there, so the row does
|
||||
* not reflow when the count crosses 10 or 100. The floor is each style's own
|
||||
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
|
||||
* and keep the arrows' height. Hardcoding one value would be right in three
|
||||
* styles and wrong in five.
|
||||
*/
|
||||
const PAGE_BUTTON_WIDTH_CLASS =
|
||||
"min-w-7"
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const { i18n, table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: i18n.labels.rowsPerPage,
|
||||
previousPageLabel: i18n.labels.previousPage,
|
||||
nextPageLabel: i18n.labels.nextPage,
|
||||
ellipsisText: i18n.labels.paginationEllipsis,
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.state.pagination.pageIndex
|
||||
const pageSize = table.state.pagination.pageSize
|
||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// A supplied `info` keeps its placeholder-template contract; the default
|
||||
// routes through the i18n label function, where word order is free.
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: i18n.labels.paginationInfo({ from, to, count: recordCount })
|
||||
|
||||
const paginationItems = getDataGridPaginationItems(
|
||||
pageIndex,
|
||||
pageCount,
|
||||
mergedProps.moreLimit ?? 5
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center gap-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
|
||||
the value "100" by 1px at nova's paddings, while fit-content
|
||||
grows the trigger for 3-digit sizes and the min keeps the
|
||||
1-2 digit ones from collapsing narrower than 64px. */}
|
||||
<SelectTrigger
|
||||
aria-label={mergedProps.rowsPerPageLabel}
|
||||
className="w-fit min-w-16"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="min-w-(--anchor-width)"
|
||||
>
|
||||
{mergedProps.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mergedProps.previousPageLabel}
|
||||
</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{paginationItems.map((item) =>
|
||||
item.type === "page" ? (
|
||||
<Button
|
||||
key={`page-${item.index}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(item.index + 1)}
|
||||
aria-current={
|
||||
pageIndex === item.index ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
PAGE_BUTTON_WIDTH_CLASS,
|
||||
"px-1.5 text-sm",
|
||||
"text-muted-foreground",
|
||||
{
|
||||
"bg-accent text-accent-foreground":
|
||||
pageIndex === item.index,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
if (pageIndex !== item.index) {
|
||||
table.setPageIndex(item.index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index + 1}
|
||||
</Button>
|
||||
) : (
|
||||
/* Clickable, unlike the shadcn PaginationEllipsis, which is
|
||||
an aria-hidden span. This one MOVES the user, so it needs
|
||||
a name saying where. */
|
||||
<Button
|
||||
key={`ellipsis-${item.direction}`}
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(item.target + 1)}
|
||||
onClick={() => table.setPageIndex(item.target)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridPagination, type DataGridPaginationProps }
|
||||
@@ -0,0 +1,557 @@
|
||||
"use client"
|
||||
|
||||
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { PointerEvent, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DirectionProvider,
|
||||
useDirection,
|
||||
} from "@base-ui/react/direction-provider"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MIN_THUMB_SIZE = 24
|
||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
||||
// Gap between each track and the grid edge: the mb-px / me-px on the tracks and
|
||||
// the overlay's inset-e-px. The overlay's height math adds it below the bar.
|
||||
const SCROLLBAR_EDGE_GAP = 1
|
||||
|
||||
const INITIAL_METRICS = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
thumbHeight: 0,
|
||||
thumbTop: 0,
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
// Track footprint: --data-grid-scrollbar-size (10px by default) in both
|
||||
// orientations, less 1px for the transparent border and 2px for p-px, so every
|
||||
// thumb lands at that size less 3px (7px), held 1px off the grid edge. Thinner
|
||||
// was hard to grab; the tracks overlay the last row and column, so the default
|
||||
// stays under 12px. The sticky-header overlay below follows the same variable.
|
||||
const SCROLLBAR_CLASSNAME =
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:mb-px data-[orientation=horizontal]:h-[var(--data-grid-scrollbar-size,0.625rem)] data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:me-px data-[orientation=vertical]:h-full data-[orientation=vertical]:w-[var(--data-grid-scrollbar-size,0.625rem)] data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
// The ::before reaches the track's outer edge, so the whole track thickness
|
||||
// grabs the thumb rather than only its painted 7px.
|
||||
const SCROLLBAR_THUMB_CLASSNAME =
|
||||
"bg-border rounded-full relative flex-1 before:absolute before:-inset-0.5"
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
hasVerticalOverflow: boolean
|
||||
headerHeight: number
|
||||
horizontalScrollbarSize: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
trackHeight: number
|
||||
}
|
||||
|
||||
type ObservedElements = {
|
||||
header: HTMLElement | null
|
||||
horizontalScrollbar: HTMLElement | null
|
||||
table: HTMLElement | null
|
||||
tableViewport: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridScrollAreaProps = Omit<
|
||||
ScrollAreaPrimitive.Root.Props,
|
||||
"children"
|
||||
> & {
|
||||
children: ReactNode
|
||||
orientation?: DataGridScrollAreaOrientation
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
||||
return (
|
||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
||||
next.headerHeight === prev.headerHeight &&
|
||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
||||
next.thumbHeight === prev.thumbHeight &&
|
||||
next.thumbTop === prev.thumbTop &&
|
||||
next.trackHeight === prev.trackHeight
|
||||
)
|
||||
}
|
||||
|
||||
// A footer held at the bottom (its tfoot or its cells are sticky, however the
|
||||
// consumer made them so) takes the track's lower end the way the sticky header
|
||||
// takes its top, so the thumb never paints over the totals.
|
||||
function getStickyFooterHeight(table: HTMLElement | null) {
|
||||
const footer = table instanceof HTMLTableElement ? table.tFoot : null
|
||||
if (!footer) return 0
|
||||
|
||||
const isSticky = [footer, footer.rows[0]?.cells[0]].some(
|
||||
(element) => element && getComputedStyle(element).position === "sticky"
|
||||
)
|
||||
return isSticky ? footer.getBoundingClientRect().height : 0
|
||||
}
|
||||
|
||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-header-height",
|
||||
`${metrics.headerHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-height",
|
||||
`${metrics.thumbHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-top",
|
||||
`${metrics.thumbTop}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-track-height",
|
||||
`${metrics.trackHeight}px`
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridScrollArea({
|
||||
children,
|
||||
className,
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps, table } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startScrollTop: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||
const observedElementsRef = useRef<ObservedElements>({
|
||||
header: null,
|
||||
horizontalScrollbar: null,
|
||||
table: null,
|
||||
tableViewport: null,
|
||||
})
|
||||
|
||||
const showHorizontal = orientation !== "vertical"
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
|
||||
// track is inset to span only the scrollable center region between them.
|
||||
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
|
||||
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
|
||||
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
|
||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||
useState(false)
|
||||
|
||||
// Base UI's thumb math reads direction from its own context, which a Radix
|
||||
// app or a bare dir="rtl" page never provides, so RTL scrolled with LTR math
|
||||
// and the thumb froze. RTL is read off the layout instead, so no provider of
|
||||
// any kind is needed. LTR passes the inherited value through untouched: no
|
||||
// state change and no extra render, exactly as before this existed.
|
||||
const inheritedDirection = useDirection()
|
||||
const [isLayoutRtl, setIsLayoutRtl] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const readDirection = () => {
|
||||
const next = getComputedStyle(container).direction === "rtl"
|
||||
setIsLayoutRtl((prev) => (prev === next ? prev : next))
|
||||
}
|
||||
|
||||
readDirection()
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
// Only a dir attribute flips it at runtime (an RTL toggle on <html>).
|
||||
const observer = new MutationObserver(readDirection)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["dir"],
|
||||
subtree: true,
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
dragRef.current = null
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
// The overlay is mounted one commit after the sync that detected overflow,
|
||||
// so it misses that sync's write. Seeding it from the ref callback lands the
|
||||
// geometry during commit, before the browser paints the track.
|
||||
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
|
||||
overlayRef.current = node
|
||||
|
||||
if (node) applyMetrics(node, metricsRef.current)
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
}, [])
|
||||
|
||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
const { header, horizontalScrollbar, table } = observedElementsRef.current
|
||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
||||
const footerHeight = getStickyFooterHeight(table)
|
||||
const viewportHeight = viewport.clientHeight
|
||||
const viewportWidth = viewport.clientWidth
|
||||
const scrollHeight = viewport.scrollHeight
|
||||
const scrollWidth = viewport.scrollWidth
|
||||
const hasHorizontalOverflow =
|
||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
||||
? (horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE) +
|
||||
SCROLLBAR_EDGE_GAP
|
||||
: 0
|
||||
// The horizontal bar overlays the bottom of the viewport, which a sticky
|
||||
// footer already covers, so only the larger of the two is taken off.
|
||||
const trackHeight = Math.max(
|
||||
0,
|
||||
viewportHeight -
|
||||
headerHeight -
|
||||
Math.max(footerHeight, horizontalScrollbarSize)
|
||||
)
|
||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
||||
|
||||
let nextMetrics: ScrollbarMetrics
|
||||
|
||||
if (trackHeight === 0 || maxScroll === 0) {
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight: trackHeight,
|
||||
thumbTop: 0,
|
||||
trackHeight,
|
||||
}
|
||||
} else {
|
||||
const bodyContentHeight = Math.max(
|
||||
trackHeight,
|
||||
scrollHeight - headerHeight - footerHeight
|
||||
)
|
||||
const thumbHeight = clamp(
|
||||
trackHeight * (trackHeight / bodyContentHeight),
|
||||
MIN_THUMB_SIZE,
|
||||
trackHeight
|
||||
)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const thumbTop =
|
||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
||||
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: true,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
trackHeight,
|
||||
}
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
metricsRef.current = nextMetrics
|
||||
// Scoped to the overlay, never to the container. These four properties
|
||||
// inherit, and thumbTop changes on essentially every scroll frame, so
|
||||
// writing them on the element that wraps the whole grid invalidates
|
||||
// computed style for every row and cell each frame. The overlay subtree
|
||||
// is their only reader.
|
||||
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) =>
|
||||
prev === nextMetrics.hasVerticalOverflow
|
||||
? prev
|
||||
: nextMetrics.hasVerticalOverflow
|
||||
)
|
||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport) return
|
||||
|
||||
if (!usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
const observed = new Set<HTMLElement>()
|
||||
|
||||
const observeElement = (element: HTMLElement | null) => {
|
||||
if (element && observer && !observed.has(element)) {
|
||||
observer.observe(element)
|
||||
observed.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveObservedElements = () => {
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
observeElement(observedElementsRef.current.header)
|
||||
observeElement(observedElementsRef.current.table)
|
||||
observeElement(observedElementsRef.current.tableViewport)
|
||||
|
||||
return !!(
|
||||
observedElementsRef.current.header && observedElementsRef.current.table
|
||||
)
|
||||
}
|
||||
|
||||
observeElement(viewport)
|
||||
const resolvedOnMount = resolveObservedElements()
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
// A table that mounts after this effect (empty state swapped for data)
|
||||
// would otherwise never be observed and the custom scrollbar would
|
||||
// overlap the sticky header. One-shot: disconnects once resolved.
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
if (resolveObservedElements()) {
|
||||
mutationObserver?.disconnect()
|
||||
mutationObserver = null
|
||||
scheduleSync()
|
||||
}
|
||||
})
|
||||
mutationObserver.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
mutationObserver?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
}, [
|
||||
clearDragState,
|
||||
resetMetrics,
|
||||
syncCustomVerticalScrollbar,
|
||||
usesCustomVerticalScrollbar,
|
||||
])
|
||||
|
||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
||||
const viewport = viewportRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
|
||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
||||
viewport.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
||||
viewport.scrollTop = ratio * maxScroll
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScrollTop: viewport.scrollTop,
|
||||
startY: event.clientY,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.webkitUserSelect = "none"
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
const dragState = dragRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
|
||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
||||
|
||||
const deltaY = event.clientY - dragState.startY
|
||||
const nextScrollTop =
|
||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
||||
|
||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
||||
}
|
||||
|
||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const { thumbHeight } = metricsRef.current
|
||||
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
||||
|
||||
scrollToThumbOffset(offsetY)
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectionProvider direction={isLayoutRtl ? "rtl" : inheritedDirection}>
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
// Styling hook: present while the sticky-header scroll mode detects
|
||||
// vertical overflow, so consumers can style scrollable vs short
|
||||
// grids with a plain ancestor attribute selector.
|
||||
data-overflow-vertical={
|
||||
hasCustomVerticalOverflow ? "true" : undefined
|
||||
}
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
{showHorizontal && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
style={
|
||||
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
|
||||
? {
|
||||
marginInlineStart: scrollbarInsetStart || undefined,
|
||||
marginInlineEnd: scrollbarInsetEnd || undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
ref={setOverlayRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-px top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-[var(--data-grid-scrollbar-size,0.625rem)] touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-[calc(var(--data-grid-scrollbar-size,0.625rem)-0.1875rem)]",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full before:absolute before:-inset-0.5"
|
||||
)}
|
||||
onLostPointerCapture={clearDragState}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DirectionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -0,0 +1,820 @@
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridHeadCellResizeHeader,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
renderDataGridTableHeadCellContent,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DndContextProps,
|
||||
type DragCancelEvent,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type Modifier,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
type SortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type { Cell, Header, Row } from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GripHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Context to share sortable listeners from row to handle
|
||||
type SortableContextValue = ReturnType<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
/**
|
||||
* Tree metadata attached to every sortable row, readable from
|
||||
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
|
||||
* drops can be resolved from it without re-deriving the shape of the table.
|
||||
*/
|
||||
type DataGridTableDndRowData = {
|
||||
type: "data-grid-row"
|
||||
/** Tree depth, 0 for root rows. */
|
||||
depth: number
|
||||
/** Index within the parent's children, or within the root rows. */
|
||||
index: number
|
||||
/** Parent row id, or null for root rows. */
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-row render slot for drop indicators and depth guides. The returned node
|
||||
* is positioned over the row, so it never adds a column, shifts striping, or
|
||||
* gets clipped by a truncating resizable cell.
|
||||
*/
|
||||
type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
||||
row: Row<DataGridFeatures, TData>
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
}) => ReactNode
|
||||
|
||||
function DataGridTableDndRowHandle({
|
||||
className,
|
||||
disabled,
|
||||
disabledLabel,
|
||||
}: {
|
||||
className?: string
|
||||
/**
|
||||
* Renders the grip inert instead of withdrawing it. A grid that reorders on
|
||||
* one truth (manual order) and sorts on another cannot honour both at once,
|
||||
* but dropping the handle entirely collapses the gutter and reads as broken
|
||||
* rather than as unavailable. Keep the column's shape, mute the control.
|
||||
*/
|
||||
disabled?: boolean
|
||||
/** Announced and shown on hover in place of the drag affordance. */
|
||||
disabledLabel?: string
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
const context = useContext(SortableRowContext)
|
||||
const resolvedDisabledLabel =
|
||||
disabledLabel ?? i18n.labels.reorderingUnavailable
|
||||
|
||||
if (!context || disabled) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
// The Button's own disabled treatment supplies the muting; only the
|
||||
// cursor needs saying, so the grip reads as unavailable rather than
|
||||
// merely unresponsive.
|
||||
disabled && "cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
aria-label={
|
||||
disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow
|
||||
}
|
||||
title={disabled ? resolvedDisabledLabel : undefined}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label={i18n.labels.dragToReorderRow}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows do not move while one is being carried.
|
||||
*
|
||||
* Sliding the siblings apart opens a gap the carried row could go into, which
|
||||
* reads well in a list of identical rows and badly in a table: the gap is the
|
||||
* height of the row you are holding, so with rows of unequal height it never
|
||||
* matches the slot it claims to be, and the row you picked up slides away from
|
||||
* where it started - which is exactly the position you need to remember if you
|
||||
* decide not to drop it.
|
||||
*
|
||||
* Holding everything still keeps the origin legible, and nothing is lost: the
|
||||
* DragOverlay clone follows the pointer and the drop indicator names the seam.
|
||||
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
|
||||
*/
|
||||
const holdRowsInPlaceStrategy: SortingStrategy = () => null
|
||||
|
||||
function DataGridTableDndRow<TData extends object>({
|
||||
row,
|
||||
renderRowDecoration,
|
||||
dropIndicator = true,
|
||||
}: {
|
||||
row: Row<DataGridFeatures, TData>
|
||||
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||
dropIndicator?: boolean
|
||||
}) {
|
||||
const rowData: DataGridTableDndRowData = {
|
||||
type: "data-grid-row",
|
||||
depth: row.depth,
|
||||
index: row.index,
|
||||
parentId: row.getParentRow()?.id ?? null,
|
||||
}
|
||||
|
||||
const {
|
||||
transform,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
isOver,
|
||||
attributes,
|
||||
listeners,
|
||||
index,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
data: rowData,
|
||||
})
|
||||
|
||||
// Which edge of THIS row the carried row would land on, or null when it is
|
||||
// not the drop target. Nothing slides apart any more, so the bar is the only
|
||||
// thing that says where the drop goes: it marks the row at the destination
|
||||
// index, on the side the carried row comes to rest.
|
||||
//
|
||||
// Dragging down it lands after the target, dragging up before it, so the
|
||||
// edge follows the direction of travel.
|
||||
const dropEdge =
|
||||
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
|
||||
? activeIndex < overIndex
|
||||
? "bottom"
|
||||
: "top"
|
||||
: null
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
// dnd-kit's transition is deliberately dropped. A transition on a transform
|
||||
// property of a `tr` does not merely fail to animate in Chrome, it stops the
|
||||
// transform applying at all: the element sits at the start value forever.
|
||||
// The drag source escapes it because dnd-kit disables its own transition
|
||||
// while it is being dragged, which is why the carried row used to be the
|
||||
// ONLY one that moved and every other row silently refused to open a gap.
|
||||
// Displacement therefore lands in one step, which is what a table wants.
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
// The row you are holding is drawn by the DragOverlay, so the one left
|
||||
// behind is not a second copy of it - it is the slot you came from, and it
|
||||
// stays exactly where it was. Fading alone read as "this row is busy";
|
||||
// the outline says "this is the space you are moving out of", which is the
|
||||
// thing you need if you change your mind mid-drag.
|
||||
...(isDragging && {
|
||||
opacity: 0.4,
|
||||
// Inset so the dashes sit inside the row box and cannot be clipped by
|
||||
// the neighbouring row's border.
|
||||
outline: "1px dashed var(--border)",
|
||||
outlineOffset: "-1px",
|
||||
}),
|
||||
}
|
||||
|
||||
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
|
||||
const cells = row.getVisibleCells()
|
||||
const lastCell = cells[cells.length - 1]
|
||||
|
||||
const renderCell = (cell: Cell<DataGridFeatures, TData, unknown>) => (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{decoration && cell === lastCell ? (
|
||||
// Rides inside the last cell rather than in a `td` of its own.
|
||||
// An absolutely positioned `td` is still a cell as far as table
|
||||
// layout is concerned, so it added a NINTH column with no width
|
||||
// of its own, and under `table-layout: fixed` that new column
|
||||
// swallowed the whole surplus the real columns had been sharing,
|
||||
// so every column snapped back to its declared size and the row's
|
||||
// content visibly narrowed the moment a drag began. A plain
|
||||
// element adds no column. It anchors to the ROW, the nearest
|
||||
// positioned ancestor, and spans the full width, unless the last
|
||||
// cell is a sticky end-pinned one: that cell is positioned itself
|
||||
// and confines the decoration to its own width.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-row-decoration"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
{decoration}
|
||||
</div>
|
||||
) : null}
|
||||
{dropEdge && cell === lastCell ? (
|
||||
// Same anchoring trick as the decoration above: a plain
|
||||
// element inside the last cell, so it adds no column and
|
||||
// cannot disturb `table-layout: fixed`. It spans the row, with
|
||||
// the same sticky end-pinned exception.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-row-drop-indicator"
|
||||
data-edge={dropEdge}
|
||||
className="pointer-events-none absolute inset-0 z-20"
|
||||
>
|
||||
{/* Two solid pixels down the leading edge, the same marker
|
||||
the tree drag uses for its drop target. A wash across
|
||||
the row has to stay faint enough not to read as a
|
||||
selected row, and in the achromatic styles primary
|
||||
carries no chroma at all, so faint plus colourless is
|
||||
just grey. The bar reads at any weight and leaves the
|
||||
row's own background to hover and selection.
|
||||
|
||||
The bar is the whole indicator: the gap the rows have
|
||||
already opened says which side, so a rule across the
|
||||
seam as well only competes with the row borders it sits
|
||||
between. `data-edge` still carries the direction for
|
||||
anyone styling their own. */}
|
||||
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
|
||||
</div>
|
||||
) : null}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
dndDragging={isDragging}
|
||||
>
|
||||
{/* One keyed list with the fill in the middle, as in the head row. */}
|
||||
{[
|
||||
...cells
|
||||
.filter((cell) => cell.column.getIsPinned() !== "end")
|
||||
.map(renderCell),
|
||||
<DataGridTableFillBodyCell key="__data-grid-fill" />,
|
||||
...cells
|
||||
.filter((cell) => cell.column.getIsPinned() === "end")
|
||||
.map(renderCell),
|
||||
]}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRowsBody<TData extends object>({
|
||||
table,
|
||||
dataIds,
|
||||
renderRowDecoration,
|
||||
dropIndicator,
|
||||
sortingStrategy,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
dataIds: UniqueIdentifier[]
|
||||
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||
dropIndicator?: boolean
|
||||
sortingStrategy: SortingStrategy
|
||||
}) {
|
||||
const { isLoading, props } = useDataGrid()
|
||||
const pagination = table.state.pagination
|
||||
|
||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{[
|
||||
...[
|
||||
...table.getStartVisibleLeafColumns(),
|
||||
...table.getCenterVisibleLeafColumns(),
|
||||
].map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)),
|
||||
<DataGridTableFillBodyCell key="__data-grid-fill" />,
|
||||
...table.getEndVisibleLeafColumns().map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)),
|
||||
]}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||
|
||||
return (
|
||||
<SortableContext items={dataIds} strategy={sortingStrategy}>
|
||||
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
|
||||
return (
|
||||
<DataGridTableDndRow
|
||||
row={row}
|
||||
renderRowDecoration={renderRowDecoration}
|
||||
dropIndicator={dropIndicator}
|
||||
key={row.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SortableContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized body rows: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedDataGridTableDndRowsBody = memo(
|
||||
DataGridTableDndRowsBody,
|
||||
(_prev, next) => !!next.table.state.columnResizing?.isResizingColumn
|
||||
) as typeof DataGridTableDndRowsBody
|
||||
|
||||
function DataGridTableDndRows<TData extends object>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
collisionDetection = closestCenter,
|
||||
modifiers,
|
||||
sortingStrategy = holdRowsInPlaceStrategy,
|
||||
renderRowDecoration,
|
||||
dropIndicator = true,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragOver,
|
||||
onDragCancel,
|
||||
accessibility,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
/** Overrides the default `closestCenter` strategy. */
|
||||
collisionDetection?: CollisionDetection
|
||||
/**
|
||||
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
|
||||
* to allow the horizontal gesture that tree re-parenting relies on. The
|
||||
* table container clamp is always applied after these, so a dragged row
|
||||
* cannot leave the grid.
|
||||
*/
|
||||
modifiers?: Modifier[]
|
||||
/**
|
||||
* Replaces the default `verticalListSortingStrategy`. Return null from a
|
||||
* strategy to leave every row exactly where it is. A tree needs that: its drop
|
||||
* is either INTO the hovered row or BETWEEN two rows, and which one it is
|
||||
* flips as the pointer crosses a single row, so a gap that opens for one and
|
||||
* shuts for the other flickers the whole surface. Such a caller draws its own
|
||||
* insertion line instead, and pairs this with a modifier that holds the
|
||||
* carried row still, since a gap nothing moves into is just a hole.
|
||||
*/
|
||||
sortingStrategy?: SortingStrategy
|
||||
/** Per-row slot for drop indicators and depth guides. */
|
||||
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
|
||||
/**
|
||||
* Draws a line on the seam the carried row would land on. On by default;
|
||||
* pass `false` when `renderRowDecoration` paints its own insertion affordance
|
||||
* and the two would compete.
|
||||
*/
|
||||
dropIndicator?: boolean
|
||||
onDragStart?: (event: DragStartEvent) => void
|
||||
onDragMove?: (event: DragMoveEvent) => void
|
||||
onDragOver?: (event: DragOverEvent) => void
|
||||
onDragCancel?: (event: DragCancelEvent) => void
|
||||
/**
|
||||
* Forwarded to dnd-kit's `DndContext`: its screen-reader instructions and
|
||||
* live-region announcements, which default to dnd-kit's English strings.
|
||||
*/
|
||||
accessibility?: DndContextProps["accessibility"]
|
||||
}) {
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
|
||||
// in place, and it positions with `position: fixed` against viewport
|
||||
// coordinates - so any ancestor that establishes a containing block for fixed
|
||||
// descendants silently re-anchors it. `content-visibility`, `contain`,
|
||||
// `transform`, `filter` and `will-change` all do that, and the first two are
|
||||
// exactly what a card grid uses to defer off-screen work. The clone then
|
||||
// lands offset by that ancestor's own top/left, and the container clamp
|
||||
// below mis-clamps too, because its rects are measured in viewport space.
|
||||
//
|
||||
// Resolved in an effect rather than read at render so the server and the
|
||||
// first client render agree. A drag cannot start before hydration, so the
|
||||
// overlay being absent for one frame costs nothing.
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setPortalTarget(document.body)
|
||||
}, [])
|
||||
// The row being carried, plus the column widths measured off the header the
|
||||
// moment the drag starts. The clone lives outside the table, so it has no
|
||||
// columns of its own and has to be told what they are.
|
||||
const [carried, setCarried] = useState<{
|
||||
id: UniqueIdentifier
|
||||
width: number
|
||||
height: number
|
||||
columns: number[]
|
||||
padding: { start: string; end: string }[]
|
||||
} | null>(null)
|
||||
|
||||
const pickUpRow = useCallback((id: UniqueIdentifier) => {
|
||||
const container = tableContainerRef.current
|
||||
const head = container?.querySelector("thead tr")
|
||||
if (!container || !head) {
|
||||
setCarried(null)
|
||||
return
|
||||
}
|
||||
|
||||
// The clone has to be exactly as tall as the row it was lifted from.
|
||||
// A fixed height reads as the grid growing under the pointer the moment
|
||||
// you pick a row up, and it is wrong in both directions: rows whose
|
||||
// content wraps are taller than any constant, and dense rows are shorter.
|
||||
const source = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
|
||||
).find((candidate) => candidate.dataset.rowId === String(id))
|
||||
const height = source?.getBoundingClientRect().height ?? 0
|
||||
|
||||
// The fill cell is a spacer that soaks up the surplus a column resize
|
||||
// leaves behind, and the clone renders data cells only. Measuring it in
|
||||
// would make the clone's table wider than the cells it actually holds,
|
||||
// and `table-fixed` hands that orphaned width back out across every column
|
||||
// -- the carried row comes out visibly wider than the row it was lifted
|
||||
// from. So the width is the sum of what we render, never the header's own.
|
||||
// Read off the source row, one cell per column: a head cell can span
|
||||
// several (a group, a merged or a meta.headerColSpan cell).
|
||||
const columns = Array.from(source?.children ?? head.children)
|
||||
.filter(
|
||||
(cell) =>
|
||||
cell.getAttribute("data-slot") !== "data-grid-table-fill-body-cell" &&
|
||||
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
|
||||
)
|
||||
.map((cell) => cell.getBoundingClientRect().width)
|
||||
|
||||
// Inline padding is read off the source cells for the same reason. A fixed
|
||||
// px-3 ignored edgeCell and cellClassName, so a 44px grip column holding a
|
||||
// 28px handle overflowed by 8px and truncate painted an ellipsis beside it.
|
||||
const padding = Array.from(source?.children ?? [])
|
||||
.filter(
|
||||
(cell) =>
|
||||
cell.getAttribute("data-slot") !== "data-grid-table-fill-body-cell"
|
||||
)
|
||||
.map((cell) => {
|
||||
const style = getComputedStyle(cell)
|
||||
return { start: style.paddingInlineStart, end: style.paddingInlineEnd }
|
||||
})
|
||||
|
||||
setCarried({
|
||||
id,
|
||||
width: columns.reduce((total, width) => total + width, 0),
|
||||
height,
|
||||
columns,
|
||||
padding,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const carriedRow = carried
|
||||
? table
|
||||
.getRowModel()
|
||||
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
|
||||
: undefined
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
// Keyboard reordering moves one sortable position per keypress instead
|
||||
// of the sensor's raw 25px default.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRow) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const resolvedModifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
}) => {
|
||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||
return transform
|
||||
}
|
||||
|
||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||
const { x, y } = transform
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left
|
||||
const maxX = containerRect.right - draggingNodeRect.right
|
||||
const minY = containerRect.top - draggingNodeRect.top
|
||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||
|
||||
return {
|
||||
...transform,
|
||||
// The horizontal rail only engages while the default axis restriction
|
||||
// is in force. A row is exactly as wide as the viewport, so minX and
|
||||
// maxX both collapse to 0 and clamping x erases it entirely: harmless
|
||||
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
|
||||
// caller that replaced the restriction precisely to READ x, as a tree
|
||||
// does to resolve drop depth. Vertical is railed either way, which is
|
||||
// what actually keeps a dragged row inside the grid.
|
||||
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
// The container clamp is a safety rail rather than a policy, so it stays
|
||||
// applied even when the caller replaces the axis restriction.
|
||||
return [
|
||||
...(modifiers ?? [restrictToVerticalAxis]),
|
||||
restrictToTableContainer,
|
||||
]
|
||||
}, [modifiers])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
accessibility={accessibility}
|
||||
collisionDetection={collisionDetection}
|
||||
modifiers={resolvedModifiers}
|
||||
onDragCancel={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
setCarried(null)
|
||||
onDragCancel?.(event)
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
setCarried(null)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragMove={onDragMove}
|
||||
onDragOver={onDragOver}
|
||||
onDragStart={(event) => {
|
||||
setIsDraggingRow(true)
|
||||
pickUpRow(event.active.id)
|
||||
onDragStart?.(event)
|
||||
}}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup, index) => {
|
||||
const renderHeader = (
|
||||
header: Header<DataGridFeatures, TData, unknown>
|
||||
) => {
|
||||
const coveredHeaders = headerGroup.coveredHeaders.get(header.id)
|
||||
const resizeHeader = getDataGridHeadCellResizeHeader(
|
||||
header,
|
||||
coveredHeaders
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
coveredHeaders={coveredHeaders}
|
||||
key={header.id}
|
||||
>
|
||||
{renderDataGridTableHeadCellContent(header)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
resizeHeader.column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={resizeHeader} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{/* Every row follows DataGridTableBase's colgroup, which
|
||||
puts the fill col between the center and end-pinned
|
||||
groups; a fill cell appended last hands its width to
|
||||
the end-pinned column. One keyed list, not three, so
|
||||
a column pinned into or out of the end group moves
|
||||
instead of remounting, which would drop keyboard
|
||||
focus. The fill spans every head row, so only the
|
||||
first renders it. */}
|
||||
{[
|
||||
...[
|
||||
...headerGroup.startHeaders,
|
||||
...headerGroup.centerHeaders,
|
||||
].map(renderHeader),
|
||||
...(index === 0
|
||||
? [
|
||||
<DataGridTableFillHeadCell
|
||||
key="__data-grid-fill"
|
||||
rowSpan={mergedHeaderGroups.length}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
...headerGroup.endHeaders.map(renderHeader),
|
||||
]}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedDataGridTableDndRowsBody
|
||||
table={table}
|
||||
dataIds={dataIds}
|
||||
renderRowDecoration={renderRowDecoration}
|
||||
dropIndicator={dropIndicator}
|
||||
sortingStrategy={sortingStrategy}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
|
||||
{/* The row you are actually holding. It is a real clone rendered outside
|
||||
the table, which is the only way a dragged row can follow the pointer
|
||||
without disturbing the grid: it adds no cell, so it cannot alter the
|
||||
column widths, and it floats above the rows rather than through them.
|
||||
Its presence also tells dnd-kit to stop translating the source row, so
|
||||
the row left behind simply dims in place.
|
||||
|
||||
Portalled to the body so the fixed positioning resolves against the
|
||||
viewport wherever the grid is mounted. React context crosses a portal,
|
||||
so DndContext still reaches it. */}
|
||||
{portalTarget
|
||||
? createPortal(
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{carried && carriedRow ? (
|
||||
<table
|
||||
aria-hidden="true"
|
||||
style={{ width: carried.width, tableLayout: "fixed" }}
|
||||
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
|
||||
>
|
||||
<tbody>
|
||||
{/* Padding rides on the inner element, not the cell. A `td` can
|
||||
never render narrower than its own horizontal padding, so a
|
||||
column resized below that would silently widen here and the
|
||||
clone would stop matching the row it came from. Height comes
|
||||
from the measured source row for the same reason the widths
|
||||
do: the clone has no row of its own to inherit it from. */}
|
||||
<tr
|
||||
style={{ height: carried.height || undefined }}
|
||||
className="[&>td]:p-0 [&>td]:align-middle"
|
||||
>
|
||||
{carriedRow
|
||||
.getVisibleCells()
|
||||
.map(
|
||||
(
|
||||
cell: Cell<DataGridFeatures, TData, unknown>,
|
||||
index: number
|
||||
) => (
|
||||
<td
|
||||
key={cell.id}
|
||||
// Falls back to the column's own size so an unforeseen
|
||||
// header/cell count mismatch degrades to a real width
|
||||
// rather than to `auto`.
|
||||
style={{
|
||||
width:
|
||||
carried.columns[index] ??
|
||||
cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"truncate",
|
||||
!carried.padding[index] && "px-3"
|
||||
)}
|
||||
style={{
|
||||
paddingInlineStart:
|
||||
carried.padding[index]?.start,
|
||||
paddingInlineEnd: carried.padding[index]?.end,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
portalTarget
|
||||
)
|
||||
: null}
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||
@@ -0,0 +1,555 @@
|
||||
// @ts-nocheck: vendored from the ReUI registry, kept as shipped
|
||||
"use client"
|
||||
|
||||
// Opted out of React Compiler with the rest of the grid (see data-grid.tsx).
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import {
|
||||
getDataGridHeaderSpans,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridHeadCellResizeHeader,
|
||||
getDataGridHeadCellSizeVar,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
renderDataGridTableHeadCellContent,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DndContextProps,
|
||||
type DragEndEvent,
|
||||
type KeyboardSensorProps,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type { Cell, Header, Row } from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData extends object>({
|
||||
header,
|
||||
coveredHeaders = [],
|
||||
}: {
|
||||
header: Header<DataGridFeatures, TData, unknown>
|
||||
/** Headers this cell spans through meta.headerColSpan. */
|
||||
coveredHeaders?: Header<DataGridFeatures, TData, unknown>[]
|
||||
}) {
|
||||
const { i18n, props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Pinning decides where a pinned column renders whatever columnOrder says,
|
||||
// so dragging one could only rewrite hidden order. It gets no grip and is
|
||||
// neither a drag source nor a drop target. Neither is a group cell: its
|
||||
// id is not in columnOrder, which holds leaves only.
|
||||
const isPinned = !!column.getIsPinned()
|
||||
const isGroup = column.columns.length > 0
|
||||
// A span is no drag source: a drop moves one leaf, never its covered ones.
|
||||
const isSpan = coveredHeaders.length > 0
|
||||
const canOrder =
|
||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||
.enableColumnOrdering !== false &&
|
||||
!isPinned &&
|
||||
!isGroup &&
|
||||
!isSpan
|
||||
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
disabled: isPinned || isGroup || isSpan,
|
||||
})
|
||||
const resizeHeader = getDataGridHeadCellResizeHeader(header, coveredHeaders)
|
||||
|
||||
// This style spreads after getPinningStyles, so a sticky pinned cell must
|
||||
// not take the drag keys: relative and z-index 0 would unstick it and sink
|
||||
// it under the centre cells. Omitted, not undefined, which would still win.
|
||||
const isSticky =
|
||||
!!props.tableLayout?.columnsPinnable && column.getCanPin() && isPinned
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
...(!isSticky && {
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? getDataGridHeadCellSizeVar(header, coveredHeaders)
|
||||
: coveredHeaders.reduce(
|
||||
(sum, covered) => sum + covered.column.getSize(),
|
||||
header.column.getSize()
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
coveredHeaders={coveredHeaders}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={i18n.labels.dragToReorder}
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="grow">{renderDataGridTableHeadCellContent(header)}</div>
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
resizeHeader.column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={resizeHeader} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData extends object>({
|
||||
cell,
|
||||
}: {
|
||||
cell: Cell<DataGridFeatures, TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
disabled: !!cell.column.getIsPinned(),
|
||||
})
|
||||
|
||||
// Same rule as the header: a sticky pinned cell keeps its pinning styles.
|
||||
const isSticky =
|
||||
!!props.tableLayout?.columnsPinnable &&
|
||||
cell.column.getCanPin() &&
|
||||
!!cell.column.getIsPinned()
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
...(!isSticky && {
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||
: cell.column.getSize(),
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndBodyRows<TData extends object>({
|
||||
table,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
}) {
|
||||
const { isLoading, props } = useDataGrid()
|
||||
const pagination = table.state.pagination
|
||||
|
||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{[
|
||||
...[
|
||||
...table.getStartVisibleLeafColumns(),
|
||||
...table.getCenterVisibleLeafColumns(),
|
||||
].map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)),
|
||||
<DataGridTableFillBodyCell key="__data-grid-fill" />,
|
||||
...table.getEndVisibleLeafColumns().map((column) => (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)),
|
||||
]}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||
|
||||
return (
|
||||
<>
|
||||
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
<SortableContext
|
||||
items={table.state.columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{/* One keyed list with the fill in the middle, as in the
|
||||
head row below. */}
|
||||
{[
|
||||
...[
|
||||
...row.getStartVisibleCells(),
|
||||
...row.getCenterVisibleCells(),
|
||||
].map((cell: Cell<DataGridFeatures, TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
)),
|
||||
<DataGridTableFillBodyCell key="__data-grid-fill" />,
|
||||
...row
|
||||
.getEndVisibleCells()
|
||||
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
)),
|
||||
]}
|
||||
</SortableContext>
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized body rows: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedDataGridTableDndBodyRows = memo(
|
||||
DataGridTableDndBodyRows,
|
||||
(_prev, next) => !!next.table.state.columnResizing?.isResizingColumn
|
||||
) as typeof DataGridTableDndBodyRows
|
||||
|
||||
// dnd-kit's KeyboardSensor scrolls instead of moving when the target sits
|
||||
// past the middle of a scroller, and assumes scrollLeft runs 0 to max. RTL
|
||||
// runs -max to 0, so from the start edge ArrowRight became a scrollTo that
|
||||
// clamps to 0 and swallowed the keypress. The sensor gets an LTR-shaped view
|
||||
// of RTL scrollers, scrolled instantly: a smooth scroll drops a key pressed
|
||||
// before it settles. The document scroller and the coordinate getter's copy
|
||||
// stay real elements: both are matched by identity inside dnd-kit.
|
||||
function toLtrScrollView(element: Element): Element {
|
||||
if (
|
||||
element === element.ownerDocument.scrollingElement ||
|
||||
getComputedStyle(element).direction !== "rtl"
|
||||
) {
|
||||
return element
|
||||
}
|
||||
const offset = () => element.scrollWidth - element.clientWidth
|
||||
return new Proxy(element, {
|
||||
get(target, key) {
|
||||
if (key === "scrollLeft") return target.scrollLeft + offset()
|
||||
if (key === "scrollTo") {
|
||||
return (options: ScrollToOptions) =>
|
||||
target.scrollTo(
|
||||
options.left === undefined
|
||||
? options
|
||||
: {
|
||||
...options,
|
||||
left: options.left - offset(),
|
||||
behavior: "instant",
|
||||
}
|
||||
)
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === "function" ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** A drop on a span's leaves targets its far edge, after its last leaf moving
|
||||
* forward and before its owner moving back, so no move lands inside it. */
|
||||
function getDataGridDropBesideSpans<TData extends object>(
|
||||
table: DataGridTableInstance<TData>,
|
||||
event: DragEndEvent
|
||||
): DragEndEvent {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return event
|
||||
|
||||
const rendered = [
|
||||
...table.getStartVisibleLeafColumns(),
|
||||
...table.getCenterVisibleLeafColumns(),
|
||||
...table.getEndVisibleLeafColumns(),
|
||||
].map((column) => column.id)
|
||||
const from = rendered.indexOf(String(active.id))
|
||||
const to = rendered.indexOf(String(over.id))
|
||||
if (from === -1 || to === -1) return event
|
||||
|
||||
for (const [ownerId, covered] of getDataGridHeaderSpans(table)) {
|
||||
const leaves = [ownerId, ...covered.map((column) => column.id)]
|
||||
if (!leaves.includes(String(over.id))) continue
|
||||
if (leaves.includes(String(active.id))) return event
|
||||
const edge = from < to ? leaves[leaves.length - 1]! : leaves[0]!
|
||||
return edge === String(over.id)
|
||||
? event
|
||||
: { ...event, over: { ...over, id: edge } }
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
class DataGridKeyboardSensor extends KeyboardSensor {
|
||||
constructor(props: KeyboardSensorProps) {
|
||||
const { context, options } = props
|
||||
const getCoordinates = options.coordinateGetter
|
||||
super({
|
||||
...props,
|
||||
context: {
|
||||
get current() {
|
||||
const current = context.current
|
||||
return {
|
||||
...current,
|
||||
scrollableAncestors:
|
||||
current.scrollableAncestors.map(toLtrScrollView),
|
||||
}
|
||||
},
|
||||
},
|
||||
options: getCoordinates
|
||||
? {
|
||||
...options,
|
||||
coordinateGetter: (event, args) =>
|
||||
getCoordinates(event, { ...args, context: context.current }),
|
||||
}
|
||||
: options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData extends object>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
accessibility,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
/**
|
||||
* Forwarded to dnd-kit's `DndContext`: its screen-reader instructions and
|
||||
* live-region announcements, which default to dnd-kit's English strings.
|
||||
*/
|
||||
accessibility?: DndContextProps["accessibility"]
|
||||
}) {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
// Keyboard reordering moves one sortable position per keypress instead
|
||||
// of the sensor's raw 25px default.
|
||||
useSensor(DataGridKeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingColumn) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableBounds: Modifier = ({
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToTableBounds]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
accessibility={accessibility}
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(getDataGridDropBesideSpans(table, event))
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.state.columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{/* Every row follows DataGridTableBase's colgroup,
|
||||
which puts the fill col between the center and
|
||||
end-pinned groups; a fill cell appended last hands
|
||||
its width to the end-pinned column. One keyed
|
||||
list, not three, so a column pinned into or out of
|
||||
the end group moves instead of remounting, which
|
||||
would drop keyboard focus. The fill spans every
|
||||
head row, so only the first renders it. */}
|
||||
{[
|
||||
...[
|
||||
...headerGroup.startHeaders,
|
||||
...headerGroup.centerHeaders,
|
||||
].map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
coveredHeaders={headerGroup.coveredHeaders.get(
|
||||
header.id
|
||||
)}
|
||||
key={header.id}
|
||||
/>
|
||||
)),
|
||||
...(index === 0
|
||||
? [
|
||||
<DataGridTableFillHeadCell
|
||||
key="__data-grid-fill"
|
||||
rowSpan={mergedHeaderGroups.length}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
...headerGroup.endHeaders.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
coveredHeaders={headerGroup.coveredHeaders.get(
|
||||
header.id
|
||||
)}
|
||||
key={header.id}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedDataGridTableDndBodyRows table={table} />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,305 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Cascader,
|
||||
CascaderEmpty,
|
||||
CascaderList,
|
||||
CascaderPanel,
|
||||
CascaderStatus,
|
||||
} from "@/components/reui/cascader/cascader"
|
||||
import { CascaderFooter } from "@/components/reui/cascader/cascader-footer"
|
||||
import {
|
||||
CascaderBreadcrumb,
|
||||
CascaderInput,
|
||||
CascaderNav,
|
||||
} from "@/components/reui/cascader/cascader-nav"
|
||||
import type {
|
||||
CascaderActionItem,
|
||||
CascaderLabels,
|
||||
CascaderNode,
|
||||
} from "@/components/reui/cascader/cascader-types"
|
||||
import { CascaderVirtualItems } from "@/components/reui/cascader/cascader-virtual"
|
||||
import {
|
||||
filterControlSizes,
|
||||
filterReadOnlyProps,
|
||||
useFilterActions,
|
||||
useFilterState,
|
||||
} from "@/components/reui/filters/filters-context"
|
||||
import {
|
||||
FILTER_FIELD_PICKER_CLASS,
|
||||
getFilterField,
|
||||
getFilterFieldCount,
|
||||
joinFilterPath,
|
||||
splitFilterPath,
|
||||
} from "@/components/reui/filters/filters-lib"
|
||||
import { getDefaultFilterOperator } from "@/components/reui/filters/filters-operators"
|
||||
import type { FilterField } from "@/components/reui/filters/filters-types"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { ListFilterPlusIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Projects ONLY the field tree: operators or values as nodes would force the
|
||||
* wizard through a combobox that owns the arrows and `aria-activedescendant`.
|
||||
*/
|
||||
function toCascaderNodes<V, O>(
|
||||
fields: readonly FilterField<V, O>[],
|
||||
parentPath: string[] = []
|
||||
): CascaderNode<FilterField<V, O>>[] {
|
||||
return fields.map((field) => {
|
||||
const path = [...parentPath, field.id]
|
||||
const node: CascaderNode<FilterField<V, O>> = {
|
||||
value: joinFilterPath(path),
|
||||
label: field.label,
|
||||
icon: field.icon,
|
||||
description: field.description,
|
||||
keywords: field.keywords,
|
||||
disabled: field.disabled,
|
||||
data: field,
|
||||
}
|
||||
if (field.fields?.length) {
|
||||
node.children = toCascaderNodes(field.fields, path)
|
||||
node.count = getFilterFieldCount(field)
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
export interface FilterFieldPickerProps {
|
||||
/** The level being browsed. Not the chosen field. */
|
||||
path: string[]
|
||||
onPathChange: (path: string[]) => void
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
/** A field was chosen, with its starting operator already resolved. */
|
||||
onSelect: (path: string[], defaultOperator: string | null) => void
|
||||
/** Viewport height before the list scrolls. */
|
||||
maxHeight?: number
|
||||
/** Cascader-only strings; the `FilterLabels` bridge below covers the rest. */
|
||||
labels?: Partial<CascaderLabels>
|
||||
/** Pinned footer rows, OUT of the option ring so arrows never land on one. */
|
||||
actions?: CascaderActionItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully controlled, not draft-driven, so ONE picker serves both the create
|
||||
* popover (driven by the draft reducer) and an advanced row (its own state).
|
||||
*/
|
||||
export function FilterFieldPicker<V, O>({
|
||||
path,
|
||||
onPathChange,
|
||||
query,
|
||||
onQueryChange,
|
||||
onSelect,
|
||||
maxHeight = 260,
|
||||
labels: labelsProp,
|
||||
actions: actionItems,
|
||||
}: FilterFieldPickerProps) {
|
||||
const actions = useFilterActions<V, O>()
|
||||
|
||||
const items = React.useMemo(
|
||||
() => toCascaderNodes(actions.index.roots),
|
||||
[actions.index]
|
||||
)
|
||||
|
||||
// Bridges `FilterLabels` onto the cascader's own key names. `labelsProp` is
|
||||
// spread LAST, so a consumer override always wins.
|
||||
const cascaderLabels = React.useMemo<Partial<CascaderLabels>>(
|
||||
() => ({
|
||||
search: actions.labels.searchFields,
|
||||
back: actions.labels.back,
|
||||
empty: actions.labels.empty,
|
||||
pathSeparator: actions.labels.pathSeparator.trim() || "/",
|
||||
itemCount: actions.labels.itemCount,
|
||||
branchAffordance: actions.labels.branchAffordance,
|
||||
rootLevel: actions.labels.fieldsLabel,
|
||||
panelLabel: actions.labels.fieldsLabel,
|
||||
resultsAnnouncement: actions.labels.resultsAnnouncement,
|
||||
actionsLabel: actions.labels.actionsLabel,
|
||||
...labelsProp,
|
||||
}),
|
||||
[actions.labels, labelsProp]
|
||||
)
|
||||
|
||||
return (
|
||||
<Cascader
|
||||
inline
|
||||
/* Pinned open: inline renders no popup and forces `open`, so the
|
||||
single-select commit's `setOpen(false)` dismisses nothing. That avoids
|
||||
the `multiple` plus `max` workaround and its `aria-multiselectable`. */
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
items={items}
|
||||
/* No `selectable` predicate: LEAVES commit, branches navigate. Honouring
|
||||
a branch's opt-in made one click mean both drill and commit. */
|
||||
searchScope="deep"
|
||||
/* Nothing is ever selected, so the ~24px check gutter is dead space. */
|
||||
indicator={false}
|
||||
path={path}
|
||||
onPathChange={onPathChange}
|
||||
/* Controlled: the single-select arm of `commit()` never clears the query,
|
||||
so an uncontrolled one would bleed into the next step. */
|
||||
inputValue={query}
|
||||
onInputValueChange={onQueryChange}
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
const nextPath = splitFilterPath(value)
|
||||
const field = getFilterField(actions.index, nextPath)
|
||||
if (!field) return
|
||||
onSelect(
|
||||
nextPath,
|
||||
getDefaultFilterOperator(field, actions.resolveOperators(field))
|
||||
)
|
||||
}}
|
||||
labels={cascaderLabels}
|
||||
actions={actionItems}
|
||||
/* Set here only: `CascaderList` takes its own `maxHeight` and that WINS,
|
||||
so a second copy on the list is a divergence waiting to happen. */
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderInput placeholder={actions.labels.searchFields} />
|
||||
</CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderEmpty />
|
||||
<CascaderList>
|
||||
{/* WINDOWED: the picker is where scale lives and nothing here pins.
|
||||
Below the threshold it renders what `CascaderItems` does. */}
|
||||
<CascaderVirtualItems />
|
||||
</CascaderList>
|
||||
<CascaderFooter />
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</Cascader>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldStep<V, O>() {
|
||||
const actions = useFilterActions<V, O>()
|
||||
const { draft } = useFilterState<V>()
|
||||
|
||||
return (
|
||||
<FilterFieldPicker<V, O>
|
||||
path={draft?.cascaderPath ?? []}
|
||||
onPathChange={(next) =>
|
||||
actions.dispatchDraft({ type: "setCascaderPath", path: next })
|
||||
}
|
||||
query={draft?.query ?? ""}
|
||||
onQueryChange={(query) =>
|
||||
actions.dispatchDraft({ type: "setQuery", query })
|
||||
}
|
||||
onSelect={(path, defaultOperator) =>
|
||||
actions.dispatchDraft({ type: "selectField", path, defaultOperator })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FiltersBuilderProps {
|
||||
/** Replaces the default Add filter button. */
|
||||
trigger?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The Add filter popover. ONE panel, the field step: picking a field commits
|
||||
* the rule and opens the operator menu on the new chip. `open` derives from the
|
||||
* draft and `openCreate` is gated, so read-only and disabled shut every route.
|
||||
*/
|
||||
export function FiltersBuilder<V, O>({
|
||||
trigger,
|
||||
className,
|
||||
}: FiltersBuilderProps) {
|
||||
const actions = useFilterActions<V, O>()
|
||||
const sizes = filterControlSizes(actions)
|
||||
const { draft, ruleCount } = useFilterState<V>()
|
||||
const open = draft !== null && draft.ruleId === null
|
||||
// The HANDOFF close, the one that must not FADE: the panel is a 224px card
|
||||
// dissolving over the very menu the user is now meant to read.
|
||||
const committing =
|
||||
draft !== null && draft.ruleId === null && draft.status === "ready"
|
||||
// LATCHED, because `committing` is true for one render only, before the exit
|
||||
// it suppresses begins. Adjusted during render; an effect is one frame late.
|
||||
const [instantExit, setInstantExit] = React.useState(false)
|
||||
if (committing && !instantExit) setInstantExit(true)
|
||||
else if (open && !committing && instantExit) setInstantExit(false)
|
||||
// Icon-only once chips sit beside it. Both states come off ONE size ladder,
|
||||
// so they share height and radius in every style at every bar size. The
|
||||
// hardcoded `icon`/`default` pair this replaced IS the `default` rung, so it
|
||||
// drifted the moment the bar was `sm`.
|
||||
const compact = ruleCount > 0
|
||||
|
||||
// Base UI's own microtask-then-rAF ordering hands focus to the new chip, NOT
|
||||
// anything this file arranges. Suppressing the close restore with
|
||||
// `finalFocus` only removes the fallback that keeps focus off the BODY.
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) actions.closeDraft()
|
||||
else actions.openCreate()
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
/* On the TRIGGER so a consumer's own `trigger` wears the state too. */
|
||||
disabled={actions.disabled}
|
||||
{...filterReadOnlyProps(actions)}
|
||||
render={
|
||||
trigger ? (
|
||||
(trigger as React.ReactElement)
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={compact ? sizes.icon : sizes.button}
|
||||
aria-label={compact ? actions.labels.addFilter : undefined}
|
||||
/* THE RESET BOUNCE: the button's base class carries
|
||||
`transition-all`, so flipping the size class eased padding 0 to
|
||||
`px-6` in sera over 150ms. Naming a transition REPLACES it. */
|
||||
className="transition-[color,background-color,border-color,box-shadow]"
|
||||
>
|
||||
{/* No filter-plus glyph in phosphor or remixicon. */}
|
||||
<ListFilterPlusIcon
|
||||
/>
|
||||
{compact ? null : actions.labels.addFilter}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* The default, then the root override, then this `className` last, so
|
||||
the specific wins. */}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={cn(
|
||||
FILTER_FIELD_PICKER_CLASS,
|
||||
/* See `instantExit`. Both PROPERTIES (an `exit` animation AND a
|
||||
transition each paint over the successor) and both twins (Base UI
|
||||
`data-ending-style`, Radix `data-[state=closed]`); only one can
|
||||
match in a given build. The string stays identical on both sides
|
||||
because listing only the Base UI half left the radix twin fading
|
||||
a 224px card over its successor. */
|
||||
instantExit &&
|
||||
cn(
|
||||
"data-ending-style:animate-none data-ending-style:transition-none",
|
||||
"data-[state=closed]:animate-none data-[state=closed]:transition-none"
|
||||
),
|
||||
actions.fieldPickerClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<FieldStep<V, O> />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** Exported for custom field pickers. */
|
||||
export { toCascaderNodes }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,660 @@
|
||||
import * as React from "react"
|
||||
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
|
||||
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
|
||||
import type {
|
||||
FilterCombinator,
|
||||
FilterDraft,
|
||||
FilterDraftStep,
|
||||
FilterEditor,
|
||||
FilterEditorRegistry,
|
||||
FilterEmptyStateContext,
|
||||
FilterField,
|
||||
FilterIndex,
|
||||
FilterLabels,
|
||||
FilterOperator,
|
||||
FilterOption,
|
||||
FilterQuery,
|
||||
FilterRule,
|
||||
FilterValueDisplayContext,
|
||||
FilterValueType,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
|
||||
/** Four publishing channels, not one, so a chip never re-renders at the rate
|
||||
* of the fastest: `actions` republishes on a schema, labels or config change,
|
||||
* `state` on every query edit and keystroke, `render` on a `renderValue` /
|
||||
* `renderChip` identity change, `focus` on every arrow key. The reorder flag
|
||||
* and the row-state store further down are contexts too, but not channels the
|
||||
* query is published through. */
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Stable for the life of the component, bar a config change. Every mutator
|
||||
* reads a latest-props ref rather than closing over the state it needs, so a
|
||||
* keystroke rebuilds no handler and no memoized chip re-renders. The two lock
|
||||
* flags are published rather than kept only in that ref: a refusal that lives
|
||||
* in a ref arrives one commit late. The two resolvers are the deliberate
|
||||
* exception: they are read DURING render, so they memoize on the values they
|
||||
* read instead of going through the ref. Every mutator is also the MUTATION
|
||||
* BOUNDARY - `disabled` and `readOnly` are enforced here, not at the call
|
||||
* sites that draw the buttons, so a route added next month is refused by
|
||||
* construction rather than by remembering. See `isFilterLocked`. */
|
||||
export interface FilterActionsContextValue<V = unknown, O = unknown> {
|
||||
/** Normalized schema. Also on the state context, from the same memo. */
|
||||
index: FilterIndex<V, O>
|
||||
labels: FilterLabels
|
||||
operatorCatalog: Record<FilterValueType, FilterOperator[]>
|
||||
editors: FilterEditorRegistry
|
||||
size: "sm" | "default"
|
||||
/** The bar is off: nothing operable, and the controls leave the tab order. */
|
||||
disabled: boolean
|
||||
/** Readable and navigable, but not changeable. See `isFilterLocked`. */
|
||||
readOnly: boolean
|
||||
|
||||
/** Which chrome is drawing, so a chip kebab can hide its convert row. */
|
||||
variant: "basic" | "advanced"
|
||||
|
||||
/** Consumer classes for the menus and the field picker, merged AFTER the
|
||||
* primitive's own defaults so a consumer `w-*` wins through tailwind-merge
|
||||
* rather than on source order. On the context: four chromes mount the same
|
||||
* menu, and a per-chrome prop is set in four places and missed in a fifth. */
|
||||
menuClassName: string | undefined
|
||||
fieldPickerClassName: string | undefined
|
||||
|
||||
/** Turns on the chip kebab's "Convert to advanced filter" row: present means
|
||||
* offered. A callback with no boolean beside it, since only the consumer
|
||||
* can switch chromes. Changes no query, so it skips the mutation boundary,
|
||||
* which is safe only because it is handed no setter: a consumer's handler
|
||||
* has no route to the query at all. */
|
||||
convertToAdvanced: (() => void) | undefined
|
||||
|
||||
/** Path shortening, published so BOTH chromes render the same path. */
|
||||
pathCollapse: FilterPathCollapse
|
||||
maxPathSegments: number
|
||||
|
||||
/** Operators for a field, memoized on the catalog and the schema. */
|
||||
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
|
||||
resolveEditor: (
|
||||
field: FilterField<V, O>,
|
||||
operator: FilterOperator | undefined
|
||||
) => FilterEditor<V, O> | undefined
|
||||
/** Value-to-label store shared by every `useFilterOptions` under the root. */
|
||||
resolution: FilterResolutionStore
|
||||
|
||||
/** Appends a rule. Defaults to the ROOT group. The parent is a parameter
|
||||
* rather than always the root because a nested group's add button is the
|
||||
* keyboard path into that group, and routing it through the root then
|
||||
* moving it would be two edits and two announcements for one action. */
|
||||
addRule: (rule: FilterRule<V>, parentId?: string) => void
|
||||
/** Appends an empty group and returns its id, so the caller can focus it.
|
||||
* Returns an EMPTY STRING when the bar is disabled or read only, the one
|
||||
* mutator that has to report its refusal: focusing a group that was never
|
||||
* created strands the tab stop. `""` rather than `string | null` keeps the
|
||||
* callers that already test the id honest without widening a published
|
||||
* signature. */
|
||||
addGroup: (parentId?: string, combinator?: FilterCombinator) => string
|
||||
updateRule: (
|
||||
id: string,
|
||||
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
|
||||
) => void
|
||||
/** Removes a rule OR a group, and prunes the groups that empties. Node-level
|
||||
* rather than rule-level because a chip only ever removes a rule, while a
|
||||
* builder row's trash and a group header's trash are the same button on two
|
||||
* kinds of node. */
|
||||
removeNode: (id: string) => void
|
||||
duplicateNode: (id: string) => void
|
||||
negateRule: (id: string) => void
|
||||
/** Reorders within the node's own parent. Out of range moves are no-ops. */
|
||||
moveNode: (id: string, delta: number) => void
|
||||
/** Moves a node into another group, at an index. The drag-and-drop half. */
|
||||
moveNodeTo: (id: string, parentId: string, index: number) => void
|
||||
/** Copies a node into another group, at an index. The Alt-drag half, apart
|
||||
* from `duplicateNode` (which copies BESIDE, with no destination): the two
|
||||
* composed would emit two queries for one gesture, and a controlled
|
||||
* consumer would persist a tree the user never asked for. */
|
||||
copyNodeTo: (id: string, parentId: string, index: number) => void
|
||||
/** Nests one node in a new group. The keyboard half of the same idea. */
|
||||
wrapNodeInGroup: (id: string, combinator?: FilterCombinator) => void
|
||||
/** Dissolves a group into its parent. The inverse of `wrapNodeInGroup`. */
|
||||
unwrapGroup: (groupId: string) => void
|
||||
setCombinator: (groupId: string, combinator: FilterCombinator) => void
|
||||
toggleCombinator: (groupId: string) => void
|
||||
clearQuery: () => void
|
||||
|
||||
openCreate: () => void
|
||||
openAmend: (id: string, step: FilterDraftStep) => void
|
||||
closeDraft: () => void
|
||||
dispatchDraft: (action: FilterDraftAction<V>) => void
|
||||
|
||||
/** Writes the bar's live region, for a change no visible surface reports: an
|
||||
* editor's popover is the one place something destructive happens to rows
|
||||
* the user is not on, with focus, name and text unchanged afterwards. Not a
|
||||
* query write, so it skips `emit` and asks the lock nothing. */
|
||||
announce: (message: string) => void
|
||||
|
||||
/** Getters for event handlers, so a handler never closes over stale state. */
|
||||
getQuery: () => FilterQuery<V>
|
||||
getDraft: () => FilterDraft<V> | null
|
||||
/** Fresh id from the SSR-safe factory. */
|
||||
nextId: () => string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The mutation lock */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** `disabled` and `readOnly` are NOT two words for one state. `disabled` is
|
||||
* the native attribute: not operable, out of the tab order. `readOnly` blocks
|
||||
* MUTATION and preserves NAVIGATION, because a read-only bar exists so a
|
||||
* keyboard or screen reader user can walk the chips and find out what the
|
||||
* view is filtered by. Collapsing the two once put the native attribute on
|
||||
* all thirteen advanced-builder cell controls while the roving tab stop sat
|
||||
* on a disabled element, so not one row could be reached from the keyboard.
|
||||
*
|
||||
* So a mutating control keeps its tab stop and wears `aria-disabled` plus
|
||||
* `data-readonly` (`filterReadOnlyProps`), this repo's convention for
|
||||
* "present, focusable, not operable". `aria-readonly` is never used, being
|
||||
* disallowed on the button, group and toolbar roles, so the BAR says it in
|
||||
* prose through `labels.readOnly`. The refusal itself is enforced once, where
|
||||
* all fourteen query writes pass through `emit` in `filters.tsx`. */
|
||||
export function isFilterLocked(state: {
|
||||
disabled: boolean
|
||||
readOnly: boolean
|
||||
}): boolean {
|
||||
return state.disabled || state.readOnly
|
||||
}
|
||||
|
||||
/** What a MUTATING control wears while the bar is read only. `null` when the
|
||||
* bar is disabled, because the native attribute already says it. A
|
||||
* conditional spread, not explicit `undefined`s: `aria-disabled="false"` on
|
||||
* an enabled control is noise, and `data-readonly` is a presence hook. */
|
||||
export function filterReadOnlyProps(state: {
|
||||
disabled: boolean
|
||||
readOnly: boolean
|
||||
}) {
|
||||
if (state.disabled || !state.readOnly) return null
|
||||
return { "aria-disabled": true, "data-readonly": "" } as const
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The size ladder */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** The two shadcn button sizes one filters size resolves to. Two rungs because
|
||||
* shadcn ships a separate ladder for labelled and for icon-only buttons, and
|
||||
* pairing them keeps a row's kebab as tall as the cell beside it. */
|
||||
export interface FilterControlSizes {
|
||||
/** Labelled buttons: the row cells, both triggers, the panel footer. */
|
||||
button: "sm" | "default"
|
||||
/** Icon-only buttons, and also the CHIP's height: a chip is an
|
||||
* `items-stretch` `ButtonGroup` and no style gives its text segment a
|
||||
* height, so the segments stretch to the kebab, the one child that has one.
|
||||
* Sizing the kebab sizes the pill. */
|
||||
icon: "icon-sm" | "icon"
|
||||
}
|
||||
|
||||
/** ONE ladder, keyed off `size`, for every control the chrome renders, because
|
||||
* the alternative already happened: five advanced-builder cells took
|
||||
* `actions.size` while seven sites hardcoded `icon-sm` and three `sm`, giving
|
||||
* one row three heights. Nothing here is a pixel - each value is a shadcn
|
||||
* size NAME that `Button` resolves per style, since the control-height ladder
|
||||
* is per style (nova 7/8, sera 9/10, mira 6/7, and so on), and the glyph size
|
||||
* rides the same name, so pinning an icon size in here would fight the style
|
||||
* rather than match it. Two rungs only: `lg` would make the bar taller than
|
||||
* the style's own default control height (want taller, pick a taller STYLE),
|
||||
* and `icon-xs` is a 20-24px square in most styles, too small for a chip's
|
||||
* own label to clear. */
|
||||
const FILTER_CONTROL_SIZES: Record<"sm" | "default", FilterControlSizes> = {
|
||||
sm: { button: "sm", icon: "icon-sm" },
|
||||
default: { button: "default", icon: "icon" },
|
||||
}
|
||||
|
||||
/** The pair for a bar's size, off anything with a `size`, so a
|
||||
* consumer-composed chrome uses the same ladder as the shipped one. The
|
||||
* fallback is for JavaScript callers: a `"lg"` TypeScript would have rejected
|
||||
* must still draw buttons rather than throw on `.button`. */
|
||||
export function filterControlSizes(state: {
|
||||
size: "sm" | "default"
|
||||
}): FilterControlSizes {
|
||||
return FILTER_CONTROL_SIZES[state.size] ?? FILTER_CONTROL_SIZES.default
|
||||
}
|
||||
|
||||
const FilterActionsContext =
|
||||
React.createContext<FilterActionsContextValue | null>(null)
|
||||
|
||||
export function useFilterActions<
|
||||
V = unknown,
|
||||
O = unknown,
|
||||
>(): FilterActionsContextValue<V, O> {
|
||||
const context = React.useContext(FilterActionsContext)
|
||||
if (!context) {
|
||||
throw new Error("useFilterActions must be used inside <Filters>")
|
||||
}
|
||||
return context as unknown as FilterActionsContextValue<V, O>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* State */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Volatile by construction: typing one character into a value editor
|
||||
* republishes it. Subscribe from the bar and the panel, never from a chip. */
|
||||
export interface FilterStateContextValue<V = unknown> {
|
||||
query: FilterQuery<V>
|
||||
draft: FilterDraft<V> | null
|
||||
ruleCount: number
|
||||
/** Live region text. Empty except immediately after an announced change. */
|
||||
announcement: string
|
||||
/** How many announcements have been made, so a REPEATED one is still heard:
|
||||
* `aria-live` reports a DOM mutation and React writes nothing when the
|
||||
* string is unchanged, so the chrome keys the region's contents on this. A
|
||||
* counter rather than a timestamp: it is compared for identity, never read
|
||||
* as a value, and is stable across a rerender that a clock is not. */
|
||||
announcementSeq: number
|
||||
}
|
||||
|
||||
const FilterStateContext = React.createContext<FilterStateContextValue | null>(
|
||||
null
|
||||
)
|
||||
|
||||
export function useFilterState<V = unknown>(): FilterStateContextValue<V> {
|
||||
const context = React.useContext(FilterStateContext)
|
||||
if (!context) {
|
||||
throw new Error("useFilterState must be used inside <Filters>")
|
||||
}
|
||||
return context as unknown as FilterStateContextValue<V>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Render */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Consumer render overrides, on their own channel. They must be
|
||||
* always-current closures yet change identity on every parent render when
|
||||
* written inline, so isolating them re-renders only what calls them. */
|
||||
export interface FilterRenderContextValue<V = unknown, O = unknown> {
|
||||
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
|
||||
renderChip?: (rule: FilterRule<V>) => React.ReactNode
|
||||
renderEmpty?: (context: FilterEmptyStateContext) => React.ReactNode
|
||||
}
|
||||
|
||||
const FilterRenderContext = React.createContext<FilterRenderContextValue>({})
|
||||
|
||||
export function useFilterRender<
|
||||
V = unknown,
|
||||
O = unknown,
|
||||
>(): FilterRenderContextValue<V, O> {
|
||||
return React.useContext(FilterRenderContext) as FilterRenderContextValue<V, O>
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Focus store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Which chip currently owns the row's single tab stop. */
|
||||
export interface FilterFocus {
|
||||
id: string | null
|
||||
/** Which cell inside that row, for restoring focus after an edit. A chip
|
||||
* draws only `field`, `operator`, `value` and `menu`; the rest are advanced
|
||||
* builder cells, where a GROUP header is a row too (its `field` is the
|
||||
* combinator sentence). One union, because the chromes share the store. */
|
||||
segment:
|
||||
| "combinator"
|
||||
| "field"
|
||||
| "operator"
|
||||
| "value"
|
||||
| "add"
|
||||
| "menu"
|
||||
| "ungroup"
|
||||
| "remove"
|
||||
| "drag"
|
||||
| null
|
||||
/** Open that segment's popover, not merely focus it. Picking a field commits
|
||||
* the rule straight away and the chip appears with no condition yet, so the
|
||||
* operator menu has to open ON THE CHIP without a second click. */
|
||||
autoOpen: boolean
|
||||
}
|
||||
|
||||
/** An external store, deliberately NOT React state. A roving tabindex
|
||||
* republishes on every arrow key, and `setState` would re-render the whole
|
||||
* bar to move one outline. The SELECTOR hooks below each narrow to one value,
|
||||
* so arrowing across forty chips re-renders two components. `useFilterFocus`
|
||||
* is the exception: it returns the whole snapshot. */
|
||||
export interface FilterFocusStore {
|
||||
subscribe: (onStoreChange: () => void) => () => void
|
||||
getSnapshot: () => FilterFocus
|
||||
/** No-ops when nothing changed. */
|
||||
set: (next: FilterFocus) => void
|
||||
}
|
||||
|
||||
const NO_FOCUS: FilterFocus = { id: null, segment: null, autoOpen: false }
|
||||
|
||||
export function createFilterFocusStore(): FilterFocusStore {
|
||||
let snapshot: FilterFocus = NO_FOCUS
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
return {
|
||||
subscribe(onStoreChange) {
|
||||
listeners.add(onStoreChange)
|
||||
return () => {
|
||||
listeners.delete(onStoreChange)
|
||||
}
|
||||
},
|
||||
// The SAME object until something actually changes, which is what
|
||||
// `useSyncExternalStore` requires to avoid an infinite render loop.
|
||||
getSnapshot() {
|
||||
return snapshot
|
||||
},
|
||||
set(next) {
|
||||
if (
|
||||
next.id === snapshot.id &&
|
||||
next.segment === snapshot.segment &&
|
||||
next.autoOpen === snapshot.autoOpen
|
||||
) {
|
||||
return
|
||||
}
|
||||
snapshot = next
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A shared, permanently empty store, so the hook degrades to "nothing
|
||||
* focused" outside a `Filters`. Nothing ever writes to it: each root creates
|
||||
* and writes its own. */
|
||||
const FALLBACK_FOCUS_STORE = createFilterFocusStore()
|
||||
|
||||
const FilterFocusContext =
|
||||
React.createContext<FilterFocusStore>(FALLBACK_FOCUS_STORE)
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Reordering */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Whether rows may be reordered at all, published once for the subtree. Two
|
||||
* files have to agree about it: the builder gates the grip and Alt+Arrow,
|
||||
* while the row and group menus commit the same mutator by a third route, so
|
||||
* gating only the builder left it off for a pointer and on from a menu. */
|
||||
const FilterReorderContext = React.createContext(false)
|
||||
|
||||
export const FilterReorderProvider = FilterReorderContext.Provider
|
||||
|
||||
export function useFilterReorderable(): boolean {
|
||||
return React.useContext(FilterReorderContext)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Touched */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Which rules the user has actually edited the VALUE of, which decides WHEN
|
||||
* an error may appear: "Add filter" mints a row with no value, so flagging
|
||||
* every invalid row turns the builder red before the user did anything wrong.
|
||||
* An issue is still COLLECTED for every rule - the footer count,
|
||||
* `onQueryChange` and tree validity do not change - and only DRAWN once the
|
||||
* user has committed a value. An external store for the focus store's reason,
|
||||
* keyed by rule id and never pruned: a stale id costs one Set entry, and
|
||||
* reconciling against the query on every commit is hot-path work. */
|
||||
export interface FilterRowStateStore {
|
||||
subscribe: (listener: () => void) => () => void
|
||||
/** Bumped on every write. What a memo depends on, since the Sets are mutable. */
|
||||
version: () => number
|
||||
/** Whether this rule has had a value committed to it by the user. */
|
||||
has: (id: string) => boolean
|
||||
mark: (id: string) => void
|
||||
/** Starts this rule over. Changing a rule's ATTRIBUTE resets its operator
|
||||
* and its value, so it has to reset this too, or the row stays warned about
|
||||
* a value that no longer exists on a field the user navigated away from. */
|
||||
unmark: (id: string) => void
|
||||
/** Whether this rule is still being CREATED: minted by Add filter and not
|
||||
* yet given an attribute. The row enters the query with a guessed field to
|
||||
* keep the tree valid, so while pending the builder draws only that cell. */
|
||||
isPending: (id: string) => boolean
|
||||
markPending: (id: string) => void
|
||||
/** The attribute was chosen. The rest of the row appears. */
|
||||
resolvePending: (id: string) => void
|
||||
/** Forgets everything. The bar's own Clear all, and a whole-tree replace. */
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export function createFilterRowStateStore(): FilterRowStateStore {
|
||||
const touched = new Set<string>()
|
||||
const pending = new Set<string>()
|
||||
const listeners = new Set<() => void>()
|
||||
let version = 0
|
||||
const notify = () => {
|
||||
version += 1
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
return {
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
version: () => version,
|
||||
has: (id) => touched.has(id),
|
||||
mark: (id) => {
|
||||
if (touched.has(id)) return
|
||||
touched.add(id)
|
||||
notify()
|
||||
},
|
||||
unmark: (id) => {
|
||||
if (!touched.delete(id)) return
|
||||
notify()
|
||||
},
|
||||
isPending: (id) => pending.has(id),
|
||||
markPending: (id) => {
|
||||
if (pending.has(id)) return
|
||||
pending.add(id)
|
||||
notify()
|
||||
},
|
||||
resolvePending: (id) => {
|
||||
if (!pending.delete(id)) return
|
||||
notify()
|
||||
},
|
||||
reset: () => {
|
||||
if (touched.size === 0 && pending.size === 0) return
|
||||
touched.clear()
|
||||
pending.clear()
|
||||
notify()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const FALLBACK_ROW_STATE_STORE = createFilterRowStateStore()
|
||||
|
||||
const FilterRowStateContext = React.createContext<FilterRowStateStore>(
|
||||
FALLBACK_ROW_STATE_STORE
|
||||
)
|
||||
|
||||
export const FilterRowStateProvider = FilterRowStateContext.Provider
|
||||
|
||||
/** The store itself, for handlers that write without subscribing. */
|
||||
export function useFilterRowStateStore(): FilterRowStateStore {
|
||||
return React.useContext(FilterRowStateContext)
|
||||
}
|
||||
|
||||
/** Whether THIS rule is still waiting for its attribute. */
|
||||
export function useFilterRowPending(id: string): boolean {
|
||||
const store = React.useContext(FilterRowStateContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => store.isPending(id),
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether THIS rule may show an error yet. */
|
||||
export function useFilterTouched(id: string): boolean {
|
||||
const store = React.useContext(FilterRowStateContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => store.has(id),
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
/** The whole focus snapshot, so the caller re-renders on EVERY move anywhere
|
||||
* in the row. A chip wants `useFilterChipFocused` or `useFilterSegmentFocus`
|
||||
* below instead. */
|
||||
export function useFilterFocus(): FilterFocus {
|
||||
const store = React.useContext(FilterFocusContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
/** The store itself, for event handlers that write without subscribing. */
|
||||
export function useFilterFocusStore(): FilterFocusStore {
|
||||
return React.useContext(FilterFocusContext)
|
||||
}
|
||||
|
||||
/** The segment of THIS chip that should open itself, or null. */
|
||||
export function useFilterChipAutoOpen(
|
||||
id: string
|
||||
): FilterFocus["segment"] | null {
|
||||
const store = React.useContext(FilterFocusContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => {
|
||||
const snapshot = store.getSnapshot()
|
||||
return snapshot.autoOpen && snapshot.id === id ? snapshot.segment : null
|
||||
},
|
||||
() => null
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the row holds no focus, so the first chip keeps the tab stop. */
|
||||
export function useFilterFocusEmpty(): boolean {
|
||||
const store = React.useContext(FilterFocusContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => store.getSnapshot().id === null,
|
||||
() => true
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether THIS chip owns the row's tab stop. */
|
||||
export function useFilterChipFocused(id: string): boolean {
|
||||
const store = React.useContext(FilterFocusContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => store.getSnapshot().id === id,
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
/** Which segment of THIS rule owns the tab stop, or null when another rule
|
||||
* does. A chip needs one boolean, but an advanced row is a grid ROW whose tab
|
||||
* stop is a (row, column) pair, and the column has to come from this store
|
||||
* too or the chromes disagree about where focus is after an edit. */
|
||||
export function useFilterSegmentFocus(id: string): FilterFocus["segment"] {
|
||||
const store = React.useContext(FilterFocusContext)
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
() => {
|
||||
const snapshot = store.getSnapshot()
|
||||
return snapshot.id === id ? snapshot.segment : null
|
||||
},
|
||||
() => null
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Resolution store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** The instance-wide value-to-label store. `useFilterOptions` caches per HOOK
|
||||
* INSTANCE, and a chip's display and its editor are two instances, so a
|
||||
* `loadOptions`-only field rendered its raw id the moment the menu closed.
|
||||
* Every instance under one root writes here, and `resolveValues` results land
|
||||
* here too. External rather than React state because labels arrive from
|
||||
* effects and promises at their own pace, and the version bumps only when a
|
||||
* NEW label lands, so a subscriber re-renders once per page of results. */
|
||||
export interface FilterResolutionStore {
|
||||
subscribe: (onStoreChange: () => void) => () => void
|
||||
getVersion: () => number
|
||||
/** The option behind a stored value, or undefined while unresolved. */
|
||||
get: (fieldKey: string, value: string) => FilterOption | undefined
|
||||
/** Records options under a field key. New values bump the version. */
|
||||
set: (fieldKey: string, options: readonly FilterOption[]) => void
|
||||
/** Marks values as resolving and returns the subset nobody has claimed yet,
|
||||
* so two chips holding the same id issue ONE request. Claims are permanent
|
||||
* for values a fulfilled resolve did not return, which stops an id the
|
||||
* server does not know from being re-asked forever. */
|
||||
claim: (fieldKey: string, values: readonly string[]) => string[]
|
||||
/** Releases claims after a FAILED resolve, so a later mount may retry. */
|
||||
release: (fieldKey: string, values: readonly string[]) => void
|
||||
}
|
||||
|
||||
export function createFilterResolutionStore(): FilterResolutionStore {
|
||||
const resolved = new Map<string, Map<string, FilterOption>>()
|
||||
const claimed = new Map<string, Set<string>>()
|
||||
const listeners = new Set<() => void>()
|
||||
let version = 0
|
||||
|
||||
const bucket = (fieldKey: string) => {
|
||||
let map = resolved.get(fieldKey)
|
||||
if (!map) {
|
||||
map = new Map()
|
||||
resolved.set(fieldKey, map)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(onStoreChange) {
|
||||
listeners.add(onStoreChange)
|
||||
return () => {
|
||||
listeners.delete(onStoreChange)
|
||||
}
|
||||
},
|
||||
getVersion() {
|
||||
return version
|
||||
},
|
||||
get(fieldKey, value) {
|
||||
return resolved.get(fieldKey)?.get(value)
|
||||
},
|
||||
set(fieldKey, options) {
|
||||
const map = bucket(fieldKey)
|
||||
let landed = false
|
||||
for (const option of options) {
|
||||
if (!map.has(option.value)) landed = true
|
||||
map.set(option.value, option)
|
||||
}
|
||||
if (!landed) return
|
||||
version += 1
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
claim(fieldKey, values) {
|
||||
let set = claimed.get(fieldKey)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
claimed.set(fieldKey, set)
|
||||
}
|
||||
const map = resolved.get(fieldKey)
|
||||
const fresh: string[] = []
|
||||
for (const value of values) {
|
||||
if (set.has(value) || map?.has(value)) continue
|
||||
set.add(value)
|
||||
fresh.push(value)
|
||||
}
|
||||
return fresh
|
||||
},
|
||||
release(fieldKey, values) {
|
||||
const set = claimed.get(fieldKey)
|
||||
if (!set) return
|
||||
for (const value of values) set.delete(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
FilterActionsContext,
|
||||
FilterFocusContext,
|
||||
FilterRenderContext,
|
||||
FilterStateContext,
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
addYears,
|
||||
format,
|
||||
isValid,
|
||||
parse,
|
||||
startOfDay,
|
||||
} from "date-fns"
|
||||
|
||||
/**
|
||||
* A date filter value. NOT a `Date`: a filter gets persisted (saved views,
|
||||
* shared URLs), and a resolved `Date` would freeze "created today" into one
|
||||
* fixed day. An absolute pick still stores one day, because "on 14 August"
|
||||
* does mean that day.
|
||||
*/
|
||||
export interface FilterDateValue {
|
||||
/** An absolute day, `yyyy-MM-dd`. Mutually exclusive with `relative`. */
|
||||
date?: string
|
||||
/** A token re-resolved on every read. */
|
||||
relative?: FilterRelativeDate
|
||||
/** Optional time of day, `HH:mm`. */
|
||||
time?: string
|
||||
}
|
||||
|
||||
/** A calendar offset from now, resolved at read time. */
|
||||
export interface FilterRelativeDate {
|
||||
unit: "day" | "week" | "month" | "year"
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const FILTER_DATE_FORMAT = "yyyy-MM-dd"
|
||||
|
||||
export const RELATIVE_TODAY: FilterRelativeDate = { unit: "day", offset: 0 }
|
||||
export const RELATIVE_TOMORROW: FilterRelativeDate = { unit: "day", offset: 1 }
|
||||
export const RELATIVE_YESTERDAY: FilterRelativeDate = {
|
||||
unit: "day",
|
||||
offset: -1,
|
||||
}
|
||||
export const RELATIVE_NEXT_WEEK: FilterRelativeDate = {
|
||||
unit: "week",
|
||||
offset: 1,
|
||||
}
|
||||
|
||||
export function applyFilterRelative(
|
||||
relative: FilterRelativeDate,
|
||||
now: Date
|
||||
): Date {
|
||||
const base = startOfDay(now)
|
||||
if (relative.unit === "day") return addDays(base, relative.offset)
|
||||
if (relative.unit === "week") return addWeeks(base, relative.offset)
|
||||
if (relative.unit === "month") return addMonths(base, relative.offset)
|
||||
return addYears(base, relative.offset)
|
||||
}
|
||||
|
||||
export function resolveFilterDate(
|
||||
value: FilterDateValue | undefined,
|
||||
now: Date = new Date()
|
||||
): Date | null {
|
||||
if (!value) return null
|
||||
if (value.relative) return applyFilterRelative(value.relative, now)
|
||||
if (value.date) {
|
||||
const parsed = parse(value.date, FILTER_DATE_FORMAT, now)
|
||||
return isValid(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function toFilterDateValue(date: Date, time?: string): FilterDateValue {
|
||||
const value: FilterDateValue = { date: format(date, FILTER_DATE_FORMAT) }
|
||||
if (time) value.time = time
|
||||
return value
|
||||
}
|
||||
|
||||
// ----- natural language input -----
|
||||
|
||||
const WEEKDAYS = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
]
|
||||
|
||||
/**
|
||||
* Typed formats, most specific first. `date-selector.tsx` overlaps on a few
|
||||
* of these, but its parser is a callback inside the component returning a
|
||||
* `DateSelectorValue`, so there is nothing importable.
|
||||
*/
|
||||
const EXPLICIT_FORMATS = [
|
||||
"yyyy-MM-dd",
|
||||
"MM/dd/yyyy",
|
||||
"M/d/yyyy",
|
||||
"dd/MM/yyyy",
|
||||
"MMMM d, yyyy",
|
||||
"MMM d, yyyy",
|
||||
"MMMM d",
|
||||
"MMM d",
|
||||
"d MMMM yyyy",
|
||||
"d MMM yyyy",
|
||||
]
|
||||
|
||||
/**
|
||||
* Parses a typed phrase, or null when nothing matches. Relative phrasing gives
|
||||
* a RELATIVE value and an explicit date an ABSOLUTE one; flattening the two is
|
||||
* what freezes a saved view.
|
||||
*/
|
||||
export function parseFilterDate(
|
||||
text: string,
|
||||
now: Date = new Date()
|
||||
): FilterDateValue | null {
|
||||
const input = text.trim().toLowerCase().replace(/\s+/g, " ")
|
||||
if (!input) return null
|
||||
|
||||
if (input === "today" || input === "now") return { relative: RELATIVE_TODAY }
|
||||
if (input === "tomorrow") return { relative: RELATIVE_TOMORROW }
|
||||
if (input === "yesterday") return { relative: RELATIVE_YESTERDAY }
|
||||
|
||||
const nextLast = input.match(/^(next|last|this) (day|week|month|year)$/)
|
||||
if (nextLast) {
|
||||
const direction =
|
||||
nextLast[1] === "next" ? 1 : nextLast[1] === "last" ? -1 : 0
|
||||
return {
|
||||
relative: {
|
||||
unit: nextLast[2] as FilterRelativeDate["unit"],
|
||||
offset: direction,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// "in 3 days" / "3 days ago" / "in 2 weeks"
|
||||
const counted = input.match(
|
||||
/^(?:in )?(\d+) (day|week|month|year)s?(?: ago)?$/
|
||||
)
|
||||
if (counted) {
|
||||
const magnitude = Number.parseInt(counted[1], 10)
|
||||
const past = input.endsWith("ago")
|
||||
return {
|
||||
relative: {
|
||||
unit: counted[2] as FilterRelativeDate["unit"],
|
||||
offset: past ? -magnitude : magnitude,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// "next tuesday" / "last friday" / bare "tuesday"
|
||||
const weekday = input.match(/^(?:(next|last|this) )?([a-z]+)$/)
|
||||
if (weekday) {
|
||||
const index = WEEKDAYS.indexOf(weekday[2])
|
||||
if (index !== -1) {
|
||||
const today = startOfDay(now)
|
||||
const current = today.getDay()
|
||||
let delta = index - current
|
||||
const qualifier = weekday[1]
|
||||
|
||||
if (qualifier === "last") {
|
||||
// Always strictly in the past.
|
||||
if (delta >= 0) delta -= 7
|
||||
} else {
|
||||
// "next"/"this" and a bare weekday all mean the NEXT one, not today.
|
||||
if (delta <= 0) delta += 7
|
||||
}
|
||||
|
||||
return { relative: { unit: "day", offset: delta } }
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of EXPLICIT_FORMATS) {
|
||||
const parsed = parse(input, pattern, now)
|
||||
if (isValid(parsed)) {
|
||||
// "Aug 14" has no year, so it takes the reference year.
|
||||
return toFilterDateValue(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Human wording for a value. Relative values render as their phrase, not the
|
||||
* resolved day, so a chip reading "is today" does not go stale tomorrow.
|
||||
*/
|
||||
export function formatFilterDate(
|
||||
value: FilterDateValue | undefined,
|
||||
now: Date = new Date(),
|
||||
pattern = "MMM d, yyyy"
|
||||
): string {
|
||||
if (!value) return ""
|
||||
|
||||
if (value.relative) {
|
||||
const { unit, offset } = value.relative
|
||||
if (unit === "day" && offset === 0) return "today"
|
||||
if (unit === "day" && offset === 1) return "tomorrow"
|
||||
if (unit === "day" && offset === -1) return "yesterday"
|
||||
|
||||
const plural = Math.abs(offset) === 1 ? unit : `${unit}s`
|
||||
if (offset > 0) return `in ${offset} ${plural}`
|
||||
if (offset < 0) return `${Math.abs(offset)} ${plural} ago`
|
||||
return `this ${unit}`
|
||||
}
|
||||
|
||||
const resolved = resolveFilterDate(value, now)
|
||||
if (!resolved) return ""
|
||||
const day = format(resolved, pattern)
|
||||
return value.time ? `${day} ${value.time}` : day
|
||||
}
|
||||
|
||||
export function isFilterTime(time: string): boolean {
|
||||
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time)
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* Gesture model copied from `event-calendar-dnd.tsx`, deliberately not
|
||||
* imported: filters installs on its own and must not pull a whole calendar into
|
||||
* `shadcn add filters`. Vanilla-DOM overlay, so a drag renders no React.
|
||||
*/
|
||||
export const FILTER_DND_ACTIVATION = {
|
||||
/** Mouse travel before a press becomes a drag. Below this it stays a click. */
|
||||
moveDistancePx: 5,
|
||||
/** Touch long-press before a drag starts, so a swipe still scrolls. */
|
||||
touchDelayMs: 250,
|
||||
touchTolerancePx: 5,
|
||||
autoScrollEdgePx: 40,
|
||||
autoScrollMaxStepPx: 12,
|
||||
/** A seed only: `createCarry` measures the real grab offset before paint. */
|
||||
carryPointerOffsetPx: 12,
|
||||
} as const
|
||||
|
||||
/** One rule row, or one group's header row. Both are drag sources and targets. */
|
||||
export const FILTER_ROW_SELECTOR = '[data-slot="filter-row"]'
|
||||
|
||||
/** Explicit "drop INSIDE": a row hit test can only ever say "beside". */
|
||||
export const FILTER_DROP_ZONE_SELECTOR = "[data-drop-parent]"
|
||||
|
||||
/** Marked alongside the zone, so lighting the group costs no `:has()`. */
|
||||
export const FILTER_GROUP_SELECTOR = '[data-slot="filter-group"]'
|
||||
|
||||
/** Geometry only, so resolution is a pure function and a table test. */
|
||||
export interface FilterDropBox {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
/** The group the drop writes into. */
|
||||
parentId: string
|
||||
/** Where in that group's children the node lands, not a slot in this list. */
|
||||
index: number
|
||||
/** How many rows enclose this box. Deeper wins. */
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface FilterDropResolution<T extends FilterDropBox> {
|
||||
target: T
|
||||
/** Null for a zone: INTO the group rather than beside anything. */
|
||||
edge: "before" | "after" | null
|
||||
}
|
||||
|
||||
function contains(box: FilterDropBox, x: number, y: number, grow = 0): boolean {
|
||||
return (
|
||||
x >= box.left &&
|
||||
x <= box.right &&
|
||||
y >= box.top - grow &&
|
||||
y <= box.bottom + grow
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a ROW reaches past its own rect when hit tested: HALF the sibling
|
||||
* gap, which is `gap-3`, so six. `FILTER_ROW_GAP_CLASS` is the other half of
|
||||
* one measurement; this moves whenever that does.
|
||||
*
|
||||
* The seam between adjacent rows is inside NEITHER rect, so a pointer aimed
|
||||
* between two rows fell through to whatever did enclose it: at the top level
|
||||
* nothing did, so the cursor said `no-drop`; one level in it was the group.
|
||||
* Half the gap makes siblings tile, and the depth sort keeps the child ahead of
|
||||
* that group. Rows only: zones are explicit strips.
|
||||
*/
|
||||
export const FILTER_ROW_SEAM_PX = 6
|
||||
|
||||
/**
|
||||
* Zones before rows, then the DEEPEST box: a group's rect encloses everything
|
||||
* it holds, so a nested condition would otherwise resolve to its whole group.
|
||||
* `seam` is a parameter, not the constant inlined, so the tiling rule itself is
|
||||
* a table test.
|
||||
*/
|
||||
export function resolveFilterDrop<T extends FilterDropBox>(
|
||||
zones: T[],
|
||||
rows: T[],
|
||||
x: number,
|
||||
y: number,
|
||||
seam: number = FILTER_ROW_SEAM_PX
|
||||
): FilterDropResolution<T> | null {
|
||||
let zone: T | null = null
|
||||
for (const candidate of zones) {
|
||||
if (!contains(candidate, x, y)) continue
|
||||
if (!zone || candidate.depth > zone.depth) zone = candidate
|
||||
}
|
||||
if (zone) return { target: zone, edge: null }
|
||||
|
||||
let row: T | null = null
|
||||
for (const candidate of rows) {
|
||||
if (!contains(candidate, x, y, seam)) continue
|
||||
if (!row || candidate.depth > row.depth) row = candidate
|
||||
}
|
||||
if (!row) return null
|
||||
|
||||
// The midpoint, not the nearest edge: a row is the width of the panel, so a
|
||||
// proximity test would leave the indicator undecided across most of one.
|
||||
const after = y > row.top + (row.bottom - row.top) / 2
|
||||
return {
|
||||
target: { ...row, index: row.index + (after ? 1 : 0) },
|
||||
edge: after ? "after" : "before",
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a dragged node started, which is what makes a drop a no-op or not. */
|
||||
export interface FilterDropOrigin {
|
||||
parentId: string
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Both indices name the slot the node already occupies, because
|
||||
* `moveFilterNodeTo` closes the gap the detach leaves. Copy is never a no-op.
|
||||
*/
|
||||
export function isFilterDropNoop(
|
||||
resolution: FilterDropResolution<FilterDropBox> | null,
|
||||
origin: FilterDropOrigin | null,
|
||||
copy: boolean
|
||||
): boolean {
|
||||
if (!resolution || !origin || copy) return false
|
||||
if (resolution.target.parentId !== origin.parentId) return false
|
||||
return (
|
||||
resolution.target.index === origin.index ||
|
||||
resolution.target.index === origin.index + 1
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows unmount mid-gesture, so it is the PANEL leaving that aborts a drag. */
|
||||
const activeCancels = new Set<() => void>()
|
||||
|
||||
/** Cancel and fully revert every in-flight filter row drag. */
|
||||
export function cancelActiveFilterDrags(): void {
|
||||
for (const cancel of [...activeCancels]) cancel()
|
||||
}
|
||||
|
||||
let lastDragEndedAt = 0
|
||||
|
||||
/**
|
||||
* Whether a drag ended within the last few frames. Exported: a consumer's own
|
||||
* row click handler needs the same answer the suppressor below needs.
|
||||
*/
|
||||
export function wasRecentFilterDrag(): boolean {
|
||||
return performance.now() - lastDragEndedAt < 250
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT `{ once: true }` alone: a drag that ends over a different element fires
|
||||
* no click at all, and a stale arming would eat the user's next click. The
|
||||
* timestamp is what makes that stale arming harmless.
|
||||
*/
|
||||
function suppressTrailingClick(event: MouseEvent) {
|
||||
window.removeEventListener("click", suppressTrailingClick, true)
|
||||
if (!wasRecentFilterDrag()) return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/** The carry is a compositing layer: a subpixel translate blurs its text. */
|
||||
function snapToPixel(value: number): number {
|
||||
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
|
||||
return Math.round(value * dpr) / dpr
|
||||
}
|
||||
|
||||
/**
|
||||
* `card` is a ZONE's group, resolved once rather than walked per frame. Null on
|
||||
* a row, which stands for itself.
|
||||
*/
|
||||
type MeasuredBox = FilterDropBox & {
|
||||
el: HTMLElement
|
||||
card: HTMLElement | null
|
||||
}
|
||||
|
||||
interface Surface {
|
||||
zones: MeasuredBox[]
|
||||
rows: MeasuredBox[]
|
||||
/**
|
||||
* Rects are captured ONCE, so one subtraction against this scrollTop corrects
|
||||
* for scrolling instead of a per-move `getBoundingClientRect`. THE ELEMENT,
|
||||
* NOT A MIRRORED NUMBER: a mirror tracks only this engine's own scrolling, so
|
||||
* a wheel mid-gesture left the rects stale while the correction read zero,
|
||||
* painting the indicator 60px off the pointer.
|
||||
*/
|
||||
viewport: HTMLElement | null
|
||||
viewportRect: DOMRect | null
|
||||
/** The one number kept: where the rects were taken, which cannot change. */
|
||||
startScrollTop: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest scroller, THIS ONE INCLUDED: the panel is exactly what a consumer
|
||||
* caps with `overflow-y: auto`, and a walk from the parent answered null for it.
|
||||
*/
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el ?? null
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
const overflow = `${style.overflowY} ${style.overflow}`
|
||||
if (
|
||||
/(auto|scroll|overlay)/.test(overflow) &&
|
||||
node.scrollHeight > node.clientHeight
|
||||
) {
|
||||
return node
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function rowDepth(el: HTMLElement, root: HTMLElement): number {
|
||||
let depth = 0
|
||||
let node = el.parentElement
|
||||
while (node && node !== root) {
|
||||
if (node.matches(FILTER_ROW_SELECTOR)) depth++
|
||||
node = node.parentElement
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured once. DESCENDANTS of the dragged node are excluded rather than
|
||||
* refused later: an indicator the drop ignores is a broken promise. The dragged
|
||||
* row ITSELF is kept, so its own slot reads as a no-op, not as a hole.
|
||||
*/
|
||||
function collectSurface(root: HTMLElement, dragged: HTMLElement): Surface {
|
||||
const zones: MeasuredBox[] = []
|
||||
const rows: MeasuredBox[] = []
|
||||
|
||||
for (const el of root.querySelectorAll<HTMLElement>(
|
||||
FILTER_DROP_ZONE_SELECTOR
|
||||
)) {
|
||||
if (dragged.contains(el)) continue
|
||||
const parentId = el.dataset.dropParent
|
||||
if (!parentId) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
zones.push({
|
||||
el,
|
||||
card: el.closest<HTMLElement>(FILTER_GROUP_SELECTOR),
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
parentId,
|
||||
index: Number(el.dataset.dropIndex ?? 0),
|
||||
depth: rowDepth(el, root),
|
||||
})
|
||||
}
|
||||
|
||||
for (const el of root.querySelectorAll<HTMLElement>(FILTER_ROW_SELECTOR)) {
|
||||
if (el !== dragged && dragged.contains(el)) continue
|
||||
const parentId = el.dataset.parentId
|
||||
if (!parentId) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
rows.push({
|
||||
el,
|
||||
card: null,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
parentId,
|
||||
index: Number(el.dataset.index ?? 0),
|
||||
depth: rowDepth(el, root),
|
||||
})
|
||||
}
|
||||
|
||||
const viewport = findScrollParent(root)
|
||||
return {
|
||||
zones,
|
||||
rows,
|
||||
viewport,
|
||||
viewportRect: viewport?.getBoundingClientRect() ?? null,
|
||||
startScrollTop: viewport?.scrollTop ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface FilterDropDetails {
|
||||
parentId: string
|
||||
index: number
|
||||
/** Alt was held at release: copy the node rather than moving it. */
|
||||
copy: boolean
|
||||
}
|
||||
|
||||
export interface FilterRowDragOptions {
|
||||
/** The PANEL, not its body: the top-level append zone is in the footer. */
|
||||
root: () => HTMLElement | null
|
||||
onDrop: (nodeId: string, details: FilterDropDetails) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Straight to the DOM: a React state write per move is a tree render. A zone
|
||||
* marks TWO elements, itself and its card, and both clear every frame.
|
||||
*/
|
||||
function paint(
|
||||
surface: Surface,
|
||||
hit: FilterDropResolution<MeasuredBox> | null
|
||||
) {
|
||||
for (const box of surface.zones) {
|
||||
delete box.el.dataset.dropInto
|
||||
if (box.card) delete box.card.dataset.dropInto
|
||||
}
|
||||
for (const box of surface.rows) delete box.el.dataset.dropEdge
|
||||
if (!hit) return
|
||||
if (hit.edge) {
|
||||
hit.target.el.dataset.dropEdge = hit.edge
|
||||
return
|
||||
}
|
||||
hit.target.el.dataset.dropInto = ""
|
||||
if (hit.target.card) hit.target.card.dataset.dropInto = ""
|
||||
}
|
||||
|
||||
function startDrag(
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
nodeId: string,
|
||||
options: FilterRowDragOptions
|
||||
) {
|
||||
const handle = event.currentTarget
|
||||
const root = options.root()
|
||||
const source = handle.closest<HTMLElement>(FILTER_ROW_SELECTOR)
|
||||
if (!root || !source) return
|
||||
|
||||
const pointerId = event.pointerId
|
||||
const isTouch = event.pointerType === "touch"
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
|
||||
// Off the row's own attributes, which it already publishes for the hit test.
|
||||
// Asking the panel would mean threading a position through the drag context.
|
||||
const origin: FilterDropOrigin | null = source.dataset.parentId
|
||||
? {
|
||||
parentId: source.dataset.parentId,
|
||||
index: Number(source.dataset.index ?? 0),
|
||||
}
|
||||
: null
|
||||
|
||||
let active = false
|
||||
let finished = false
|
||||
let surface: Surface | null = null
|
||||
let hit: FilterDropResolution<MeasuredBox> | null = null
|
||||
let copy = event.altKey
|
||||
let lastEvent: PointerEvent | null = null
|
||||
let touchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let rafScroll = 0
|
||||
|
||||
// Three states, not a boolean: `"ok"` lands somewhere new, `"noop"` puts the
|
||||
// node back where it came from, `"none"` is over nothing at all.
|
||||
let dropState: "ok" | "noop" | "none" = "none"
|
||||
|
||||
let carryEl: HTMLDivElement | null = null
|
||||
let badgeEl: HTMLSpanElement | null = null
|
||||
// Where inside the row the pointer went down, so the copy holds that spot.
|
||||
// Measured in `createCarry`, which runs before anything reads these.
|
||||
let grabOffsetX = FILTER_DND_ACTIVATION.carryPointerOffsetPx
|
||||
let grabOffsetY = FILTER_DND_ACTIVATION.carryPointerOffsetPx
|
||||
|
||||
const createCarry = () => {
|
||||
const rect = source.getBoundingClientRect()
|
||||
grabOffsetX = startX - rect.left
|
||||
grabOffsetY = startY - rect.top
|
||||
|
||||
carryEl = document.createElement("div")
|
||||
carryEl.setAttribute("data-slot", "filters-drag-carry")
|
||||
carryEl.setAttribute("aria-hidden", "true")
|
||||
// `data-drop-invalid` rather than a toggled class name, so the style stays
|
||||
// one static string Tailwind can see at build time.
|
||||
//
|
||||
// Muted and translucent, no border or shadow: the clone carries the row's
|
||||
// own controls, and a box around them read as a card leaving the page. Only
|
||||
// the invalid state marks the clone, since nothing else says the drop is
|
||||
// dead, and it RINGS rather than going dashed: dashed is this builder's
|
||||
// word for "a row can land here", and the clone is not one.
|
||||
carryEl.className =
|
||||
"pointer-events-none fixed " +
|
||||
"top-0 left-0 z-100 " +
|
||||
"opacity-95 will-change-transform " +
|
||||
"data-drop-invalid:ring-destructive/70 data-drop-invalid:ring-2 " +
|
||||
"data-drop-invalid:opacity-60"
|
||||
// A bare anchor: the clip box below owns the size, and no overflow lets the
|
||||
// badge overhang.
|
||||
carryEl.style.position = "fixed"
|
||||
|
||||
const clone = source.cloneNode(true) as HTMLElement
|
||||
// The source is faded while in flight; its copy must not inherit that.
|
||||
delete clone.dataset.dragging
|
||||
delete clone.dataset.dropNoop
|
||||
delete clone.dataset.dropEdge
|
||||
if (rect.width > 0) clone.style.width = `${rect.width}px`
|
||||
|
||||
/*
|
||||
SANITIZED, because the clone lives on `<body>`: two elements would claim
|
||||
one node id, `aria-hidden` leaves real buttons in the tab order and must
|
||||
never wrap a focused one, and cloned ids make a `for` resolve to a copy.
|
||||
*/
|
||||
const strip = (el: HTMLElement) => {
|
||||
el.removeAttribute("id")
|
||||
el.removeAttribute("data-node-id")
|
||||
el.removeAttribute("data-filter-cell")
|
||||
if (el.dataset.slot === "filter-row") el.removeAttribute("data-slot")
|
||||
// `-1` rather than removed: the attribute is also how the roving scheme
|
||||
// marks its cells, and a row copied mid-gesture has one cell at `0`.
|
||||
el.setAttribute("tabindex", "-1")
|
||||
}
|
||||
strip(clone)
|
||||
for (const el of clone.querySelectorAll<HTMLElement>("*")) strip(el)
|
||||
|
||||
/*
|
||||
The CLIPPING box is the inner one: the carry is the badge's containing
|
||||
block, so `overflow-hidden` there clipped the badge that overhangs it.
|
||||
*/
|
||||
const clip = document.createElement("div")
|
||||
clip.className = "bg-background overflow-hidden rounded-md"
|
||||
// Sized only when the rect is real: a document that is not being laid out
|
||||
// measures zero, and a copy pinned to `0px` is invisible.
|
||||
if (rect.width > 0) clip.style.width = `${rect.width}px`
|
||||
if (rect.height > 0) clip.style.height = `${rect.height}px`
|
||||
clip.appendChild(clone)
|
||||
carryEl.appendChild(clip)
|
||||
|
||||
badgeEl = document.createElement("span")
|
||||
badgeEl.setAttribute("data-slot", "filters-drag-copy")
|
||||
badgeEl.className =
|
||||
"bg-primary text-primary-foreground absolute -end-1.5 -top-1.5 flex " +
|
||||
"size-4 items-center justify-center rounded-full text-[10px] leading-none"
|
||||
badgeEl.textContent = "+"
|
||||
// Legible BEFORE the release, or Alt is discoverable only by error.
|
||||
badgeEl.hidden = !copy
|
||||
carryEl.appendChild(badgeEl)
|
||||
|
||||
// <body>, not the panel: a transform or a `content-visibility` on a wrapper
|
||||
// becomes the containing block for `position: fixed`, and the clone jumps.
|
||||
document.body.appendChild(carryEl)
|
||||
positionCarry(startX, startY)
|
||||
}
|
||||
|
||||
const positionCarry = (x: number, y: number) => {
|
||||
if (!carryEl) return
|
||||
// THE GRAB OFFSET, not a fixed nudge: the grip sits at the row's trailing
|
||||
// edge, so anchoring at the pointer hung the copy a row's width away.
|
||||
carryEl.style.transform = `translate3d(${snapToPixel(x - grabOffsetX)}px, ${snapToPixel(y - grabOffsetY)}px, 0)`
|
||||
}
|
||||
|
||||
// Two things decide the cursor and the RESOLUTION outranks Alt, which is why
|
||||
// `none` is tested first: `copy` over nothing is still nothing.
|
||||
const paintCursor = () => {
|
||||
if (!active) return
|
||||
document.body.style.cursor =
|
||||
dropState === "none" ? "no-drop" : copy ? "copy" : "grabbing"
|
||||
}
|
||||
|
||||
// Deliberately NOT guarded on `dropState === next`: the first resolution of a
|
||||
// gesture is routinely `"none"`, which is also the initial value, so a guard
|
||||
// left the first frame of a drag onto nothing wearing no mark at all.
|
||||
const setDropState = (next: typeof dropState) => {
|
||||
dropState = next
|
||||
// On the SOURCE: "it stays here" is a statement about the row carried.
|
||||
if (next === "noop") source.dataset.dropNoop = ""
|
||||
else delete source.dataset.dropNoop
|
||||
// And on the CARRY: over nothing there is no target left to mark.
|
||||
if (carryEl) {
|
||||
if (next === "none") carryEl.dataset.dropInvalid = ""
|
||||
else delete carryEl.dataset.dropInvalid
|
||||
}
|
||||
paintCursor()
|
||||
}
|
||||
|
||||
const setCopy = (next: boolean) => {
|
||||
if (copy === next) return
|
||||
copy = next
|
||||
if (badgeEl) badgeEl.hidden = !next
|
||||
paintCursor()
|
||||
// Alt changes the ANSWER, not just the cursor: a release beside yourself is
|
||||
// a no-op while moving and a duplicate in place while copying.
|
||||
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
}
|
||||
|
||||
const hitTest = (x: number, y: number) => {
|
||||
if (!surface) return
|
||||
// One subtraction instead of a re-measure. Read off the ELEMENT every time:
|
||||
// see `Surface.viewport`, a mirror misses the user's own scroll.
|
||||
const scrolled = surface.viewport?.scrollTop ?? surface.startScrollTop
|
||||
const drift = scrolled - surface.startScrollTop
|
||||
const resolved = resolveFilterDrop(
|
||||
surface.zones,
|
||||
surface.rows,
|
||||
x,
|
||||
y + drift
|
||||
)
|
||||
const noop = isFilterDropNoop(resolved, origin, copy)
|
||||
// A no-op commits as NOTHING, not as a move the tree would discard.
|
||||
hit = noop ? null : resolved
|
||||
paint(surface, hit)
|
||||
setDropState(noop ? "noop" : hit ? "ok" : "none")
|
||||
}
|
||||
|
||||
const autoScroll = (y: number) => {
|
||||
if (rafScroll) cancelAnimationFrame(rafScroll)
|
||||
rafScroll = 0
|
||||
const viewport = surface?.viewport
|
||||
const rect = surface?.viewportRect
|
||||
if (!viewport || !rect) return
|
||||
|
||||
const edge = FILTER_DND_ACTIVATION.autoScrollEdgePx
|
||||
const step = FILTER_DND_ACTIVATION.autoScrollMaxStepPx
|
||||
let delta = 0
|
||||
if (y >= rect.top && y < rect.top + edge) {
|
||||
delta = -step * ((rect.top + edge - y) / edge)
|
||||
} else if (y <= rect.bottom && y > rect.bottom - edge) {
|
||||
delta = step * Math.min(1, (y - (rect.bottom - edge)) / edge)
|
||||
}
|
||||
if (delta === 0) return
|
||||
|
||||
const tick = () => {
|
||||
// The read-back STOPS the loop: the browser clamps at the scroll extent,
|
||||
// so a panel at its limit would keep asking for unusable frames.
|
||||
const before = viewport.scrollTop
|
||||
viewport.scrollTop = before + delta
|
||||
if (viewport.scrollTop === before) return
|
||||
if (lastEvent) hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
rafScroll = requestAnimationFrame(tick)
|
||||
}
|
||||
rafScroll = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
/**
|
||||
* A still pointer fires no `pointermove`, so a wheel mid-drag would leave the
|
||||
* last answer painted while the rows slid out from under it. The overlap
|
||||
* with the autoScroll tick is deliberate: this is arithmetic over boxes
|
||||
* already measured, and one redundant pass per frame is cheaper than a rule
|
||||
* about who owns the repaint.
|
||||
*/
|
||||
const onViewportScroll = () => {
|
||||
if (!active || !lastEvent) return
|
||||
hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
}
|
||||
|
||||
const activate = () => {
|
||||
if (active) return
|
||||
active = true
|
||||
surface = collectSurface(root, source)
|
||||
|
||||
// Passive: nothing here calls `preventDefault`, and a non-passive listener
|
||||
// on a scroller is a frame of jank per notch.
|
||||
surface.viewport?.addEventListener("scroll", onViewportScroll, {
|
||||
passive: true,
|
||||
})
|
||||
source.dataset.dragging = ""
|
||||
document.body.style.cursor = copy ? "copy" : "grabbing"
|
||||
document.body.style.userSelect = "none"
|
||||
createCarry()
|
||||
// Armed with the GESTURE, not the press: a press that never travels far
|
||||
// enough strands no overlay, no body style and no listener.
|
||||
window.addEventListener("blur", onWindowBlur)
|
||||
}
|
||||
|
||||
// Idempotent. pointerup, pointercancel, Escape, window blur and the panel's
|
||||
// own teardown can all race; whichever lands first wins and the rest no-op.
|
||||
const cleanup = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
activeCancels.delete(cancel)
|
||||
window.removeEventListener("pointermove", onPointerMove)
|
||||
window.removeEventListener("pointerup", onPointerUp)
|
||||
window.removeEventListener("pointercancel", onPointerCancel)
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
window.removeEventListener("keyup", onKeyUp, true)
|
||||
window.removeEventListener("blur", onWindowBlur)
|
||||
surface?.viewport?.removeEventListener("scroll", onViewportScroll)
|
||||
if (touchTimer) clearTimeout(touchTimer)
|
||||
if (rafScroll) cancelAnimationFrame(rafScroll)
|
||||
if (surface) paint(surface, null)
|
||||
delete source.dataset.dragging
|
||||
delete source.dataset.dropNoop
|
||||
carryEl?.remove()
|
||||
carryEl = null
|
||||
badgeEl = null
|
||||
document.body.style.cursor = ""
|
||||
document.body.style.userSelect = ""
|
||||
}
|
||||
|
||||
/** A bare teardown: nothing is committed until release, so no snapshot. */
|
||||
const cancel = () => {
|
||||
if (active) lastDragEndedAt = performance.now()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const onWindowBlur = () => cancel()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && active) {
|
||||
// Stopped, or the popover the builder may be living in closes underneath
|
||||
// the gesture and takes the panel the drop was aimed at with it.
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (e.key === "Alt") setCopy(true)
|
||||
}
|
||||
|
||||
// Alt tracked on its own keys too: reaching for the modifier without moving
|
||||
// the mouse fires no pointermove, and the badge has to appear regardless.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Alt") setCopy(false)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
// A second finger must not drive or cancel the gesture this one started.
|
||||
if (e.pointerId !== pointerId) return
|
||||
lastEvent = e
|
||||
|
||||
if (!active) {
|
||||
const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
|
||||
if (isTouch) {
|
||||
// Past the tolerance the user is scrolling, so the press stays a scroll.
|
||||
if (distance > FILTER_DND_ACTIVATION.touchTolerancePx) cancel()
|
||||
return
|
||||
}
|
||||
if (distance < FILTER_DND_ACTIVATION.moveDistancePx) return
|
||||
activate()
|
||||
}
|
||||
|
||||
setCopy(e.altKey)
|
||||
positionCarry(e.clientX, e.clientY)
|
||||
hitTest(e.clientX, e.clientY)
|
||||
autoScroll(e.clientY)
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return
|
||||
const landed = active ? hit : null
|
||||
const wasCopy = copy
|
||||
cleanup()
|
||||
if (!active) return
|
||||
|
||||
lastDragEndedAt = performance.now()
|
||||
// The trailing click belongs to the gesture, not to whatever it ended over.
|
||||
window.addEventListener("click", suppressTrailingClick, true)
|
||||
|
||||
if (!landed) return
|
||||
options.onDrop(nodeId, {
|
||||
parentId: landed.target.parentId,
|
||||
index: landed.target.index,
|
||||
copy: wasCopy,
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerCancel = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return
|
||||
cancel()
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove)
|
||||
window.addEventListener("pointerup", onPointerUp)
|
||||
window.addEventListener("pointercancel", onPointerCancel)
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
window.addEventListener("keyup", onKeyUp, true)
|
||||
activeCancels.add(cancel)
|
||||
|
||||
if (isTouch) {
|
||||
touchTimer = setTimeout(() => {
|
||||
activate()
|
||||
if (lastEvent) {
|
||||
positionCarry(lastEvent.clientX, lastEvent.clientY)
|
||||
hitTest(lastEvent.clientX, lastEvent.clientY)
|
||||
}
|
||||
}, FILTER_DND_ACTIVATION.touchDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only `onPointerDown`: the handle is a real button that Tab reaches, so it must
|
||||
* not also be `draggable` and race the browser's HTML5 drag, with its ghost, no
|
||||
* copy affordance and no way to report a drop into a nested target.
|
||||
*/
|
||||
export function useFilterRowDrag(options: FilterRowDragOptions) {
|
||||
// Read at gesture time, not captured at wiring time, so the handler identity
|
||||
// is stable and a memoized row never re-renders on a re-created callback.
|
||||
const latest = React.useRef(options)
|
||||
React.useEffect(() => {
|
||||
latest.current = options
|
||||
})
|
||||
|
||||
// A gesture outlives its row by design, but never the panel: its listeners
|
||||
// are on `window` and its carry on `document.body`.
|
||||
React.useEffect(() => cancelActiveFilterDrags, [])
|
||||
|
||||
return React.useCallback(
|
||||
(nodeId: string) => ({
|
||||
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
|
||||
if (latest.current.disabled) return
|
||||
// Primary button only: a right press strands the drag under a menu.
|
||||
if (event.button !== 0) return
|
||||
startDrag(event, nodeId, latest.current)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
FilterDraft,
|
||||
FilterDraftStep,
|
||||
FilterOperatorArity,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
|
||||
/**
|
||||
* The filter builder's step machine. Pure: no React, Base UI or shadcn import,
|
||||
* so the whole create and amend flow is a table test in node with no DOM.
|
||||
*
|
||||
* Arity arrives ON THE ACTION, which keeps the reducer free of the schema index
|
||||
* while it still owns the branch that skips the value step. The shipped chrome
|
||||
* drives `openCreate`, browse, query, `selectField` and `close`; `openAmend`,
|
||||
* `selectOperator`, `setValue`, `commit`, `back` and `goto` are published
|
||||
* surface for a consumer composing their own panel.
|
||||
*/
|
||||
export type FilterDraftAction<V = unknown> =
|
||||
| { type: "openCreate"; cascaderPath?: string[] }
|
||||
| {
|
||||
type: "openAmend"
|
||||
ruleId: string
|
||||
step: FilterDraftStep
|
||||
path: string[]
|
||||
operator: string | null
|
||||
value: V | undefined
|
||||
cascaderPath?: string[]
|
||||
}
|
||||
| { type: "close" }
|
||||
/** Where the user is BROWSING, not the selected field's path. */
|
||||
| { type: "setCascaderPath"; path: string[] }
|
||||
| { type: "setQuery"; query: string }
|
||||
| {
|
||||
type: "selectField"
|
||||
path: string[]
|
||||
defaultOperator: string | null
|
||||
}
|
||||
| {
|
||||
type: "selectOperator"
|
||||
operator: string
|
||||
arity: FilterOperatorArity
|
||||
/** Carried across the operator change, already coerced. */
|
||||
value: V | undefined
|
||||
}
|
||||
| { type: "setValue"; value: V | undefined }
|
||||
| { type: "commit"; value?: V }
|
||||
| { type: "back" }
|
||||
| { type: "goto"; step: FilterDraftStep }
|
||||
|
||||
export function createFilterDraft<V = unknown>(
|
||||
cascaderPath: string[] = []
|
||||
): FilterDraft<V> {
|
||||
return {
|
||||
step: "field",
|
||||
status: "editing",
|
||||
ruleId: null,
|
||||
path: [],
|
||||
cascaderPath,
|
||||
operator: null,
|
||||
value: undefined,
|
||||
query: "",
|
||||
}
|
||||
}
|
||||
|
||||
/** null means Back closes the builder rather than stepping back. */
|
||||
function previousStep(step: FilterDraftStep): FilterDraftStep | null {
|
||||
if (step === "value") return "operator"
|
||||
if (step === "operator") return "field"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` is the closed state, not a separate boolean: `{ open, draft }` would
|
||||
* make "open with no draft" and "closed but still holding one" representable.
|
||||
*/
|
||||
export function filterDraftReducer<V = unknown>(
|
||||
state: FilterDraft<V> | null,
|
||||
action: FilterDraftAction<V>
|
||||
): FilterDraft<V> | null {
|
||||
switch (action.type) {
|
||||
case "openCreate":
|
||||
return createFilterDraft<V>(action.cascaderPath ?? [])
|
||||
|
||||
case "openAmend":
|
||||
return {
|
||||
step: action.step,
|
||||
status: "editing",
|
||||
ruleId: action.ruleId,
|
||||
path: action.path,
|
||||
// Defaults the picker to the field's own level, so amend opens beside
|
||||
// its siblings, not at the root. Callers may override.
|
||||
cascaderPath: action.cascaderPath ?? action.path.slice(0, -1),
|
||||
operator: action.operator,
|
||||
value: action.value,
|
||||
query: "",
|
||||
}
|
||||
|
||||
case "close":
|
||||
return null
|
||||
|
||||
case "setCascaderPath":
|
||||
if (!state) return state
|
||||
if (state.cascaderPath === action.path) return state
|
||||
return { ...state, cascaderPath: action.path }
|
||||
|
||||
case "setQuery":
|
||||
if (!state) return state
|
||||
if (state.query === action.query) return state
|
||||
return { ...state, query: action.query }
|
||||
|
||||
case "selectField": {
|
||||
if (!state) return state
|
||||
// Choosing a field COMMITS at once; the condition is picked on the chip,
|
||||
// so the popover never holds a second and third step to walk.
|
||||
return {
|
||||
...state,
|
||||
path: action.path,
|
||||
operator: action.defaultOperator,
|
||||
status: "ready",
|
||||
// A field change invalidates the value: "Active" means nothing once
|
||||
// the field becomes "Created at".
|
||||
value: undefined,
|
||||
step: "operator",
|
||||
query: "",
|
||||
}
|
||||
}
|
||||
|
||||
case "selectOperator": {
|
||||
if (!state) return state
|
||||
// arity "none" is the whole filter; a value step would be an empty panel.
|
||||
if (action.arity === "none") {
|
||||
return {
|
||||
...state,
|
||||
operator: action.operator,
|
||||
value: undefined,
|
||||
status: "ready",
|
||||
query: "",
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
operator: action.operator,
|
||||
value: action.value,
|
||||
step: "value",
|
||||
status: "editing",
|
||||
query: "",
|
||||
}
|
||||
}
|
||||
|
||||
case "setValue":
|
||||
if (!state) return state
|
||||
return { ...state, value: action.value }
|
||||
|
||||
case "commit":
|
||||
if (!state) return state
|
||||
return {
|
||||
...state,
|
||||
value: action.value === undefined ? state.value : action.value,
|
||||
status: "ready",
|
||||
}
|
||||
|
||||
case "back": {
|
||||
if (!state) return state
|
||||
const step = previousStep(state.step)
|
||||
if (!step) return null
|
||||
return { ...state, step, status: "editing", query: "" }
|
||||
}
|
||||
|
||||
case "goto":
|
||||
if (!state) return state
|
||||
if (state.step === action.step) return state
|
||||
return { ...state, step: action.step, status: "editing", query: "" }
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A path is enough. Not an operator: the flow commits when the field is picked,
|
||||
* so `operator: ""` is a legitimate committed state. Not a value either, since
|
||||
* `arity: "none"` has none and an editor may commit `undefined` to clear.
|
||||
*/
|
||||
export function isFilterDraftCommittable<V>(
|
||||
draft: FilterDraft<V> | null
|
||||
): draft is FilterDraft<V> {
|
||||
return Boolean(draft && draft.path.length > 0)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
FilterDraftStep,
|
||||
FilterIssue,
|
||||
FilterIssueReason,
|
||||
FilterLabels,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
|
||||
/**
|
||||
* The shipped English copy. `stepAnnouncement` (consumer-composed create
|
||||
* wizard) and `showRecords` (a consumer's own panel heading) are unread by the
|
||||
* shipped chrome and kept on purpose; do not prune them as dead keys.
|
||||
*/
|
||||
export const DEFAULT_FILTER_LABELS: FilterLabels = {
|
||||
addFilter: "Add filter",
|
||||
advancedFilter: "Advanced filter",
|
||||
showRecords: "In this view, show records",
|
||||
builderEmpty: "No filters yet",
|
||||
builderEmptyHint: "Add a filter to narrow down what you see.",
|
||||
addCondition: "Add filter",
|
||||
addConditionGroup: "Add group",
|
||||
addToGroup: "Add filter to this group",
|
||||
removeGroup: "Remove group",
|
||||
wrapInGroup: "Wrap in group",
|
||||
ungroup: "Ungroup",
|
||||
moveToTopLevel: "Move to top",
|
||||
moveToGroup: (position) => `Move to group ${position}`,
|
||||
reorder: "Reorder",
|
||||
reorderHint: "Press Alt with Arrow Up or Arrow Down to reorder",
|
||||
groupAll: "All of the following are true...",
|
||||
groupAny: "Any of the following are true...",
|
||||
groupPlaceholder: "Drag filters here",
|
||||
rowLabel: (condition, depth) => `${condition}, level ${depth}`,
|
||||
groupLabel: (description, depth) => `${description} level ${depth}`,
|
||||
groupAnnouncement: (added) => (added ? "Group added" : "Group removed"),
|
||||
reorderAnnouncement: (label, position, total) =>
|
||||
`${label} moved to position ${position} of ${total}`,
|
||||
// Destination FIRST after the verb: it is the half a plain reorder cannot
|
||||
// say and the half the user cannot see once the row has stopped moving.
|
||||
// Position stays last, so this still ends on the "of N" a reorder ends on.
|
||||
moveAnnouncement: (label, destination, position, total) =>
|
||||
`${label} moved into ${destination}, position ${position} of ${total}`,
|
||||
clearAll: "Clear all",
|
||||
groupMenu: "Group options",
|
||||
searchFields: "Search attributes...",
|
||||
searchOperators: "Search operators...",
|
||||
searchOptions: "Search...",
|
||||
back: "Back",
|
||||
clear: "Clear",
|
||||
apply: "Apply",
|
||||
discard: "Discard changes",
|
||||
empty: "No results",
|
||||
loading: "Loading...",
|
||||
loadingMore: "Loading more...",
|
||||
loadMore: "Load more",
|
||||
error: "Could not load",
|
||||
retry: "Retry",
|
||||
where: "Where",
|
||||
and: "And",
|
||||
or: "Or",
|
||||
combinator: "Change combinator",
|
||||
// The word first: it is what the control SAYS and what a truncated pill
|
||||
// stops showing. The action follows, so the name still names an action.
|
||||
combinatorLabel: (word) => `${word}, change combinator`,
|
||||
duplicate: "Duplicate",
|
||||
negate: "Negate",
|
||||
convertToAdvanced: "Advanced editor",
|
||||
remove: "Remove",
|
||||
chipMenu: (fieldLabel) => `${fieldLabel} filter options`,
|
||||
filtersLabel: "Filters",
|
||||
filterLabel: (condition) => condition,
|
||||
readOnly: "Read only. These filters cannot be changed.",
|
||||
pathSeparator: " > ",
|
||||
valuePlaceholder: "enter text...",
|
||||
selectPlaceholder: "Select...",
|
||||
noValue: "no value",
|
||||
selectCondition: "Select condition",
|
||||
incomplete: "incomplete filter",
|
||||
branchAffordance: "opens a list",
|
||||
exclusiveHint: "cannot be combined with the other options",
|
||||
exclusiveAnnouncement: (label, cleared) =>
|
||||
cleared === 1
|
||||
? `${label} selected. 1 other selection cleared.`
|
||||
: `${label} selected. ${cleared} other selections cleared.`,
|
||||
itemCount: (count) => `${count} items`,
|
||||
fieldsLabel: "Attributes",
|
||||
resultsAnnouncement: (count) =>
|
||||
count === 1 ? "1 result" : `${count} results`,
|
||||
actionsLabel: "Actions",
|
||||
stepAnnouncement: (step, label) => {
|
||||
if (step === "field") return `Choose an attribute. ${label}`
|
||||
if (step === "operator") return `Choose a condition for ${label}`
|
||||
return `Enter a value for ${label}`
|
||||
},
|
||||
countAnnouncement: (count) =>
|
||||
count === 1 ? "1 filter applied" : `${count} filters applied`,
|
||||
valueCount: (count) => `${count} selected`,
|
||||
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`,
|
||||
valueRange: (from, to) => `${from} to ${to}`,
|
||||
rangeFrom: (fieldLabel) => `${fieldLabel} from`,
|
||||
rangeTo: (fieldLabel) => `${fieldLabel} to`,
|
||||
rangeSeparator: "to",
|
||||
negated: (operatorLabel) => `not ${operatorLabel}`,
|
||||
issueOperator: "Choose a condition",
|
||||
issueValue: "Enter a value",
|
||||
issueRange: "Enter both ends of the range",
|
||||
issueRangeOrder: "The end of the range comes before its start",
|
||||
issueEmptyGroup: "This group has no conditions yet",
|
||||
// "Row", not "condition": an empty GROUP is counted here too and a group is
|
||||
// not a condition. Rule and group divs alike carry `data-slot="filter-row"`.
|
||||
issueSummary: (count) =>
|
||||
count === 1 ? "1 row needs attention" : `${count} rows need attention`,
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow merge over the defaults, like the cascader's. A deep merge would leak
|
||||
* the default back into a replaced function-valued label for the arguments the
|
||||
* consumer did not think about.
|
||||
*/
|
||||
export function resolveFilterLabels(
|
||||
labels?: Partial<FilterLabels>
|
||||
): FilterLabels {
|
||||
if (!labels) return DEFAULT_FILTER_LABELS
|
||||
return { ...DEFAULT_FILTER_LABELS, ...labels }
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence for one issue. Takes the ISSUE, not just the reason: a field's
|
||||
* `validate` supplies its own words, so `custom` has nothing to look up.
|
||||
*/
|
||||
export function filterIssueLabel(
|
||||
issue: Pick<FilterIssue, "reason" | "message"> | FilterIssueReason,
|
||||
labels: FilterLabels
|
||||
): string {
|
||||
const reason = typeof issue === "string" ? issue : issue.reason
|
||||
if (typeof issue !== "string" && issue.message) return issue.message
|
||||
if (reason === "missing-operator") return labels.issueOperator
|
||||
if (reason === "incomplete-range") return labels.issueRange
|
||||
if (reason === "reversed-range") return labels.issueRangeOrder
|
||||
if (reason === "empty-group") return labels.issueEmptyGroup
|
||||
return labels.issueValue
|
||||
}
|
||||
|
||||
/** The search placeholder for a step's panel. */
|
||||
export function stepPlaceholder(
|
||||
step: FilterDraftStep,
|
||||
labels: FilterLabels
|
||||
): string {
|
||||
if (step === "field") return labels.searchFields
|
||||
if (step === "operator") return labels.searchOperators
|
||||
return labels.searchOptions
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import { collapseCascaderPath } from "@/components/reui/cascader/cascader-lib"
|
||||
import type { CascaderCollapse } from "@/components/reui/cascader/cascader-types"
|
||||
import type {
|
||||
FilterField,
|
||||
FilterIndex,
|
||||
FilterOperator,
|
||||
FilterOption,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Menus */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* One menu size for the chip's kebab and the builder's three menus, which draw
|
||||
* the same rows. `w-max` OVERRIDES the shadcn content width, which pins to the
|
||||
* anchor and hides horizontal overflow, so a menu hung off a 28px icon button
|
||||
* CUT every longer row. Floor 128px, down from 224px: the rows run about 130px,
|
||||
* and five of the eight styles already floor a dropdown there (luma, maia and
|
||||
* sera sit at 192px). The 24rem cap stops a pathological label pushing the menu
|
||||
* off the side of a phone. Past it a row truncates rather than wraps, since a
|
||||
* wrapped row changes HEIGHT and would slide the destructive row under the
|
||||
* pointer: `min-w-0` lets a row shrink below its content at all (a flex child's
|
||||
* floor is its content otherwise) and the label span carries `truncate`, see
|
||||
* `FILTER_MENU_LABEL_CLASS`.
|
||||
*/
|
||||
export const FILTER_MENU_CLASS =
|
||||
"w-max min-w-32 max-w-[min(24rem,calc(100vw-2rem))] [&_[data-slot=dropdown-menu-item]]:min-w-0"
|
||||
|
||||
/**
|
||||
* What a menu row's LABEL wears, and it has to be a real span: `text-overflow`
|
||||
* needs a block container, not the anonymous flex item a bare text child is.
|
||||
* `min-w-0` so the span can shrink below its own content.
|
||||
*/
|
||||
export const FILTER_MENU_LABEL_CLASS = "min-w-0 truncate"
|
||||
|
||||
/**
|
||||
* The FIELD PICKER's panel, shared by the Add filter popover and the advanced
|
||||
* row's attribute cell so one schema is never drawn at two widths. `w-auto`
|
||||
* grows to the longest row, so the 224px floor (down from 256px) only decides
|
||||
* the reported case: a flat schema of short names in a mostly empty panel.
|
||||
* Measured: 224px still clears the search input, the breadcrumb and a leaf
|
||||
* row's icon-plus-label.
|
||||
*/
|
||||
export const FILTER_FIELD_PICKER_CLASS = "w-auto min-w-56 p-0"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Paths */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Root key for `childrenOf`; a control char cannot collide with a field id. */
|
||||
export const FILTER_ROOT_KEY = "\u0000root"
|
||||
|
||||
/** Separator between path segments in the flat map keys. */
|
||||
const PATH_SEPARATOR = "\u0000"
|
||||
|
||||
export function joinFilterPath(path: readonly string[]): string {
|
||||
return path.join(PATH_SEPARATOR)
|
||||
}
|
||||
|
||||
export function splitFilterPath(key: string): string[] {
|
||||
return key === "" ? [] : key.split(PATH_SEPARATOR)
|
||||
}
|
||||
|
||||
/** The joined key of a path's parent, or `FILTER_ROOT_KEY` at the top level. */
|
||||
export function parentFilterKey(path: readonly string[]): string {
|
||||
return path.length <= 1 ? FILTER_ROOT_KEY : joinFilterPath(path.slice(0, -1))
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Signature */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A content hash of the schema, used to skip a rebuild. Identity alone is
|
||||
* useless: every block and example declares `fields` as an inline literal, so a
|
||||
* memo keyed on the array misses on every parent render. Render props (`icon`,
|
||||
* `renderValue`, a component-valued `editor`) are hashed by PRESENCE only,
|
||||
* because inline JSX recreates them: swapping one's body keeps serving the
|
||||
* previous field objects, so change a structural field or give `fields` a
|
||||
* stable identity.
|
||||
*/
|
||||
export function computeFilterSchemaSignature<V, O>(
|
||||
fields: readonly FilterField<V, O>[]
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
|
||||
const walk = (list: readonly FilterField<V, O>[], depth: number) => {
|
||||
for (const field of list) {
|
||||
parts.push(
|
||||
depth +
|
||||
":" +
|
||||
field.id +
|
||||
"|" +
|
||||
(field.label ?? "") +
|
||||
"|" +
|
||||
(field.type ?? "") +
|
||||
"|" +
|
||||
(field.defaultOperator ?? "") +
|
||||
"|" +
|
||||
(field.column ?? "") +
|
||||
"|" +
|
||||
(field.selectable ? "1" : "0") +
|
||||
(field.disabled ? "1" : "0") +
|
||||
(field.icon ? "1" : "0") +
|
||||
(field.renderValue ? "1" : "0") +
|
||||
(field.loadOptions ? "1" : "0") +
|
||||
(field.resolveValues ? "1" : "0") +
|
||||
"|" +
|
||||
(typeof field.editor === "string"
|
||||
? field.editor
|
||||
: field.editor
|
||||
? "fn"
|
||||
: "") +
|
||||
"|" +
|
||||
(field.keywords?.join(",") ?? "") +
|
||||
"|" +
|
||||
(field.count ?? "") +
|
||||
"|" +
|
||||
// Options are part of the shape, and `exclusive` rides along: it
|
||||
// decides what a pick DOES, so turning it on must force a rebuild.
|
||||
(field.options
|
||||
? field.options
|
||||
.map(
|
||||
(option) =>
|
||||
option.value +
|
||||
"~" +
|
||||
option.label +
|
||||
(option.exclusive ? "~x" : "")
|
||||
)
|
||||
.join(",")
|
||||
: "") +
|
||||
"|" +
|
||||
(Array.isArray(field.operators)
|
||||
? field.operators.map((operator) => operator.value).join(",")
|
||||
: field.operators
|
||||
? "fn"
|
||||
: "")
|
||||
)
|
||||
if (field.fields?.length) walk(field.fields, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
walk(fields, 0)
|
||||
return parts.join("\n")
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Index */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const EMPTY_INDEX: FilterIndex<never, never> = {
|
||||
byPath: new Map(),
|
||||
childrenOf: new Map(),
|
||||
parentOf: new Map(),
|
||||
all: [],
|
||||
roots: [],
|
||||
signature: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a field schema into flat maps. When `previous`'s signature
|
||||
* matches, the PREVIOUS OBJECT is returned: an equal-but-new one would leave
|
||||
* every downstream memo missing exactly as often as before. Duplicate sibling
|
||||
* ids are ignored after the first, as the cascader does for node values, and
|
||||
* `findFilterSchemaIssues` reports them in development.
|
||||
*/
|
||||
export function buildFilterIndex<V = unknown, O = unknown>(
|
||||
fields: readonly FilterField<V, O>[],
|
||||
previous?: FilterIndex<V, O> | null,
|
||||
/** A signature the caller already computed, to avoid walking twice. */
|
||||
precomputedSignature?: string
|
||||
): FilterIndex<V, O> {
|
||||
const signature = precomputedSignature ?? computeFilterSchemaSignature(fields)
|
||||
if (previous && previous.signature === signature) return previous
|
||||
if (fields.length === 0) {
|
||||
return { ...(EMPTY_INDEX as unknown as FilterIndex<V, O>), signature }
|
||||
}
|
||||
|
||||
const byPath = new Map<string, FilterField<V, O>>()
|
||||
const childrenOf = new Map<string, FilterField<V, O>[]>()
|
||||
const parentOf = new Map<string, string>()
|
||||
const all: { field: FilterField<V, O>; path: string[] }[] = []
|
||||
const roots: FilterField<V, O>[] = []
|
||||
|
||||
const walk = (
|
||||
list: readonly FilterField<V, O>[],
|
||||
parentPath: string[],
|
||||
parentKey: string
|
||||
) => {
|
||||
const accepted: FilterField<V, O>[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const field of list) {
|
||||
if (seen.has(field.id)) continue
|
||||
seen.add(field.id)
|
||||
|
||||
const path = [...parentPath, field.id]
|
||||
const key = joinFilterPath(path)
|
||||
if (byPath.has(key)) continue
|
||||
|
||||
byPath.set(key, field)
|
||||
parentOf.set(key, parentKey === FILTER_ROOT_KEY ? "" : parentKey)
|
||||
all.push({ field, path })
|
||||
accepted.push(field)
|
||||
|
||||
if (field.fields?.length) walk(field.fields, path, key)
|
||||
}
|
||||
|
||||
childrenOf.set(parentKey, accepted)
|
||||
if (parentKey === FILTER_ROOT_KEY) roots.push(...accepted)
|
||||
}
|
||||
|
||||
walk(fields, [], FILTER_ROOT_KEY)
|
||||
|
||||
return { byPath, childrenOf, parentOf, all, roots, signature }
|
||||
}
|
||||
|
||||
export function getFilterField<V, O>(
|
||||
index: FilterIndex<V, O>,
|
||||
path: readonly string[]
|
||||
): FilterField<V, O> | undefined {
|
||||
return index.byPath.get(joinFilterPath(path))
|
||||
}
|
||||
|
||||
/** The ancestor chain for a path, root first and the field itself last. */
|
||||
export function getFilterFieldChain<V, O>(
|
||||
index: FilterIndex<V, O>,
|
||||
path: readonly string[]
|
||||
): FilterField<V, O>[] {
|
||||
const chain: FilterField<V, O>[] = []
|
||||
for (let i = 1; i <= path.length; i++) {
|
||||
const field = index.byPath.get(joinFilterPath(path.slice(0, i)))
|
||||
if (!field) break
|
||||
chain.push(field)
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
/** Child fields of a path. Pass an empty path for the root level. */
|
||||
export function getFilterChildren<V, O>(
|
||||
index: FilterIndex<V, O>,
|
||||
path: readonly string[]
|
||||
): FilterField<V, O>[] {
|
||||
const key = path.length === 0 ? FILTER_ROOT_KEY : joinFilterPath(path)
|
||||
return index.childrenOf.get(key) ?? []
|
||||
}
|
||||
|
||||
export function isFilterBranch<V, O>(field: FilterField<V, O>): boolean {
|
||||
return Boolean(field.fields?.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a field DECLARES itself filterable on: leaves always, a branch only
|
||||
* with `selectable`. The shipped pickers ignore it, see `isFilterFieldPickable`.
|
||||
*/
|
||||
export function isFilterFieldSelectable<V, O>(
|
||||
field: FilterField<V, O>
|
||||
): boolean {
|
||||
if (field.disabled) return false
|
||||
return isFilterBranch(field) ? Boolean(field.selectable) : true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SHIPPED pickers may commit a field: leaves only. A row that both
|
||||
* drills in and commits cannot disambiguate a click - its chevron and child
|
||||
* count promise "opens a list", so the press that opened it also filtered on
|
||||
* the parent and dismissed the picker. `selectable` stays honoured by
|
||||
* `isFilterFieldSelectable` for a chrome that separates the two.
|
||||
*/
|
||||
export function isFilterFieldPickable<V, O>(field: FilterField<V, O>): boolean {
|
||||
if (field.disabled) return false
|
||||
return !isFilterBranch(field)
|
||||
}
|
||||
|
||||
/** Trailing count for a branch row. */
|
||||
export function getFilterFieldCount<V, O>(field: FilterField<V, O>): number {
|
||||
return field.count ?? field.fields?.length ?? 0
|
||||
}
|
||||
|
||||
/** Renders a path as "Name > First" using the caller's separator. */
|
||||
export function formatFilterPath<V, O>(
|
||||
index: FilterIndex<V, O>,
|
||||
path: readonly string[],
|
||||
separator: string
|
||||
): string {
|
||||
const chain = getFilterFieldChain(index, path)
|
||||
if (chain.length === 0) return path.join(separator)
|
||||
return chain.map((field) => field.label).join(separator)
|
||||
}
|
||||
|
||||
/** How a path is shortened; the cascader's own union, so the two cannot drift. */
|
||||
export type FilterPathCollapse = CascaderCollapse
|
||||
|
||||
export type FilterPathSegment<V = unknown, O = unknown> =
|
||||
| { type: "field"; field: FilterField<V, O> }
|
||||
/** The run that was elided, kept so a host can surface it. */
|
||||
| { type: "ellipsis"; hidden: FilterField<V, O>[] }
|
||||
|
||||
/**
|
||||
* Shortens an ancestor chain to at most `maxSegments` names, on the cascader's
|
||||
* arithmetic so the chip's path and the builder's attribute cell cannot round
|
||||
* differently. The adapter is POSITIONAL because a `FilterField`'s `id` is
|
||||
* unique only among its siblings, so the index is the only key that survives a
|
||||
* collapser keyed on `value`. Default `"none"`: no upgrade shortens a path.
|
||||
*/
|
||||
export function collapseFilterPath<V, O>(
|
||||
chain: readonly FilterField<V, O>[],
|
||||
options: { maxSegments?: number; collapse?: FilterPathCollapse } = {}
|
||||
): FilterPathSegment<V, O>[] {
|
||||
const segments = collapseCascaderPath(
|
||||
chain.map((field, index) => ({ value: String(index), label: field.label })),
|
||||
{ maxSegments: options.maxSegments, collapse: options.collapse ?? "none" }
|
||||
)
|
||||
return segments.map((segment) =>
|
||||
segment.type === "node"
|
||||
? { type: "field" as const, field: chain[Number(segment.node.value)] }
|
||||
: {
|
||||
type: "ellipsis" as const,
|
||||
hidden: segment.hidden.map((node) => chain[Number(node.value)]),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Combinator */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type FilterCombinatorSlot = "where" | "toggle" | "echo"
|
||||
|
||||
/**
|
||||
* Which of the three forms the combinator slot before rule `index` takes. A
|
||||
* group has exactly ONE combinator, so only one slot may be interactive: three
|
||||
* editable "and"s down a column would imply three independent choices.
|
||||
*/
|
||||
export function filterCombinatorSlot(index: number): FilterCombinatorSlot {
|
||||
if (index === 0) return "where"
|
||||
return index === 1 ? "toggle" : "echo"
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Matching */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** `toLocaleLowerCase`, so Turkish dotted and dotless i fold as a reader expects. */
|
||||
export function foldFilterText(text: string): string {
|
||||
return text.toLocaleLowerCase()
|
||||
}
|
||||
|
||||
export function normalizeFilterQuery(query: string): string {
|
||||
return foldFilterText(query.trim())
|
||||
}
|
||||
|
||||
/** Whether a field matches a normalized query, by label or keywords. */
|
||||
export function matchesFilterQuery<V, O>(
|
||||
field: FilterField<V, O>,
|
||||
normalizedQuery: string
|
||||
): boolean {
|
||||
if (normalizedQuery === "") return true
|
||||
if (foldFilterText(field.label).includes(normalizedQuery)) return true
|
||||
if (field.keywords) {
|
||||
for (const keyword of field.keywords) {
|
||||
if (foldFilterText(keyword).includes(normalizedQuery)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters one level, preserving input order. Unused here like
|
||||
* `searchFilterDeep`: kept so a consumer's own picker need not re-implement the
|
||||
* fold and the keyword matching.
|
||||
*/
|
||||
export function filterFilterLevel<V, O>(
|
||||
fields: readonly FilterField<V, O>[],
|
||||
normalizedQuery: string
|
||||
): FilterField<V, O>[] {
|
||||
if (normalizedQuery === "") return fields as FilterField<V, O>[]
|
||||
return fields.filter((field) => matchesFilterQuery(field, normalizedQuery))
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches every SELECTABLE field at any depth, because a result the user
|
||||
* cannot pick is a dead end. Unused here - the shipped picker is the cascader
|
||||
* and delegates deep search to it - but it keeps a consumer's own picker from
|
||||
* re-implementing the fold, the keyword matching and the selectable rule.
|
||||
*/
|
||||
export function searchFilterDeep<V, O>(
|
||||
index: FilterIndex<V, O>,
|
||||
normalizedQuery: string,
|
||||
limit = 200
|
||||
): { field: FilterField<V, O>; path: string[] }[] {
|
||||
if (normalizedQuery === "") return []
|
||||
const results: { field: FilterField<V, O>; path: string[] }[] = []
|
||||
for (const entry of index.all) {
|
||||
if (results.length >= limit) break
|
||||
if (!isFilterFieldSelectable(entry.field)) continue
|
||||
if (matchesFilterQuery(entry.field, normalizedQuery)) results.push(entry)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/** Filters an option list by a normalized query, by label or keywords. */
|
||||
export function filterFilterOptions<O>(
|
||||
options: readonly FilterOption<O>[],
|
||||
normalizedQuery: string
|
||||
): FilterOption<O>[] {
|
||||
if (normalizedQuery === "") return options as FilterOption<O>[]
|
||||
return options.filter((option) => {
|
||||
if (foldFilterText(option.label).includes(normalizedQuery)) return true
|
||||
if (option.keywords) {
|
||||
for (const keyword of option.keywords) {
|
||||
if (foldFilterText(keyword).includes(normalizedQuery)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exclusive options */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The None rule, as pure algebra over two selections: pick the `exclusive`
|
||||
* option and it is the only pick, pick anything else and it goes. Written
|
||||
* against the DELTA, not the result, which is what makes it total: an empty
|
||||
* delta is a removal, including UNTICKING the exclusive row; an exclusive
|
||||
* arrival is the whole answer; an ordinary arrival drops every exclusive value,
|
||||
* restored ones included; both at once gives it to the LAST exclusive.
|
||||
* `isExclusive` is a lookup, not an array, because a query hiding the None row
|
||||
* while it stays SELECTED would answer "not exclusive" for the value the click
|
||||
* must clear. Never applied on read - a saved view is the consumer's data - so
|
||||
* it heals on the first ordinary pick, and only while `isExclusive` can ANSWER:
|
||||
* a None row on a `loadOptions` field belongs in the static `options`.
|
||||
*/
|
||||
export function applyFilterExclusiveSelection(
|
||||
next: readonly string[],
|
||||
previous: readonly string[],
|
||||
isExclusive: (value: string) => boolean
|
||||
): string[] {
|
||||
const held = new Set(previous)
|
||||
const added = next.filter((value) => !held.has(value))
|
||||
if (added.length === 0) return next as string[]
|
||||
|
||||
let arrived: string | null = null
|
||||
for (const value of added) {
|
||||
if (isExclusive(value)) arrived = value
|
||||
}
|
||||
if (arrived !== null) return [arrived]
|
||||
|
||||
const kept = next.filter((value) => !isExclusive(value))
|
||||
// The same array when nothing was exclusive, which is nearly every call: a
|
||||
// fresh one would commit a new value on every toggle of an ordinary row.
|
||||
return kept.length === next.length ? (next as string[]) : kept
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Ids */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Builds a deterministic id generator. `Date.now()` and `Math.random()`, as the
|
||||
* old `createFilter` used, give the server and the client different ids for the
|
||||
* same query, so any page shipping default filters hydrated mismatched.
|
||||
*/
|
||||
export function createFilterIdFactory(seed: string): () => string {
|
||||
let counter = 0
|
||||
return () => {
|
||||
counter += 1
|
||||
return `${seed}${counter}`
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Development checks */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const warned = new Set<string>()
|
||||
|
||||
/** Warns once per key. Never throws, and is a no-op in production. */
|
||||
export function warnFilterOnce(key: string, message: string): void {
|
||||
if (process.env.NODE_ENV === "production") return
|
||||
if (warned.has(key)) return
|
||||
warned.add(key)
|
||||
console.warn(`[filters] ${message}`)
|
||||
}
|
||||
|
||||
/** Clears the warn-once memory. For tests. */
|
||||
export function resetFilterWarnings(): void {
|
||||
warned.clear()
|
||||
}
|
||||
|
||||
export interface FilterSchemaIssues {
|
||||
/** Ids that are empty, or that collide with a sibling. */
|
||||
duplicatePaths: string[]
|
||||
emptyIds: string[]
|
||||
/** Branches that declare `selectable` but hold no children. */
|
||||
emptyBranches: string[]
|
||||
/** Fields whose `defaultOperator` is not in their operator list. */
|
||||
unknownDefaultOperators: string[]
|
||||
}
|
||||
|
||||
/** Reported, never thrown: a bad schema must degrade, not blank the page. */
|
||||
export function findFilterSchemaIssues<V, O>(
|
||||
fields: readonly FilterField<V, O>[],
|
||||
resolveOperators: (field: FilterField<V, O>) => FilterOperator[]
|
||||
): FilterSchemaIssues {
|
||||
const duplicatePaths: string[] = []
|
||||
const emptyIds: string[] = []
|
||||
const emptyBranches: string[] = []
|
||||
const unknownDefaultOperators: string[] = []
|
||||
|
||||
const walk = (list: readonly FilterField<V, O>[], parentPath: string[]) => {
|
||||
const seen = new Set<string>()
|
||||
for (const field of list) {
|
||||
const label = [...parentPath, field.id].join(".")
|
||||
if (!field.id) emptyIds.push(label)
|
||||
else if (seen.has(field.id)) duplicatePaths.push(label)
|
||||
seen.add(field.id)
|
||||
|
||||
if (field.selectable && field.fields && field.fields.length === 0) {
|
||||
emptyBranches.push(label)
|
||||
}
|
||||
|
||||
if (field.defaultOperator) {
|
||||
const operators = resolveOperators(field)
|
||||
if (!operators.some((op) => op.value === field.defaultOperator)) {
|
||||
unknownDefaultOperators.push(`${label} -> ${field.defaultOperator}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (field.fields?.length) walk(field.fields, [...parentPath, field.id])
|
||||
}
|
||||
}
|
||||
|
||||
walk(fields, [])
|
||||
return { duplicatePaths, emptyIds, emptyBranches, unknownDefaultOperators }
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
FilterField,
|
||||
FilterOperator,
|
||||
FilterValueType,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
|
||||
/**
|
||||
* Every operator label, keyed by value. Split from `FilterLabels`: operator
|
||||
* wording is reworded per domain, chrome copy is translated once per app.
|
||||
*/
|
||||
export type FilterOperatorLabels = Record<string, string>
|
||||
|
||||
export const DEFAULT_FILTER_OPERATOR_LABELS: FilterOperatorLabels = {
|
||||
contains: "contains",
|
||||
not_contains: "does not contain",
|
||||
starts_with: "starts with",
|
||||
ends_with: "ends with",
|
||||
is: "is",
|
||||
is_not: "is not",
|
||||
is_any_of: "is any of",
|
||||
is_none_of: "is none of",
|
||||
has_any_of: "has any of",
|
||||
has_all_of: "has all of",
|
||||
has_none_of: "has none of",
|
||||
eq: "equals",
|
||||
neq: "does not equal",
|
||||
gt: "is greater than",
|
||||
gte: "is greater than or equal to",
|
||||
lt: "is less than",
|
||||
lte: "is less than or equal to",
|
||||
between: "is between",
|
||||
not_between: "is not between",
|
||||
is_before: "is before",
|
||||
is_after: "is after",
|
||||
is_on_or_before: "is on or before",
|
||||
is_on_or_after: "is on or after",
|
||||
empty: "is empty",
|
||||
not_empty: "is not empty",
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator catalog per value type: value, arity, inverse, labels resolved at
|
||||
* build time so translating one operator never restates the catalog. `arity`
|
||||
* of "none" drops the chip's value segment and the wizard's value step, in a
|
||||
* consumer's own `operators` array as much as in this catalog.
|
||||
*/
|
||||
const CATALOG: Record<
|
||||
FilterValueType,
|
||||
{ value: string; arity?: FilterOperator["arity"]; inverse?: string }[]
|
||||
> = {
|
||||
text: [
|
||||
{ value: "contains", inverse: "not_contains" },
|
||||
{ value: "not_contains", inverse: "contains" },
|
||||
{ value: "starts_with" },
|
||||
{ value: "ends_with" },
|
||||
{ value: "is", inverse: "is_not" },
|
||||
{ value: "is_not", inverse: "is" },
|
||||
{ value: "empty", arity: "none", inverse: "not_empty" },
|
||||
{ value: "not_empty", arity: "none", inverse: "empty" },
|
||||
],
|
||||
number: [
|
||||
{ value: "eq", inverse: "neq" },
|
||||
{ value: "neq", inverse: "eq" },
|
||||
{ value: "gt", inverse: "lte" },
|
||||
{ value: "gte", inverse: "lt" },
|
||||
{ value: "lt", inverse: "gte" },
|
||||
{ value: "lte", inverse: "gt" },
|
||||
{ value: "between", arity: "range", inverse: "not_between" },
|
||||
{ value: "not_between", arity: "range", inverse: "between" },
|
||||
{ value: "empty", arity: "none", inverse: "not_empty" },
|
||||
{ value: "not_empty", arity: "none", inverse: "empty" },
|
||||
],
|
||||
range: [
|
||||
{ value: "between", arity: "range", inverse: "not_between" },
|
||||
{ value: "not_between", arity: "range", inverse: "between" },
|
||||
{ value: "empty", arity: "none", inverse: "not_empty" },
|
||||
{ value: "not_empty", arity: "none", inverse: "empty" },
|
||||
],
|
||||
select: [
|
||||
{ value: "is", inverse: "is_not" },
|
||||
{ value: "is_not", inverse: "is" },
|
||||
{ value: "is_any_of", arity: "many", inverse: "is_none_of" },
|
||||
{ value: "is_none_of", arity: "many", inverse: "is_any_of" },
|
||||
{ value: "empty", arity: "none", inverse: "not_empty" },
|
||||
{ value: "not_empty", arity: "none", inverse: "empty" },
|
||||
],
|
||||
multiselect: [
|
||||
{ value: "has_any_of", arity: "many", inverse: "has_none_of" },
|
||||
{ value: "has_all_of", arity: "many" },
|
||||
{ value: "has_none_of", arity: "many", inverse: "has_any_of" },
|
||||
{ value: "empty", arity: "none", inverse: "not_empty" },
|
||||
{ value: "not_empty", arity: "none", inverse: "empty" },
|
||||
],
|
||||
boolean: [
|
||||
{ value: "is", inverse: "is_not" },
|
||||
{ value: "is_not", inverse: "is" },
|
||||
{ value: "empty", arity: "none", inverse: "not_empty" },
|
||||
{ value: "not_empty", arity: "none", inverse: "empty" },
|
||||
],
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTER_VALUE_TYPE: FilterValueType = "text"
|
||||
|
||||
/**
|
||||
* Builds the operator catalog for one set of labels. Callers memoize on the
|
||||
* label object: the old primitive built its equivalent inside
|
||||
* `getOperatorsForField`, keyed on a `values` array that was fresh after every
|
||||
* filter update, so every miss reallocated four arrays of 27 objects each.
|
||||
*/
|
||||
export function createFilterOperators(
|
||||
labels: FilterOperatorLabels
|
||||
): Record<FilterValueType, FilterOperator[]> {
|
||||
const built = {} as Record<FilterValueType, FilterOperator[]>
|
||||
for (const type of Object.keys(CATALOG) as FilterValueType[]) {
|
||||
built[type] = CATALOG[type].map((entry) => ({
|
||||
value: entry.value,
|
||||
label: labels[entry.value] ?? entry.value,
|
||||
arity: entry.arity ?? "one",
|
||||
inverse: entry.inverse,
|
||||
}))
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTER_OPERATORS: Record<
|
||||
FilterValueType,
|
||||
FilterOperator[]
|
||||
> = createFilterOperators(DEFAULT_FILTER_OPERATOR_LABELS)
|
||||
|
||||
/**
|
||||
* The operators for a field: its own `operators` wins, array or function,
|
||||
* otherwise the catalog entry for its `type`. No implicit select to multiselect
|
||||
* promotion by selection count: that changed the list as the user picked.
|
||||
*/
|
||||
export function resolveFilterOperators<V, O>(
|
||||
field: FilterField<V, O>,
|
||||
catalog: Record<FilterValueType, FilterOperator[]> = DEFAULT_FILTER_OPERATORS
|
||||
): FilterOperator[] {
|
||||
if (typeof field.operators === "function") return field.operators(field)
|
||||
if (field.operators) return field.operators
|
||||
return catalog[field.type ?? DEFAULT_FILTER_VALUE_TYPE] ?? catalog.text
|
||||
}
|
||||
|
||||
export function visibleFilterOperators(
|
||||
operators: readonly FilterOperator[]
|
||||
): FilterOperator[] {
|
||||
return operators.filter((operator) => !operator.hidden)
|
||||
}
|
||||
|
||||
export function getFilterOperator(
|
||||
operators: readonly FilterOperator[],
|
||||
value: string | null | undefined
|
||||
): FilterOperator | undefined {
|
||||
if (!value) return undefined
|
||||
return operators.find((operator) => operator.value === value)
|
||||
}
|
||||
|
||||
export function getFilterArity(
|
||||
operator: FilterOperator | undefined
|
||||
): FilterOperator["arity"] {
|
||||
return operator?.arity ?? "one"
|
||||
}
|
||||
|
||||
export function operatorTakesValue(
|
||||
operator: FilterOperator | undefined
|
||||
): boolean {
|
||||
return getFilterArity(operator) !== "none"
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator a field starts with: `defaultOperator` when it names one the
|
||||
* field offers, otherwise the first visible operator. Falling back rather than
|
||||
* trusting the schema keeps the rule out of a state its own list cannot show.
|
||||
*/
|
||||
export function getDefaultFilterOperator<V, O>(
|
||||
field: FilterField<V, O>,
|
||||
operators: readonly FilterOperator[]
|
||||
): string | null {
|
||||
if (field.defaultOperator) {
|
||||
const named = getFilterOperator(operators, field.defaultOperator)
|
||||
if (named) return named.value
|
||||
}
|
||||
const visible = visibleFilterOperators(operators)
|
||||
return visible[0]?.value ?? operators[0]?.value ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Negating a rule: swap to the declared `inverse` so the chip reads "does not
|
||||
* contain", else toggle `negated` so Negate still works without an inverse.
|
||||
*/
|
||||
export function negateFilterOperator(
|
||||
operator: FilterOperator | undefined,
|
||||
operators: readonly FilterOperator[],
|
||||
negated: boolean | undefined
|
||||
): { operator: string | null; negated: boolean } {
|
||||
if (operator?.inverse) {
|
||||
const inverse = getFilterOperator(operators, operator.inverse)
|
||||
if (inverse) return { operator: inverse.value, negated: Boolean(negated) }
|
||||
}
|
||||
return { operator: operator?.value ?? null, negated: !negated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reshapes a value when arity changes: widening "is" to "is any of" keeps what
|
||||
* the user picked, narrowing keeps the first of them, `"none"` always drops it.
|
||||
*/
|
||||
export function coerceFilterValue(
|
||||
value: unknown,
|
||||
from: FilterOperator | undefined,
|
||||
to: FilterOperator | undefined
|
||||
): unknown {
|
||||
const nextArity = getFilterArity(to)
|
||||
if (nextArity === "none") return undefined
|
||||
if (value === undefined || value === null) return value
|
||||
|
||||
const previousArity = getFilterArity(from)
|
||||
if (previousArity === nextArity) return value
|
||||
|
||||
const asArray = Array.isArray(value) ? value : [value]
|
||||
|
||||
if (nextArity === "many") return asArray
|
||||
if (nextArity === "one") return asArray[0]
|
||||
if (nextArity === "range") {
|
||||
return [asArray[0], asArray[1]] as [unknown, unknown]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
import type {
|
||||
FilterCombinator,
|
||||
FilterGroupNode,
|
||||
FilterIssue,
|
||||
FilterNode,
|
||||
FilterOperator,
|
||||
FilterQuery,
|
||||
FilterRule,
|
||||
} from "@/components/reui/filters/filters-types"
|
||||
|
||||
export function isFilterRule<V>(node: FilterNode<V>): node is FilterRule<V> {
|
||||
return node.type === "rule"
|
||||
}
|
||||
|
||||
export function isFilterGroup<V>(
|
||||
node: FilterNode<V>
|
||||
): node is FilterGroupNode<V> {
|
||||
return node.type === "group"
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule. `id` is passed in, never generated here: a non-deterministic value in
|
||||
* a pure function broke hydration. Callers use `createFilterIdFactory`.
|
||||
*/
|
||||
export function createFilterRule<V = unknown>(input: {
|
||||
id: string
|
||||
path: string[]
|
||||
operator: string
|
||||
value?: V
|
||||
negated?: boolean
|
||||
}): FilterRule<V> {
|
||||
const rule: FilterRule<V> = {
|
||||
id: input.id,
|
||||
type: "rule",
|
||||
path: input.path,
|
||||
operator: input.operator,
|
||||
value: input.value,
|
||||
}
|
||||
if (input.negated) rule.negated = true
|
||||
return rule
|
||||
}
|
||||
|
||||
export function createFilterGroup<V = unknown>(input: {
|
||||
id: string
|
||||
combinator?: FilterCombinator
|
||||
rules?: FilterNode<V>[]
|
||||
}): FilterGroupNode<V> {
|
||||
return {
|
||||
id: input.id,
|
||||
type: "group",
|
||||
combinator: input.combinator ?? "and",
|
||||
rules: input.rules ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty query. The root is always a group, never a bare array. */
|
||||
export function createFilterQuery<V = unknown>(
|
||||
rules: FilterNode<V>[] = [],
|
||||
combinator: FilterCombinator = "and",
|
||||
id = "root"
|
||||
): FilterQuery<V> {
|
||||
return { id, type: "group", combinator, rules }
|
||||
}
|
||||
|
||||
/** Every rule in the tree, depth first. Groups are flattened away. */
|
||||
export function flattenFilterRules<V>(query: FilterQuery<V>): FilterRule<V>[] {
|
||||
const out: FilterRule<V>[] = []
|
||||
const walk = (node: FilterNode<V>) => {
|
||||
if (isFilterRule(node)) {
|
||||
out.push(node)
|
||||
return
|
||||
}
|
||||
for (const child of node.rules) walk(child)
|
||||
}
|
||||
walk(query)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* One rule, flattened for a predicate. `values` is always an array even though
|
||||
* `FilterRule.value` is singular, so no caller re-derives arity.
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
/** Full field path, `["name", "first"]`. */
|
||||
path: string[]
|
||||
/** First path segment, for the common flat-schema case. */
|
||||
field: string
|
||||
operator: string
|
||||
/** `[]` for an operator that takes no value. */
|
||||
values: unknown[]
|
||||
negated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a rule says anything yet. A rule exists as soon as an attribute is
|
||||
* picked, so `operator: ""` is a real state: `flattenFilterConditions` leaves
|
||||
* it out, `countFilterRules` still counts it, and `collectFilterIssues` reports
|
||||
* it as `missing-operator`.
|
||||
*/
|
||||
export function isFilterRuleComplete<V>(rule: FilterRule<V>): boolean {
|
||||
return rule.operator !== ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a query to conditions. Lossy: safe only when the query is flat or
|
||||
* every group shares the root's combinator. Read `query.combinator` and walk
|
||||
* the tree yourself for anything else. Incomplete rules are left out.
|
||||
*/
|
||||
export function flattenFilterConditions<V>(
|
||||
query: FilterQuery<V>
|
||||
): FilterCondition[] {
|
||||
return flattenFilterRules(query)
|
||||
.filter(isFilterRuleComplete)
|
||||
.map((rule) => ({
|
||||
path: rule.path,
|
||||
field: rule.path[0],
|
||||
operator: rule.operator,
|
||||
values:
|
||||
rule.value === undefined || rule.value === null
|
||||
? []
|
||||
: Array.isArray(rule.value)
|
||||
? (rule.value as unknown[])
|
||||
: [rule.value],
|
||||
negated: Boolean(rule.negated),
|
||||
}))
|
||||
}
|
||||
|
||||
/** How many rules the query holds, at any depth. */
|
||||
export function countFilterRules<V>(query: FilterQuery<V>): number {
|
||||
let count = 0
|
||||
const walk = (node: FilterNode<V>) => {
|
||||
if (isFilterRule(node)) {
|
||||
count += 1
|
||||
return
|
||||
}
|
||||
for (const child of node.rules) walk(child)
|
||||
}
|
||||
walk(query)
|
||||
return count
|
||||
}
|
||||
|
||||
/** Whether the query would match everything. */
|
||||
export function isFilterQueryEmpty<V>(query: FilterQuery<V>): boolean {
|
||||
return countFilterRules(query) === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* How an arity is answered for one rule. The caller resolves it, since arity
|
||||
* lives on the FIELD's operator list. `null` is a rule it cannot judge.
|
||||
*/
|
||||
export type FilterArityResolver<V> = (
|
||||
rule: FilterRule<V>
|
||||
) => FilterOperator["arity"] | null
|
||||
|
||||
/** Runs a field's own `validate`. A resolver like `arityOf`: no schema here. */
|
||||
export type FilterValidateResolver<V> = (
|
||||
rule: FilterRule<V>
|
||||
) => string | null | undefined | false
|
||||
|
||||
/** A value slot the user has not filled in. `false` and `0` are values. */
|
||||
function isBlankFilterValue(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two range bounds, or gives up. Numbers, dates and ISO strings only:
|
||||
* inventing an order for a range of colour names would flag a correct filter as
|
||||
* reversed. `Date.parse` must parse BOTH strings before they are compared.
|
||||
*/
|
||||
function compareFilterBounds(from: unknown, to: unknown): number | null {
|
||||
if (typeof from === "number" && typeof to === "number") {
|
||||
return Number.isNaN(from) || Number.isNaN(to) ? null : from - to
|
||||
}
|
||||
if (from instanceof Date && to instanceof Date) {
|
||||
const a = from.getTime()
|
||||
const b = to.getTime()
|
||||
return Number.isNaN(a) || Number.isNaN(b) ? null : a - b
|
||||
}
|
||||
if (typeof from === "string" && typeof to === "string") {
|
||||
const a = Date.parse(from)
|
||||
const b = Date.parse(to)
|
||||
return Number.isNaN(a) || Number.isNaN(b) ? null : a - b
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reason a query cannot be run as written, in document order: the five
|
||||
* ways this builder can hold a condition that SILENTLY does the wrong thing.
|
||||
* `missing-operator` and `empty-group` carry no predicate and are dropped by
|
||||
* `flattenFilterConditions`; `missing-value` reaches the consumer with a
|
||||
* `values` array that is empty or all blank, which most backends read as
|
||||
* "match nothing"; `incomplete-range` is the same for a range and often
|
||||
* arrives with one bound FILLED, so an emptiness check will not catch it;
|
||||
* `reversed-range` is legal and matches nothing anywhere. A group of exactly
|
||||
* ONE node is NOT an issue (it is what "Convert to group" produces), and the
|
||||
* root is exempt from `empty-group`.
|
||||
*/
|
||||
export function collectFilterIssues<V>(
|
||||
query: FilterQuery<V>,
|
||||
arityOf: FilterArityResolver<V>,
|
||||
validateOf?: FilterValidateResolver<V>
|
||||
): FilterIssue[] {
|
||||
const issues: FilterIssue[] = []
|
||||
|
||||
const visit = (group: FilterGroupNode<V>, isRoot: boolean) => {
|
||||
if (!isRoot && group.rules.length === 0) {
|
||||
issues.push({
|
||||
nodeId: group.id,
|
||||
column: "group",
|
||||
reason: "empty-group",
|
||||
})
|
||||
}
|
||||
|
||||
for (const child of group.rules) {
|
||||
if (isFilterGroup(child)) {
|
||||
visit(child, false)
|
||||
continue
|
||||
}
|
||||
// Asked FIRST: `null` means the caller cannot judge this rule at all, so
|
||||
// even `missing-operator` would point at a control that is not on screen.
|
||||
const arity = arityOf(child)
|
||||
if (arity === null) continue
|
||||
|
||||
if (!isFilterRuleComplete(child)) {
|
||||
issues.push({
|
||||
nodeId: child.id,
|
||||
column: "operator",
|
||||
reason: "missing-operator",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (arity === "none") continue
|
||||
|
||||
const values =
|
||||
child.value === undefined || child.value === null
|
||||
? []
|
||||
: Array.isArray(child.value)
|
||||
? (child.value as unknown[])
|
||||
: [child.value]
|
||||
|
||||
if (arity === "range") {
|
||||
if (
|
||||
values.length < 2 ||
|
||||
isBlankFilterValue(values[0]) ||
|
||||
isBlankFilterValue(values[1])
|
||||
) {
|
||||
issues.push({
|
||||
nodeId: child.id,
|
||||
column: "value",
|
||||
reason: "incomplete-range",
|
||||
})
|
||||
continue
|
||||
}
|
||||
const order = compareFilterBounds(values[0], values[1])
|
||||
if (order !== null && order > 0) {
|
||||
issues.push({
|
||||
nodeId: child.id,
|
||||
column: "value",
|
||||
reason: "reversed-range",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// `many` and `one` collapse here: both are unsatisfied by an empty list,
|
||||
// and `one` normalises to a single-element list above.
|
||||
if (values.length === 0 || values.every(isBlankFilterValue)) {
|
||||
issues.push({
|
||||
nodeId: child.id,
|
||||
column: "value",
|
||||
reason: "missing-value",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// LAST, and only on a rule the primitive is already happy with: stacking
|
||||
// a second message on one cell breaks the one-issue-per-node shape.
|
||||
const message = validateOf?.(child)
|
||||
if (message) {
|
||||
issues.push({
|
||||
nodeId: child.id,
|
||||
column: "value",
|
||||
reason: "custom",
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(query, true)
|
||||
return issues
|
||||
}
|
||||
|
||||
/** Locates a node and its parent. Returns null when the id is unknown. */
|
||||
export function findFilterNode<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string
|
||||
): {
|
||||
node: FilterNode<V>
|
||||
parent: FilterGroupNode<V> | null
|
||||
index: number
|
||||
} | null {
|
||||
if (query.id === id) return { node: query, parent: null, index: -1 }
|
||||
|
||||
const walk = (
|
||||
group: FilterGroupNode<V>
|
||||
): {
|
||||
node: FilterNode<V>
|
||||
parent: FilterGroupNode<V>
|
||||
index: number
|
||||
} | null => {
|
||||
for (let i = 0; i < group.rules.length; i++) {
|
||||
const child = group.rules[i]
|
||||
if (child.id === id) return { node: child, parent: group, index: i }
|
||||
if (isFilterGroup(child)) {
|
||||
const found = walk(child)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return walk(query)
|
||||
}
|
||||
|
||||
/** The rule with this id, or null when the id names a group or is unknown. */
|
||||
export function findFilterRule<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string
|
||||
): FilterRule<V> | null {
|
||||
const found = findFilterNode(query, id)
|
||||
if (!found || !isFilterRule(found.node)) return null
|
||||
return found.node
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the tree, applying `transform` to the group holding `id`. An
|
||||
* unchanged subtree comes back BY IDENTITY, so `React.memo` holds for all but
|
||||
* the moved chip. Tests assert it with `toBe`. `removeFilterNode` and
|
||||
* `detachFilterNode` drop children rather than replace a group, so they
|
||||
* hand-roll the same identity-preserving walk.
|
||||
*/
|
||||
function rewriteGroup<V>(
|
||||
group: FilterGroupNode<V>,
|
||||
shouldRewrite: (group: FilterGroupNode<V>) => boolean,
|
||||
transform: (group: FilterGroupNode<V>) => FilterGroupNode<V>
|
||||
): FilterGroupNode<V> {
|
||||
if (shouldRewrite(group)) return transform(group)
|
||||
|
||||
let changed = false
|
||||
const rules = group.rules.map((child) => {
|
||||
if (!isFilterGroup(child)) return child
|
||||
const next = rewriteGroup(child, shouldRewrite, transform)
|
||||
if (next !== child) changed = true
|
||||
return next
|
||||
})
|
||||
|
||||
return changed ? { ...group, rules } : group
|
||||
}
|
||||
|
||||
/** Replaces a rule's fields. Unknown ids return the query unchanged. */
|
||||
export function updateFilterRule<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string,
|
||||
updates: Partial<Omit<FilterRule<V>, "id" | "type">>
|
||||
): FilterQuery<V> {
|
||||
return rewriteGroup(
|
||||
query,
|
||||
(group) =>
|
||||
group.rules.some((child) => child.id === id && isFilterRule(child)),
|
||||
(group) => ({
|
||||
...group,
|
||||
rules: group.rules.map((child) =>
|
||||
child.id === id && isFilterRule(child)
|
||||
? { ...child, ...updates }
|
||||
: child
|
||||
),
|
||||
})
|
||||
) as FilterQuery<V>
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node, plus any group it empties, all the way up (not the root): an
|
||||
* empty group is invisible in the flat UI yet still compiles to parentheses.
|
||||
*/
|
||||
export function removeFilterNode<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string
|
||||
): FilterQuery<V> {
|
||||
const prune = (group: FilterGroupNode<V>): FilterGroupNode<V> => {
|
||||
let changed = false
|
||||
const rules: FilterNode<V>[] = []
|
||||
|
||||
for (const child of group.rules) {
|
||||
if (child.id === id) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (isFilterGroup(child)) {
|
||||
const next = prune(child)
|
||||
if (next !== child) changed = true
|
||||
if (next.rules.length === 0) continue
|
||||
rules.push(next)
|
||||
continue
|
||||
}
|
||||
rules.push(child)
|
||||
}
|
||||
|
||||
return changed ? { ...group, rules } : group
|
||||
}
|
||||
|
||||
return prune(query) as FilterQuery<V>
|
||||
}
|
||||
|
||||
/** Appends a node to a group, defaulting to the root. */
|
||||
export function insertFilterNode<V>(
|
||||
query: FilterQuery<V>,
|
||||
node: FilterNode<V>,
|
||||
parentId?: string,
|
||||
index?: number
|
||||
): FilterQuery<V> {
|
||||
const targetId = parentId ?? query.id
|
||||
return rewriteGroup(
|
||||
query,
|
||||
(group) => group.id === targetId,
|
||||
(group) => {
|
||||
const rules = [...group.rules]
|
||||
const at =
|
||||
index === undefined
|
||||
? rules.length
|
||||
: Math.max(0, Math.min(index, rules.length))
|
||||
rules.splice(at, 0, node)
|
||||
return { ...group, rules }
|
||||
}
|
||||
) as FilterQuery<V>
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep copy under fresh ids, the whole way down: children keeping their old
|
||||
* ids would give two live nodes one id, and every lookup here is by id.
|
||||
*/
|
||||
function cloneFilterNode<V>(
|
||||
node: FilterNode<V>,
|
||||
nextId: () => string
|
||||
): FilterNode<V> {
|
||||
return isFilterRule(node)
|
||||
? { ...node, id: nextId() }
|
||||
: {
|
||||
...node,
|
||||
id: nextId(),
|
||||
rules: node.rules.map((child) => cloneFilterNode(child, nextId)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies a node in beside the original. */
|
||||
export function duplicateFilterNode<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string,
|
||||
nextId: () => string
|
||||
): FilterQuery<V> {
|
||||
const found = findFilterNode(query, id)
|
||||
if (!found || !found.parent) return query
|
||||
|
||||
return insertFilterNode(
|
||||
query,
|
||||
cloneFilterNode(found.node, nextId),
|
||||
found.parent.id,
|
||||
found.index + 1
|
||||
)
|
||||
}
|
||||
|
||||
export function setFilterCombinator<V>(
|
||||
query: FilterQuery<V>,
|
||||
groupId: string,
|
||||
combinator: FilterCombinator
|
||||
): FilterQuery<V> {
|
||||
return rewriteGroup(
|
||||
query,
|
||||
(group) => group.id === groupId,
|
||||
(group) =>
|
||||
group.combinator === combinator ? group : { ...group, combinator }
|
||||
) as FilterQuery<V>
|
||||
}
|
||||
|
||||
export function toggleFilterCombinator<V>(
|
||||
query: FilterQuery<V>,
|
||||
groupId: string
|
||||
): FilterQuery<V> {
|
||||
const found = findFilterNode(query, groupId)
|
||||
if (!found || !isFilterGroup(found.node)) return query
|
||||
return setFilterCombinator(
|
||||
query,
|
||||
groupId,
|
||||
found.node.combinator === "and" ? "or" : "and"
|
||||
)
|
||||
}
|
||||
|
||||
/** Moves a node within its own group. Out of range moves are no-ops. */
|
||||
export function moveFilterNode<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string,
|
||||
delta: number
|
||||
): FilterQuery<V> {
|
||||
const found = findFilterNode(query, id)
|
||||
if (!found || !found.parent) return query
|
||||
|
||||
const from = found.index
|
||||
const to = from + delta
|
||||
if (to < 0 || to >= found.parent.rules.length || delta === 0) return query
|
||||
|
||||
return rewriteGroup(
|
||||
query,
|
||||
(group) => group.id === found.parent!.id,
|
||||
(group) => {
|
||||
const rules = [...group.rules]
|
||||
const [moved] = rules.splice(from, 1)
|
||||
rules.splice(to, 0, moved)
|
||||
return { ...group, rules }
|
||||
}
|
||||
) as FilterQuery<V>
|
||||
}
|
||||
|
||||
/** Whether `id` names `node` itself or anything beneath it. */
|
||||
function containsFilterNode<V>(node: FilterNode<V>, id: string): boolean {
|
||||
if (node.id === id) return true
|
||||
if (isFilterRule(node)) return false
|
||||
return node.rules.some((child) => containsFilterNode(child, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node WITHOUT pruning what it empties. A move cannot reuse
|
||||
* `removeFilterNode`: it would delete the group the drop is aimed at.
|
||||
*/
|
||||
function detachFilterNode<V>(
|
||||
group: FilterGroupNode<V>,
|
||||
id: string
|
||||
): FilterGroupNode<V> {
|
||||
let changed = false
|
||||
const rules: FilterNode<V>[] = []
|
||||
|
||||
for (const child of group.rules) {
|
||||
if (child.id === id) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (isFilterGroup(child)) {
|
||||
const next = detachFilterNode(child, id)
|
||||
if (next !== child) changed = true
|
||||
rules.push(next)
|
||||
continue
|
||||
}
|
||||
rules.push(child)
|
||||
}
|
||||
|
||||
return changed ? { ...group, rules } : group
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a node into another group, at an index: the cross-parent form of
|
||||
* `moveFilterNode`, which only reorders within one parent. Refuses a group into
|
||||
* itself or a descendant, which would leave a cycle, and refuses the root.
|
||||
*/
|
||||
export function moveFilterNodeTo<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string,
|
||||
parentId: string,
|
||||
index: number
|
||||
): FilterQuery<V> {
|
||||
const found = findFilterNode(query, id)
|
||||
if (!found || !found.parent) return query
|
||||
if (containsFilterNode(found.node, parentId)) return query
|
||||
|
||||
const destination = findFilterNode(query, parentId)
|
||||
if (!destination || !isFilterGroup(destination.node)) return query
|
||||
|
||||
// Within one parent the node's own slot disappears when it detaches, so
|
||||
// indexes after it shift down by one: without this a drag down is a no-op.
|
||||
const sameParent = found.parent.id === parentId
|
||||
const target = sameParent && found.index < index ? index - 1 : index
|
||||
if (sameParent && target === found.index) return query
|
||||
|
||||
return insertFilterNode(
|
||||
detachFilterNode(query, id),
|
||||
found.node,
|
||||
parentId,
|
||||
target
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a node into a group at a position: the Alt path of the drag layer. Not
|
||||
* `duplicateFilterNode` then `moveFilterNodeTo`, which emits two queries for one
|
||||
* gesture and needs the id the first step minted and never returned. The clone
|
||||
* is taken BEFORE the insert, so copying a group into itself stays finite.
|
||||
*/
|
||||
export function copyFilterNodeTo<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string,
|
||||
parentId: string,
|
||||
index: number,
|
||||
nextId: () => string
|
||||
): FilterQuery<V> {
|
||||
const found = findFilterNode(query, id)
|
||||
if (!found || !found.parent) return query
|
||||
|
||||
const destination = findFilterNode(query, parentId)
|
||||
if (!destination || !isFilterGroup(destination.node)) return query
|
||||
|
||||
return insertFilterNode(
|
||||
query,
|
||||
cloneFilterNode(found.node, nextId),
|
||||
parentId,
|
||||
index
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a node in a new group: the "Wrap in condition group" action, and the
|
||||
* keyboard path to nesting for a user who cannot drag.
|
||||
*/
|
||||
export function wrapFilterNodeInGroup<V>(
|
||||
query: FilterQuery<V>,
|
||||
id: string,
|
||||
groupId: string,
|
||||
combinator: FilterCombinator = "or"
|
||||
): FilterQuery<V> {
|
||||
const found = findFilterNode(query, id)
|
||||
if (!found || !found.parent) return query
|
||||
|
||||
return rewriteGroup(
|
||||
query,
|
||||
(group) => group.id === found.parent!.id,
|
||||
(group) => ({
|
||||
...group,
|
||||
rules: group.rules.map((child) =>
|
||||
child.id === id
|
||||
? createFilterGroup<V>({ id: groupId, combinator, rules: [child] })
|
||||
: child
|
||||
),
|
||||
})
|
||||
) as FilterQuery<V>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dissolves a group into its parent, splicing its rules in at the position the
|
||||
* group held so wrap and unwrap round-trip. The root, a rule id and an unknown
|
||||
* id each return the query unchanged.
|
||||
*/
|
||||
export function unwrapFilterGroup<V>(
|
||||
query: FilterQuery<V>,
|
||||
groupId: string
|
||||
): FilterQuery<V> {
|
||||
if (query.id === groupId) return query
|
||||
const found = findFilterNode(query, groupId)
|
||||
if (!found || !found.parent || !isFilterGroup(found.node)) return query
|
||||
|
||||
const dissolved = found.node
|
||||
return rewriteGroup(
|
||||
query,
|
||||
(group) => group.id === found.parent!.id,
|
||||
(group) => {
|
||||
const rules = [...group.rules]
|
||||
rules.splice(found.index, 1, ...dissolved.rules)
|
||||
return { ...group, rules }
|
||||
}
|
||||
) as FilterQuery<V>
|
||||
}
|
||||
|
||||
/** Empties the query, keeping the root's identity fields. */
|
||||
export function clearFilterQuery<V>(query: FilterQuery<V>): FilterQuery<V> {
|
||||
return query.rules.length === 0 ? query : { ...query, rules: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops empty groups and collapses a group whose only child is a group. Not
|
||||
* automatic: a user mid-edit may hold an almost-empty group. Call on persist.
|
||||
*/
|
||||
export function pruneFilterQuery<V>(query: FilterQuery<V>): FilterQuery<V> {
|
||||
const prune = (node: FilterNode<V>): FilterNode<V> | null => {
|
||||
if (isFilterRule(node)) return node
|
||||
|
||||
const rules: FilterNode<V>[] = []
|
||||
for (const child of node.rules) {
|
||||
const next = prune(child)
|
||||
if (next) rules.push(next)
|
||||
}
|
||||
|
||||
if (rules.length === 0) return null
|
||||
if (rules.length === 1 && isFilterGroup(rules[0])) return rules[0]
|
||||
return { ...node, rules }
|
||||
}
|
||||
|
||||
const rules: FilterNode<V>[] = []
|
||||
for (const child of query.rules) {
|
||||
const next = prune(child)
|
||||
if (next) rules.push(next)
|
||||
}
|
||||
return { ...query, rules }
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import type * as React from "react"
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Query tree */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type FilterCombinator = "and" | "or"
|
||||
|
||||
/**
|
||||
* One condition. `path` maps one to one onto the cascader's `details.path`, so
|
||||
* a field selection commits untranslated. `value` is SINGULAR and the
|
||||
* operator's `arity` decides its shape: `"many"` holds an array, `"range"` a
|
||||
* tuple, `"none"` undefined.
|
||||
*/
|
||||
export interface FilterRule<V = unknown> {
|
||||
id: string
|
||||
type: "rule"
|
||||
/** Field path, root first. `["name", "first"]` for a nested attribute. */
|
||||
path: string[]
|
||||
operator: string
|
||||
value: V | undefined
|
||||
/** Flips the meaning in place. Set by Negate when there is no `inverse`. */
|
||||
negated?: boolean
|
||||
}
|
||||
|
||||
/** Rules under one combinator; nests, so nesting is never a later break. */
|
||||
export interface FilterGroupNode<V = unknown> {
|
||||
id: string
|
||||
type: "group"
|
||||
combinator: FilterCombinator
|
||||
rules: FilterNode<V>[]
|
||||
}
|
||||
|
||||
export type FilterNode<V = unknown> = FilterRule<V> | FilterGroupNode<V>
|
||||
|
||||
/** A whole query. Always a group, so flat and nested are one code path. */
|
||||
export type FilterQuery<V = unknown> = FilterGroupNode<V>
|
||||
|
||||
export type FilterChangeReason =
|
||||
| "add"
|
||||
| "update"
|
||||
| "remove"
|
||||
| "duplicate"
|
||||
| "negate"
|
||||
| "reorder"
|
||||
| "combinator"
|
||||
| "clear"
|
||||
|
||||
/** Second argument to `onQueryChange`, so nobody has to diff two trees. */
|
||||
export interface FilterChangeDetails<V = unknown, O = unknown> {
|
||||
reason: FilterChangeReason
|
||||
/** The rule that changed, or null for whole-query changes like `clear`. */
|
||||
rule: FilterRule<V> | null
|
||||
/** The field the rule points at, resolved. Null when the path is unknown. */
|
||||
field: FilterField<V, O> | null
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Operators */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* How many values an operator takes. Answers "does this need a value editor"
|
||||
* for every operator, a consumer's own included, in place of the hardcoded
|
||||
* `operator === "empty"` checks it replaced.
|
||||
*/
|
||||
export type FilterOperatorArity = "none" | "one" | "many" | "range"
|
||||
|
||||
export interface FilterOperator {
|
||||
value: string
|
||||
label: string
|
||||
/** Defaults to `"one"`. */
|
||||
arity?: FilterOperatorArity
|
||||
/** The opposite operator. Negate flips to it, else sets `rule.negated`. */
|
||||
inverse?: string
|
||||
/** Hidden from the operator list but still valid in a restored query. */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Fields */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Which built-in editor a field uses by DEFAULT: `editor` overrides it, and an
|
||||
* operator may override both. No date type on purpose - every product wants a
|
||||
* different date control, and a built-in one would add a calendar dependency to
|
||||
* every install, so a date ships as an `editor`.
|
||||
*/
|
||||
export type FilterValueType =
|
||||
| "text"
|
||||
| "number"
|
||||
| "range"
|
||||
| "select"
|
||||
| "multiselect"
|
||||
| "boolean"
|
||||
|
||||
export interface FilterOption<O = unknown> {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
disabled?: boolean
|
||||
/**
|
||||
* The row that means NONE OF THE ABOVE: Unassigned, No label, No due date.
|
||||
* Picking it clears every other pick and picking anything else clears it, and
|
||||
* it is drawn apart under a rule of its own, because a list that wipes a
|
||||
* selection must not look like one that does not; hide that line with
|
||||
* `"[&_[data-slot=filter-menu-divider]]:hidden"`. Applied under EVERY
|
||||
* operator, negative ones included. The value must be RESOLVABLE, since the
|
||||
* rule runs through the option service: an option that exists only in an
|
||||
* unfetched `loadOptions` page is invisible to it.
|
||||
*/
|
||||
exclusive?: boolean
|
||||
/** Arbitrary payload, carried untouched through every render callback. */
|
||||
data?: O
|
||||
}
|
||||
|
||||
export interface FilterLoadContext {
|
||||
/** Aborted when the query changes, the editor closes, or a load supersedes. */
|
||||
signal: AbortSignal
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/** Result of `loadOptions`, or a bare array. Shape-locked to the cascader. */
|
||||
export interface FilterLoadResult<O = unknown> {
|
||||
items: FilterOption<O>[]
|
||||
nextCursor?: string
|
||||
/** Defaults to whether `nextCursor` was supplied. */
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
/** A field, or a branch. A group is a field with `fields`, not `selectable`. */
|
||||
export interface FilterField<V = unknown, O = unknown> {
|
||||
/** Stable id. Unique among its siblings; the full path must be unique. */
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
/** Trailing count. Defaults to known children; set it when they are lazy. */
|
||||
count?: number
|
||||
fields?: FilterField<V, O>[]
|
||||
/** Whether a BRANCH is itself filterable. Leaves always are. */
|
||||
selectable?: boolean
|
||||
disabled?: boolean
|
||||
|
||||
type?: FilterValueType
|
||||
options?: FilterOption<O>[]
|
||||
/** Async options, paged via `cursor`. Any `options` also seed the cache. */
|
||||
loadOptions?: (
|
||||
query: string,
|
||||
context: FilterLoadContext
|
||||
) => FilterOption<O>[] | Promise<FilterOption<O>[] | FilterLoadResult<O>>
|
||||
/** Resolves stored values the loader never returned, for restored chips. */
|
||||
resolveValues?: (
|
||||
values: string[]
|
||||
) => FilterOption<O>[] | Promise<FilterOption<O>[]>
|
||||
|
||||
/** Operators for this field. Falls back to the catalog for its `type`. */
|
||||
operators?:
|
||||
| FilterOperator[]
|
||||
| ((field: FilterField<V, O>) => FilterOperator[])
|
||||
/**
|
||||
* The operator this field FALLS BACK to, not a way to preselect one. Both
|
||||
* create paths deliberately start a rule on `operator: ""` so the condition
|
||||
* menu opens with nothing chosen, so this is read when an existing rule
|
||||
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
|
||||
* Ignored, with a dev warning, when it names an operator the field does not
|
||||
* offer; the first visible operator wins instead.
|
||||
*/
|
||||
defaultOperator?: string
|
||||
|
||||
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
|
||||
editor?: FilterEditorRef<V, O>
|
||||
renderValue?: (context: FilterValueDisplayContext<V, O>) => React.ReactNode
|
||||
/** The value as PLAIN TEXT for a11y; the default is `String(value)`. */
|
||||
valueText?: (context: FilterValueDisplayContext<V, O>) => string
|
||||
|
||||
/** Placeholder for the value editor's input, or an option list's search. */
|
||||
placeholder?: string
|
||||
/** SHOWS the search box (default true). Off keeps it, hidden: it owns focus. */
|
||||
searchable?: boolean
|
||||
/**
|
||||
* Whether an option-backed editor STACKS the picks at the top of its list.
|
||||
* Off by default, because a short closed list is read as a whole and lifting
|
||||
* a row out of a memorised order costs more than it buys. The partition is
|
||||
* taken LIVE unless `sortSelected: "snapshot"`, so ticking a row moves the
|
||||
* rows below it and the highlight is carried across by VALUE. Exclusive
|
||||
* options never join the stack; they are grouped by ROLE.
|
||||
*/
|
||||
pinSelected?: boolean
|
||||
/**
|
||||
* How an option list is ordered INSIDE each group, plus WHEN the partition is
|
||||
* taken under `pinSelected`. With pinning off there is no partition, so
|
||||
* `"snapshot"` is the same thing as `"none"`.
|
||||
*
|
||||
* - `"none"` (default) keeps declaration order. Not alphabetical, because
|
||||
* option order is usually semantic (To do, In progress, Done).
|
||||
* - `"label"` sorts with `localeCompare`, so "Ålesund" files next to
|
||||
* "Alesund" rather than after "Zurich".
|
||||
* - `"snapshot"` keeps declaration order too, and freezes the partition as
|
||||
* the menu opened it: a steady pointer target where the other two re-pin
|
||||
* live.
|
||||
*/
|
||||
sortSelected?: "none" | "label" | "snapshot"
|
||||
/**
|
||||
* This field's own validity check. Return a MESSAGE to mark the value cell
|
||||
* invalid, or `null` / `undefined` / `false` when the value is fine. A string
|
||||
* rather than a schema object keeps the primitive library-agnostic.
|
||||
*
|
||||
* ORDER MATTERS: the built-in checks run first and this runs only when they
|
||||
* pass, so a validator never re-answers "is there a value at all". Not called
|
||||
* for a valueless operator, nor for a rule whose field the schema no longer
|
||||
* has, and shown only once the user has committed a value to that rule.
|
||||
*/
|
||||
validate?: (
|
||||
context: FilterValidateContext<V, O>
|
||||
) => string | null | undefined | false
|
||||
/**
|
||||
* Reaches the value editor's PANEL. Merged last through tailwind-merge, so a
|
||||
* `w-*` here beats the default rather than losing to it on source order.
|
||||
*
|
||||
* HEIGHT IS A VARIABLE, NOT A UTILITY: a `max-h-*` here bounds the PANEL and
|
||||
* does nothing to the list inside it, which owns its own `max-height`. Write
|
||||
* `--cascader-max-height: 28rem` as an arbitrary property instead; the cap is
|
||||
* the SMALLER of that and the space the popup has.
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/** The storage column when it differs from the UI path. Never read here. */
|
||||
column?: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Editors */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Where an editor renders. The SAME component serves both: `"create"` is the
|
||||
* wizard step, which has Back and advances on commit, `"amend"` the
|
||||
* chip-anchored popover, which offers Discard instead.
|
||||
*/
|
||||
export type FilterEditorHost = "create" | "amend"
|
||||
|
||||
export interface FilterOptionsState<O = unknown> {
|
||||
items: FilterOption<O>[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
hasMore: boolean
|
||||
/** Current search text. Debounced before it reaches `loadOptions`. */
|
||||
query: string
|
||||
setQuery: (query: string) => void
|
||||
loadMore: () => void
|
||||
retry: () => void
|
||||
/** Resolves a stored value to its option, from cache when possible. */
|
||||
resolve: (value: string) => FilterOption<O> | undefined
|
||||
}
|
||||
|
||||
export interface FilterCommitOptions {
|
||||
/** Dismiss the host after writing. Defaults to true. */
|
||||
close?: boolean
|
||||
}
|
||||
|
||||
export interface FilterEditorProps<V = unknown, O = unknown> {
|
||||
field: FilterField<V, O>
|
||||
operator: FilterOperator
|
||||
/** The DRAFT value. An editor edits a draft; the host commits it. */
|
||||
value: V | undefined
|
||||
onValueChange: (value: V | undefined) => void
|
||||
host: FilterEditorHost
|
||||
/**
|
||||
* Spread onto whichever element should take focus, so no editor reaches for
|
||||
* `setTimeout`. A CALLBACK ref at `HTMLElement` is the one shape assignable
|
||||
* to every element's own ref prop, so it spreads onto an input, a slider or a
|
||||
* button without a cast.
|
||||
*/
|
||||
autoFocusProps: {
|
||||
ref: React.RefCallback<HTMLElement>
|
||||
autoFocus: boolean
|
||||
}
|
||||
/** Accept the draft. `{ close: false }` writes through without dismissing. */
|
||||
commit: (value?: V, options?: FilterCommitOptions) => void
|
||||
cancel: () => void
|
||||
/** Step back. Only meaningful when `host === "create"`. */
|
||||
back: () => void
|
||||
options: FilterOptionsState<O>
|
||||
labels: FilterLabels
|
||||
}
|
||||
|
||||
export type FilterEditor<V = unknown, O = unknown> = React.ComponentType<
|
||||
FilterEditorProps<V, O>
|
||||
>
|
||||
|
||||
/**
|
||||
* An editor with its generics erased, for the registry. `unknown` rather than
|
||||
* `never`: props are contravariant, so a `FilterEditor<never, never>` registry
|
||||
* accepts nothing at all. The single widening cast happens where it renders.
|
||||
*/
|
||||
export type AnyFilterEditor = React.ComponentType<
|
||||
FilterEditorProps<unknown, unknown>
|
||||
>
|
||||
|
||||
export type FilterEditorRegistry = Record<string, AnyFilterEditor>
|
||||
|
||||
/**
|
||||
* A registered editor's name, or a component. The `any` arm lets a CONCRETE
|
||||
* editor sit on an unknown-typed field without a cast: props are contravariant,
|
||||
* so `FilterEditor<DateValue>` is not a `FilterEditor<unknown>`. The widening
|
||||
* happens once, inside `resolveFilterEditor`.
|
||||
*/
|
||||
export type FilterEditorRef<V = unknown, O = unknown> =
|
||||
| string
|
||||
| FilterEditor<V, O>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| FilterEditor<any, any>
|
||||
|
||||
/** Context for a custom builder empty state. Its actions ARE the footer's. */
|
||||
export interface FilterEmptyStateContext {
|
||||
labels: FilterLabels
|
||||
/** The bar is locked. A custom state should not offer an action here. */
|
||||
readOnly: boolean
|
||||
/** Which box the builder is in, for a state that wants to be denser inline. */
|
||||
mode: "popover" | "inline"
|
||||
/** Appends a condition and opens its attribute picker. */
|
||||
addFilter: () => void
|
||||
addGroup: () => void
|
||||
}
|
||||
|
||||
export interface FilterValueDisplayContext<V = unknown, O = unknown> {
|
||||
value: V | undefined
|
||||
/** `value` normalised to an array, so callbacks never re-derive it. */
|
||||
values: unknown[]
|
||||
field: FilterField<V, O>
|
||||
operator: FilterOperator
|
||||
/** Options already resolved for `value`, when the field is option-backed. */
|
||||
options: FilterOption<O>[]
|
||||
labels: FilterLabels
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Index */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Normalized schema, keyed by SIGNATURE: call sites inline the array. */
|
||||
export interface FilterIndex<V = unknown, O = unknown> {
|
||||
/** Every field by its joined path, `"name.first"`. */
|
||||
byPath: Map<string, FilterField<V, O>>
|
||||
/** Child fields by parent path. Root fields are keyed by `FILTER_ROOT_KEY`. */
|
||||
childrenOf: Map<string, FilterField<V, O>[]>
|
||||
/** Parent path by path. Empty string for a root field. */
|
||||
parentOf: Map<string, string>
|
||||
/** Every field in stable, depth-first order. Deep search walks it, and the
|
||||
* builder seeds a new row from the FIRST pickable entry, so order is load
|
||||
* bearing, not incidental. */
|
||||
all: { field: FilterField<V, O>; path: string[] }[]
|
||||
/** Top level fields, in input order. */
|
||||
roots: FilterField<V, O>[]
|
||||
/** Content hash; equal schemas share one, so a rebuild reuses the index. */
|
||||
signature: string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Draft */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type FilterDraftStep = "field" | "operator" | "value"
|
||||
|
||||
/**
|
||||
* The in-flight filter. `cascaderPath` is kept SEPARATE from `path`: `path` is
|
||||
* what the user chose, `cascaderPath` is where they were browsing when they
|
||||
* chose it, and Back has to return there. Deriving it from `path` breaks the
|
||||
* moment a deep search jumps across the tree.
|
||||
*/
|
||||
export interface FilterDraft<V = unknown> {
|
||||
step: FilterDraftStep
|
||||
/** `"ready"` = complete: the host writes it into the query and closes. The
|
||||
* pure reducer decides that, not a click handler. */
|
||||
status: "editing" | "ready"
|
||||
/** Set when amending an existing rule, null when creating a new one. */
|
||||
ruleId: string | null
|
||||
/** The chosen field path. Empty until the field step commits. */
|
||||
path: string[]
|
||||
cascaderPath: string[]
|
||||
operator: string | null
|
||||
value: V | undefined
|
||||
query: string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Labels */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Every user facing string. `stepAnnouncement` alone is headless-only. */
|
||||
export interface FilterLabels {
|
||||
addFilter: string
|
||||
advancedFilter: string
|
||||
/** The line above the builder's rows, "In this view, show records". */
|
||||
showRecords: string
|
||||
/** Empty-state title. The HINT below is withheld from a read-only bar. */
|
||||
builderEmpty: string
|
||||
builderEmptyHint: string
|
||||
/** Appends a condition to the root group, from the builder's footer. */
|
||||
addCondition: string
|
||||
addConditionGroup: string
|
||||
/** A group's own add button: the only keyboard route into a nested group. It
|
||||
* NAMES a button that shows `addCondition`, so a translation must contain
|
||||
* that string, which is what WCAG Label in Name asks for. */
|
||||
addToGroup: string
|
||||
removeGroup: string
|
||||
wrapInGroup: string
|
||||
/** Dissolves a group into its parent. The inverse of `wrapInGroup`. */
|
||||
ungroup: string
|
||||
/** Moves a condition to the root group. The keyboard path to that drag. */
|
||||
moveToTopLevel: string
|
||||
/** Groups have no names, so they are numbered in document order, one-based. */
|
||||
moveToGroup: (position: number) => string
|
||||
/** Accessible name of a row's or a group's drag handle. */
|
||||
reorder: string
|
||||
/** Description on the drag handle, teaching the Alt+Arrow keyboard model. */
|
||||
reorderHint: string
|
||||
groupAll: string
|
||||
groupAny: string
|
||||
groupPlaceholder: string
|
||||
/** Names a builder row. Depth is in it: indentation is invisible to AT. */
|
||||
rowLabel: (condition: string, depth: number) => string
|
||||
groupLabel: (description: string, depth: number) => string
|
||||
groupAnnouncement: (added: boolean) => string
|
||||
/** Alt+Arrow is otherwise silent. The total says whether this is the end. */
|
||||
reorderAnnouncement: (
|
||||
label: string,
|
||||
position: number,
|
||||
total: number
|
||||
) => string
|
||||
/** A cross-PARENT move; a plain reorder announcement cannot be told apart.
|
||||
* `destination` is the group's own headline, or the bar's label at the top
|
||||
* level. */
|
||||
moveAnnouncement: (
|
||||
label: string,
|
||||
destination: string,
|
||||
position: number,
|
||||
total: number
|
||||
) => string
|
||||
clearAll: string
|
||||
/** Names a group's menu; distinct from `chipMenu`, which is per-rule. */
|
||||
groupMenu: string
|
||||
searchFields: string
|
||||
searchOperators: string
|
||||
searchOptions: string
|
||||
back: string
|
||||
clear: string
|
||||
apply: string
|
||||
discard: string
|
||||
empty: string
|
||||
loading: string
|
||||
loadingMore: string
|
||||
loadMore: string
|
||||
error: string
|
||||
retry: string
|
||||
/** Leading word before the first chip, where a combinator would otherwise go. */
|
||||
where: string
|
||||
and: string
|
||||
or: string
|
||||
/** Accessible name of the combinator toggle between two chips. */
|
||||
combinator: string
|
||||
/** Builder combinator toggle. English "and" wants 58.72px of a 64px track,
|
||||
* so the name must CONTAIN the word: truncated, the pill shows "a...". */
|
||||
combinatorLabel: (word: string) => string
|
||||
duplicate: string
|
||||
negate: string
|
||||
/** Chip kebab's route into the builder. Needs `onConvertToAdvanced`. */
|
||||
convertToAdvanced: string
|
||||
remove: string
|
||||
/** Names a chip's menu button. The builder's row menu reads the same key. */
|
||||
chipMenu: (fieldLabel: string) => string
|
||||
filtersLabel: string
|
||||
filterLabel: (condition: string) => string
|
||||
/** Prose, not ARIA: `aria-readonly` is invalid on toolbar, group and button. */
|
||||
readOnly: string
|
||||
/** Joins ancestors in a nested field path, "Name > First", for the names
|
||||
* `formatFilterPath` builds. The chip draws a decorative chevron instead. */
|
||||
pathSeparator: string
|
||||
valuePlaceholder: string
|
||||
/** Empty word for an OPTION value; `placeholder` is the search prompt. */
|
||||
selectPlaceholder: string
|
||||
/** Spoken for an empty value: "contains enter text..." is not a name. */
|
||||
noValue: string
|
||||
/** Shown in the operator segment before a condition has been chosen. */
|
||||
selectCondition: string
|
||||
/** Appended to a chip with no condition, matching the dashed outline. */
|
||||
incomplete: string
|
||||
/** Appended to a branch row's accessible name in the field picker. */
|
||||
branchAffordance: string
|
||||
/** A FRAGMENT, appended after a comma to an exclusive row's accessible name.
|
||||
* Warns before the press; `exclusiveAnnouncement` is the receipt after. */
|
||||
exclusiveHint: string
|
||||
/** The clearing moves nothing on screen, so it is otherwise silent. */
|
||||
exclusiveAnnouncement: (label: string, cleared: number) => string
|
||||
itemCount: (count: number) => string
|
||||
fieldsLabel: string
|
||||
/** Live-region text after a query narrows an option list or the picker. */
|
||||
resultsAnnouncement: (count: number) => string
|
||||
/** Accessible name of an option menu's footer (Load more, Retry). */
|
||||
actionsLabel: string
|
||||
/** For a CONSUMER-composed wizard; the shipped flow announces counts. */
|
||||
stepAnnouncement: (step: FilterDraftStep, label: string) => string
|
||||
countAnnouncement: (count: number) => string
|
||||
valueCount: (count: number) => string
|
||||
/** Spells out the list behind the `valueCount` summary, and contains it. */
|
||||
valueDetail: (summary: string, values: string[]) => string
|
||||
valueRange: (from: string, to: string) => string
|
||||
rangeFrom: (fieldLabel: string) => string
|
||||
rangeTo: (fieldLabel: string) => string
|
||||
rangeSeparator: string
|
||||
/** Rendered for a `negated` rule, wrapping the operator label. */
|
||||
negated: (operatorLabel: string) => string
|
||||
/** Guidance ("Choose a condition"), not diagnosis: shown three ways over. */
|
||||
issueOperator: string
|
||||
issueValue: string
|
||||
issueRange: string
|
||||
issueRangeOrder: string
|
||||
issueEmptyGroup: string
|
||||
/** The roll-up, and the name of the button that jumps to the first issue. */
|
||||
issueSummary: (count: number) => string
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Why a node cannot run as written. `collectFilterIssues` produces these. */
|
||||
export type FilterIssueReason =
|
||||
| "missing-operator"
|
||||
| "missing-value"
|
||||
| "incomplete-range"
|
||||
| "reversed-range"
|
||||
| "empty-group"
|
||||
/** The one reason whose message is the validator's, not `FilterLabels`. */
|
||||
| "custom"
|
||||
|
||||
export interface FilterIssue {
|
||||
nodeId: string
|
||||
/** WHICH cell to mark. Two reasons share the value cell; groups have none. */
|
||||
column: "operator" | "value" | "group"
|
||||
reason: FilterIssueReason
|
||||
/** Set only for `reason: "custom"`; the rest look up `FilterLabels`. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a field's `validate` is handed. Mirrors `FilterValueDisplayContext`.
|
||||
* SYNCHRONOUS: issues are collected in a pure memo pass, so a check that has to
|
||||
* hit a server belongs in the consumer's own submit path.
|
||||
*/
|
||||
export interface FilterValidateContext<V = unknown, O = unknown> {
|
||||
value: V | undefined
|
||||
values: unknown[]
|
||||
field: FilterField<V, O>
|
||||
operator: FilterOperator
|
||||
/** How many values this operator takes, already resolved. */
|
||||
arity: FilterOperatorArity
|
||||
rule: FilterRule<V>
|
||||
labels: FilterLabels
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,180 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
|
||||
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
|
||||
* So variant="inverse" re-points every panel from one place, and a consumer's
|
||||
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
|
||||
*/
|
||||
const frameVariants = cva(
|
||||
[
|
||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||
// Each rung is the radius that style's own shadcn Card resolves through, so
|
||||
// a Frame and a Card side by side agree. lyra/sera are 0px, NOT
|
||||
// var(--radius-none): no such token exists, and it only reached 0 by being
|
||||
// invalid, which also left --frame-radius empty for anything reading it.
|
||||
"[--frame-radius:var(--radius-xl)]",
|
||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
|
||||
// Luma alone re-times the frame: wider gap and padding, roomier bars.
|
||||
"",
|
||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||
// Concentric: the panel nests inside the frame's corner rather than copying
|
||||
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
|
||||
// that keeps the two arcs parallel. `ghost` drops the border term (no
|
||||
// border), `dense` pins it to the frame radius (panels sit flush).
|
||||
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||
inverse:
|
||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
|
||||
},
|
||||
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
|
||||
// against a body py of 2/3.5/4/5, while px stays level with the body so
|
||||
// header, content and footer left-align. xs floors at 0.5 (2px), below
|
||||
// which it stops reading as padding. No style-*.css overrides these.
|
||||
spacing: {
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
||||
default:
|
||||
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
|
||||
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
|
||||
},
|
||||
stacked: {
|
||||
true: [
|
||||
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
|
||||
"*:has-[+[data-slot=frame-panel]]:before:hidden",
|
||||
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
|
||||
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
|
||||
],
|
||||
false: [
|
||||
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
|
||||
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
|
||||
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
|
||||
],
|
||||
},
|
||||
dense: {
|
||||
// Parent selectors, not CSS vars: these are positional. Panels are
|
||||
// pulled flush (-mx-px), so corners align with the frame's own radius.
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
spacing: "default",
|
||||
stacked: false,
|
||||
dense: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Frame({
|
||||
className,
|
||||
variant,
|
||||
spacing,
|
||||
stacked,
|
||||
dense,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
frameVariants({ variant, spacing, stacked, dense }),
|
||||
className
|
||||
)}
|
||||
data-slot="frame"
|
||||
data-spacing={spacing}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FramePanel({
|
||||
className,
|
||||
fit,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { fit?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
/* `fit` sizes the panel to its content; otherwise it grows to fill
|
||||
the frame. */
|
||||
!fit && "grow",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
|
||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
data-slot="frame-panel-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="frame-panel-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-footer"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Frame,
|
||||
FramePanel,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
frameVariants,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
Loaded 100 of 680 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user