refactor(workspace-settings): extract useWorkspaceSetting hook

Deduplicate the "save a workspace setting" plumbing shared by HtmlEmbedSettings
and TrackerSettings (workspace atom read, isLoading state, updateWorkspace + atom
merge forcing settings[key], success/error notifications) into a new
feature-scoped hook useWorkspaceSetting(key).

- Each component keeps its own interaction model: html-embed is an optimistic
  toggle with revert-on-failure; tracker is edit-then-save on an explicit button.
- Unify error handling on the better pattern: surface err.response?.data?.message
  and use console.error (html-embed previously used console.log + a generic message).

No user-facing behavior change; client typecheck clean.

Test-coverage follow-ups (untested trackerHead injection in ShareSeoController and
the no-op audit branch) tracked in #100.
This commit is contained in:
claude_code
2026-06-21 04:17:54 +03:00
parent cecb560fce
commit 3936c482d9
5 changed files with 101 additions and 56 deletions

View File

@@ -1,9 +1,6 @@
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
import { Switch, Stack, Paper, Group, Text, List } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
@@ -18,40 +15,18 @@ import { useTranslation } from "react-i18next";
*/
export default function HtmlEmbedSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { workspace, isLoading, save } = useWorkspaceSetting("htmlEmbed");
const { isAdmin } = useUserRole();
const [checked, setChecked] = useState<boolean>(
workspace?.settings?.htmlEmbed ?? false,
);
const [isLoading, setIsLoading] = useState(false);
async function handleToggle(value: boolean) {
setIsLoading(true);
const previous = checked;
setChecked(value); // optimistic update
try {
const updated = await updateWorkspace({ htmlEmbed: value });
// Force settings.htmlEmbed to the new value so the atom is consistent even
// if the response shape omits it.
setWorkspace({
...updated,
settings: {
...updated.settings,
htmlEmbed: value,
},
});
notifications.show({ message: t("Updated successfully") });
} catch (err) {
console.log(err);
setChecked(previous); // revert on failure
notifications.show({
message: t("Failed to update data"),
color: "red",
});
} finally {
setIsLoading(false);
}
const ok = await save(value);
if (!ok) setChecked(previous); // revert on failure
}
return (

View File

@@ -1,7 +1,5 @@
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { useWorkspaceSetting } from "@/features/workspace/hooks/use-workspace-setting.ts";
import {
Button,
Group,
@@ -10,7 +8,6 @@ import {
Text,
Textarea,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
@@ -25,36 +22,15 @@ import { useTranslation } from "react-i18next";
*/
export default function TrackerSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { workspace, isLoading, save } = useWorkspaceSetting("trackerHead");
const { isAdmin } = useUserRole();
const [value, setValue] = useState<string>(
workspace?.settings?.trackerHead ?? "",
);
const [isLoading, setIsLoading] = useState(false);
async function handleSave() {
setIsLoading(true);
try {
const updated = await updateWorkspace({ trackerHead: value });
setWorkspace({
...updated,
settings: {
...updated.settings,
trackerHead: value,
},
});
notifications.show({ message: t("Updated successfully") });
} catch (err) {
console.error("Failed to update tracker settings", err);
notifications.show({
message:
(err as any)?.response?.data?.message ?? t("Failed to update data"),
color: "red",
});
} finally {
setIsLoading(false);
}
await save(value);
}
return (

View File

@@ -0,0 +1,65 @@
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai";
import { useCallback, useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
/**
* Workspace setting keys that this hook can persist. Each key is both a
* write-only field on the update payload and a read field under
* `workspace.settings`, so the value type is derived from the settings shape.
*/
type WorkspaceSettingKey = "htmlEmbed" | "trackerHead";
type WorkspaceSettingValue<K extends WorkspaceSettingKey> =
NonNullable<IWorkspace["settings"][K]>;
/**
* Shared "save a workspace setting" plumbing extracted from the individual
* settings components. Owns the `isLoading` state and the persist-then-merge
* flow (call `updateWorkspace`, merge the response back into the workspace atom
* while forcing `settings[key]` to the saved value, and surface a success/error
* notification). Callers keep their own interaction model (optimistic toggle,
* edit-then-save, etc.) on top of this.
*/
export function useWorkspaceSetting<K extends WorkspaceSettingKey>(key: K) {
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const save = useCallback(
async (value: WorkspaceSettingValue<K>): Promise<boolean> => {
setIsLoading(true);
try {
const updated = await updateWorkspace({
[key]: value,
} as Partial<IWorkspace>);
// Force settings[key] to the new value so the atom is consistent even
// if the response shape omits it.
setWorkspace({
...updated,
settings: {
...updated.settings,
[key]: value,
},
});
notifications.show({ message: t("Updated successfully") });
return true;
} catch (err) {
console.error(`Failed to update workspace setting "${key}"`, err);
notifications.show({
message:
(err as any)?.response?.data?.message ?? t("Failed to update data"),
color: "red",
});
return false;
} finally {
setIsLoading(false);
}
},
[key, setWorkspace, t],
);
return { workspace, isLoading, save };
}

View File

@@ -87,9 +87,16 @@ export class ShareController {
workspace.id,
);
// Resolve the identity name only when the assistant is enabled, so the
// anonymous widget can label messages with the configured persona name.
const aiAssistantName = aiAssistant
? await this.aiSettings.resolvePublicShareAssistantName(workspace.id)
: null;
return {
...shareData,
aiAssistant,
aiAssistantName,
features: this.licenseCheckService.resolveFeatures(
workspace.licenseKey,
workspace.plan,

View File

@@ -3,6 +3,7 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueName, QueueJob } from '../queue/constants';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@@ -49,6 +50,7 @@ export interface UpdateAiSettingsInput {
export class AiSettingsService {
constructor(
private readonly workspaceRepo: WorkspaceRepo,
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
private readonly aiProviderCredentialsRepo: AiProviderCredentialsRepo,
private readonly pageEmbeddingRepo: PageEmbeddingRepo,
private readonly pageRepo: PageRepo,
@@ -110,6 +112,26 @@ export class AiSettingsService {
return settings?.ai?.publicShareAssistant === true;
}
/**
* Resolve the display name of the agent role acting as the public-share
* assistant's identity, so the anonymous widget can label messages with the
* persona name instead of the generic "AI agent". Returns null when no role
* is configured, or the referenced role is missing/disabled (built-in persona
* → the client falls back to "AI agent"). Mirrors the role resolution in
* PublicShareChatService.resolveShareRole.
*/
async resolvePublicShareAssistantName(
workspaceId: string,
): Promise<string | null> {
const resolved = await this.resolve(workspaceId);
const roleId = resolved?.publicShareAssistantRoleId;
if (!roleId) return null;
const role = await this.aiAgentRoleRepo.findById(roleId, workspaceId);
if (!role || !role.enabled) return null;
const name = role.name?.trim();
return name ? name : null;
}
/** Read the stored non-secret provider settings for a workspace. */
private async readProvider(
workspaceId: string,