Directories: Description column and a Test card on each directory #110
No files matched your search
@@ -69,6 +69,7 @@ import { celebrateIfFirstDomain } from '@/features/celebrate/firstDomain';
|
||||
import { logFormChange } from '@/lib/debug';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
|
||||
import { DirectoryTestCard } from '@/features/directory/DirectoryTestCard';
|
||||
import { changedFields } from '@/features/settings/changedFromDefault';
|
||||
import { isSieveScriptField } from '@/lib/sievepad';
|
||||
import { interpreterFor } from '@/lib/sieveLimits';
|
||||
@@ -932,6 +933,11 @@ export function DynamicForm({ viewName, objectId, intro, foldSections }: Dynamic
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* inbuxa: try a saved directory, as the guided setup does (admin #109) */}
|
||||
{resolved.obj.objectName === 'x:Directory' && objectId && !isCreate && (
|
||||
<DirectoryTestCard directoryId={String(objectId)} kind={selectedVariant} dirty={isDirty} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 pb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
{canDelete && (
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -25,13 +25,14 @@ import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell';
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { getAccountId, jmapRequest, jmapSet } from '@/services/jmap/client';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { directoryObject, PRESETS, type Connection, type Preset } from './presets';
|
||||
import { testDirectory, type DirectoryTestResult } from './directoryTest';
|
||||
import { DirectoryTestResults } from './DirectoryTestResults';
|
||||
|
||||
interface DomainRow {
|
||||
id: string;
|
||||
@@ -40,22 +41,6 @@ interface DomainRow {
|
||||
accounts: number | null;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
kind?: string;
|
||||
opened: boolean;
|
||||
error?: string;
|
||||
oidc?: { issuer: string };
|
||||
lookup?: {
|
||||
found?: 'account' | 'group' | 'none';
|
||||
email?: string;
|
||||
aliases?: string[];
|
||||
groups?: string[];
|
||||
description?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
signIn?: { ok: boolean; wrongPassword?: boolean; error?: string; email?: string };
|
||||
}
|
||||
|
||||
function failure(body: JmapSetResponse | undefined, kind: 'notCreated' | 'notUpdated'): string | null {
|
||||
const e = body?.[kind] ? Object.values(body[kind]!)[0] : undefined;
|
||||
if (!e) return null;
|
||||
@@ -118,7 +103,7 @@ export function DirectorySetupPage() {
|
||||
const [savedId, setSavedId] = useState<string | null>(null);
|
||||
const [testAddress, setTestAddress] = useState('');
|
||||
const [testPassword, setTestPassword] = useState('');
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
const [result, setResult] = useState<DirectoryTestResult | null>(null);
|
||||
const [data, setData] = useState<{ domains: DomainRow[]; serverDefault: string | null } | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [asDefault, setAsDefault] = useState(false);
|
||||
@@ -193,28 +178,11 @@ export function DirectorySetupPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// The server rebuilds directories a moment after a save.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const res = await apiFetch('/api/directory/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// OpenID Connect has no lookup to try, so any address will do there.
|
||||
body: JSON.stringify({
|
||||
directoryId: id,
|
||||
address: testAddress.trim() || 'check@invalid',
|
||||
password: testPassword || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const problem = (await res.json().catch(() => ({}))) as { detail?: string; title?: string };
|
||||
throw new Error(problem.detail ?? problem.title ?? `The test failed (${res.status}).`);
|
||||
}
|
||||
const body = (await res.json()) as TestResult;
|
||||
if (body.opened || !body.error?.startsWith("The server hasn't loaded")) {
|
||||
setResult(body);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
// OpenID Connect has no lookup to try, so any address will do there.
|
||||
const body = await testDirectory(id, testAddress, testPassword);
|
||||
if (body) {
|
||||
setResult(body);
|
||||
return;
|
||||
}
|
||||
setResult({ opened: false, error: t('dirSetup.notLoaded', 'The server hasn’t loaded the directory yet.') });
|
||||
} catch (e) {
|
||||
@@ -494,70 +462,7 @@ export function DirectorySetupPage() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{r && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
{r.opened ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
{r.opened
|
||||
? r.oidc
|
||||
? t('dirSetup.oidcOk', 'The provider answered, as {{issuer}}.', { issuer: r.oidc.issuer })
|
||||
: t('dirSetup.opened', 'The server can use this directory.')
|
||||
: t('dirSetup.notOpened', 'The directory didn’t open: {{error}}', { error: r.error ?? '' })}
|
||||
</li>
|
||||
{r.lookup && (
|
||||
<li className="flex items-start gap-2">
|
||||
{r.lookup.found === 'account' ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span>
|
||||
{r.lookup.error
|
||||
? t('dirSetup.lookupError', 'The lookup failed: {{error}}', { error: r.lookup.error })
|
||||
: r.lookup.found === 'account'
|
||||
? t('dirSetup.found', 'Found {{name}} <{{email}}>.', {
|
||||
name: r.lookup.description ?? '',
|
||||
email: r.lookup.email,
|
||||
})
|
||||
: r.lookup.found === 'group'
|
||||
? t('dirSetup.foundGroup', 'That address is a group, not a person. Try a person’s address.')
|
||||
: t(
|
||||
'dirSetup.notFound',
|
||||
'Nobody with that address. Check the search base and that the address is in the mail attribute.',
|
||||
)}
|
||||
{r.lookup.found === 'account' && (r.lookup.aliases?.length || r.lookup.groups?.length) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{r.lookup.aliases?.length
|
||||
? t('dirSetup.aliases', 'Also receives: {{list}}. ', { list: r.lookup.aliases.join(', ') })
|
||||
: ''}
|
||||
{r.lookup.groups?.length
|
||||
? t('dirSetup.groups', 'Groups: {{list}}.', { list: r.lookup.groups.join(', ') })
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{r.signIn && (
|
||||
<li className="flex items-start gap-2">
|
||||
{r.signIn.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
{r.signIn.ok
|
||||
? t('dirSetup.signInOk', 'The password signs in.')
|
||||
: r.signIn.wrongPassword
|
||||
? t('dirSetup.wrongPassword', 'The directory says that password is wrong.')
|
||||
: t('dirSetup.signInError', 'Signing in failed: {{error}}', { error: r.signIn.error ?? '' })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{r && <DirectoryTestResults result={r} />}
|
||||
{r && !testPassed && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dirSetup.fixHint', 'Go back to change the connection. Saving again updates the same directory.')}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: "Test this directory" on a saved directory's page (admin #109).
|
||||
* The guided setup tests a directory as it goes; one added or changed here
|
||||
* had no way to. First check the connection, then, once it works, try a
|
||||
* person: the same server test the guide uses, on the saved settings.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CHECK_ADDRESS, testDirectory, type DirectoryTestResult } from './directoryTest';
|
||||
import { DirectoryTestResults, TestLine } from './DirectoryTestResults';
|
||||
|
||||
interface Props {
|
||||
directoryId: string;
|
||||
/** The saved directory's variant: Ldap, Sql or Oidc. */
|
||||
kind: string | undefined;
|
||||
/** Unsaved changes on the form: the test would try the saved settings, not these. */
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export function DirectoryTestCard({ directoryId, kind, dirty }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const oidc = kind === 'Oidc';
|
||||
const [check, setCheck] = useState<DirectoryTestResult | null>(null);
|
||||
const [person, setPerson] = useState<DirectoryTestResult | null>(null);
|
||||
const [address, setAddress] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState<'check' | 'person' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// An edit changes what the server will test, so earlier results no longer hold.
|
||||
const [wasDirty, setWasDirty] = useState(dirty);
|
||||
if (dirty !== wasDirty) {
|
||||
setWasDirty(dirty);
|
||||
if (dirty) {
|
||||
setCheck(null);
|
||||
setPerson(null);
|
||||
}
|
||||
}
|
||||
|
||||
const run = async (which: 'check' | 'person') => {
|
||||
setBusy(which);
|
||||
setError(null);
|
||||
if (which === 'check') setPerson(null);
|
||||
try {
|
||||
const body =
|
||||
which === 'check'
|
||||
? await testDirectory(directoryId, CHECK_ADDRESS)
|
||||
: await testDirectory(directoryId, address, password);
|
||||
const result = body ?? {
|
||||
opened: false,
|
||||
error: t('dirSetup.notLoaded', 'The server hasn’t loaded the directory yet.'),
|
||||
};
|
||||
if (which === 'check') setCheck(result);
|
||||
else setPerson(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Looking up an address nobody has still searches the directory: an error there is a connection problem.
|
||||
const connected = !!check?.opened && !check.lookup?.error;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('dirTest.title', 'Test this directory')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dirty
|
||||
? t('dirTest.saveFirst', 'Save your changes first. The test uses the saved settings.')
|
||||
: t(
|
||||
'dirTest.how',
|
||||
'The test uses the saved settings. Nothing is created, and a wrong password doesn’t count against anyone.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button type="button" variant="outline" onClick={() => void run('check')} disabled={dirty || busy !== null}>
|
||||
{busy === 'check' && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{oidc ? t('dirSetup.testOidc', 'Check the provider') : t('dirTest.check', 'Check the connection')}
|
||||
</Button>
|
||||
|
||||
{check && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<TestLine ok={connected}>
|
||||
{!check.opened
|
||||
? t('dirSetup.notOpened', 'The directory didn’t open: {{error}}', { error: check.error ?? '' })
|
||||
: check.lookup?.error
|
||||
? t('dirTest.searchFailed', 'The directory opened, but searching it failed: {{error}}', {
|
||||
error: check.lookup.error,
|
||||
})
|
||||
: check.oidc
|
||||
? t('dirSetup.oidcOk', 'The provider answered, as {{issuer}}.', { issuer: check.oidc.issuer })
|
||||
: t('dirTest.connected', 'The server can connect to this directory and search it.')}
|
||||
</TestLine>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{connected && !oidc && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dir-test-addr">{t('dirSetup.testAddress', 'A person to test with')}</Label>
|
||||
<Input id="dir-test-addr" type="email" value={address} onChange={(e) => setAddress(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dir-test-pass">{t('dirSetup.testPassword', 'Their password (optional)')}</Label>
|
||||
<Input
|
||||
id="dir-test-pass"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void run('person')}
|
||||
disabled={dirty || busy !== null || !address.trim()}
|
||||
>
|
||||
{busy === 'person' && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('dirSetup.test', 'Test')}
|
||||
</Button>
|
||||
</div>
|
||||
{person && <DirectoryTestResults result={person} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** inbuxa: what a directory test found, line by line (guided setup and admin #109). */
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import type { DirectoryTestResult } from './directoryTest';
|
||||
|
||||
export function TestLine({ ok, children }: { ok: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<li className="flex items-start gap-2">
|
||||
{ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
)}
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectoryTestResults({ result: r }: { result: DirectoryTestResult }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<TestLine ok={r.opened}>
|
||||
{r.opened
|
||||
? r.oidc
|
||||
? t('dirSetup.oidcOk', 'The provider answered, as {{issuer}}.', { issuer: r.oidc.issuer })
|
||||
: t('dirSetup.opened', 'The server can use this directory.')
|
||||
: t('dirSetup.notOpened', 'The directory didn’t open: {{error}}', { error: r.error ?? '' })}
|
||||
</TestLine>
|
||||
{r.lookup && (
|
||||
<TestLine ok={r.lookup.found === 'account'}>
|
||||
<span>
|
||||
{r.lookup.error
|
||||
? t('dirSetup.lookupError', 'The lookup failed: {{error}}', { error: r.lookup.error })
|
||||
: r.lookup.found === 'account'
|
||||
? t('dirSetup.found', 'Found {{name}} <{{email}}>.', {
|
||||
name: r.lookup.description ?? '',
|
||||
email: r.lookup.email,
|
||||
})
|
||||
: r.lookup.found === 'group'
|
||||
? t('dirSetup.foundGroup', 'That address is a group, not a person. Try a person’s address.')
|
||||
: t(
|
||||
'dirSetup.notFound',
|
||||
'Nobody with that address. Check the search base and that the address is in the mail attribute.',
|
||||
)}
|
||||
{r.lookup.found === 'account' && (r.lookup.aliases?.length || r.lookup.groups?.length) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{r.lookup.aliases?.length
|
||||
? t('dirSetup.aliases', 'Also receives: {{list}}. ', { list: r.lookup.aliases.join(', ') })
|
||||
: ''}
|
||||
{r.lookup.groups?.length
|
||||
? t('dirSetup.groups', 'Groups: {{list}}.', { list: r.lookup.groups.join(', ') })
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</TestLine>
|
||||
)}
|
||||
{r.signIn && (
|
||||
<TestLine ok={r.signIn.ok}>
|
||||
{r.signIn.ok
|
||||
? t('dirSetup.signInOk', 'The password signs in.')
|
||||
: r.signIn.wrongPassword
|
||||
? t('dirSetup.wrongPassword', 'The directory says that password is wrong.')
|
||||
: t('dirSetup.signInError', 'Signing in failed: {{error}}', { error: r.signIn.error ?? '' })}
|
||||
</TestLine>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: the server's directory test, `POST /api/directory/test`, shared by
|
||||
* the guided setup and a saved directory's own page (admin #109). It tries a
|
||||
* saved directory below the sign-in path: nothing is created, and a wrong
|
||||
* password doesn't count against anyone.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/services/api';
|
||||
|
||||
export interface DirectoryTestResult {
|
||||
kind?: string;
|
||||
opened: boolean;
|
||||
error?: string;
|
||||
oidc?: { issuer: string };
|
||||
lookup?: {
|
||||
found?: 'account' | 'group' | 'none';
|
||||
email?: string;
|
||||
aliases?: string[];
|
||||
groups?: string[];
|
||||
description?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
signIn?: { ok: boolean; wrongPassword?: boolean; error?: string; email?: string };
|
||||
}
|
||||
|
||||
/** An address no directory holds: looking it up shows the directory can be searched. */
|
||||
export const CHECK_ADDRESS = 'check@invalid';
|
||||
|
||||
/**
|
||||
* Test directory `id`, looking `address` up and, with a password, signing in.
|
||||
* Null when the server still hasn't loaded the directory after a few tries.
|
||||
*/
|
||||
export async function testDirectory(
|
||||
id: string,
|
||||
address: string,
|
||||
password?: string,
|
||||
): Promise<DirectoryTestResult | null> {
|
||||
// The server rebuilds directories a moment after a save.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const res = await apiFetch('/api/directory/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directoryId: id, address: address.trim() || CHECK_ADDRESS, password: password || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const problem = (await res.json().catch(() => ({}))) as { detail?: string; title?: string };
|
||||
throw new Error(problem.detail ?? problem.title ?? `The test failed (${res.status}).`);
|
||||
}
|
||||
const body = (await res.json()) as DirectoryTestResult;
|
||||
if (body.opened || !body.error?.startsWith("The server hasn't loaded")) return body;
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2329,5 +2329,13 @@
|
||||
"expires": "Expires {{date}}",
|
||||
"domainAliases_one": "{{count}} alias domain",
|
||||
"domainAliases_other": "{{count}} alias domains"
|
||||
},
|
||||
"dirTest": {
|
||||
"title": "Test this directory",
|
||||
"saveFirst": "Save your changes first. The test uses the saved settings.",
|
||||
"how": "The test uses the saved settings. Nothing is created, and a wrong password doesn’t count against anyone.",
|
||||
"check": "Check the connection",
|
||||
"searchFailed": "The directory opened, but searching it failed: {{error}}",
|
||||
"connected": "The server can connect to this directory and search it."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { withExtraColumns } from './listColumns';
|
||||
import type { List, Schema } from '@/types/schema';
|
||||
|
||||
function list(columns: List['columns']): List {
|
||||
return { title: 'Directories', subtitle: '', singularName: 'directory', pluralName: 'directories', columns };
|
||||
}
|
||||
|
||||
function schema(lists: Record<string, List>): Schema {
|
||||
return { lists } as unknown as Schema;
|
||||
}
|
||||
|
||||
describe('withExtraColumns', () => {
|
||||
it('adds Description after Type on the Directories list', () => {
|
||||
const out = withExtraColumns(schema({ 'x:Directory': list([{ name: '@type', label: 'Type' }]) }));
|
||||
expect(out.lists['x:Directory'].columns).toEqual([
|
||||
{ name: '@type', label: 'Type' },
|
||||
{ name: 'description', label: 'Description' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves a list that already has the column alone', () => {
|
||||
const served = schema({
|
||||
'x:Directory': list([
|
||||
{ name: 'description', label: 'Name' },
|
||||
{ name: '@type', label: 'Type' },
|
||||
]),
|
||||
});
|
||||
expect(withExtraColumns(served)).toBe(served);
|
||||
});
|
||||
|
||||
it('leaves other lists, and a schema without the list, alone', () => {
|
||||
const served = schema({ 'x:Domain': list([{ name: 'name', label: 'Name' }]) });
|
||||
expect(withExtraColumns(served)).toBe(served);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: columns a served list leaves out. The Directories list shows only
|
||||
* each directory's type, so two LDAP directories look the same until you
|
||||
* open them (admin #108).
|
||||
*/
|
||||
|
||||
import type { Column, Schema } from '@/types/schema';
|
||||
|
||||
const EXTRA_COLUMNS: Record<string, Column[]> = {
|
||||
'x:Directory': [{ name: 'description', label: 'Description' }],
|
||||
};
|
||||
|
||||
/** The schema with the extra columns added after the served ones; applied once, when the schema loads. */
|
||||
export function withExtraColumns(schema: Schema): Schema {
|
||||
let lists = schema.lists;
|
||||
for (const [viewName, extra] of Object.entries(EXTRA_COLUMNS)) {
|
||||
const list = lists[viewName];
|
||||
if (!list) continue;
|
||||
const missing = extra.filter((c) => !list.columns.some((have) => have.name === c.name));
|
||||
if (missing.length === 0) continue;
|
||||
lists = { ...lists, [viewName]: { ...list, columns: [...list.columns, ...missing] } };
|
||||
}
|
||||
return lists === schema.lists ? schema : { ...schema, lists };
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Schema, LayoutSubItem } from '@/types/schema';
|
||||
import { withRegroupedSettings } from '@/lib/settingsLayout';
|
||||
import { withExtraColumns } from '@/lib/listColumns';
|
||||
|
||||
export interface SearchIndexEntry {
|
||||
text: string;
|
||||
@@ -168,8 +169,9 @@ export const useSchemaStore = create<SchemaState>()((set) => ({
|
||||
searchIndex: [],
|
||||
|
||||
setSchema: (served) => {
|
||||
// inbuxa: Settings grouped by task, not by config file (settings-reorg).
|
||||
const schema = withRegroupedSettings(served);
|
||||
// inbuxa: Settings grouped by task, not by config file (settings-reorg),
|
||||
// and columns the served lists leave out.
|
||||
const schema = withExtraColumns(withRegroupedSettings(served));
|
||||
const { viewToSection, linkEntries } = walkLayouts(schema);
|
||||
const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries);
|
||||
set({
|
||||
|
||||
Reference in new issue
Block a user