feat(uat-batch): Groups J + K — activity feed + onboarding resolver-chain
J38, J39, K40 (core) from the 2026-05-21 plan.
Shipped:
J38 EntityActivityFeed sentence rendering surfaces the new value
inline. Was "<actor> updated the X"; now "<actor> set X to
<value>" when the audit row carries `newValue`. Field-level
diff line underneath keeps showing the old → new strikethrough
for context. Truncates inline value at 60 chars to keep long
notes / descriptions from blowing out the row.
J39 Client → Companies tab CTA. Empty state gains a "Link to a
company" action; populated state grows a top-right "Link to
company" button. New <LinkCompanyDialog> wraps the existing
<CompanyPicker> + a membership-role select + an "is primary"
checkbox, then POSTs to /api/v1/companies/[id]/members.
Empty-state copy dropped "Add a membership from a company's
detail page" — the rep can act inline now.
K40 OnboardingChecklist resolver-chain. The auto-check no longer
reads raw `/admin/settings` rows (which miss env fallbacks).
Resolved endpoint widened to accept `?keys=k1,k2,...` so the
checklist can batch-resolve any heterogenous set of registry
keys through port → global → env → default in one round-trip.
Checklist captures the dominant source per step ("env fallback",
"global default", "built-in default") and surfaces it inline
under the green tick so super-admins see when a step is
relying on env rather than a per-port override. Compound-key
gates report the weakest sub-key's source so a partially-env
config still flags clearly.
Topbar banner / dashboard tile / weekly nudge / celebration
sub-items remain queued — the core resolver-chain gap was
the actual cause of the "step never ticks" UAT complaint.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,14 @@ const STEPS: OnboardingStep[] = [
|
||||
},
|
||||
];
|
||||
|
||||
interface ResolvedValue {
|
||||
isSet: boolean;
|
||||
source?: 'port' | 'global' | 'env' | 'default' | 'none';
|
||||
value?: unknown;
|
||||
}
|
||||
interface ResolvedResp {
|
||||
data: { entries: Array<{ key: string }>; values: Record<string, ResolvedValue> };
|
||||
}
|
||||
interface SettingRow {
|
||||
key: string;
|
||||
value: unknown;
|
||||
@@ -125,6 +133,11 @@ export function OnboardingChecklist() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [autoChecks, setAutoChecks] = useState<Record<string, boolean>>({});
|
||||
// Per-step source flags — populated for steps whose auto-check resolved
|
||||
// via the env / default fallback rather than a port / global override.
|
||||
// Surfaces "Resolving from env" copy so super admins see what's
|
||||
// backing each green tick without digging into the settings page.
|
||||
const [autoSources, setAutoSources] = useState<Record<string, ResolvedValue['source']>>({});
|
||||
const [manualChecks, setManualChecks] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
@@ -133,23 +146,40 @@ export function OnboardingChecklist() {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const settings = await apiFetch<SettingsResp>('/api/v1/admin/settings');
|
||||
const all = [...settings.data.portSettings, ...settings.data.globalSettings];
|
||||
const byKey = new Map(all.map((r) => [r.key, r.value]));
|
||||
|
||||
const isPresent = (v: unknown) => v !== undefined && v !== null && v !== '' && v !== false;
|
||||
// Collect every setting key referenced by the checklist so we can
|
||||
// batch-resolve them through the full chain in one round-trip.
|
||||
// The `resolved` endpoint reads port→global→env→default, so a
|
||||
// port using env-only credentials still auto-ticks (the old
|
||||
// raw `/admin/settings` query missed env fallback entirely).
|
||||
const keys = new Set<string>();
|
||||
for (const s of STEPS) {
|
||||
if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey);
|
||||
if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k);
|
||||
}
|
||||
// Manual-checkbox state still lives in the raw system_settings
|
||||
// row (it's a JSON blob, not a per-key registry entry) — keep
|
||||
// fetching it the old way.
|
||||
const [resolved, settings] = await Promise.all([
|
||||
keys.size > 0
|
||||
? apiFetch<ResolvedResp>(
|
||||
`/api/v1/admin/settings/resolved?keys=${Array.from(keys)
|
||||
.map(encodeURIComponent)
|
||||
.join(',')}`,
|
||||
)
|
||||
: Promise.resolve({ data: { entries: [], values: {} } } as ResolvedResp),
|
||||
apiFetch<SettingsResp>('/api/v1/admin/settings'),
|
||||
]);
|
||||
const values = resolved.data.values;
|
||||
const isPresent = (key: string): boolean => Boolean(values[key]?.isSet);
|
||||
|
||||
const checks: Record<string, boolean> = {};
|
||||
const listChecks = await Promise.all(
|
||||
STEPS.map(async (s) => {
|
||||
if (s.autoCheckSettingKey) {
|
||||
return [s.id, isPresent(byKey.get(s.autoCheckSettingKey))] as const;
|
||||
return [s.id, isPresent(s.autoCheckSettingKey)] as const;
|
||||
}
|
||||
if (s.autoCheckSettingKeysAll) {
|
||||
return [
|
||||
s.id,
|
||||
s.autoCheckSettingKeysAll.every((k) => isPresent(byKey.get(k))),
|
||||
] as const;
|
||||
return [s.id, s.autoCheckSettingKeysAll.every((k) => isPresent(k))] as const;
|
||||
}
|
||||
if (s.autoCheckListEndpoint) {
|
||||
try {
|
||||
@@ -165,7 +195,35 @@ export function OnboardingChecklist() {
|
||||
for (const [id, done] of listChecks) checks[id] = done;
|
||||
setAutoChecks(checks);
|
||||
|
||||
// Capture the dominant source per step. For single-key checks this
|
||||
// is the key's source; for multi-key checks we report the
|
||||
// "weakest" source so the rep sees env if any sub-key is env.
|
||||
const sourcesByStep: Record<string, ResolvedValue['source']> = {};
|
||||
const PRIORITY: Record<string, number> = {
|
||||
port: 4,
|
||||
global: 3,
|
||||
env: 2,
|
||||
default: 1,
|
||||
none: 0,
|
||||
};
|
||||
for (const s of STEPS) {
|
||||
if (s.autoCheckSettingKey) {
|
||||
sourcesByStep[s.id] = values[s.autoCheckSettingKey]?.source;
|
||||
} else if (s.autoCheckSettingKeysAll) {
|
||||
const sources = s.autoCheckSettingKeysAll
|
||||
.map((k) => values[k]?.source)
|
||||
.filter((x): x is NonNullable<ResolvedValue['source']> => Boolean(x));
|
||||
// Pick the lowest-priority (weakest) source so the rep sees
|
||||
// "env" if the compound has any env-only sub-key.
|
||||
sources.sort((a, b) => (PRIORITY[a] ?? 0) - (PRIORITY[b] ?? 0));
|
||||
sourcesByStep[s.id] = sources[0];
|
||||
}
|
||||
}
|
||||
setAutoSources(sourcesByStep);
|
||||
|
||||
// Pull the manual-checkbox state from system_settings.
|
||||
const allSettings = [...settings.data.portSettings, ...settings.data.globalSettings];
|
||||
const byKey = new Map(allSettings.map((r) => [r.key, r.value]));
|
||||
const manual = (byKey.get('onboarding_manual_status') ?? {}) as Record<string, boolean>;
|
||||
setManualChecks(manual);
|
||||
} finally {
|
||||
@@ -254,6 +312,20 @@ export function OnboardingChecklist() {
|
||||
step.autoCheckSettingKeysAll?.join(' + ') ??
|
||||
step.autoCheckListEndpoint}
|
||||
</code>
|
||||
{autoSources[step.id] && autoSources[step.id] !== 'port' ? (
|
||||
<span className="ml-1 text-[10px] text-amber-700">
|
||||
· resolving from{' '}
|
||||
<strong className="font-medium">
|
||||
{autoSources[step.id] === 'env'
|
||||
? 'env fallback'
|
||||
: autoSources[step.id] === 'global'
|
||||
? 'global default'
|
||||
: autoSources[step.id] === 'default'
|
||||
? 'built-in default'
|
||||
: autoSources[step.id]}
|
||||
</strong>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user