mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 01:49:51 +08:00
Merge branch 'canary' into zzj/feat/database-block/upgrade-prompt
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ADD COLUMN "optional_models" VARCHAR[] DEFAULT ARRAY[]::VARCHAR[];
|
||||
@@ -397,6 +397,7 @@ model AiPrompt {
|
||||
// it is only used in the frontend and does not affect the backend
|
||||
action String? @db.VarChar
|
||||
model String @db.VarChar
|
||||
optionalModels String[] @default([]) @db.VarChar @map("optional_models")
|
||||
config Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface ChatEvent {
|
||||
}
|
||||
|
||||
type CheckResult = {
|
||||
model: string | undefined;
|
||||
model: string;
|
||||
hasAttachment?: boolean;
|
||||
};
|
||||
|
||||
@@ -94,7 +94,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
private async checkRequest(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
messageId?: string
|
||||
messageId?: string,
|
||||
modelId?: string
|
||||
): Promise<CheckResult> {
|
||||
await this.chatSession.checkQuota(userId);
|
||||
const session = await this.chatSession.get(sessionId);
|
||||
@@ -102,7 +103,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
throw new CopilotSessionNotFound();
|
||||
}
|
||||
|
||||
const ret: CheckResult = { model: session.model };
|
||||
const ret: CheckResult = {
|
||||
model: session.model,
|
||||
};
|
||||
|
||||
if (modelId && session.optionalModels.includes(modelId)) {
|
||||
ret.model = modelId;
|
||||
}
|
||||
|
||||
if (messageId && typeof messageId === 'string') {
|
||||
const message = await session.getMessageById(messageId);
|
||||
@@ -116,13 +123,16 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
private async chooseTextProvider(
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
messageId?: string
|
||||
): Promise<CopilotTextProvider> {
|
||||
messageId?: string,
|
||||
modelId?: string
|
||||
): Promise<{ provider: CopilotTextProvider; model: string }> {
|
||||
const { hasAttachment, model } = await this.checkRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
messageId
|
||||
messageId,
|
||||
modelId
|
||||
);
|
||||
|
||||
let provider = await this.provider.getProviderByCapability(
|
||||
CopilotCapability.TextToText,
|
||||
{ model }
|
||||
@@ -138,7 +148,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
throw new NoCopilotProviderAvailable();
|
||||
}
|
||||
|
||||
return provider;
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
private async appendSessionMessage(
|
||||
@@ -182,13 +192,17 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
const webSearch = Array.isArray(params.webSearch)
|
||||
? Boolean(params.webSearch[0])
|
||||
: Boolean(params.webSearch);
|
||||
const modelId = Array.isArray(params.modelId)
|
||||
? params.modelId[0]
|
||||
: params.modelId;
|
||||
|
||||
delete params.messageId;
|
||||
delete params.retry;
|
||||
delete params.reasoning;
|
||||
delete params.webSearch;
|
||||
delete params.modelId;
|
||||
|
||||
return { messageId, retry, reasoning, webSearch, params };
|
||||
return { messageId, retry, reasoning, webSearch, modelId, params };
|
||||
}
|
||||
|
||||
private getSignal(req: Request) {
|
||||
@@ -236,13 +250,14 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
const info: any = { sessionId, params };
|
||||
|
||||
try {
|
||||
const { messageId, retry, reasoning, webSearch } =
|
||||
const { messageId, retry, reasoning, webSearch, modelId } =
|
||||
this.prepareParams(params);
|
||||
|
||||
const provider = await this.chooseTextProvider(
|
||||
const { provider, model } = await this.chooseTextProvider(
|
||||
user.id,
|
||||
sessionId,
|
||||
messageId
|
||||
messageId,
|
||||
modelId
|
||||
);
|
||||
|
||||
const [latestMessage, session] = await this.appendSessionMessage(
|
||||
@@ -251,8 +266,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
retry
|
||||
);
|
||||
|
||||
info.model = session.model;
|
||||
metrics.ai.counter('chat_calls').add(1, { model: session.model });
|
||||
info.model = model;
|
||||
metrics.ai.counter('chat_calls').add(1, { model });
|
||||
|
||||
if (latestMessage) {
|
||||
params = Object.assign({}, params, latestMessage.params, {
|
||||
@@ -264,7 +279,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
const finalMessage = session.finish(params);
|
||||
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
|
||||
|
||||
const content = await provider.generateText(finalMessage, session.model, {
|
||||
const content = await provider.generateText(finalMessage, model, {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
@@ -302,13 +317,14 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
const info: any = { sessionId, params, throwInStream: false };
|
||||
|
||||
try {
|
||||
const { messageId, retry, reasoning, webSearch } =
|
||||
const { messageId, retry, reasoning, webSearch, modelId } =
|
||||
this.prepareParams(params);
|
||||
|
||||
const provider = await this.chooseTextProvider(
|
||||
const { provider, model } = await this.chooseTextProvider(
|
||||
user.id,
|
||||
sessionId,
|
||||
messageId
|
||||
messageId,
|
||||
modelId
|
||||
);
|
||||
|
||||
const [latestMessage, session] = await this.appendSessionMessage(
|
||||
@@ -317,8 +333,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
retry
|
||||
);
|
||||
|
||||
info.model = session.model;
|
||||
metrics.ai.counter('chat_stream_calls').add(1, { model: session.model });
|
||||
info.model = model;
|
||||
metrics.ai.counter('chat_stream_calls').add(1, { model });
|
||||
|
||||
if (latestMessage) {
|
||||
params = Object.assign({}, params, latestMessage.params, {
|
||||
@@ -332,7 +348,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
|
||||
|
||||
const source$ = from(
|
||||
provider.generateTextStream(finalMessage, session.model, {
|
||||
provider.generateTextStream(finalMessage, model, {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
user: user.id,
|
||||
|
||||
@@ -41,6 +41,7 @@ export class ChatPrompt {
|
||||
options.name,
|
||||
options.action || undefined,
|
||||
options.model,
|
||||
options.optionalModels,
|
||||
options.config,
|
||||
options.messages
|
||||
);
|
||||
@@ -50,6 +51,7 @@ export class ChatPrompt {
|
||||
public readonly name: string,
|
||||
public readonly action: string | undefined,
|
||||
public readonly model: string,
|
||||
public readonly optionalModels: string[],
|
||||
public readonly config: PromptConfig | undefined,
|
||||
private readonly messages: PromptMessage[]
|
||||
) {
|
||||
|
||||
@@ -5,8 +5,15 @@ import { PromptConfig, PromptMessage } from '../providers';
|
||||
|
||||
type Prompt = Omit<
|
||||
AiPrompt,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'modified' | 'action' | 'config'
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'modified'
|
||||
| 'action'
|
||||
| 'config'
|
||||
| 'optionalModels'
|
||||
> & {
|
||||
optionalModels?: string[];
|
||||
action?: string;
|
||||
messages: PromptMessage[];
|
||||
config?: PromptConfig;
|
||||
@@ -1037,7 +1044,13 @@ Finally, please only send us the content of your continuation in Markdown Format
|
||||
const chat: Prompt[] = [
|
||||
{
|
||||
name: 'Chat With AFFiNE AI',
|
||||
model: 'o4-mini',
|
||||
model: 'gpt-4.1',
|
||||
optionalModels: [
|
||||
'o3',
|
||||
'o4-mini',
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -1161,14 +1174,15 @@ export async function refreshPrompts(db: PrismaClient) {
|
||||
create: {
|
||||
name: prompt.name,
|
||||
action: prompt.action,
|
||||
config: prompt.config || undefined,
|
||||
config: prompt.config ?? undefined,
|
||||
model: prompt.model,
|
||||
optionalModels: prompt.optionalModels,
|
||||
messages: {
|
||||
create: prompt.messages.map((message, idx) => ({
|
||||
idx,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
params: message.params || undefined,
|
||||
params: message.params ?? undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -1177,6 +1191,7 @@ export async function refreshPrompts(db: PrismaClient) {
|
||||
action: prompt.action,
|
||||
config: prompt.config ?? undefined,
|
||||
model: prompt.model,
|
||||
optionalModels: prompt.optionalModels,
|
||||
updatedAt: new Date(),
|
||||
messages: {
|
||||
deleteMany: {},
|
||||
@@ -1184,7 +1199,7 @@ export async function refreshPrompts(db: PrismaClient) {
|
||||
idx,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
params: message.params || undefined,
|
||||
params: message.params ?? undefined,
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -64,6 +64,7 @@ export class PromptService implements OnApplicationBootstrap {
|
||||
name: true,
|
||||
action: true,
|
||||
model: true,
|
||||
optionalModels: true,
|
||||
config: true,
|
||||
messages: {
|
||||
select: {
|
||||
|
||||
@@ -34,7 +34,10 @@ export class AnthropicProvider
|
||||
{
|
||||
override readonly type = CopilotProviderType.Anthropic;
|
||||
override readonly capabilities = [CopilotCapability.TextToText];
|
||||
override readonly models = ['claude-3-7-sonnet-20250219'];
|
||||
override readonly models = [
|
||||
'claude-3-7-sonnet-20250219',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
];
|
||||
|
||||
private readonly MAX_STEPS = 20;
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export class OpenAIProvider
|
||||
'gpt-4.1-2025-04-14',
|
||||
'gpt-4.1-mini',
|
||||
'o1',
|
||||
'o3',
|
||||
'o4-mini',
|
||||
// embeddings
|
||||
'text-embedding-3-large',
|
||||
|
||||
@@ -110,12 +110,12 @@ export type CopilotImageOptions = z.infer<typeof CopilotImageOptionsSchema>;
|
||||
export interface CopilotTextToTextProvider extends CopilotProvider {
|
||||
generateText(
|
||||
messages: PromptMessage[],
|
||||
model?: string,
|
||||
model: string,
|
||||
options?: CopilotChatOptions
|
||||
): Promise<string>;
|
||||
generateTextStream(
|
||||
messages: PromptMessage[],
|
||||
model?: string,
|
||||
model: string,
|
||||
options?: CopilotChatOptions
|
||||
): AsyncIterable<string>;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export interface CopilotTextToImageProvider extends CopilotProvider {
|
||||
): Promise<Array<string>>;
|
||||
generateImagesStream(
|
||||
messages: PromptMessage[],
|
||||
model?: string,
|
||||
model: string,
|
||||
options?: CopilotImageOptions
|
||||
): AsyncIterable<string>;
|
||||
}
|
||||
@@ -145,12 +145,12 @@ export interface CopilotImageToTextProvider extends CopilotProvider {
|
||||
generateText(
|
||||
messages: PromptMessage[],
|
||||
model: string,
|
||||
options?: CopilotChatOptions
|
||||
options: CopilotChatOptions
|
||||
): Promise<string>;
|
||||
generateTextStream(
|
||||
messages: PromptMessage[],
|
||||
model: string,
|
||||
options?: CopilotChatOptions
|
||||
options: CopilotChatOptions
|
||||
): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export interface CopilotImageToImageProvider extends CopilotProvider {
|
||||
): Promise<Array<string>>;
|
||||
generateImagesStream(
|
||||
messages: PromptMessage[],
|
||||
model?: string,
|
||||
model: string,
|
||||
options?: CopilotImageOptions
|
||||
): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ export class ChatSession implements AsyncDisposable {
|
||||
return this.state.prompt.model;
|
||||
}
|
||||
|
||||
get optionalModels() {
|
||||
return this.state.prompt.optionalModels;
|
||||
}
|
||||
|
||||
get config() {
|
||||
const {
|
||||
sessionId,
|
||||
|
||||
Vendored
-17
@@ -55,23 +55,6 @@ export const collectionSchema = z.object({
|
||||
createDate: z.union([z.date(), z.number()]).optional(),
|
||||
updateDate: z.union([z.date(), z.number()]).optional(),
|
||||
});
|
||||
export const deletedCollectionSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
userName: z.string(),
|
||||
collection: collectionSchema,
|
||||
});
|
||||
export type DeprecatedCollection = {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
filterList: z.infer<typeof filterSchema>[];
|
||||
allowList?: string[];
|
||||
};
|
||||
export type Collection = z.input<typeof collectionSchema>;
|
||||
export type DeleteCollectionInfo = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
} | null;
|
||||
export type DeletedCollection = z.input<typeof deletedCollectionSchema>;
|
||||
|
||||
export type PropertiesMeta = DocsPropertiesMeta;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
query getIsAdmin($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
query getIsOwner($workspaceId: String!) {
|
||||
isOwner(workspaceId: $workspaceId)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
query getWorkspaceInfo($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
isOwner(workspaceId: $workspaceId)
|
||||
workspace(id: $workspaceId) {
|
||||
role
|
||||
team
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,24 +1036,6 @@ export const getInviteInfoQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getIsAdminQuery = {
|
||||
id: 'getIsAdminQuery' as const,
|
||||
op: 'getIsAdmin',
|
||||
query: `query getIsAdmin($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
}`,
|
||||
deprecations: ["'isAdmin' is deprecated: use WorkspaceType[role] instead"],
|
||||
};
|
||||
|
||||
export const getIsOwnerQuery = {
|
||||
id: 'getIsOwnerQuery' as const,
|
||||
op: 'getIsOwner',
|
||||
query: `query getIsOwner($workspaceId: String!) {
|
||||
isOwner(workspaceId: $workspaceId)
|
||||
}`,
|
||||
deprecations: ["'isOwner' is deprecated: use WorkspaceType[role] instead"],
|
||||
};
|
||||
|
||||
export const getMemberCountByWorkspaceIdQuery = {
|
||||
id: 'getMemberCountByWorkspaceIdQuery' as const,
|
||||
op: 'getMemberCountByWorkspaceId',
|
||||
@@ -1185,13 +1167,11 @@ export const getWorkspaceInfoQuery = {
|
||||
id: 'getWorkspaceInfoQuery' as const,
|
||||
op: 'getWorkspaceInfo',
|
||||
query: `query getWorkspaceInfo($workspaceId: String!) {
|
||||
isAdmin(workspaceId: $workspaceId)
|
||||
isOwner(workspaceId: $workspaceId)
|
||||
workspace(id: $workspaceId) {
|
||||
role
|
||||
team
|
||||
}
|
||||
}`,
|
||||
deprecations: ["'isAdmin' is deprecated: use WorkspaceType[role] instead","'isOwner' is deprecated: use WorkspaceType[role] instead"],
|
||||
};
|
||||
|
||||
export const getWorkspacePageByIdQuery = {
|
||||
|
||||
@@ -3665,18 +3665,6 @@ export type GetInviteInfoQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GetIsAdminQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetIsAdminQuery = { __typename?: 'Query'; isAdmin: boolean };
|
||||
|
||||
export type GetIsOwnerQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean };
|
||||
|
||||
export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -3829,9 +3817,7 @@ export type GetWorkspaceInfoQueryVariables = Exact<{
|
||||
|
||||
export type GetWorkspaceInfoQuery = {
|
||||
__typename?: 'Query';
|
||||
isAdmin: boolean;
|
||||
isOwner: boolean;
|
||||
workspace: { __typename?: 'WorkspaceType'; team: boolean };
|
||||
workspace: { __typename?: 'WorkspaceType'; role: Permission; team: boolean };
|
||||
};
|
||||
|
||||
export type GetWorkspacePageByIdQueryVariables = Exact<{
|
||||
@@ -4825,16 +4811,6 @@ export type Queries =
|
||||
variables: GetInviteInfoQueryVariables;
|
||||
response: GetInviteInfoQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getIsAdminQuery';
|
||||
variables: GetIsAdminQueryVariables;
|
||||
response: GetIsAdminQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getIsOwnerQuery';
|
||||
variables: GetIsOwnerQueryVariables;
|
||||
response: GetIsOwnerQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getMemberCountByWorkspaceIdQuery';
|
||||
variables: GetMemberCountByWorkspaceIdQueryVariables;
|
||||
|
||||
@@ -182,7 +182,7 @@ export function yjsObservePath(yjs?: any, path?: string) {
|
||||
* observable will automatically update when yjs data changed.
|
||||
*
|
||||
* @example
|
||||
* yjsObserveDeep(yjs) -> emit when any of children changed
|
||||
* yjsObserve(yjs) -> emit when yjs type changed
|
||||
*/
|
||||
export function yjsObserve(yjs?: any) {
|
||||
return new Observable(subscriber => {
|
||||
|
||||
+15
-10
@@ -170,24 +170,27 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon fontSize={20} /> Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
>
|
||||
<EditIcon fontSize={20} /> Edit
|
||||
<EditIcon fontSize={20} />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openResetPasswordDialog}
|
||||
>
|
||||
<LockIcon fontSize={20} />
|
||||
{user.hasPassword ? 'Reset Password' : 'Setup Account'}
|
||||
</DropdownMenuItem>
|
||||
{user.disabled && (
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 cursor-pointer"
|
||||
onSelect={openEnableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} /> Enable Email
|
||||
<AccountBanIcon fontSize={20} />
|
||||
Enable Email
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -196,14 +199,16 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) {
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDisableDialog}
|
||||
>
|
||||
<AccountBanIcon fontSize={20} /> Disable & Delete data
|
||||
<AccountBanIcon fontSize={20} />
|
||||
Disable & Delete data
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500"
|
||||
onSelect={openDeleteDialog}
|
||||
>
|
||||
<DeleteIcon fontSize={20} /> Delete
|
||||
<DeleteIcon fontSize={20} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useRightPanel } from '../../panel/context';
|
||||
import type { UserType } from '../schema';
|
||||
import { DiscardChanges } from './discard-changes';
|
||||
import { ExportUsersDialog } from './export-users-dialog';
|
||||
import { ImportUsersDialog } from './import-users-dialog';
|
||||
import { ImportUsersDialog } from './import-users';
|
||||
import { CreateUserForm } from './user-form';
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
downloadCsvTemplate,
|
||||
exportImportResults,
|
||||
getValidUsersToImport,
|
||||
ImportStatus,
|
||||
type ParsedUser,
|
||||
processCSVFile,
|
||||
} from '../utils/csv-utils';
|
||||
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
|
||||
import {
|
||||
useImportUsers,
|
||||
type UserImportReturnType,
|
||||
} from './use-user-management';
|
||||
import { UserTable } from './user-table';
|
||||
|
||||
interface ImportUsersDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ImportUsersDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportUsersDialogProps) {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [isFormatError, setIsFormatError] = useState(false);
|
||||
const importUsers = useImportUsers();
|
||||
const fileUploadRef = useRef<FileUploadAreaRef>(null);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
() => fileUploadRef.current?.triggerFileUpload(),
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset all states when dialog is closed
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const importUsersCallback = useCallback(
|
||||
(result: UserImportReturnType) => {
|
||||
const successfulUsers = result.filter(
|
||||
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
|
||||
user.__typename === 'UserType'
|
||||
);
|
||||
|
||||
const failedUsers = result.filter(
|
||||
(
|
||||
user
|
||||
): user is Extract<
|
||||
typeof user,
|
||||
{ __typename: 'UserImportFailedType' }
|
||||
> => user.__typename === 'UserImportFailedType'
|
||||
);
|
||||
|
||||
const successCount = successfulUsers.length;
|
||||
const failedCount = parsedUsers.length - successCount;
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.info(
|
||||
`Successfully imported ${successCount} users, ${failedCount} failed`
|
||||
);
|
||||
} else {
|
||||
toast.success(`Successfully imported ${successCount} users`);
|
||||
}
|
||||
|
||||
const successfulUserEmails = new Set(
|
||||
successfulUsers.map(user => user.email)
|
||||
);
|
||||
|
||||
const failedUserErrorMap = new Map(
|
||||
failedUsers.map(user => [user.email, user.error])
|
||||
);
|
||||
|
||||
setParsedUsers(prev => {
|
||||
return prev.map(user => {
|
||||
if (successfulUserEmails.has(user.email)) {
|
||||
return {
|
||||
...user,
|
||||
importStatus: ImportStatus.Success,
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
|
||||
return {
|
||||
...user,
|
||||
importStatus: ImportStatus.Failed,
|
||||
importError: errorMessage,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
setIsImporting(false);
|
||||
},
|
||||
[parsedUsers.length, setIsImporting]
|
||||
);
|
||||
|
||||
const handleFileSelected = useCallback(async (file: File) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await processCSVFile(
|
||||
file,
|
||||
validatedUsers => {
|
||||
setParsedUsers(validatedUsers);
|
||||
setIsPreviewMode(true);
|
||||
setIsImporting(false);
|
||||
},
|
||||
() => {
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to process file', error);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const confirmImport = useAsyncCallback(async () => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const validUsersToImport = getValidUsersToImport(parsedUsers);
|
||||
|
||||
setParsedUsers(prev =>
|
||||
prev.map(user =>
|
||||
user.valid ? { ...user, importStatus: ImportStatus.Processing } : user
|
||||
)
|
||||
);
|
||||
|
||||
await importUsers({ users: validUsersToImport }, importUsersCallback);
|
||||
// Note: setIsImporting(false) is now handled in importUsersCallback
|
||||
} catch (error) {
|
||||
console.error('Failed to import users', error);
|
||||
toast.error('Failed to import users');
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [importUsers, importUsersCallback, parsedUsers]);
|
||||
|
||||
const cancelImport = useCallback(() => {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
}, []);
|
||||
|
||||
const resetFormatError = useCallback(() => {
|
||||
setIsFormatError(false);
|
||||
}, []);
|
||||
|
||||
// Handle closing the dialog after import is complete
|
||||
const handleDone = useCallback(() => {
|
||||
// Reset all states and close the dialog
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(false);
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
// Export failed imports to CSV
|
||||
const exportResult = useCallback(() => {
|
||||
exportImportResults(parsedUsers);
|
||||
}, [parsedUsers]);
|
||||
|
||||
const isImported = useMemo(() => {
|
||||
return parsedUsers.some(
|
||||
user => user.importStatus && user.importStatus !== ImportStatus.Processing
|
||||
);
|
||||
}, [parsedUsers]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (isImported) {
|
||||
exportResult();
|
||||
handleDone();
|
||||
} else {
|
||||
confirmImport();
|
||||
}
|
||||
}, [confirmImport, exportResult, handleDone, isImported]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={
|
||||
isPreviewMode ? 'sm:max-w-[600px] flex-col' : 'sm:max-w-[425px]'
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFormatError
|
||||
? 'Incorrect import format'
|
||||
: isPreviewMode
|
||||
? isImported
|
||||
? 'Import results'
|
||||
: 'Confirm import'
|
||||
: 'Import'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-[15px] mt-3">
|
||||
{isFormatError ? (
|
||||
'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.'
|
||||
) : isPreviewMode ? (
|
||||
<div className="grid gap-3">
|
||||
{isImported ? null : (
|
||||
<p>
|
||||
{parsedUsers.length} users detected from the CSV file. Please
|
||||
confirm the user list below and import.
|
||||
</p>
|
||||
)}
|
||||
<UserTable users={parsedUsers} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<p>
|
||||
You need to import the accounts by importing a CSV file in the
|
||||
correct format. Please download the CSV template.
|
||||
</p>
|
||||
<FileUploadArea
|
||||
ref={fileUploadRef}
|
||||
onFileSelected={handleFileSelected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
<DialogFooter
|
||||
className={`flex-col mt-6 sm:flex-row sm:justify-between items-center ${isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'}`}
|
||||
>
|
||||
{isFormatError ? (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 text-[15px] px-0 py-2 h-10 underline cursor-pointer"
|
||||
>
|
||||
CSV template
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetFormatError}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
) : isPreviewMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={cancelImport}
|
||||
className="w-full mb-2 sm:mb-0 sm:w-auto"
|
||||
disabled={isImporting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
disabled={
|
||||
isImporting ||
|
||||
parsedUsers.some(
|
||||
user => user.importStatus === ImportStatus.Processing
|
||||
)
|
||||
}
|
||||
>
|
||||
{isImporting
|
||||
? 'Importing...'
|
||||
: isImported
|
||||
? 'Export'
|
||||
: 'Confirm Import'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
|
||||
>
|
||||
CSV template
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? 'Parsing...' : 'Choose a file'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { WarningIcon } from '@blocksuite/icons/rc';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import type { FC } from 'react';
|
||||
|
||||
interface CsvFormatGuidanceProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays CSV format guidelines
|
||||
*/
|
||||
export const CsvFormatGuidance: FC<CsvFormatGuidanceProps> = ({
|
||||
passwordLimits,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex p-1.5 gap-1"
|
||||
style={{
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-center py-0.5">
|
||||
<WarningIcon fontSize={16} color={cssVarV2('icon/primary')} />
|
||||
</div>
|
||||
<div>
|
||||
<p>CSV file includes username, email, and password.</p>
|
||||
<ul>
|
||||
{[
|
||||
`Username (optional): any text.`,
|
||||
`Email (required): e.g., user@example.com.`,
|
||||
`Password (optional): ${passwordLimits.minLength}–${passwordLimits.maxLength} characters.`,
|
||||
].map((text, index) => (
|
||||
<li
|
||||
key={`guidance-${index}`}
|
||||
className="relative pl-2 leading-normal"
|
||||
>
|
||||
<span className="absolute left-0 top-2 w-1 h-1 rounded-full bg-current" />
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import type { FC, RefObject } from 'react';
|
||||
|
||||
import type { ParsedUser } from '../../utils/csv-utils';
|
||||
import { UserTable } from '../user-table';
|
||||
import { CsvFormatGuidance } from './csv-format-guidance';
|
||||
import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area';
|
||||
|
||||
interface ImportPreviewContentProps {
|
||||
parsedUsers: ParsedUser[];
|
||||
isImported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the preview mode content
|
||||
*/
|
||||
export const ImportPreviewContent: FC<ImportPreviewContentProps> = ({
|
||||
parsedUsers,
|
||||
isImported,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{!isImported && (
|
||||
<p>
|
||||
{parsedUsers.length} users detected from the CSV file. Please confirm
|
||||
the user list below and import.
|
||||
</p>
|
||||
)}
|
||||
<UserTable users={parsedUsers} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportInitialContentProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
fileUploadRef: RefObject<FileUploadAreaRef | null>;
|
||||
onFileSelected: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the initial import screen
|
||||
*/
|
||||
export const ImportInitialContent: FC<ImportInitialContentProps> = ({
|
||||
passwordLimits,
|
||||
fileUploadRef,
|
||||
onFileSelected,
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<p>
|
||||
You need to import the accounts by importing a CSV file in the correct
|
||||
format. Please download the CSV template.
|
||||
</p>
|
||||
<CsvFormatGuidance passwordLimits={passwordLimits} />
|
||||
<FileUploadArea ref={fileUploadRef} onFileSelected={onFileSelected} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImportErrorContentProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying import errors
|
||||
*/
|
||||
export const ImportErrorContent: FC<ImportErrorContentProps> = ({
|
||||
message = 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.',
|
||||
}) => {
|
||||
return message;
|
||||
};
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { Button } from '@affine/admin/components/ui/button';
|
||||
import { DialogFooter } from '@affine/admin/components/ui/dialog';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { downloadCsvTemplate, ImportStatus } from '../../utils/csv-utils';
|
||||
|
||||
interface ImportUsersFooterProps {
|
||||
isFormatError: boolean;
|
||||
isPreviewMode: boolean;
|
||||
isImporting: boolean;
|
||||
isImported: boolean;
|
||||
resetFormatError: () => void;
|
||||
cancelImport: () => void;
|
||||
handleConfirm: () => void;
|
||||
handleUpload: () => void;
|
||||
parsedUsers: {
|
||||
importStatus?: ImportStatus;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the dialog footer with appropriate buttons based on the import state
|
||||
*/
|
||||
export const ImportUsersFooter: FC<ImportUsersFooterProps> = ({
|
||||
isFormatError,
|
||||
isPreviewMode,
|
||||
isImporting,
|
||||
isImported,
|
||||
resetFormatError,
|
||||
cancelImport,
|
||||
handleConfirm,
|
||||
handleUpload,
|
||||
parsedUsers,
|
||||
}) => {
|
||||
return (
|
||||
<DialogFooter
|
||||
className={`flex-col mt-6 sm:flex-row sm:justify-between items-center ${
|
||||
isPreviewMode ? 'sm:justify-end' : 'sm:justify-between'
|
||||
}`}
|
||||
>
|
||||
{isFormatError ? (
|
||||
<FormatErrorFooter
|
||||
downloadCsvTemplate={downloadCsvTemplate}
|
||||
resetFormatError={resetFormatError}
|
||||
/>
|
||||
) : isPreviewMode ? (
|
||||
<PreviewModeFooter
|
||||
isImporting={isImporting}
|
||||
isImported={isImported}
|
||||
cancelImport={cancelImport}
|
||||
handleConfirm={handleConfirm}
|
||||
parsedUsers={parsedUsers}
|
||||
/>
|
||||
) : (
|
||||
<InitialFooter
|
||||
isImporting={isImporting}
|
||||
downloadCsvTemplate={downloadCsvTemplate}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormatErrorFooterProps {
|
||||
downloadCsvTemplate: () => void;
|
||||
resetFormatError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component when there's a format error
|
||||
*/
|
||||
const FormatErrorFooter: FC<FormatErrorFooterProps> = ({
|
||||
downloadCsvTemplate,
|
||||
resetFormatError,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 text-[15px] px-0 py-2 h-10 underline cursor-pointer"
|
||||
>
|
||||
CSV template
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetFormatError}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
interface PreviewModeFooterProps {
|
||||
isImporting: boolean;
|
||||
isImported: boolean;
|
||||
cancelImport: () => void;
|
||||
handleConfirm: () => void;
|
||||
parsedUsers: {
|
||||
importStatus?: ImportStatus;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for preview mode
|
||||
*/
|
||||
const PreviewModeFooter: FC<PreviewModeFooterProps> = ({
|
||||
isImporting,
|
||||
isImported,
|
||||
cancelImport,
|
||||
handleConfirm,
|
||||
parsedUsers,
|
||||
}) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={cancelImport}
|
||||
className="w-full mb-2 sm:mb-0 sm:w-auto"
|
||||
disabled={isImporting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
disabled={
|
||||
isImporting ||
|
||||
parsedUsers.some(user => user.importStatus === ImportStatus.Processing)
|
||||
}
|
||||
>
|
||||
{isImporting ? 'Importing...' : isImported ? 'Export' : 'Confirm Import'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
interface InitialFooterProps {
|
||||
isImporting: boolean;
|
||||
downloadCsvTemplate: () => void;
|
||||
handleUpload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component for initial state
|
||||
*/
|
||||
const InitialFooter: FC<InitialFooterProps> = ({
|
||||
isImporting,
|
||||
downloadCsvTemplate,
|
||||
handleUpload,
|
||||
}) => (
|
||||
<>
|
||||
<div
|
||||
onClick={downloadCsvTemplate}
|
||||
className="mb-2 sm:mb-0 underline text-[15px] cursor-pointer"
|
||||
>
|
||||
CSV template
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
className="w-full sm:w-auto text-[15px] px-4 py-2 h-10"
|
||||
disabled={isImporting}
|
||||
>
|
||||
{isImporting ? 'Parsing...' : 'Choose a file'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useServerConfig } from '../../../common';
|
||||
import type { FileUploadAreaRef } from './file-upload-area';
|
||||
import {
|
||||
ImportErrorContent,
|
||||
ImportInitialContent,
|
||||
ImportPreviewContent,
|
||||
} from './import-content';
|
||||
import { ImportUsersFooter } from './import-footer';
|
||||
import { useImportUsersState } from './use-import-users-state';
|
||||
|
||||
export interface ImportUsersDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for importing users from a CSV file
|
||||
*/
|
||||
export function ImportUsersDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImportUsersDialogProps) {
|
||||
const fileUploadRef = useRef<FileUploadAreaRef>(null);
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
|
||||
const handleUpload = () => fileUploadRef.current?.triggerFileUpload();
|
||||
|
||||
const {
|
||||
isImporting,
|
||||
parsedUsers,
|
||||
isPreviewMode,
|
||||
isFormatError,
|
||||
isImported,
|
||||
handleFileSelected,
|
||||
cancelImport,
|
||||
resetFormatError,
|
||||
handleConfirm,
|
||||
resetState,
|
||||
} = useImportUsersState({
|
||||
passwordLimits,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
// Reset all states when dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
}
|
||||
}, [open, resetState]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={
|
||||
isPreviewMode ? 'sm:max-w-[720px] flex-col' : 'sm:max-w-[480px]'
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isFormatError
|
||||
? 'Incorrect import format'
|
||||
: isPreviewMode
|
||||
? isImported
|
||||
? 'Import results'
|
||||
: 'Confirm import'
|
||||
: 'Import'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[15px] mt-3">
|
||||
{isFormatError ? (
|
||||
<ImportErrorContent />
|
||||
) : isPreviewMode ? (
|
||||
<ImportPreviewContent
|
||||
parsedUsers={parsedUsers}
|
||||
isImported={isImported}
|
||||
/>
|
||||
) : (
|
||||
<ImportInitialContent
|
||||
passwordLimits={passwordLimits}
|
||||
fileUploadRef={fileUploadRef}
|
||||
onFileSelected={handleFileSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImportUsersFooter
|
||||
isFormatError={isFormatError}
|
||||
isPreviewMode={isPreviewMode}
|
||||
isImporting={isImporting}
|
||||
isImported={isImported}
|
||||
resetFormatError={resetFormatError}
|
||||
cancelImport={cancelImport}
|
||||
handleConfirm={handleConfirm}
|
||||
handleUpload={handleUpload}
|
||||
parsedUsers={parsedUsers}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
exportImportResults,
|
||||
getValidUsersToImport,
|
||||
ImportStatus,
|
||||
type ParsedUser,
|
||||
processCSVFile,
|
||||
validateUsers,
|
||||
} from '../../utils/csv-utils';
|
||||
import {
|
||||
useImportUsers,
|
||||
type UserImportReturnType,
|
||||
} from '../use-user-management';
|
||||
|
||||
export interface ImportUsersStateProps {
|
||||
passwordLimits: {
|
||||
minLength: number;
|
||||
maxLength: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useImportUsersState({
|
||||
passwordLimits,
|
||||
onClose,
|
||||
}: ImportUsersStateProps) {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [isFormatError, setIsFormatError] = useState(false);
|
||||
const importUsers = useImportUsers();
|
||||
|
||||
// Reset all states when dialog is closed
|
||||
const resetState = useCallback(() => {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(false);
|
||||
}, []);
|
||||
|
||||
const importUsersCallback = useCallback(
|
||||
(result: UserImportReturnType) => {
|
||||
const successfulUsers = result.filter(
|
||||
(user): user is Extract<typeof user, { __typename: 'UserType' }> =>
|
||||
user.__typename === 'UserType'
|
||||
);
|
||||
|
||||
const failedUsers = result.filter(
|
||||
(
|
||||
user
|
||||
): user is Extract<
|
||||
typeof user,
|
||||
{ __typename: 'UserImportFailedType' }
|
||||
> => user.__typename === 'UserImportFailedType'
|
||||
);
|
||||
|
||||
const successCount = successfulUsers.length;
|
||||
const failedCount = parsedUsers.length - successCount;
|
||||
|
||||
if (failedCount > 0) {
|
||||
toast.info(
|
||||
`Successfully imported ${successCount} users, ${failedCount} failed`
|
||||
);
|
||||
} else {
|
||||
toast.success(`Successfully imported ${successCount} users`);
|
||||
}
|
||||
|
||||
const successfulUserEmails = new Set(
|
||||
successfulUsers.map(user => user.email)
|
||||
);
|
||||
|
||||
const failedUserErrorMap = new Map(
|
||||
failedUsers.map(user => [user.email, user.error])
|
||||
);
|
||||
|
||||
setParsedUsers(prev => {
|
||||
return prev.map(user => {
|
||||
if (successfulUserEmails.has(user.email)) {
|
||||
return {
|
||||
...user,
|
||||
importStatus: ImportStatus.Success,
|
||||
};
|
||||
}
|
||||
|
||||
const errorMessage = failedUserErrorMap.get(user.email) || user.error;
|
||||
return {
|
||||
...user,
|
||||
importStatus: ImportStatus.Failed,
|
||||
importError: errorMessage,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
setIsImporting(false);
|
||||
},
|
||||
[parsedUsers.length]
|
||||
);
|
||||
|
||||
const handleFileSelected = useCallback(
|
||||
async (file: File) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await processCSVFile(
|
||||
file,
|
||||
usersFromCsv => {
|
||||
const validatedUsers = validateUsers(usersFromCsv, passwordLimits);
|
||||
setParsedUsers(validatedUsers);
|
||||
setIsPreviewMode(true);
|
||||
setIsImporting(false);
|
||||
},
|
||||
() => {
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to process file', error);
|
||||
setIsImporting(false);
|
||||
setIsFormatError(true);
|
||||
}
|
||||
},
|
||||
[passwordLimits]
|
||||
);
|
||||
|
||||
const confirmImport = useAsyncCallback(async () => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const validUsersToImport = getValidUsersToImport(parsedUsers);
|
||||
|
||||
setParsedUsers(prev =>
|
||||
prev.map(user => ({
|
||||
...user,
|
||||
importStatus: ImportStatus.Processing,
|
||||
}))
|
||||
);
|
||||
|
||||
await importUsers({ users: validUsersToImport }, importUsersCallback);
|
||||
} catch (error) {
|
||||
console.error('Failed to import users', error);
|
||||
toast.error('Failed to import users');
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [importUsers, importUsersCallback, parsedUsers]);
|
||||
|
||||
const cancelImport = useCallback(() => {
|
||||
setIsPreviewMode(false);
|
||||
setParsedUsers([]);
|
||||
}, []);
|
||||
|
||||
const resetFormatError = useCallback(() => {
|
||||
setIsFormatError(false);
|
||||
}, []);
|
||||
|
||||
// Handle closing the dialog after import is complete
|
||||
const handleDone = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [onClose, resetState]);
|
||||
|
||||
// Export failed imports to CSV
|
||||
const exportResult = useCallback(() => {
|
||||
exportImportResults(parsedUsers);
|
||||
}, [parsedUsers]);
|
||||
|
||||
const isImported = !!parsedUsers.some(
|
||||
user => user.importStatus && user.importStatus !== ImportStatus.Processing
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (isImported) {
|
||||
exportResult();
|
||||
handleDone();
|
||||
} else {
|
||||
confirmImport();
|
||||
}
|
||||
}, [confirmImport, exportResult, handleDone, isImported]);
|
||||
|
||||
return {
|
||||
isImporting,
|
||||
parsedUsers,
|
||||
isPreviewMode,
|
||||
isFormatError,
|
||||
isImported,
|
||||
handleFileSelected,
|
||||
cancelImport,
|
||||
resetFormatError,
|
||||
handleConfirm,
|
||||
resetState,
|
||||
};
|
||||
}
|
||||
@@ -47,12 +47,13 @@ export const useCreateUser = () => {
|
||||
const revalidate = useMutateQueryResource();
|
||||
|
||||
const create = useAsyncCallback(
|
||||
async ({ name, email, features }: UserInput) => {
|
||||
async ({ name, email, password, features }: UserInput) => {
|
||||
try {
|
||||
const account = await createAccount({
|
||||
input: {
|
||||
name,
|
||||
email,
|
||||
password: password === '' ? undefined : password,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@ import { Label } from '@affine/admin/components/ui/label';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Switch } from '@affine/admin/components/ui/switch';
|
||||
import type { FeatureType } from '@affine/graphql';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useServerConfig } from '../../common';
|
||||
import { RightPanelHeader } from '../../header';
|
||||
import type { UserInput, UserType } from '../schema';
|
||||
import { validateEmails, validatePassword } from '../utils/csv-utils';
|
||||
import { useCreateUser, useUpdateUser } from './use-user-management';
|
||||
|
||||
type UserFormProps = {
|
||||
@@ -20,6 +23,7 @@ type UserFormProps = {
|
||||
onConfirm: (user: UserInput) => void;
|
||||
onValidate: (user: Partial<UserInput>) => boolean;
|
||||
actions?: React.ReactNode;
|
||||
showOption?: boolean;
|
||||
};
|
||||
|
||||
function UserForm({
|
||||
@@ -29,6 +33,7 @@ function UserForm({
|
||||
onConfirm,
|
||||
onValidate,
|
||||
actions,
|
||||
showOption,
|
||||
}: UserFormProps) {
|
||||
const serverConfig = useServerConfig();
|
||||
|
||||
@@ -36,9 +41,10 @@ function UserForm({
|
||||
() => ({
|
||||
name: defaultValue?.name ?? '',
|
||||
email: defaultValue?.email ?? '',
|
||||
password: defaultValue?.password ?? '',
|
||||
features: defaultValue?.features ?? [],
|
||||
}),
|
||||
[defaultValue?.email, defaultValue?.features, defaultValue?.name]
|
||||
[defaultValue]
|
||||
);
|
||||
|
||||
const [changes, setChanges] = useState<Partial<UserInput>>(defaultUser);
|
||||
@@ -68,7 +74,8 @@ function UserForm({
|
||||
|
||||
// @ts-expect-error checked
|
||||
onConfirm(changes);
|
||||
}, [canSave, changes, onConfirm]);
|
||||
setChanges(defaultUser);
|
||||
}, [canSave, changes, defaultUser, onConfirm]);
|
||||
|
||||
const onFeatureChanged = useCallback(
|
||||
(feature: FeatureType, checked: boolean) => {
|
||||
@@ -116,6 +123,19 @@ function UserForm({
|
||||
onChange={setField}
|
||||
placeholder="Enter email address"
|
||||
/>
|
||||
{showOption && (
|
||||
<>
|
||||
<Separator />
|
||||
<InputItem
|
||||
label="Password"
|
||||
field="password"
|
||||
value={changes.password}
|
||||
onChange={setField}
|
||||
optional
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
@@ -167,12 +187,14 @@ function ToggleItem({
|
||||
function InputItem({
|
||||
label,
|
||||
field,
|
||||
optional,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
field: keyof UserInput;
|
||||
optional?: boolean;
|
||||
value?: string;
|
||||
onChange: (field: keyof UserInput, value: string) => void;
|
||||
placeholder?: string;
|
||||
@@ -185,9 +207,20 @@ function InputItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<Label className="text-[15px] leading-6 font-medium mb-1.5">
|
||||
<div className="flex flex-col gap-1.5 p-3">
|
||||
<Label
|
||||
className="text-[15px] font-medium flex-wrap flex"
|
||||
style={{ lineHeight: '1.6rem' }}
|
||||
>
|
||||
{label}
|
||||
{optional && (
|
||||
<span
|
||||
className="font-normal ml-1"
|
||||
style={{ color: cssVarV2('text/secondary') }}
|
||||
>
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -210,6 +243,8 @@ const validateUpdateUser = (user: Partial<UserInput>) => {
|
||||
|
||||
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
const { create, creating } = useCreateUser();
|
||||
const serverConfig = useServerConfig();
|
||||
const passwordLimits = serverConfig.credentialsRequirement.password;
|
||||
useEffect(() => {
|
||||
if (creating) {
|
||||
return () => {
|
||||
@@ -219,12 +254,30 @@ export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
|
||||
|
||||
return;
|
||||
}, [creating, onComplete]);
|
||||
|
||||
const handleCreateUser = useCallback(
|
||||
(user: UserInput) => {
|
||||
const emailValidation = validateEmails([user]);
|
||||
const passwordValidation = validatePassword(
|
||||
user.password,
|
||||
passwordLimits
|
||||
);
|
||||
if (!passwordValidation.valid || !emailValidation[0].valid) {
|
||||
toast.error(passwordValidation.error || emailValidation[0].error);
|
||||
return;
|
||||
}
|
||||
create(user);
|
||||
},
|
||||
[create, passwordLimits]
|
||||
);
|
||||
|
||||
return (
|
||||
<UserForm
|
||||
title="Create User"
|
||||
onClose={onComplete}
|
||||
onConfirm={create}
|
||||
onConfirm={handleCreateUser}
|
||||
onValidate={validateCreateUser}
|
||||
showOption={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||
return (
|
||||
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<thead className="bg-white sticky top-0">
|
||||
<tr>
|
||||
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Username
|
||||
@@ -19,6 +19,9 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Password
|
||||
</th>
|
||||
<th className="py-2 px-4 border-b text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
@@ -37,10 +40,26 @@ export const UserTable: React.FC<UserTableProps> = ({ users }) => {
|
||||
{user.name || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-4 text-sm truncate max-w-[200px] ${user.valid === false ? 'text-red-500' : 'text-gray-900'}`}
|
||||
className={`py-2 px-4 text-sm truncate max-w-[200px] ${
|
||||
user.valid === false &&
|
||||
(user.error?.toLowerCase().includes('email') ||
|
||||
!user.error?.toLowerCase().includes('password'))
|
||||
? 'text-red-500'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{user.email}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 px-4 text-sm truncate max-w-[150px] ${
|
||||
user.valid === false &&
|
||||
user.error?.toLowerCase().includes('password')
|
||||
? 'text-red-500'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{user.password || '-'}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-sm">
|
||||
{user.importStatus === ImportStatus.Success ? (
|
||||
<span className="text-gray-900">
|
||||
|
||||
@@ -4,5 +4,6 @@ export type UserType = ListUsersQuery['users'][0];
|
||||
export type UserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
features: FeatureType[];
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emailRegex } from '../../../utils';
|
||||
export interface ParsedUser {
|
||||
name: string | null;
|
||||
email: string;
|
||||
password?: string;
|
||||
valid?: boolean;
|
||||
error?: string;
|
||||
importStatus?: ImportStatus;
|
||||
@@ -17,6 +18,41 @@ export enum ImportStatus {
|
||||
Processing = 'processing',
|
||||
}
|
||||
|
||||
export const validatePassword = (
|
||||
password: string | undefined,
|
||||
passwordLimits: { minLength: number; maxLength: number }
|
||||
): { valid: boolean; error?: string } => {
|
||||
// if password is empty, it is valid
|
||||
if (!password || password.trim() === '') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// check password length
|
||||
if (
|
||||
password.length < passwordLimits.minLength ||
|
||||
password.length > passwordLimits.maxLength
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid password format',
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(@Jimmfly): check password contains at least one letter and one number
|
||||
|
||||
// const hasLetter = /[a-zA-Z]/.test(password);
|
||||
// const hasNumber = /[0-9]/.test(password);
|
||||
|
||||
// if (!hasLetter || !hasNumber) {
|
||||
// return {
|
||||
// valid: false,
|
||||
// error: 'Invalid password format',
|
||||
// };
|
||||
// }
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates email addresses for duplicates and format
|
||||
*/
|
||||
@@ -53,6 +89,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
|
||||
.map(user => ({
|
||||
name: user.name || undefined,
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -60,7 +97,7 @@ export const getValidUsersToImport = (users: ParsedUser[]) => {
|
||||
* Downloads a CSV template for user import
|
||||
*/
|
||||
export const downloadCsvTemplate = () => {
|
||||
const csvContent = 'Username,Email\n,example@example.com';
|
||||
const csvContent = 'Username,Email,Password\n,example@example.com,';
|
||||
downloadCsv(csvContent, 'user_import_template.csv');
|
||||
};
|
||||
|
||||
@@ -69,10 +106,10 @@ export const downloadCsvTemplate = () => {
|
||||
*/
|
||||
export const exportImportResults = (results: ParsedUser[]) => {
|
||||
const csvContent = [
|
||||
'Username,Email,status',
|
||||
'Username,Email,Password,Status',
|
||||
...results.map(
|
||||
user =>
|
||||
`${user.name || ''},${user.email},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
|
||||
`${user.name || ''},${user.email},${user.password || ''},${user.importStatus}${user.importError ? ` (${user.importError})` : ''}`
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
@@ -138,6 +175,7 @@ export const processCSVFile = async (
|
||||
const users = dataRows.map(row => ({
|
||||
name: row[0]?.trim() || null,
|
||||
email: row[1]?.trim() || '',
|
||||
password: row[2]?.trim() || undefined,
|
||||
}));
|
||||
|
||||
const usersWithEmail = users.filter(user => user.email);
|
||||
@@ -164,3 +202,34 @@ export const processCSVFile = async (
|
||||
onError();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate users
|
||||
*/
|
||||
export const validateUsers = (
|
||||
users: ParsedUser[],
|
||||
passwordLimits: { minLength: number; maxLength: number }
|
||||
): ParsedUser[] => {
|
||||
// validate emails
|
||||
const emailValidatedUsers = validateEmails(users);
|
||||
|
||||
// validate password
|
||||
return emailValidatedUsers.map(user => {
|
||||
// if email is invalid, return
|
||||
if (user.valid === false) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// validate password
|
||||
const passwordValidation = validatePassword(user.password, passwordLimits);
|
||||
if (!passwordValidation.valid) {
|
||||
return {
|
||||
...user,
|
||||
valid: false,
|
||||
error: passwordValidation.error,
|
||||
};
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) {
|
||||
res.arrayBuffer()
|
||||
);
|
||||
const blob = new Blob([opusBuffer], {
|
||||
type: 'audio/webm',
|
||||
type: 'audio/mp4',
|
||||
});
|
||||
const blobId = await blobEngine.set(blob);
|
||||
logger.debug('Recording saved', blobId);
|
||||
@@ -51,33 +51,35 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
const docProps: DocProps = {
|
||||
onStoreLoad: (doc, { noteId }) => {
|
||||
(async () => {
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus';
|
||||
|
||||
// add size and sourceId to the attachment later
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: 'audio/opus',
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
|
||||
if (model && status.filepath) {
|
||||
if (status.filepath) {
|
||||
// it takes a while to save the blob, so we show the attachment first
|
||||
const { blobId, blob } = await saveRecordingBlob(
|
||||
doc.workspace.blobSync,
|
||||
status.filepath
|
||||
);
|
||||
|
||||
model.props.size = blob.size;
|
||||
model.props.sourceId = blobId;
|
||||
model.props.embed = true;
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') +
|
||||
' ' +
|
||||
timestamp +
|
||||
'.opus';
|
||||
|
||||
// add size and sourceId to the attachment later
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: 'audio/opus',
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
|
||||
if (!aiEnabled) {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createStreamEncoder,
|
||||
encodeRawBufferToOpus,
|
||||
type OpusStreamEncoder,
|
||||
} from '@affine/core/utils/webm-encoding';
|
||||
} from '@affine/core/utils/opus-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
@@ -148,6 +148,7 @@ export function Recording() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let removed = false;
|
||||
let currentStreamEncoder: OpusStreamEncoder | undefined;
|
||||
|
||||
apis?.recording
|
||||
@@ -161,6 +162,9 @@ export function Recording() {
|
||||
.catch(console.error);
|
||||
|
||||
const handleRecordingStatusChanged = async (status: Status) => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
if (status?.status === 'new') {
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
@@ -196,6 +200,7 @@ export function Recording() {
|
||||
});
|
||||
|
||||
return () => {
|
||||
removed = true;
|
||||
unsubscribe?.();
|
||||
currentStreamEncoder?.close();
|
||||
};
|
||||
|
||||
@@ -80,7 +80,12 @@ export const registerHandlers = () => {
|
||||
};
|
||||
|
||||
ipcMain.handle(AFFINE_API_CHANNEL_NAME, async (e, ...args: any[]) => {
|
||||
return handleIpcMessage(e, ...args);
|
||||
try {
|
||||
return await handleIpcMessage(e, ...args);
|
||||
} catch (error) {
|
||||
logger.error(`error in ipc handler when calling ${args[0]}`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
|
||||
|
||||
@@ -520,10 +520,13 @@ export function newRecording(
|
||||
export function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
const state = recordingStateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
});
|
||||
const state = recordingStateMachine.dispatch(
|
||||
{
|
||||
type: 'START_RECORDING',
|
||||
appGroup: normalizeAppGroupInfo(appGroup),
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (state?.status === 'recording') {
|
||||
createRecording(state);
|
||||
@@ -542,6 +545,8 @@ export function startRecording(
|
||||
}
|
||||
}, MAX_DURATION_FOR_TRANSCRIPTION);
|
||||
|
||||
recordingStateMachine.status$.next(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -654,6 +659,8 @@ export async function getRawAudioBuffers(
|
||||
}
|
||||
|
||||
export async function readyRecording(id: number, buffer: Buffer) {
|
||||
logger.info('readyRecording', id);
|
||||
|
||||
const recordingStatus = recordingStatus$.value;
|
||||
const recording = recordings.get(id);
|
||||
if (!recordingStatus || recordingStatus.id !== id || !recording) {
|
||||
|
||||
@@ -63,7 +63,7 @@ export class RecordingStateMachine {
|
||||
* @param event The event to dispatch
|
||||
* @returns The new recording status after the event is processed
|
||||
*/
|
||||
dispatch(event: RecordingEvent): RecordingStatus | null {
|
||||
dispatch(event: RecordingEvent, emit = true): RecordingStatus | null {
|
||||
const currentStatus = this.recordingStatus$.value;
|
||||
let newStatus: RecordingStatus | null = null;
|
||||
|
||||
@@ -105,7 +105,9 @@ export class RecordingStateMachine {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
this.recordingStatus$.next(newStatus);
|
||||
if (emit) {
|
||||
this.recordingStatus$.next(newStatus);
|
||||
}
|
||||
|
||||
return newStatus;
|
||||
}
|
||||
|
||||
@@ -220,15 +220,17 @@ class TrayState implements Disposable {
|
||||
);
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
label: `Meetings Settings...`,
|
||||
click: () => {
|
||||
showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next({
|
||||
activeTab: 'meetings',
|
||||
});
|
||||
},
|
||||
});
|
||||
if (checkRecordingAvailable()) {
|
||||
items.push({
|
||||
label: `Meetings Settings...`,
|
||||
click: () => {
|
||||
showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next({
|
||||
activeTab: 'meetings',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -61,10 +61,7 @@ abstract class PopupWindow {
|
||||
|
||||
abstract windowOptions: Partial<BrowserWindowConstructorOptions>;
|
||||
|
||||
resolveReady: () => void = () => {};
|
||||
ready = new Promise<void>(resolve => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
ready = Promise.withResolvers<void>();
|
||||
|
||||
private readonly showing$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
@@ -109,12 +106,12 @@ abstract class PopupWindow {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
|
||||
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
|
||||
browserWindow.on('ready-to-show', () => {
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
this.resolveReady();
|
||||
});
|
||||
logger.info('loading popup', this.name, popupViewUrl);
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
this.ready.resolve();
|
||||
logger.info('popup ready', this.name);
|
||||
});
|
||||
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
@@ -126,7 +123,7 @@ abstract class PopupWindow {
|
||||
const workArea = getCurrentDisplay(browserWindow).workArea;
|
||||
const popupSize = browserWindow.getSize();
|
||||
|
||||
await this.ready;
|
||||
await this.ready.promise;
|
||||
|
||||
this.showing$.next(true);
|
||||
|
||||
@@ -141,6 +138,8 @@ abstract class PopupWindow {
|
||||
// Set initial position
|
||||
browserWindow.setPosition(startX, y);
|
||||
|
||||
logger.info('showing popup', this.name);
|
||||
|
||||
// First fade in, then slide
|
||||
await Promise.all([
|
||||
// Slide in animation
|
||||
@@ -169,6 +168,7 @@ abstract class PopupWindow {
|
||||
if (!this.browserWindow) {
|
||||
return;
|
||||
}
|
||||
logger.info('hiding popup', this.name);
|
||||
this.showing$.next(false);
|
||||
await animate(this.browserWindow.getOpacity(), 0, opacity => {
|
||||
this.browserWindow?.setOpacity(opacity);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@affine/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.13",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@dotlottie/player-component": "^2.7.12",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/css": "^11.13.5",
|
||||
@@ -66,6 +67,7 @@
|
||||
"lit": "^3.2.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lottie-react": "^2.4.0",
|
||||
"mp4-muxer": "^5.2.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"next-themes": "^0.4.4",
|
||||
"query-string": "^9.1.1",
|
||||
@@ -80,7 +82,6 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swr": "2.3.3",
|
||||
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
|
||||
"webm-muxer": "^5.1.0",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -135,6 +135,7 @@ declare global {
|
||||
isRootSession?: boolean;
|
||||
webSearch?: boolean;
|
||||
reasoning?: boolean;
|
||||
modelId?: string;
|
||||
contexts?: {
|
||||
docs: AIDocContextOption[];
|
||||
files: AIFileContextOption[];
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Peekable } from '@blocksuite/affine/components/peek';
|
||||
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||
import { BlockComponent } from '@blocksuite/affine/std';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
|
||||
import type { TextRendererOptions } from '../../components/text-renderer';
|
||||
import { ChatWithAIIcon } from './components/icon';
|
||||
import { type AIChatBlockModel } from './model';
|
||||
import { AIChatBlockStyles } from './styles';
|
||||
@@ -17,6 +19,8 @@ import { AIChatBlockStyles } from './styles';
|
||||
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||
static override styles = AIChatBlockStyles;
|
||||
|
||||
private _textRendererOptions: TextRendererOptions = {};
|
||||
|
||||
// Deserialize messages from JSON string and verify the type using zod
|
||||
private readonly _deserializeChatMessages = computed(() => {
|
||||
const messages = this.model.props.messages$.value;
|
||||
@@ -32,18 +36,23 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||
}
|
||||
});
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._textRendererOptions = {
|
||||
customHeading: true,
|
||||
extensions: this.previewExtensions,
|
||||
};
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const messages = this._deserializeChatMessages.value.slice(-2);
|
||||
const textRendererOptions = {
|
||||
customHeading: true,
|
||||
};
|
||||
|
||||
return html`<div class="affine-ai-chat-block-container">
|
||||
<div class="ai-chat-messages-container">
|
||||
<ai-chat-messages
|
||||
.host=${this.host}
|
||||
.messages=${messages}
|
||||
.textRendererOptions=${textRendererOptions}
|
||||
.textRendererOptions=${this._textRendererOptions}
|
||||
.withMask=${true}
|
||||
></ai-chat-messages>
|
||||
</div>
|
||||
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||
</div>
|
||||
</div> `;
|
||||
}
|
||||
|
||||
get previewExtensions() {
|
||||
return this.std.get(ViewExtensionManagerIdentifier).get('preview-page');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
+66
-1
@@ -1,9 +1,11 @@
|
||||
import { Bound } from '@blocksuite/affine/global/gfx';
|
||||
import { Bound, clamp } from '@blocksuite/affine/global/gfx';
|
||||
import { toGfxBlockComponent } from '@blocksuite/affine/std';
|
||||
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { AIChatBlockComponent } from './ai-chat-block';
|
||||
import { AIChatBlockSchema } from './model';
|
||||
|
||||
export class EdgelessAIChatBlockComponent extends toGfxBlockComponent(
|
||||
AIChatBlockComponent
|
||||
@@ -31,6 +33,69 @@ export class EdgelessAIChatBlockComponent extends toGfxBlockComponent(
|
||||
}
|
||||
}
|
||||
|
||||
export const EdgelessAIChatBlockInteraction =
|
||||
GfxViewInteractionExtension<EdgelessAIChatBlockComponent>(
|
||||
AIChatBlockSchema.model.flavour,
|
||||
{
|
||||
resizeConstraint: {
|
||||
minWidth: 260,
|
||||
minHeight: 160,
|
||||
maxWidth: 320,
|
||||
maxHeight: 300,
|
||||
},
|
||||
|
||||
handleRotate() {
|
||||
return {
|
||||
beforeRotate(context) {
|
||||
context.set({
|
||||
rotatable: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
handleResize({ model }) {
|
||||
const initialScale = model.props.scale$.peek();
|
||||
|
||||
return {
|
||||
onResizeStart(context) {
|
||||
context.default(context);
|
||||
model.stash('scale');
|
||||
},
|
||||
|
||||
onResizeMove(context) {
|
||||
const { newBound, originalBound, lockRatio, constraint } = context;
|
||||
const { minWidth, maxWidth, minHeight, maxHeight } = constraint;
|
||||
|
||||
let scale = initialScale;
|
||||
const originalRealWidth = originalBound.w / scale;
|
||||
|
||||
// update scale if resize is proportional
|
||||
if (lockRatio) {
|
||||
scale = newBound.w / originalRealWidth;
|
||||
}
|
||||
|
||||
let newRealWidth = clamp(newBound.w / scale, minWidth, maxWidth);
|
||||
let newRealHeight = clamp(newBound.h / scale, minHeight, maxHeight);
|
||||
|
||||
newBound.w = newRealWidth * scale;
|
||||
newBound.h = newRealHeight * scale;
|
||||
|
||||
model.props.xywh = newBound.serialize();
|
||||
if (scale !== initialScale) {
|
||||
model.props.scale = scale;
|
||||
}
|
||||
},
|
||||
|
||||
onResizeEnd(context) {
|
||||
context.default(context);
|
||||
model.pop('scale');
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-edgeless-ai-chat': EdgelessAIChatBlockComponent;
|
||||
|
||||
@@ -158,6 +158,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@query('.chat-panel-messages-container')
|
||||
accessor messagesContainer: HTMLDivElement | null = null;
|
||||
|
||||
@@ -271,6 +274,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
.status=${isLast ? status : 'idle'}
|
||||
.error=${isLast ? error : null}
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
.getSessionId=${this.getSessionId}
|
||||
.retry=${() => this.retry()}
|
||||
></chat-message-assistant>`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
@@ -20,11 +21,15 @@ export class ChatContentRichText extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
protected override render() {
|
||||
const { text, host } = this;
|
||||
return html`${createTextRenderer(host, {
|
||||
customHeading: true,
|
||||
extensions: this.extensions,
|
||||
affineFeatureFlagService: this.affineFeatureFlagService,
|
||||
})(text, this.state)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './chat-panel-messages';
|
||||
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -205,6 +206,9 @@ export class ChatPanel extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@state()
|
||||
accessor isLoading = false;
|
||||
|
||||
@@ -399,6 +403,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
.host=${this.host}
|
||||
.isLoading=${this.isLoading}
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
></chat-panel-messages>
|
||||
<ai-chat-composer
|
||||
.host=${this.host}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../content/assistant-avatar';
|
||||
import '../content/rich-text';
|
||||
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -48,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor getSessionId!: () => Promise<string | undefined>;
|
||||
|
||||
@@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
.text=${item.content}
|
||||
.state=${state}
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
></chat-content-rich-text>
|
||||
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
|
||||
${this.renderEditorActions()}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { toast } from '@affine/component';
|
||||
import type {
|
||||
CollectionMeta,
|
||||
TagMeta,
|
||||
} from '@affine/core/components/page-list';
|
||||
import type { TagMeta } from '@affine/core/components/page-list';
|
||||
import type { CollectionMeta } from '@affine/core/modules/collection';
|
||||
import track from '@affine/track';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import type { TagMeta } from '@affine/core/components/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { createLitPortal } from '@blocksuite/affine/components/portal';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
@@ -128,7 +127,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
|
||||
private _tags: Signal<TagMeta[]> = signal([]);
|
||||
|
||||
private _collections: Signal<Collection[]> = signal([]);
|
||||
private _collections: Signal<{ id: string; name: string }[]> = signal([]);
|
||||
|
||||
private _cleanup: (() => void) | null = null;
|
||||
|
||||
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { CollectionsIcon } from '@blocksuite/icons/lit';
|
||||
@@ -18,7 +17,7 @@ export class ChatPanelCollectionChip extends SignalWatcher(
|
||||
accessor removeChip!: (chip: CollectionChip) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor collection!: Collection;
|
||||
accessor collection!: { id: string; name: string };
|
||||
|
||||
override render() {
|
||||
const { state } = this.chip;
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
SearchDocMenuAction,
|
||||
SearchTagMenuAction,
|
||||
} from '@affine/core/modules/search-menu/services';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import type { DocMeta, Store } from '@blocksuite/affine/store';
|
||||
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
@@ -71,7 +70,7 @@ export interface DocDisplayConfig {
|
||||
getTagTitle: (tagId: string) => string;
|
||||
getTagPageIds: (tagId: string) => string[];
|
||||
getCollections: () => {
|
||||
signal: Signal<Collection[]>;
|
||||
signal: Signal<{ id: string; name: string }[]>;
|
||||
cleanup: () => void;
|
||||
};
|
||||
getCollectionPageIds: (collectionId: string) => string[];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createReactComponentFromLit } from '@affine/component';
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import {
|
||||
@@ -6,10 +7,7 @@ import {
|
||||
defaultImageProxyMiddleware,
|
||||
ImageProxyService,
|
||||
} from '@blocksuite/affine/shared/adapters';
|
||||
import {
|
||||
LinkPreviewerService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { ThemeProvider } from '@blocksuite/affine/shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import {
|
||||
BlockStdScope,
|
||||
@@ -104,6 +102,7 @@ export type TextRendererOptions = {
|
||||
extensions?: ExtensionType[];
|
||||
additionalMiddlewares?: TransformerMiddleware[];
|
||||
testId?: string;
|
||||
affineFeatureFlagService?: FeatureFlagService;
|
||||
};
|
||||
|
||||
// todo: refactor it for more general purpose usage instead of AI only?
|
||||
@@ -266,7 +265,14 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
|
||||
codeBlockWrapMiddleware(true),
|
||||
...(this.options.additionalMiddlewares ?? []),
|
||||
];
|
||||
markDownToDoc(provider, schema, latestAnswer, middlewares)
|
||||
const affineFeatureFlagService = this.options.affineFeatureFlagService;
|
||||
markDownToDoc(
|
||||
provider,
|
||||
schema,
|
||||
latestAnswer,
|
||||
middlewares,
|
||||
affineFeatureFlagService
|
||||
)
|
||||
.then(doc => {
|
||||
this.disposeDoc();
|
||||
this._doc = doc.doc.getStore({
|
||||
@@ -278,16 +284,10 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
|
||||
this._doc.readonly = true;
|
||||
this.requestUpdate();
|
||||
if (this.state !== 'generating') {
|
||||
// LinkPreviewerService & ImageProxyService config should read from host settings
|
||||
const linkPreviewerService =
|
||||
this.host?.std.store.get(LinkPreviewerService);
|
||||
this._doc.load();
|
||||
// LinkPreviewService & ImageProxyService config should read from host settings
|
||||
const imageProxyService =
|
||||
this.host?.std.store.get(ImageProxyService);
|
||||
if (linkPreviewerService) {
|
||||
this._doc
|
||||
?.get(LinkPreviewerService)
|
||||
.setEndpoint(linkPreviewerService.endpoint);
|
||||
}
|
||||
if (imageProxyService) {
|
||||
this._doc
|
||||
?.get(ImageProxyService)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||
import {
|
||||
CanvasElementType,
|
||||
@@ -445,7 +446,10 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
.get(ViewExtensionManagerIdentifier)
|
||||
.get('preview-page');
|
||||
|
||||
this._textRendererOptions = { extensions };
|
||||
this._textRendererOptions = {
|
||||
extensions,
|
||||
affineFeatureFlagService: this.affineFeatureFlagService,
|
||||
};
|
||||
this._historyMessages = this._deserializeHistoryChatMessages(
|
||||
this.historyMessagesString
|
||||
);
|
||||
@@ -556,6 +560,9 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
@property({ attribute: false })
|
||||
accessor searchMenuConfig!: SearchMenuConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@state()
|
||||
accessor _historyMessages: ChatMessage[] = [];
|
||||
|
||||
@@ -584,7 +591,8 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
docDisplayConfig: DocDisplayConfig,
|
||||
searchMenuConfig: SearchMenuConfig,
|
||||
networkSearchConfig: AINetworkSearchConfig,
|
||||
reasoningConfig: AIReasoningConfig
|
||||
reasoningConfig: AIReasoningConfig,
|
||||
affineFeatureFlagService: FeatureFlagService
|
||||
) => {
|
||||
return html`<ai-chat-block-peek-view
|
||||
.blockModel=${blockModel}
|
||||
@@ -593,5 +601,6 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
.docDisplayConfig=${docDisplayConfig}
|
||||
.searchMenuConfig=${searchMenuConfig}
|
||||
.reasoningConfig=${reasoningConfig}
|
||||
.affineFeatureFlagService=${affineFeatureFlagService}
|
||||
></ai-chat-block-peek-view>`;
|
||||
};
|
||||
|
||||
@@ -352,12 +352,14 @@ export class CopilotClient {
|
||||
messageId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
signal,
|
||||
}: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
reasoning?: boolean;
|
||||
webSearch?: boolean;
|
||||
modelId?: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
let url = `/api/copilot/chat/${sessionId}`;
|
||||
@@ -365,6 +367,7 @@ export class CopilotClient {
|
||||
messageId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
});
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
@@ -380,11 +383,13 @@ export class CopilotClient {
|
||||
messageId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
}: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
reasoning?: boolean;
|
||||
webSearch?: boolean;
|
||||
modelId?: string;
|
||||
},
|
||||
endpoint = 'stream'
|
||||
) {
|
||||
@@ -393,6 +398,7 @@ export class CopilotClient {
|
||||
messageId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
});
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
|
||||
@@ -21,6 +21,7 @@ export type TextToTextOptions = {
|
||||
postfix?: (text: string) => string;
|
||||
reasoning?: boolean;
|
||||
webSearch?: boolean;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
export type ToImageOptions = TextToTextOptions & {
|
||||
@@ -117,6 +118,7 @@ export function textToText({
|
||||
postfix,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
}: TextToTextOptions) {
|
||||
let messageId: string | undefined;
|
||||
|
||||
@@ -138,6 +140,7 @@ export function textToText({
|
||||
messageId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
},
|
||||
workflow ? 'workflow' : undefined
|
||||
);
|
||||
@@ -199,6 +202,7 @@ export function textToText({
|
||||
messageId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
modelId,
|
||||
});
|
||||
})(),
|
||||
]);
|
||||
|
||||
@@ -82,7 +82,7 @@ export function setupAIProvider(
|
||||
|
||||
//#region actions
|
||||
AIProvider.provide('chat', async options => {
|
||||
const { input, contexts, webSearch } = options;
|
||||
const { input, contexts, webSearch, reasoning } = options;
|
||||
|
||||
const sessionId = await createSession({
|
||||
promptName: 'Chat With AFFiNE AI',
|
||||
@@ -90,6 +90,7 @@ export function setupAIProvider(
|
||||
});
|
||||
return textToText({
|
||||
...options,
|
||||
modelId: options.modelId ?? (reasoning ? 'o4-mini' : undefined),
|
||||
client,
|
||||
sessionId,
|
||||
content: input,
|
||||
|
||||
+4
-2
@@ -165,11 +165,13 @@ const usePreviewExtensions = () => {
|
||||
const extensions = useMemo(() => {
|
||||
const manager = getViewManager()
|
||||
.config.init()
|
||||
.common(framework, enableAI)
|
||||
.foundation(framework)
|
||||
.ai(enableAI, framework)
|
||||
.theme(framework)
|
||||
.database(framework)
|
||||
.linkedDoc(framework)
|
||||
.paragraph(enableAI).value;
|
||||
.paragraph(enableAI)
|
||||
.linkPreview(framework).value;
|
||||
const specs = manager.get('preview-page');
|
||||
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
|
||||
}, [reactToLit, referenceRenderer, framework, enableAI]);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ImageProxyService,
|
||||
} from '@blocksuite/affine/shared/adapters';
|
||||
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
|
||||
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
|
||||
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
||||
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
@@ -212,13 +211,7 @@ const BlockSuiteEditorImpl = ({
|
||||
server.baseUrl
|
||||
).toString();
|
||||
|
||||
const linkPreviewUrl = new URL(
|
||||
BUILD_CONFIG.linkPreviewUrl,
|
||||
server.baseUrl
|
||||
).toString();
|
||||
|
||||
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
|
||||
page.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
|
||||
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
|
||||
|
||||
editor.updateComplete
|
||||
|
||||
@@ -80,7 +80,8 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
const patchedSpecs = useMemo(() => {
|
||||
const manager = getViewManager()
|
||||
.config.init()
|
||||
.common(framework, enableAI)
|
||||
.foundation(framework)
|
||||
.ai(enableAI, framework)
|
||||
.theme(framework)
|
||||
.editorConfig(framework)
|
||||
.editorView({
|
||||
@@ -98,7 +99,10 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
})
|
||||
.database(framework)
|
||||
.linkedDoc(framework)
|
||||
.paragraph(enableAI).value;
|
||||
.paragraph(enableAI)
|
||||
.mobile(framework)
|
||||
.electron(framework)
|
||||
.linkPreview(framework).value;
|
||||
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
if (mode === 'page') {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Avatar, uniReactRoot } from '@affine/component';
|
||||
import {
|
||||
createGroupByConfig,
|
||||
type GroupRenderProps,
|
||||
t,
|
||||
ungroups,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import type { UserService } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import { useMemberInfo } from '../hooks/use-member-info';
|
||||
import {
|
||||
avatar,
|
||||
memberName,
|
||||
memberPreviewContainer,
|
||||
} from '../properties/member/style.css';
|
||||
|
||||
const MemberPreview = ({
|
||||
memberId,
|
||||
userService,
|
||||
}: {
|
||||
memberId: string;
|
||||
userService: UserService | null | undefined;
|
||||
}) => {
|
||||
const userInfo = useMemberInfo(memberId, userService);
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={memberPreviewContainer}>
|
||||
<Avatar
|
||||
name={userInfo.removed ? undefined : (userInfo.name ?? undefined)}
|
||||
className={avatar}
|
||||
url={!userInfo.removed ? userInfo.avatar : undefined}
|
||||
size={20}
|
||||
/>
|
||||
<div className={memberName}>
|
||||
{userInfo.removed ? 'Deleted user' : userInfo.name || 'Unnamed'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const MemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
|
||||
const tType = props.group.tType;
|
||||
if (!t.user.is(tType)) return 'Ungroup';
|
||||
const memberId = props.group.value;
|
||||
if (memberId == null) return 'Ungroup';
|
||||
|
||||
return (
|
||||
<MemberPreview
|
||||
memberId={memberId}
|
||||
userService={tType.data?.userService}
|
||||
></MemberPreview>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiMemberGroupView = (props: GroupRenderProps<string | null, {}>) => {
|
||||
const tType = props.group.tType;
|
||||
if (!t.array.is(tType) || !t.user.is(tType.element)) return 'Ungroup';
|
||||
const memberId = props.group.value;
|
||||
if (memberId == null) return 'Ungroup';
|
||||
|
||||
return (
|
||||
<MemberPreview
|
||||
memberId={memberId}
|
||||
userService={tType.element.data?.userService}
|
||||
></MemberPreview>
|
||||
);
|
||||
};
|
||||
|
||||
export const groupByConfigList = [
|
||||
createGroupByConfig({
|
||||
name: 'member',
|
||||
matchType: t.user.instance(),
|
||||
groupName: (type, value: string | null) => {
|
||||
if (t.user.is(type) && typeof value === 'string') {
|
||||
const userService = type.data?.userService;
|
||||
if (userService) {
|
||||
const userInfo = userService.userInfo$(value).value;
|
||||
if (userInfo && !userInfo?.removed) {
|
||||
return userInfo.name ?? 'Unnamed';
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
defaultKeys: () => {
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: value => {
|
||||
if (typeof value !== 'string') {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: value,
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
},
|
||||
view: uniReactRoot.createUniComponent(MemberGroupView),
|
||||
}),
|
||||
createGroupByConfig({
|
||||
name: 'multi-member',
|
||||
matchType: t.array.instance(t.user.instance()),
|
||||
groupName: (_type, value: string | null) => {
|
||||
if (
|
||||
t.array.is(_type) &&
|
||||
t.user.is(_type.element) &&
|
||||
typeof value === 'string'
|
||||
) {
|
||||
const userService = _type.element.data?.userService;
|
||||
if (userService) {
|
||||
const userInfo = userService.userInfo$(value).value;
|
||||
if (userInfo && !userInfo?.removed) {
|
||||
return userInfo.name ?? 'Unnamed';
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [ungroups];
|
||||
}
|
||||
return value.map(id => ({
|
||||
key: id,
|
||||
value: id,
|
||||
}));
|
||||
},
|
||||
addToGroup: (value, old) => {
|
||||
if (value == null) {
|
||||
return old;
|
||||
}
|
||||
return Array.isArray(old) ? [...old, value] : [value];
|
||||
},
|
||||
removeFromGroup: (value, old) => {
|
||||
if (Array.isArray(old)) {
|
||||
return old.filter(v => v !== value);
|
||||
}
|
||||
return old;
|
||||
},
|
||||
view: uniReactRoot.createUniComponent(MultiMemberGroupView),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { UserService } from '@blocksuite/affine-shared/services';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useSignalValue } from '../../../modules/doc-info/utils';
|
||||
|
||||
export const useMemberInfo = (
|
||||
id: string,
|
||||
userService: UserService | null | undefined
|
||||
) => {
|
||||
useEffect(() => {
|
||||
userService?.revalidateUserInfo(id);
|
||||
}, [id, userService]);
|
||||
return useSignalValue(userService?.userInfo$(id));
|
||||
};
|
||||
+23
-2
@@ -1,4 +1,12 @@
|
||||
import { propertyType, t } from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
EditorHostKey,
|
||||
propertyType,
|
||||
t,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
UserListProvider,
|
||||
UserProvider,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import zod from 'zod';
|
||||
|
||||
export const createdByColumnType = propertyType('created-by');
|
||||
@@ -26,6 +34,19 @@ export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
|
||||
jsonValue: {
|
||||
schema: zod.string().nullable(),
|
||||
isEmpty: () => false,
|
||||
type: () => t.string.instance(),
|
||||
type: ({ dataSource }) => {
|
||||
const host = dataSource.serviceGet(EditorHostKey);
|
||||
const userService = host?.std.getOptional(UserProvider);
|
||||
const userListService = host?.std.getOptional(UserListProvider);
|
||||
|
||||
return t.user.instance(
|
||||
userListService && userService
|
||||
? {
|
||||
userService,
|
||||
userListService,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+3
-13
@@ -3,7 +3,7 @@ import {
|
||||
type CellRenderProps,
|
||||
createIcon,
|
||||
type DataViewCellLifeCycle,
|
||||
HostContextKey,
|
||||
EditorHostKey,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
UserProvider,
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
forwardRef,
|
||||
type ForwardRefRenderFunction,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
|
||||
import { useSignalValue } from '../../../../modules/doc-info/utils';
|
||||
import { useMemberInfo } from '../../hooks/use-member-info';
|
||||
import { createdByPropertyModelConfig } from './define';
|
||||
|
||||
const cellContainer = css({
|
||||
@@ -64,7 +64,7 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const host = props.cell.view.contextGet(HostContextKey);
|
||||
const host = props.cell.view.serviceGet(EditorHostKey);
|
||||
const userService = host?.std.getOptional(UserProvider);
|
||||
const memberId = useSignalValue(props.cell.value$);
|
||||
if (!memberId) {
|
||||
@@ -83,16 +83,6 @@ const CreatedByCellComponent: ForwardRefRenderFunction<
|
||||
);
|
||||
};
|
||||
|
||||
const useMemberInfo = (
|
||||
id: string,
|
||||
userService: UserService | null | undefined
|
||||
) => {
|
||||
useEffect(() => {
|
||||
userService?.revalidateUserInfo(id);
|
||||
}, [id, userService]);
|
||||
return useSignalValue(userService?.userInfo$(id));
|
||||
};
|
||||
|
||||
const MemberPreview = ({
|
||||
memberId,
|
||||
userService,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type CellRenderProps,
|
||||
createIcon,
|
||||
type DataViewCellLifeCycle,
|
||||
HostContextKey,
|
||||
EditorHostKey,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
|
||||
import type { BlobEngine } from '@blocksuite/affine/sync';
|
||||
@@ -226,8 +226,8 @@ class FileCellManager {
|
||||
this.cell = props.cell;
|
||||
this.selectCurrentCell = props.selectCurrentCell;
|
||||
this.isEditing = props.isEditing$;
|
||||
this.blobSync = this.cell?.view?.contextGet
|
||||
? this.cell.view.contextGet(HostContextKey)?.store.blobSync
|
||||
this.blobSync = this.cell?.view?.serviceGet
|
||||
? this.cell.view.serviceGet(EditorHostKey)?.store.blobSync
|
||||
: undefined;
|
||||
|
||||
this.fileUploadManager = this.blobSync
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { propertyType, t } from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
EditorHostKey,
|
||||
propertyType,
|
||||
t,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
UserListProvider,
|
||||
UserProvider,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import zod from 'zod';
|
||||
|
||||
export const memberColumnType = propertyType('member');
|
||||
@@ -31,7 +39,21 @@ export const memberPropertyModelConfig = memberColumnType.modelConfig({
|
||||
},
|
||||
jsonValue: {
|
||||
schema: MemberCellJsonValueTypeSchema,
|
||||
type: () => t.array.instance(t.string.instance()),
|
||||
type: ({ dataSource }) => {
|
||||
const host = dataSource.serviceGet(EditorHostKey);
|
||||
const userService = host?.std.getOptional(UserProvider);
|
||||
const userListService = host?.std.getOptional(UserListProvider);
|
||||
return t.array.instance(
|
||||
t.user.instance(
|
||||
userListService && userService
|
||||
? {
|
||||
userService: userService,
|
||||
userListService: userListService,
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
},
|
||||
isEmpty: ({ value }) => value.length === 0,
|
||||
},
|
||||
});
|
||||
|
||||
+2
-8
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { useSignalValue } from '../../../../../modules/doc-info/utils';
|
||||
import { Spinner } from '../../../components/loading';
|
||||
import { useMemberInfo } from '../../../hooks/use-member-info';
|
||||
import * as styles from './style.css';
|
||||
|
||||
type BaseOptions = {
|
||||
@@ -172,13 +173,6 @@ class MemberManager {
|
||||
};
|
||||
}
|
||||
|
||||
export const useMemberInfo = (id: string, memberManager: MemberManager) => {
|
||||
useEffect(() => {
|
||||
memberManager.userService?.revalidateUserInfo(id);
|
||||
}, [id, memberManager.userService]);
|
||||
return useSignalValue(memberManager.userService?.userInfo$(id));
|
||||
};
|
||||
|
||||
export const MemberListItem = (props: {
|
||||
member: ExistedUserInfo;
|
||||
memberManager: MemberManager;
|
||||
@@ -225,7 +219,7 @@ export const MemberPreview = ({
|
||||
memberManager: MemberManager;
|
||||
onDelete?: () => void;
|
||||
}) => {
|
||||
const userInfo = useMemberInfo(memberId, memberManager);
|
||||
const userInfo = useMemberInfo(memberId, memberManager.userService);
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const memberPopoverContainer = style({
|
||||
@@ -10,45 +9,10 @@ export const memberPopoverContent = style({
|
||||
padding: '0',
|
||||
});
|
||||
|
||||
export const searchContainer = style({
|
||||
padding: '12px 12px 8px 12px',
|
||||
});
|
||||
|
||||
export const searchInput = style({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const memberListContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
export const memberItem = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
gap: '8px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s ease',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
},
|
||||
':active': {
|
||||
backgroundColor: cssVarV2.layer.background.secondary,
|
||||
},
|
||||
});
|
||||
|
||||
export const memberItemContent = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const memberName = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
@@ -70,21 +34,6 @@ export const avatar = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const loadingContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '16px',
|
||||
});
|
||||
|
||||
export const noResultContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '16px',
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
|
||||
export const memberPreviewContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type CellRenderProps,
|
||||
createIcon,
|
||||
type DataViewCellLifeCycle,
|
||||
HostContextKey,
|
||||
EditorHostKey,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
UserListProvider,
|
||||
@@ -17,12 +17,12 @@ import {
|
||||
forwardRef,
|
||||
type ForwardRefRenderFunction,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { useSignalValue } from '../../../../modules/doc-info/utils';
|
||||
import { useMemberInfo } from '../../hooks/use-member-info';
|
||||
import type {
|
||||
MemberCellJsonValueType,
|
||||
MemberCellRawValueType,
|
||||
@@ -55,7 +55,7 @@ class MemberManager {
|
||||
this.cell = props.cell;
|
||||
this.selectCurrentCell = props.selectCurrentCell;
|
||||
this.isEditing = props.isEditing$;
|
||||
const host = this.cell.view.contextGet(HostContextKey);
|
||||
const host = this.cell.view.serviceGet(EditorHostKey);
|
||||
this.userService = host?.std.getOptional(UserProvider);
|
||||
this.userListService = host?.std.getOptional(UserListProvider);
|
||||
}
|
||||
@@ -140,13 +140,6 @@ const MemberCellComponent: ForwardRefRenderFunction<
|
||||
);
|
||||
};
|
||||
|
||||
const useMemberInfo = (id: string, memberManager: MemberManager) => {
|
||||
useEffect(() => {
|
||||
memberManager.userService?.revalidateUserInfo(id);
|
||||
}, [id, memberManager.userService]);
|
||||
return useSignalValue(memberManager.userService?.userInfo$(id));
|
||||
};
|
||||
|
||||
const MemberPreview = ({
|
||||
memberId,
|
||||
memberManager,
|
||||
@@ -154,7 +147,7 @@ const MemberPreview = ({
|
||||
memberId: string;
|
||||
memberManager: MemberManager;
|
||||
}) => {
|
||||
const userInfo = useMemberInfo(memberId, memberManager);
|
||||
const userInfo = useMemberInfo(memberId, memberManager.userService);
|
||||
if (!userInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+24
-46
@@ -10,12 +10,6 @@ import { AiSlashMenuConfigExtension } from '@affine/core/blocksuite/ai/extension
|
||||
import { CopilotTool } from '@affine/core/blocksuite/ai/tool/copilot-tool';
|
||||
import { aiPanelWidget } from '@affine/core/blocksuite/ai/widgets/ai-panel/ai-panel';
|
||||
import { edgelessCopilotWidget } from '@affine/core/blocksuite/ai/widgets/edgeless-copilot';
|
||||
import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta';
|
||||
import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit';
|
||||
import { getFontConfigExtension } from '@affine/core/blocksuite/extensions/font-config';
|
||||
import { patchPeekViewService } from '@affine/core/blocksuite/extensions/peek-view-service';
|
||||
import { getTelemetryExtension } from '@affine/core/blocksuite/extensions/telemetry';
|
||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
@@ -25,31 +19,37 @@ import { BlockFlavourIdentifier } from '@blocksuite/affine/std';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EdgelessClipboardAIChatConfig } from './edgeless-clipboard';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
enableAI: z.boolean().optional(),
|
||||
enable: z.boolean().optional(),
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
export class AffineCommonViewExtension extends ViewExtensionProvider<
|
||||
z.infer<typeof optionsSchema>
|
||||
> {
|
||||
override name = 'affine-view-extensions';
|
||||
type AIViewOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
|
||||
override name = 'affine-ai-view-extension';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
private _setupAI(
|
||||
context: ViewExtensionContext,
|
||||
framework: FrameworkProvider
|
||||
) {
|
||||
context.register(AIChatBlockSpec);
|
||||
context.register(AITranscriptionBlockSpec);
|
||||
context.register([
|
||||
AICodeBlockWatcher,
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:image'),
|
||||
config: imageToolbarAIEntryConfig(),
|
||||
}),
|
||||
]);
|
||||
override setup(context: ViewExtensionContext, options?: AIViewOptions) {
|
||||
super.setup(context, options);
|
||||
if (!options?.enable) return;
|
||||
const framework = options.framework;
|
||||
if (!framework) return;
|
||||
|
||||
context
|
||||
.register(AIChatBlockSpec)
|
||||
.register(AITranscriptionBlockSpec)
|
||||
.register(EdgelessClipboardAIChatConfig)
|
||||
.register(AICodeBlockWatcher)
|
||||
.register(
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:image'),
|
||||
config: imageToolbarAIEntryConfig(),
|
||||
})
|
||||
);
|
||||
if (context.scope === 'edgeless' || context.scope === 'page') {
|
||||
context.register([
|
||||
aiPanelWidget,
|
||||
@@ -76,26 +76,4 @@ export class AffineCommonViewExtension extends ViewExtensionProvider<
|
||||
context.register(getAIPageRootWatcher(framework));
|
||||
}
|
||||
}
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: z.infer<typeof optionsSchema>
|
||||
) {
|
||||
super.setup(context, options);
|
||||
const { framework, enableAI } = options || {};
|
||||
if (framework) {
|
||||
context.register(patchPeekViewService(framework.get(PeekViewService)));
|
||||
context.register([
|
||||
getFontConfigExtension(),
|
||||
buildDocDisplayMetaExtension(framework),
|
||||
]);
|
||||
context.register(getTelemetryExtension());
|
||||
if (context.scope === 'edgeless' || context.scope === 'page') {
|
||||
context.register(patchFileSizeLimitExtension(framework));
|
||||
}
|
||||
if (enableAI) {
|
||||
this._setupAI(context, framework);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import type { ExtensionType } from '@blocksuite/store';
|
||||
export function patchForAudioEmbedView(reactToLit: ReactToLit): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
// do not show audio block on mobile
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
return;
|
||||
}
|
||||
di.override(AttachmentEmbedConfigIdentifier('audio'), () => ({
|
||||
name: 'audio',
|
||||
check: (model, maxFileSize) =>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { DatabaseBlockDataSource } from '@blocksuite/affine/blocks/database';
|
||||
import {
|
||||
DatabaseBlockDataSource,
|
||||
ExternalGroupByConfigProvider,
|
||||
} from '@blocksuite/affine/blocks/database';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
|
||||
import { groupByConfigList } from '../database-block/group-by';
|
||||
import { propertiesPresets } from '../database-block/properties';
|
||||
|
||||
export function patchDatabaseBlockConfigService(): ExtensionType {
|
||||
//TODO use service
|
||||
DatabaseBlockDataSource.externalProperties.value = propertiesPresets;
|
||||
return {
|
||||
setup: () => {},
|
||||
setup: di => {
|
||||
groupByConfigList.forEach(config => {
|
||||
di.addValue(ExternalGroupByConfigProvider(config.name), config);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine/ext-loader';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { patchForClipboardInElectron } from './electron-clipboard';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
type ElectronViewExtensionOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class ElectronViewExtension extends ViewExtensionProvider<ElectronViewExtensionOptions> {
|
||||
override name = 'electron-view-extensions';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: ElectronViewExtensionOptions
|
||||
) {
|
||||
super.setup(context, options);
|
||||
if (!BUILD_CONFIG.isElectron) return;
|
||||
|
||||
const framework = options?.framework;
|
||||
if (!framework) return;
|
||||
|
||||
context.register(patchForClipboardInElectron(framework));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {
|
||||
AffineCanvasTextFonts,
|
||||
FontConfigExtension,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
|
||||
export function getFontConfigExtension() {
|
||||
return FontConfigExtension(
|
||||
AffineCanvasTextFonts.map(font => ({
|
||||
...font,
|
||||
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine/ext-loader';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { patchLinkPreviewService } from './link-preview-service';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
type AffineLinkPreviewViewOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class AffineLinkPreviewExtension extends ViewExtensionProvider<AffineLinkPreviewViewOptions> {
|
||||
override name = 'affine-link-preview-extension';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: AffineLinkPreviewViewOptions
|
||||
) {
|
||||
super.setup(context, options);
|
||||
if (!options?.framework) {
|
||||
return;
|
||||
}
|
||||
const { framework } = options;
|
||||
context.register(patchLinkPreviewService(framework));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine/shared/consts';
|
||||
import {
|
||||
LinkPreviewCacheIdentifier,
|
||||
type LinkPreviewCacheProvider,
|
||||
LinkPreviewService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/affine/std';
|
||||
import { type ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { ServerService } from '../../../modules/cloud/services/server';
|
||||
|
||||
class AffineLinkPreviewService extends LinkPreviewService {
|
||||
constructor(
|
||||
endpoint: string,
|
||||
std: BlockStdScope,
|
||||
cache: LinkPreviewCacheProvider
|
||||
) {
|
||||
super(std, cache);
|
||||
this.setEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the link preview service, set the endpoint and cache
|
||||
* @param framework
|
||||
* @returns
|
||||
*/
|
||||
export function patchLinkPreviewService(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType {
|
||||
// get link preview service endpoint from server and BUILD_CONFIG
|
||||
let linkPreviewUrl: string;
|
||||
try {
|
||||
const server = framework.get(ServerService).server;
|
||||
linkPreviewUrl = new URL(
|
||||
BUILD_CONFIG.linkPreviewUrl || '/',
|
||||
server.baseUrl
|
||||
).toString();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Invalid BUILD_CONFIG.linkPreviewUrl, falling back to default',
|
||||
err
|
||||
);
|
||||
linkPreviewUrl = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
}
|
||||
|
||||
return {
|
||||
setup: (di: Container) => {
|
||||
di.override(LinkPreviewServiceIdentifier, provider => {
|
||||
return new AffineLinkPreviewService(
|
||||
linkPreviewUrl,
|
||||
provider.get(StdIdentifier),
|
||||
provider.get(LinkPreviewCacheIdentifier)
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { KeyboardToolbarExtension } from '@affine/core/blocksuite/extensions/mobile/keyboard-toolbar-extension';
|
||||
import { MobileFeatureFlagControl } from '@affine/core/blocksuite/extensions/mobile/mobile-feature-flag-control';
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine/ext-loader';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
type MobileViewOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class MobileViewExtension extends ViewExtensionProvider<MobileViewOptions> {
|
||||
override name = 'mobile-view-extension';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override setup(context: ViewExtensionContext, options?: MobileViewOptions) {
|
||||
super.setup(context, options);
|
||||
const isMobile = BUILD_CONFIG.isMobileEdition;
|
||||
if (!isMobile) return;
|
||||
|
||||
const framework = options?.framework;
|
||||
if (framework) {
|
||||
context.register(KeyboardToolbarExtension(framework));
|
||||
}
|
||||
|
||||
context.register(MobileFeatureFlagControl);
|
||||
}
|
||||
}
|
||||
+3
-36
@@ -1,50 +1,15 @@
|
||||
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
|
||||
import { CodeBlockConfigExtension } from '@blocksuite/affine/blocks/code';
|
||||
import { ParagraphBlockConfigExtension } from '@blocksuite/affine/blocks/paragraph';
|
||||
import type { Container } from '@blocksuite/affine/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/affine/global/disposable';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
VirtualKeyboardProvider as BSVirtualKeyboardProvider,
|
||||
type VirtualKeyboardProviderWithAction,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std';
|
||||
import { LifeCycleWatcher } from '@blocksuite/affine/std';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import { batch, signal } from '@preact/signals-core';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
export class MobileSpecsPatches extends LifeCycleWatcher {
|
||||
static override key = 'mobile-patches';
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
const featureFlagService = std.get(FeatureFlagService);
|
||||
|
||||
featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true);
|
||||
featureFlagService.setFlag('enable_mobile_linked_doc_menu', true);
|
||||
}
|
||||
}
|
||||
|
||||
export const mobileParagraphConfig = ParagraphBlockConfigExtension({
|
||||
getPlaceholder: model => {
|
||||
const placeholders = {
|
||||
text: '',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
h5: 'Heading 5',
|
||||
h6: 'Heading 6',
|
||||
quote: '',
|
||||
};
|
||||
return placeholders[model.props.type];
|
||||
},
|
||||
});
|
||||
|
||||
export const mobileCodeConfig = CodeBlockConfigExtension({
|
||||
showLineNumbers: false,
|
||||
});
|
||||
|
||||
export function KeyboardToolbarExtension(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType {
|
||||
@@ -89,6 +54,7 @@ export function KeyboardToolbarExtension(
|
||||
|
||||
if ('show' in affineVirtualKeyboardProvider) {
|
||||
const providerWithAction = affineVirtualKeyboardProvider;
|
||||
|
||||
class BSVirtualKeyboardServiceWithShowAndHide
|
||||
extends BSVirtualKeyboardService
|
||||
implements VirtualKeyboardProviderWithAction
|
||||
@@ -96,6 +62,7 @@ export function KeyboardToolbarExtension(
|
||||
show() {
|
||||
providerWithAction.show();
|
||||
}
|
||||
|
||||
hide() {
|
||||
providerWithAction.hide();
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { FeatureFlagService } from '@blocksuite/affine/shared/services';
|
||||
import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/affine/std';
|
||||
|
||||
export class MobileFeatureFlagControl extends LifeCycleWatcher {
|
||||
static override key = 'mobile-patches';
|
||||
|
||||
constructor(std: BlockStdScope) {
|
||||
super(std);
|
||||
const featureFlagService = std.get(FeatureFlagService);
|
||||
|
||||
featureFlagService.setFlag('enable_mobile_keyboard_toolbar', true);
|
||||
featureFlagService.setFlag('enable_mobile_linked_doc_menu', true);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { PeekViewService } from '@affine/core/modules/peek-view';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type {
|
||||
PeekOptions,
|
||||
PeekViewService as BSPeekViewService,
|
||||
} from '@blocksuite/affine/components/peek';
|
||||
import { PeekViewExtension } from '@blocksuite/affine/components/peek';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
const logger = new DebugLogger('affine::patch-peek-view-service');
|
||||
|
||||
export function patchPeekViewService(service: PeekViewService) {
|
||||
return PeekViewExtension({
|
||||
peek: (
|
||||
element: {
|
||||
target: HTMLElement;
|
||||
docId: string;
|
||||
blockIds?: string[];
|
||||
template?: TemplateResult;
|
||||
},
|
||||
options?: PeekOptions
|
||||
) => {
|
||||
logger.debug('center peek', element);
|
||||
const { template, target, ...props } = element;
|
||||
|
||||
return service.peekView.open(
|
||||
{
|
||||
element: target,
|
||||
docRef: props,
|
||||
},
|
||||
template,
|
||||
options?.abortSignal
|
||||
);
|
||||
},
|
||||
} satisfies BSPeekViewService);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { mixpanel } from '@affine/track';
|
||||
import {
|
||||
type TelemetryEventMap,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
|
||||
export function getTelemetryExtension(): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(TelemetryProvider, () => ({
|
||||
track: <T extends keyof TelemetryEventMap>(
|
||||
eventName: T,
|
||||
props: TelemetryEventMap[T]
|
||||
) => {
|
||||
mixpanel.track(eventName as string, props as Record<string, unknown>);
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ConfirmModalProps, ElementOrFactory } from '@affine/component';
|
||||
import { patchForAudioEmbedView } from '@affine/core/blocksuite/extensions/audio/audio-view';
|
||||
import { patchDatabaseBlockConfigService } from '@affine/core/blocksuite/extensions/database-block-config-service';
|
||||
import { buildDocDisplayMetaExtension } from '@affine/core/blocksuite/extensions/display-meta';
|
||||
import { patchDocModeService } from '@affine/core/blocksuite/extensions/doc-mode-service';
|
||||
import { patchDocUrlExtensions } from '@affine/core/blocksuite/extensions/doc-url';
|
||||
import { EdgelessClipboardAIChatConfig } from '@affine/core/blocksuite/extensions/edgeless-clipboard';
|
||||
import { patchForClipboardInElectron } from '@affine/core/blocksuite/extensions/electron-clipboard';
|
||||
import { patchFileSizeLimitExtension } from '@affine/core/blocksuite/extensions/file-size-limit';
|
||||
import { patchNotificationService } from '@affine/core/blocksuite/extensions/notification-service';
|
||||
import { patchOpenDocExtension } from '@affine/core/blocksuite/extensions/open-doc';
|
||||
import { patchQuickSearchService } from '@affine/core/blocksuite/extensions/quick-search-service';
|
||||
@@ -29,13 +29,6 @@ import { FrameworkProvider } from '@toeverything/infra';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
KeyboardToolbarExtension,
|
||||
mobileCodeConfig,
|
||||
mobileParagraphConfig,
|
||||
MobileSpecsPatches,
|
||||
} from '../extensions/mobile-config';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
// services
|
||||
framework: z.instanceof(FrameworkProvider),
|
||||
@@ -106,8 +99,6 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
|
||||
reactToLit,
|
||||
confirmModal,
|
||||
} = options;
|
||||
const isMobileEdition = BUILD_CONFIG.isMobileEdition;
|
||||
const isElectron = BUILD_CONFIG.isElectron;
|
||||
|
||||
const docService = framework.get(DocService);
|
||||
const docsService = framework.get(DocsService);
|
||||
@@ -115,30 +106,21 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
|
||||
|
||||
const referenceRenderer = this._getCustomReferenceRenderer(framework);
|
||||
|
||||
context.register([
|
||||
patchReferenceRenderer(reactToLit, referenceRenderer),
|
||||
patchNotificationService(confirmModal),
|
||||
patchOpenDocExtension(),
|
||||
EdgelessClipboardAIChatConfig,
|
||||
patchSideBarService(framework),
|
||||
patchDocModeService(docService, docsService, editorService),
|
||||
]);
|
||||
context.register(patchDocUrlExtensions(framework));
|
||||
context.register(patchQuickSearchService(framework));
|
||||
context.register([
|
||||
patchDatabaseBlockConfigService(),
|
||||
patchForAudioEmbedView(reactToLit),
|
||||
]);
|
||||
if (isMobileEdition) {
|
||||
context.register([
|
||||
KeyboardToolbarExtension(framework),
|
||||
MobileSpecsPatches,
|
||||
mobileParagraphConfig,
|
||||
mobileCodeConfig,
|
||||
context
|
||||
.register([
|
||||
patchReferenceRenderer(reactToLit, referenceRenderer),
|
||||
patchNotificationService(confirmModal),
|
||||
patchOpenDocExtension(),
|
||||
patchSideBarService(framework),
|
||||
patchDocModeService(docService, docsService, editorService),
|
||||
patchFileSizeLimitExtension(framework),
|
||||
buildDocDisplayMetaExtension(framework),
|
||||
])
|
||||
.register(patchDocUrlExtensions(framework))
|
||||
.register(patchQuickSearchService(framework))
|
||||
.register([
|
||||
patchDatabaseBlockConfigService(),
|
||||
patchForAudioEmbedView(reactToLit),
|
||||
]);
|
||||
}
|
||||
if (isElectron) {
|
||||
context.register(patchForClipboardInElectron(framework));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ class MigratingAffineStoreExtension extends StoreExtensionProvider {
|
||||
|
||||
interface Configure {
|
||||
init: () => Configure;
|
||||
|
||||
featureFlag: (featureFlagService?: FeatureFlagService) => Configure;
|
||||
|
||||
value: StoreExtensionManager;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactToLit } from '@affine/component';
|
||||
import { AIViewExtension } from '@affine/core/blocksuite/extensions/ai';
|
||||
import { CloudViewExtension } from '@affine/core/blocksuite/extensions/cloud';
|
||||
import {
|
||||
EdgelessBlockHeaderConfigViewExtension,
|
||||
@@ -7,23 +8,59 @@ import {
|
||||
import { AffineEditorConfigViewExtension } from '@affine/core/blocksuite/extensions/editor-config';
|
||||
import { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database';
|
||||
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
|
||||
import { ElectronViewExtension } from '@affine/core/blocksuite/extensions/electron';
|
||||
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/extensions/link-preview-service';
|
||||
import { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile';
|
||||
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
|
||||
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
|
||||
import { TurboRendererViewExtension } from '@affine/core/blocksuite/extensions/turbo-renderer';
|
||||
import { AffineCommonViewExtension } from '@affine/core/blocksuite/manager/common-view';
|
||||
import {
|
||||
AffineEditorViewExtension,
|
||||
type AffineEditorViewOptions,
|
||||
} from '@affine/core/blocksuite/manager/editor-view';
|
||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { mixpanel } from '@affine/track';
|
||||
import { DatabaseViewExtension } from '@blocksuite/affine/blocks/database/view';
|
||||
import { ParagraphViewExtension } from '@blocksuite/affine/blocks/paragraph/view';
|
||||
import type {
|
||||
PeekOptions,
|
||||
PeekViewService as BSPeekViewService,
|
||||
} from '@blocksuite/affine/components/peek';
|
||||
import { ViewExtensionManager } from '@blocksuite/affine/ext-loader';
|
||||
import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view';
|
||||
import { FoundationViewExtension } from '@blocksuite/affine/foundation/view';
|
||||
import { AffineCanvasTextFonts } from '@blocksuite/affine/shared/services';
|
||||
import { LinkedDocViewExtension } from '@blocksuite/affine/widgets/linked-doc/view';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import { CodeBlockPreviewViewExtension } from './code-block-preview';
|
||||
|
||||
type Configure = {
|
||||
init: () => Configure;
|
||||
|
||||
foundation: (framework?: FrameworkProvider) => Configure;
|
||||
editorView: (options?: AffineEditorViewOptions) => Configure;
|
||||
theme: (framework?: FrameworkProvider) => Configure;
|
||||
editorConfig: (framework?: FrameworkProvider) => Configure;
|
||||
edgelessBlockHeader: (options?: EdgelessBlockHeaderViewOptions) => Configure;
|
||||
database: (framework?: FrameworkProvider) => Configure;
|
||||
linkedDoc: (framework?: FrameworkProvider) => Configure;
|
||||
paragraph: (enableAI?: boolean) => Configure;
|
||||
cloud: (framework?: FrameworkProvider, enableCloud?: boolean) => Configure;
|
||||
turboRenderer: (enableTurboRenderer?: boolean) => Configure;
|
||||
pdf: (enablePDFEmbedPreview?: boolean, reactToLit?: ReactToLit) => Configure;
|
||||
mobile: (framework?: FrameworkProvider) => Configure;
|
||||
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
|
||||
electron: (framework?: FrameworkProvider) => Configure;
|
||||
linkPreview: (framework?: FrameworkProvider) => Configure;
|
||||
|
||||
value: ViewExtensionManager;
|
||||
};
|
||||
|
||||
const peekViewLogger = new DebugLogger('affine::patch-peek-view-service');
|
||||
|
||||
class ViewProvider {
|
||||
static instance: ViewProvider | null = null;
|
||||
static getInstance() {
|
||||
@@ -40,7 +77,6 @@ class ViewProvider {
|
||||
...getInternalViewExtensions(),
|
||||
|
||||
AffineThemeViewExtension,
|
||||
AffineCommonViewExtension,
|
||||
AffineEditorViewExtension,
|
||||
AffineEditorConfigViewExtension,
|
||||
CodeBlockPreviewViewExtension,
|
||||
@@ -48,6 +84,10 @@ class ViewProvider {
|
||||
TurboRendererViewExtension,
|
||||
CloudViewExtension,
|
||||
PdfViewExtension,
|
||||
MobileViewExtension,
|
||||
AIViewExtension,
|
||||
ElectronViewExtension,
|
||||
AffineLinkPreviewExtension,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -55,10 +95,10 @@ class ViewProvider {
|
||||
return this._manager;
|
||||
}
|
||||
|
||||
get config() {
|
||||
get config(): Configure {
|
||||
return {
|
||||
init: this._initDefaultConfig,
|
||||
common: this._configureCommon,
|
||||
foundation: this._configureFoundation,
|
||||
editorView: this._configureEditorView,
|
||||
theme: this._configureTheme,
|
||||
editorConfig: this._configureEditorConfig,
|
||||
@@ -69,13 +109,17 @@ class ViewProvider {
|
||||
cloud: this._configureCloud,
|
||||
turboRenderer: this._configureTurboRenderer,
|
||||
pdf: this._configurePdf,
|
||||
mobile: this._configureMobile,
|
||||
ai: this._configureAI,
|
||||
electron: this._configureElectron,
|
||||
linkPreview: this._configureLinkPreview,
|
||||
value: this._manager,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly _initDefaultConfig = () => {
|
||||
this.config
|
||||
.common()
|
||||
.foundation()
|
||||
.theme()
|
||||
.editorView()
|
||||
.editorConfig()
|
||||
@@ -85,19 +129,55 @@ class ViewProvider {
|
||||
.paragraph()
|
||||
.cloud()
|
||||
.turboRenderer()
|
||||
.pdf();
|
||||
.pdf()
|
||||
.mobile()
|
||||
.ai()
|
||||
.electron()
|
||||
.linkPreview();
|
||||
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureCommon = (
|
||||
framework?: FrameworkProvider,
|
||||
enableAI?: boolean
|
||||
) => {
|
||||
this._manager.configure(AffineCommonViewExtension, {
|
||||
framework,
|
||||
enableAI,
|
||||
private readonly _configureFoundation = (framework?: FrameworkProvider) => {
|
||||
const peekViewService = framework?.get(PeekViewService);
|
||||
|
||||
this._manager.configure(FoundationViewExtension, {
|
||||
telemetry: {
|
||||
track: (eventName, props) => {
|
||||
mixpanel.track(eventName, props);
|
||||
},
|
||||
},
|
||||
fontConfig: AffineCanvasTextFonts.map(font => ({
|
||||
...font,
|
||||
url: environment.publicPath + 'fonts/' + font.url.split('/').pop(),
|
||||
})),
|
||||
peekView: !peekViewService
|
||||
? undefined
|
||||
: ({
|
||||
peek: (
|
||||
element: {
|
||||
target: HTMLElement;
|
||||
docId: string;
|
||||
blockIds?: string[];
|
||||
template?: TemplateResult;
|
||||
},
|
||||
options?: PeekOptions
|
||||
) => {
|
||||
peekViewLogger.debug('center peek', element);
|
||||
const { template, target, ...props } = element;
|
||||
|
||||
return peekViewService.peekView.open(
|
||||
{
|
||||
element: target,
|
||||
docRef: props,
|
||||
},
|
||||
template,
|
||||
options?.abortSignal
|
||||
);
|
||||
},
|
||||
} satisfies BSPeekViewService),
|
||||
});
|
||||
|
||||
return this.config;
|
||||
};
|
||||
|
||||
@@ -146,7 +226,23 @@ class ViewProvider {
|
||||
};
|
||||
|
||||
private readonly _configureParagraph = (enableAI?: boolean) => {
|
||||
if (enableAI) {
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
this._manager.configure(ParagraphViewExtension, {
|
||||
getPlaceholder: model => {
|
||||
const placeholders = {
|
||||
text: '',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
h5: 'Heading 5',
|
||||
h6: 'Heading 6',
|
||||
quote: '',
|
||||
};
|
||||
return placeholders[model.props.type] ?? '';
|
||||
},
|
||||
});
|
||||
} else if (enableAI) {
|
||||
this._manager.configure(ParagraphViewExtension, {
|
||||
getPlaceholder: model => {
|
||||
const placeholders = {
|
||||
@@ -193,6 +289,29 @@ class ViewProvider {
|
||||
});
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureMobile = (framework?: FrameworkProvider) => {
|
||||
this._manager.configure(MobileViewExtension, { framework });
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureAI = (
|
||||
enable?: boolean,
|
||||
framework?: FrameworkProvider
|
||||
) => {
|
||||
this._manager.configure(AIViewExtension, { framework, enable });
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureElectron = (framework?: FrameworkProvider) => {
|
||||
this._manager.configure(ElectronViewExtension, { framework });
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureLinkPreview = (framework?: FrameworkProvider) => {
|
||||
this._manager.configure(AffineLinkPreviewExtension, { framework });
|
||||
return this.config;
|
||||
};
|
||||
}
|
||||
|
||||
export function getViewManager() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
|
||||
import type { ServiceProvider } from '@blocksuite/affine/global/di';
|
||||
import {
|
||||
@@ -161,11 +162,13 @@ export async function markDownToDoc(
|
||||
provider: ServiceProvider,
|
||||
schema: Schema,
|
||||
answer: string,
|
||||
middlewares?: TransformerMiddleware[]
|
||||
middlewares?: TransformerMiddleware[],
|
||||
affineFeatureFlagService?: FeatureFlagService
|
||||
) {
|
||||
// Should not create a new doc in the original collection
|
||||
const collection = new WorkspaceImpl({
|
||||
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
|
||||
featureFlagService: affineFeatureFlagService,
|
||||
});
|
||||
collection.meta.initialize();
|
||||
const transformer = new Transformer({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Collection } from '@affine/core/modules/collection';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { AllDocsIcon, FilterIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
|
||||
@@ -5,10 +5,8 @@ import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { createEmptyCollection } from '../../page-list';
|
||||
import { ActionButton } from './action-button';
|
||||
import collectionListDark from './assets/collection-list.dark.png';
|
||||
import collectionListLight from './assets/collection-list.light.png';
|
||||
@@ -39,8 +37,7 @@ export const EmptyCollections = (props: UniversalEmptyProps) => {
|
||||
variant: 'primary',
|
||||
},
|
||||
onConfirm(name) {
|
||||
const id = nanoid();
|
||||
collectionService.addCollection(createEmptyCollection(id, { name }));
|
||||
const id = collectionService.createCollection({ name });
|
||||
navigateHelper.jumpToCollection(currentWorkspace.id, id);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IconButton, Menu, MenuItem, MenuSeparator } from '@affine/component';
|
||||
import type { FilterParams } from '@affine/core/modules/collection-rules';
|
||||
import { WorkspacePropertyService } from '@affine/core/modules/workspace-property';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { FavoriteIcon, PlusIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { WorkspacePropertyIcon, WorkspacePropertyName } from '../properties';
|
||||
@@ -24,6 +24,36 @@ export const AddFilterMenu = ({
|
||||
{t['com.affine.filter']()}
|
||||
</div>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
|
||||
key={'favorite'}
|
||||
onClick={() => {
|
||||
onAdd({
|
||||
type: 'system',
|
||||
key: 'favorite',
|
||||
method: 'is',
|
||||
value: 'true',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterTypeItemName}>{t['Favorited']()}</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<FavoriteIcon className={styles.filterTypeItemIcon} />}
|
||||
key={'shared'}
|
||||
onClick={() => {
|
||||
onAdd({
|
||||
type: 'system',
|
||||
key: 'shared',
|
||||
method: 'is',
|
||||
value: 'true',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.filterTypeItemName}>
|
||||
{t['com.affine.filter.is-public']()}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{workspaceProperties.map(property => {
|
||||
const type = WorkspacePropertyTypes[property.type];
|
||||
const defaultFilter = type?.defaultFilter;
|
||||
|
||||
@@ -81,13 +81,13 @@ export function useAIChatConfig() {
|
||||
return tag$.value?.pageIds$.value ?? [];
|
||||
},
|
||||
getCollections: () => {
|
||||
const collections$ = collectionService.collections$;
|
||||
return createSignalFromObservable(collections$, []);
|
||||
const collectionMetas$ = collectionService.collectionMetas$;
|
||||
return createSignalFromObservable(collectionMetas$, []);
|
||||
},
|
||||
getCollectionPageIds: (collectionId: string) => {
|
||||
const collection$ = collectionService.collection$(collectionId);
|
||||
// TODO: lack of documents that meet the collection rules
|
||||
return collection$?.value?.allowList ?? [];
|
||||
return collection$?.value?.info$.value.allowList ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { AuthService } from '@affine/core/modules/cloud';
|
||||
import type { DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useDeleteCollectionInfo = () => {
|
||||
const authService = useService(AuthService);
|
||||
|
||||
const user = useLiveData(authService.session.account$);
|
||||
|
||||
return useMemo<DeleteCollectionInfo | null>(
|
||||
() => (user ? { userName: user.label, userId: user.id } : null),
|
||||
[user]
|
||||
);
|
||||
};
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import type {
|
||||
Filter,
|
||||
LiteralValue,
|
||||
PropertiesMeta,
|
||||
Ref,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import { getOrCreateI18n, I18nextProvider } from '@affine/i18n';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { Condition } from '../filter/condition';
|
||||
import { tBoolean, tDate } from '../filter/logical/custom-type';
|
||||
import { toLiteral } from '../filter/shared-types';
|
||||
import type { FilterMatcherDataType } from '../filter/vars';
|
||||
import { filterMatcher } from '../filter/vars';
|
||||
import { filterByFilterList } from '../use-collection-manager';
|
||||
const ref = (name: keyof VariableMap): Ref => {
|
||||
return {
|
||||
type: 'ref',
|
||||
name,
|
||||
};
|
||||
};
|
||||
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
|
||||
return {
|
||||
Created: 0,
|
||||
Updated: 0,
|
||||
'Is Favourited': false,
|
||||
'Is Public': false,
|
||||
Tags: [],
|
||||
...vars,
|
||||
};
|
||||
};
|
||||
const mockPropertiesMeta = (meta: Partial<PropertiesMeta>): PropertiesMeta => {
|
||||
return {
|
||||
tags: {
|
||||
options: [],
|
||||
},
|
||||
...meta,
|
||||
};
|
||||
};
|
||||
const filter = (
|
||||
matcherData: FilterMatcherDataType,
|
||||
left: Ref,
|
||||
args: LiteralValue[]
|
||||
): Filter => {
|
||||
return {
|
||||
type: 'filter',
|
||||
left,
|
||||
funcName: matcherData.name,
|
||||
args: args.map(toLiteral),
|
||||
};
|
||||
};
|
||||
describe('match filter', () => {
|
||||
test('boolean variable will match `is` filter', () => {
|
||||
const is = filterMatcher
|
||||
.allMatchedData(tBoolean.create())
|
||||
.find(v => v.name === 'is');
|
||||
expect(is?.name).toBe('is');
|
||||
});
|
||||
test('Date variable will match `before` filter', () => {
|
||||
const before = filterMatcher
|
||||
.allMatchedData(tDate.create())
|
||||
.find(v => v.name === 'before');
|
||||
expect(before?.name).toBe('before');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eval filter', () => {
|
||||
test('before', async () => {
|
||||
const before = filterMatcher.findData(v => v.name === 'before');
|
||||
if (!before) {
|
||||
throw new Error('before is not found');
|
||||
}
|
||||
const filter1 = filter(before, ref('Created'), [
|
||||
new Date(2023, 5, 28).getTime(),
|
||||
]);
|
||||
const filter2 = filter(before, ref('Created'), [
|
||||
new Date(2023, 5, 30).getTime(),
|
||||
]);
|
||||
const filter3 = filter(before, ref('Created'), [
|
||||
new Date(2023, 5, 29).getTime(),
|
||||
]);
|
||||
const varMap = mockVariableMap({
|
||||
Created: new Date(2023, 5, 29).getTime(),
|
||||
});
|
||||
expect(filterByFilterList([filter1], varMap)).toBe(false);
|
||||
expect(filterByFilterList([filter2], varMap)).toBe(true);
|
||||
expect(filterByFilterList([filter3], varMap)).toBe(false);
|
||||
});
|
||||
test('after', async () => {
|
||||
const after = filterMatcher.findData(v => v.name === 'after');
|
||||
if (!after) {
|
||||
throw new Error('after is not found');
|
||||
}
|
||||
const filter1 = filter(after, ref('Created'), [
|
||||
new Date(2023, 5, 28).getTime(),
|
||||
]);
|
||||
const filter2 = filter(after, ref('Created'), [
|
||||
new Date(2023, 5, 30).getTime(),
|
||||
]);
|
||||
const filter3 = filter(after, ref('Created'), [
|
||||
new Date(2023, 5, 29).getTime(),
|
||||
]);
|
||||
const varMap = mockVariableMap({
|
||||
Created: new Date(2023, 5, 29).getTime(),
|
||||
});
|
||||
expect(filterByFilterList([filter1], varMap)).toBe(true);
|
||||
expect(filterByFilterList([filter2], varMap)).toBe(false);
|
||||
expect(filterByFilterList([filter3], varMap)).toBe(false);
|
||||
});
|
||||
test('is', async () => {
|
||||
const is = filterMatcher.findData(v => v.name === 'is');
|
||||
if (!is) {
|
||||
throw new Error('is is not found');
|
||||
}
|
||||
const filter1 = filter(is, ref('Is Favourited'), [false]);
|
||||
const filter2 = filter(is, ref('Is Favourited'), [true]);
|
||||
const varMap = mockVariableMap({
|
||||
'Is Favourited': true,
|
||||
});
|
||||
expect(filterByFilterList([filter1], varMap)).toBe(false);
|
||||
expect(filterByFilterList([filter2], varMap)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render filter', () => {
|
||||
test('boolean condition value change', async () => {
|
||||
const is = filterMatcher.match(tBoolean.create());
|
||||
const i18n = getOrCreateI18n();
|
||||
if (!is) {
|
||||
throw new Error('is is not found');
|
||||
}
|
||||
const Wrapper = () => {
|
||||
const [value, onChange] = useState(
|
||||
filter(is, ref('Is Favourited'), [true])
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Condition
|
||||
propertiesMeta={mockPropertiesMeta({})}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByText('true');
|
||||
dom.click();
|
||||
await result.findByText('false');
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
const WrapperCreator = (fn: FilterMatcherDataType) =>
|
||||
function Wrapper(): ReactElement {
|
||||
const [value, onChange] = useState(
|
||||
filter(fn, ref('Created'), [new Date(2023, 5, 29).getTime()])
|
||||
);
|
||||
return (
|
||||
<Condition
|
||||
propertiesMeta={mockPropertiesMeta({})}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test('date condition function change', async () => {
|
||||
const dateFunction = filterMatcher.match(tDate.create());
|
||||
if (!dateFunction) {
|
||||
throw new Error('dateFunction is not found');
|
||||
}
|
||||
const Wrapper = WrapperCreator(dateFunction);
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByTestId('filter-name');
|
||||
dom.click();
|
||||
await result.findByTestId('filter-name');
|
||||
result.unmount();
|
||||
});
|
||||
test('date condition variable change', async () => {
|
||||
const dateFunction = filterMatcher.match(tDate.create());
|
||||
if (!dateFunction) {
|
||||
throw new Error('dateFunction is not found');
|
||||
}
|
||||
const Wrapper = WrapperCreator(dateFunction);
|
||||
const result = render(<Wrapper />);
|
||||
const dom = await result.findByTestId('variable-name');
|
||||
dom.click();
|
||||
await result.findByTestId('variable-name');
|
||||
result.unmount();
|
||||
});
|
||||
});
|
||||
+19
-49
@@ -1,51 +1,25 @@
|
||||
import { useDeleteCollectionInfo } from '@affine/core/components/hooks/affine/use-delete-collection-info';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { CollectionService } from '../../../modules/collection';
|
||||
import {
|
||||
type CollectionMeta,
|
||||
CollectionService,
|
||||
} from '../../../modules/collection';
|
||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
||||
import { collectionHeaderColsDef } from '../header-col-def';
|
||||
import { CollectionOperationCell } from '../operation-cell';
|
||||
import { CollectionListItemRenderer } from '../page-group';
|
||||
import { ListTableHeader } from '../page-header';
|
||||
import type { CollectionMeta, ItemListHandle, ListItem } from '../types';
|
||||
import type { ItemListHandle, ListItem } from '../types';
|
||||
import { VirtualizedList } from '../virtualized-list';
|
||||
import { CollectionListHeader } from './collection-list-header';
|
||||
|
||||
const useCollectionOperationsRenderer = ({
|
||||
info,
|
||||
service,
|
||||
}: {
|
||||
info: DeleteCollectionInfo;
|
||||
service: CollectionService;
|
||||
}) => {
|
||||
const collectionOperationsRenderer = useCallback(
|
||||
(collection: Collection) => {
|
||||
return (
|
||||
<CollectionOperationCell
|
||||
info={info}
|
||||
collection={collection}
|
||||
service={service}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[info, service]
|
||||
);
|
||||
|
||||
return collectionOperationsRenderer;
|
||||
};
|
||||
|
||||
export const VirtualizedCollectionList = ({
|
||||
collections,
|
||||
collectionMetas,
|
||||
setHideHeaderCreateNewCollection,
|
||||
handleCreateCollection,
|
||||
}: {
|
||||
collections: Collection[];
|
||||
collectionMetas: CollectionMeta[];
|
||||
handleCreateCollection: () => void;
|
||||
setHideHeaderCreateNewCollection: (hide: boolean) => void;
|
||||
}) => {
|
||||
@@ -55,30 +29,24 @@ export const VirtualizedCollectionList = ({
|
||||
[]
|
||||
);
|
||||
const collectionService = useService(CollectionService);
|
||||
const collectionMetas = useLiveData(collectionService.collectionMetas$);
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const info = useDeleteCollectionInfo();
|
||||
|
||||
const collectionOperations = useCollectionOperationsRenderer({
|
||||
info,
|
||||
service: collectionService,
|
||||
});
|
||||
|
||||
const filteredSelectedCollectionIds = useMemo(() => {
|
||||
const ids = new Set(collections.map(collection => collection.id));
|
||||
const ids = new Set(collectionMetas.map(collection => collection.id));
|
||||
return selectedCollectionIds.filter(id => ids.has(id));
|
||||
}, [collections, selectedCollectionIds]);
|
||||
}, [collectionMetas, selectedCollectionIds]);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
listRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
const collectionOperationRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
const collection = item as CollectionMeta;
|
||||
return collectionOperations(collection);
|
||||
},
|
||||
[collectionOperations]
|
||||
);
|
||||
const collectionOperationRenderer = useCallback((item: ListItem) => {
|
||||
const collection = item;
|
||||
return (
|
||||
<CollectionOperationCell collectionMeta={collection as CollectionMeta} />
|
||||
);
|
||||
}, []);
|
||||
|
||||
const collectionHeaderRenderer = useCallback(() => {
|
||||
return <ListTableHeader headerCols={collectionHeaderColsDef} />;
|
||||
@@ -92,9 +60,11 @@ export const VirtualizedCollectionList = ({
|
||||
if (selectedCollectionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
collectionService.deleteCollection(info, ...selectedCollectionIds);
|
||||
for (const collectionId of selectedCollectionIds) {
|
||||
collectionService.deleteCollection(collectionId);
|
||||
}
|
||||
hideFloatingToolbar();
|
||||
}, [collectionService, hideFloatingToolbar, info, selectedCollectionIds]);
|
||||
}, [collectionService, hideFloatingToolbar, selectedCollectionIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { TagService } from '@affine/core/modules/tag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { inferOpenMode } from '@affine/core/utils';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import type { DocMode } from '@blocksuite/affine/model';
|
||||
@@ -29,8 +28,10 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { usePageHelper } from '../../../blocksuite/block-suite-page-list/utils';
|
||||
import { CollectionService } from '../../../modules/collection';
|
||||
import { createTagFilter } from '../filter/utils';
|
||||
import {
|
||||
type Collection,
|
||||
CollectionService,
|
||||
} from '../../../modules/collection';
|
||||
import { SaveAsCollectionButton } from '../view';
|
||||
import * as styles from './page-list-header.css';
|
||||
import { PageListNewPageButton } from './page-list-new-page-button';
|
||||
@@ -133,11 +134,12 @@ export const CollectionPageListHeader = ({
|
||||
const workspace = workspaceService.workspace;
|
||||
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const name = useLiveData(collection.name$);
|
||||
|
||||
const createAndAddDocument = useCallback(
|
||||
(createDocumentFn: () => DocRecord) => {
|
||||
const newDoc = createDocumentFn();
|
||||
collectionService.addPageToCollection(collection.id, newDoc.id);
|
||||
collectionService.addDocToCollection(collection.id, newDoc.id);
|
||||
},
|
||||
[collection.id, collectionService]
|
||||
);
|
||||
@@ -183,7 +185,7 @@ export const CollectionPageListHeader = ({
|
||||
<div className={styles.titleIcon}>
|
||||
<ViewLayersIcon />
|
||||
</div>
|
||||
<div className={styles.titleCollectionName}>{collection.name}</div>
|
||||
<div className={styles.titleCollectionName}>{name}</div>
|
||||
</div>
|
||||
<div className={styles.rightButtonGroup}>
|
||||
<Button onClick={handleEdit}>{t['Edit']()}</Button>
|
||||
@@ -221,12 +223,21 @@ export const TagPageListHeader = ({
|
||||
}, [jumpToTags, workspaceId]);
|
||||
|
||||
const saveToCollection = useCallback(
|
||||
(collection: Collection) => {
|
||||
collectionService.addCollection({
|
||||
...collection,
|
||||
filterList: [createTagFilter(tag.id)],
|
||||
(collectionName: string) => {
|
||||
const id = collectionService.createCollection({
|
||||
name: collectionName,
|
||||
rules: {
|
||||
filters: [
|
||||
{
|
||||
type: 'system',
|
||||
key: 'tags',
|
||||
method: 'include-all',
|
||||
value: tag.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
jumpToCollection(workspaceId, collection.id);
|
||||
jumpToCollection(workspaceId, id);
|
||||
},
|
||||
[collectionService, tag.id, jumpToCollection, workspaceId]
|
||||
);
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { IconButton, Menu, toast } from '@affine/component';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||
import {
|
||||
CollectionRulesService,
|
||||
type FilterParams,
|
||||
} from '@affine/core/modules/collection-rules';
|
||||
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
|
||||
import { ShareDocsListService } from '@affine/core/modules/share-doc';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { PublicDocMode } from '@affine/graphql';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { FilterIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Filters } from '../../filter';
|
||||
import { AddFilterMenu } from '../../filter/add-filter';
|
||||
import { AffineShapeIcon, FavoriteTag } from '..';
|
||||
import { FilterList } from '../filter';
|
||||
import { VariableSelect } from '../filter/vars';
|
||||
import { usePageHeaderColsDef } from '../header-col-def';
|
||||
import { PageListItemRenderer } from '../page-group';
|
||||
import { ListTableHeader } from '../page-header';
|
||||
@@ -20,7 +29,6 @@ import { SelectorLayout } from '../selector/selector-layout';
|
||||
import type { ListItem } from '../types';
|
||||
import { VirtualizedList } from '../virtualized-list';
|
||||
import * as styles from './select-page.css';
|
||||
import { useFilter } from './use-filter';
|
||||
import { useSearch } from './use-search';
|
||||
|
||||
export const SelectPage = ({
|
||||
@@ -58,12 +66,13 @@ export const SelectPage = ({
|
||||
workspaceService,
|
||||
compatibleFavoriteItemsAdapter,
|
||||
shareDocsListService,
|
||||
collectionRulesService,
|
||||
} = useServices({
|
||||
ShareDocsListService,
|
||||
WorkspaceService,
|
||||
CompatibleFavoriteItemsAdapter,
|
||||
CollectionRulesService,
|
||||
});
|
||||
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
|
||||
const workspace = workspaceService.workspace;
|
||||
const docCollection = workspace.docCollection;
|
||||
const pageMetas = useBlockSuiteDocMeta(docCollection);
|
||||
@@ -73,20 +82,6 @@ export const SelectPage = ({
|
||||
shareDocsListService.shareDocs?.revalidate();
|
||||
}, [shareDocsListService.shareDocs]);
|
||||
|
||||
const getPublicMode = useCallback(
|
||||
(id: string) => {
|
||||
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
|
||||
if (mode === PublicDocMode.Edgeless) {
|
||||
return 'edgeless';
|
||||
} else if (mode === PublicDocMode.Page) {
|
||||
return 'page';
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[shareDocs]
|
||||
);
|
||||
|
||||
const isFavorite = useCallback(
|
||||
(meta: DocMeta) => favourites.some(fav => fav.id === meta.id),
|
||||
[favourites]
|
||||
@@ -106,22 +101,41 @@ export const SelectPage = ({
|
||||
);
|
||||
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
const {
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filters,
|
||||
showFilter,
|
||||
updateFilters,
|
||||
filteredList,
|
||||
} = useFilter(
|
||||
pageMetas.map(meta => ({
|
||||
meta,
|
||||
publicMode: getPublicMode(meta.id),
|
||||
favorite: isFavorite(meta),
|
||||
}))
|
||||
);
|
||||
const [filters, setFilters] = useState<FilterParams[]>([]);
|
||||
|
||||
const [filteredDocIds, setFilteredDocIds] = useState<string[]>([]);
|
||||
const filteredPageMetas = useMemo(() => {
|
||||
const idSet = new Set(filteredDocIds);
|
||||
return pageMetas.filter(page => idSet.has(page.id));
|
||||
}, [pageMetas, filteredDocIds]);
|
||||
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
useSearch(filteredPageMetas);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = collectionRulesService
|
||||
.watch([
|
||||
...filters,
|
||||
{
|
||||
type: 'system',
|
||||
key: 'empty-journal',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
key: 'trash',
|
||||
method: 'is',
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
.subscribe(result => {
|
||||
setFilteredDocIds(result.groups.flatMap(group => group.items));
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [collectionRulesService, filters]);
|
||||
|
||||
const operationsRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
@@ -162,29 +176,21 @@ export const SelectPage = ({
|
||||
{t['com.affine.selectPage.title']()}
|
||||
</div>
|
||||
)}
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
{filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={docCollection.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
<AddFilterMenu
|
||||
onAdd={params => setFilters([...filters, params])}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
|
||||
<IconButton icon={<FilterIcon />} />
|
||||
</Menu>
|
||||
) : (
|
||||
<IconButton icon={<FilterIcon />} onClick={clickFilter} />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
{filters.length !== 0 ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={docCollection.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
<Filters filters={filters} onChange={setFilters} />
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
filterPageByRules,
|
||||
type PageDataForFilter,
|
||||
} from '../use-collection-manager';
|
||||
|
||||
export const useFilter = (list: PageDataForFilter[]) => {
|
||||
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const clickFilter = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (showFilter || filters.length !== 0) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowFilter(!showFilter);
|
||||
}
|
||||
},
|
||||
[filters.length, showFilter]
|
||||
);
|
||||
const onCreateFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
changeFilters([...filters, filter]);
|
||||
setShowFilter(true);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
return {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters: changeFilters,
|
||||
clickFilter,
|
||||
createFilter: onCreateFilter,
|
||||
filteredList: list
|
||||
.filter(pageData => {
|
||||
if (pageData.meta.trash) {
|
||||
return false;
|
||||
}
|
||||
return filterPageByRules(filters, [], pageData);
|
||||
})
|
||||
.map(pageData => pageData.meta),
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,13 @@
|
||||
import { toast, useConfirmModal } from '@affine/component';
|
||||
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
|
||||
import { CollectionService } from '@affine/core/modules/collection';
|
||||
import { type Collection } from '@affine/core/modules/collection';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import type { Tag } from '@affine/core/modules/tag';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import type { DocMeta } from '@blocksuite/affine/store';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
|
||||
import { usePageItemGroupDefinitions } from '../group-definitions';
|
||||
@@ -17,7 +16,6 @@ import { PageOperationCell } from '../operation-cell';
|
||||
import { PageListItemRenderer } from '../page-group';
|
||||
import { ListTableHeader } from '../page-header';
|
||||
import type { ItemListHandle, ListItem } from '../types';
|
||||
import { useFilteredPageMetas } from '../use-filtered-page-metas';
|
||||
import { VirtualizedList } from '../virtualized-list';
|
||||
import {
|
||||
CollectionPageListHeader,
|
||||
@@ -25,15 +23,14 @@ import {
|
||||
TagPageListHeader,
|
||||
} from './page-list-header';
|
||||
|
||||
const usePageOperationsRenderer = () => {
|
||||
const usePageOperationsRenderer = (collection?: Collection) => {
|
||||
const t = useI18n();
|
||||
const collectionService = useService(CollectionService);
|
||||
const removeFromAllowList = useCallback(
|
||||
(id: string) => {
|
||||
collectionService.deletePagesFromCollections([id]);
|
||||
collection?.removeDoc(id);
|
||||
toast(t['com.affine.collection.removePage.success']());
|
||||
},
|
||||
[collectionService, t]
|
||||
[collection, t]
|
||||
);
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: DocMeta, isInAllowList?: boolean) => {
|
||||
@@ -53,14 +50,12 @@ const usePageOperationsRenderer = () => {
|
||||
export const VirtualizedPageList = memo(function VirtualizedPageList({
|
||||
tag,
|
||||
collection,
|
||||
filters,
|
||||
listItem,
|
||||
setHideHeaderCreateNewPage,
|
||||
disableMultiDelete,
|
||||
}: {
|
||||
tag?: Tag;
|
||||
collection?: Collection;
|
||||
filters?: Filter[];
|
||||
listItem?: DocMeta[];
|
||||
setHideHeaderCreateNewPage?: (hide: boolean) => void;
|
||||
disableMultiDelete?: boolean;
|
||||
@@ -72,19 +67,28 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
const docsService = useService(DocsService);
|
||||
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
|
||||
const pageOperations = usePageOperationsRenderer();
|
||||
const pageOperations = usePageOperationsRenderer(collection);
|
||||
const pageHeaderColsDef = usePageHeaderColsDef();
|
||||
|
||||
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
|
||||
filters,
|
||||
collection,
|
||||
});
|
||||
const [filteredPageIds, setFilteredPageIds] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const subscription = collection?.watch().subscribe(docIds => {
|
||||
setFilteredPageIds(docIds);
|
||||
});
|
||||
return () => subscription?.unsubscribe();
|
||||
}, [collection]);
|
||||
const allowList = useLiveData(collection?.info$.map(info => info.allowList));
|
||||
const pageMetasToRender = useMemo(() => {
|
||||
if (listItem) {
|
||||
return listItem;
|
||||
}
|
||||
return filteredPageMetas;
|
||||
}, [filteredPageMetas, listItem]);
|
||||
if (collection) {
|
||||
return pageMetas.filter(
|
||||
page => filteredPageIds.includes(page.id) && !page.trash
|
||||
);
|
||||
}
|
||||
return pageMetas.filter(page => !page.trash);
|
||||
}, [collection, filteredPageIds, listItem, pageMetas]);
|
||||
|
||||
const filteredSelectedPageIds = useMemo(() => {
|
||||
const ids = new Set(pageMetasToRender.map(page => page.id));
|
||||
@@ -98,10 +102,10 @@ export const VirtualizedPageList = memo(function VirtualizedPageList({
|
||||
const pageOperationRenderer = useCallback(
|
||||
(item: ListItem) => {
|
||||
const page = item as DocMeta;
|
||||
const isInAllowList = collection?.allowList?.includes(page.id);
|
||||
const isInAllowList = allowList?.includes(page.id);
|
||||
return pageOperations(page, isInAllowList);
|
||||
},
|
||||
[collection, pageOperations]
|
||||
[allowList, pageOperations]
|
||||
);
|
||||
|
||||
const pageHeaderRenderer = useCallback(() => {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { Menu, MenuItem, Tooltip } from '@affine/component';
|
||||
import type { Filter, Literal, PropertiesMeta } from '@affine/env/filter';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FilterTag } from './filter-tag-translation';
|
||||
import * as styles from './index.css';
|
||||
import { literalMatcher } from './literal-matcher';
|
||||
import { tBoolean } from './logical/custom-type';
|
||||
import type { TFunction, TType } from './logical/typesystem';
|
||||
import { typesystem } from './logical/typesystem';
|
||||
import { variableDefineMap } from './shared-types';
|
||||
import { filterMatcher, VariableSelect, vars } from './vars';
|
||||
|
||||
export const Condition = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter;
|
||||
onChange: (filter: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const data = useMemo(() => {
|
||||
const data = filterMatcher.find(v => v.data.name === value.funcName);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const instance = typesystem.instance(
|
||||
{},
|
||||
[variableDefineMap[value.left.name].type(propertiesMeta)],
|
||||
tBoolean.create(),
|
||||
data.type
|
||||
);
|
||||
return {
|
||||
render: data.data.render,
|
||||
type: instance,
|
||||
};
|
||||
}, [propertiesMeta, value.funcName, value.left.name]);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
const render =
|
||||
data.render ??
|
||||
(({ ast }) => {
|
||||
const args = renderArgs(value, onChange, data.type);
|
||||
return (
|
||||
<div className={styles.filterContainerStyle}>
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
selected={[]}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-testid="variable-name"
|
||||
className={clsx(styles.filterTypeStyle, styles.ellipsisTextStyle)}
|
||||
>
|
||||
<Tooltip content={ast.left.name}>
|
||||
<div className={styles.filterTypeIconStyle}>
|
||||
{variableDefineMap[ast.left.name].icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<FilterTag name={ast.left.name} />
|
||||
</div>
|
||||
</Menu>
|
||||
<Menu
|
||||
items={
|
||||
<FunctionSelect
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)}
|
||||
data-testid="filter-name"
|
||||
>
|
||||
<FilterTag name={ast.funcName} />
|
||||
</div>
|
||||
</Menu>
|
||||
{args}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return <>{render({ ast: value })}</>;
|
||||
};
|
||||
|
||||
const FunctionSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
propertiesMeta,
|
||||
}: {
|
||||
value: Filter;
|
||||
onChange: (value: Filter) => void;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
}) => {
|
||||
const list = useMemo(() => {
|
||||
const type = vars.find(v => v.name === value.left.name)?.type;
|
||||
if (!type) {
|
||||
return [];
|
||||
}
|
||||
return filterMatcher.allMatchedData(type(propertiesMeta));
|
||||
}, [propertiesMeta, value.left.name]);
|
||||
return (
|
||||
<div data-testid="filter-name-select">
|
||||
{list.map(v => (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...value,
|
||||
funcName: v.name,
|
||||
args: v.defaultArgs().map(v => ({ type: 'literal', value: v })),
|
||||
});
|
||||
}}
|
||||
key={v.name}
|
||||
>
|
||||
<FilterTag name={v.name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Arg = ({
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
type: TType;
|
||||
value: Literal;
|
||||
onChange: (lit: Literal) => void;
|
||||
}) => {
|
||||
const data = useMemo(() => literalMatcher.match(type), [type]);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-testid="filter-arg"
|
||||
className={clsx(styles.argStyle, styles.ellipsisTextStyle)}
|
||||
>
|
||||
{data.render({
|
||||
type,
|
||||
value: value?.value,
|
||||
onChange: v => onChange({ type: 'literal', value: v }),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const renderArgs = (
|
||||
filter: Filter,
|
||||
onChange: (value: Filter) => void,
|
||||
type: TFunction
|
||||
): ReactNode => {
|
||||
const rest = type.args.slice(1);
|
||||
return rest.map((argType, i) => {
|
||||
const value = filter.args[i];
|
||||
return (
|
||||
<Arg
|
||||
key={`${argType.type}-${i}`}
|
||||
type={argType}
|
||||
value={value}
|
||||
onChange={value => {
|
||||
const args = type.args.map((_, index) =>
|
||||
i === index ? value : filter.args[index]
|
||||
);
|
||||
onChange({
|
||||
...filter,
|
||||
args,
|
||||
});
|
||||
}}
|
||||
></Arg>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const datePickerTriggerInput = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
width: '50px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '22px',
|
||||
textAlign: 'center',
|
||||
':hover': {
|
||||
background: cssVar('hoverColor'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { PopoverProps } from '@affine/component';
|
||||
import { DatePicker, Popover } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { datePickerTriggerInput } from './date-select.css';
|
||||
|
||||
const datePickerPopperContentOptions: PopoverProps['contentOptions'] = {
|
||||
style: { padding: 20, marginTop: 10 },
|
||||
};
|
||||
|
||||
export const DateSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onDateChange = useCallback(
|
||||
(e: string) => {
|
||||
setOpen(false);
|
||||
onChange(dayjs(e, 'YYYY-MM-DD').valueOf());
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
contentOptions={datePickerPopperContentOptions}
|
||||
content={
|
||||
<DatePicker
|
||||
weekDays={t['com.affine.calendar-date-picker.week-days']()}
|
||||
monthNames={t['com.affine.calendar-date-picker.month-names']()}
|
||||
todayLabel={t['com.affine.calendar-date-picker.today']()}
|
||||
value={dayjs(value as number).format('YYYY-MM-DD')}
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<input
|
||||
value={dayjs(value as number).format('MMM DD')}
|
||||
className={datePickerTriggerInput}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Filter, Literal, Ref, VariableMap } from '@affine/env/filter';
|
||||
|
||||
import { filterMatcher } from './vars';
|
||||
|
||||
const evalRef = (ref: Ref, variableMap: VariableMap) => {
|
||||
return variableMap[ref.name];
|
||||
};
|
||||
const evalLiteral = (lit?: Literal) => {
|
||||
return lit?.value;
|
||||
};
|
||||
const evalFilter = (filter: Filter, variableMap: VariableMap): boolean => {
|
||||
const impl = filterMatcher.findData(v => v.name === filter.funcName)?.impl;
|
||||
if (!impl) {
|
||||
throw new Error('No function implementation found');
|
||||
}
|
||||
const leftValue = evalRef(filter.left, variableMap);
|
||||
const args = filter.args.map(evalLiteral);
|
||||
return impl(leftValue, ...args);
|
||||
};
|
||||
export const evalFilterList = (
|
||||
filterList: Filter[],
|
||||
variableMap: VariableMap
|
||||
) => {
|
||||
return filterList.every(filter => evalFilter(filter, variableMap));
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user