@nocobase/app-template-default 1.0.0-beta.59 → 1.0.0-beta.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/README.MD +2 -0
- package/client/components/ui/sidebar.tsx +1 -1
- package/client/locales/en-US.ts +8 -178
- package/client/locales/system/en-US.ts +344 -0
- package/client/locales/system/zh-CN.ts +320 -0
- package/client/locales/zh-CN.ts +3 -165
- package/client/pages/home/capabilities.ts +52 -0
- package/client/pages/home/index.tsx +190 -0
- package/client/pages/home/prompt-dialog.tsx +122 -0
- package/client/pages/home/use-copy.ts +38 -0
- package/client/plugins.ts +2 -0
- package/client/routes.ts +1 -1
- package/config.example.yml +5 -0
- package/package.json +7 -5
- package/server/config/index.ts +3 -0
- package/server/config/mail.ts +3 -0
- package/server/plugins.ts +2 -0
- package/tests/components/home.test.tsx +174 -0
- package/client/pages/home.tsx +0 -89
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
+
import {
|
|
3
|
+
ArrowRightIcon,
|
|
4
|
+
CheckIcon,
|
|
5
|
+
CopyIcon,
|
|
6
|
+
MessageSquareTextIcon,
|
|
7
|
+
SparklesIcon,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import { type ReactElement, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import { PageContainer } from '#components/page-container';
|
|
12
|
+
import { Button } from '#components/ui/button';
|
|
13
|
+
import {
|
|
14
|
+
Card,
|
|
15
|
+
CardDescription,
|
|
16
|
+
CardFooter,
|
|
17
|
+
CardHeader,
|
|
18
|
+
CardTitle,
|
|
19
|
+
} from '#components/ui/card';
|
|
20
|
+
|
|
21
|
+
import { CAPABILITIES, type Capability } from './capabilities.js';
|
|
22
|
+
import { PromptDialog } from './prompt-dialog.js';
|
|
23
|
+
import { useCopy } from './use-copy.js';
|
|
24
|
+
|
|
25
|
+
const STEPS = ['describe', 'build', 'review'] as const;
|
|
26
|
+
|
|
27
|
+
export default function HomePage(): ReactElement {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [selected, setSelected] = useState<Capability>();
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
// Changes on every opening, so the dialog remounts and drops the edits of the previous one.
|
|
32
|
+
const [openCount, setOpenCount] = useState(0);
|
|
33
|
+
|
|
34
|
+
const openCapability = (capability: Capability): void => {
|
|
35
|
+
setSelected(capability);
|
|
36
|
+
setOpenCount((count) => count + 1);
|
|
37
|
+
setOpen(true);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<PageContainer className='mx-auto max-w-6xl'>
|
|
42
|
+
<AgentIntro />
|
|
43
|
+
<section
|
|
44
|
+
aria-labelledby='home-capabilities'
|
|
45
|
+
className='flex flex-col gap-4'
|
|
46
|
+
>
|
|
47
|
+
<div className='flex flex-col gap-1'>
|
|
48
|
+
<h2 id='home-capabilities' className='text-base font-medium'>
|
|
49
|
+
{t('home.capabilitiesTitle')}
|
|
50
|
+
</h2>
|
|
51
|
+
<p className='text-sm text-muted-foreground'>
|
|
52
|
+
{t('home.capabilitiesDescription')}
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
<ul className='grid gap-4 sm:grid-cols-2 xl:grid-cols-3'>
|
|
56
|
+
{CAPABILITIES.map((capability) => (
|
|
57
|
+
<li key={capability.id} className='flex'>
|
|
58
|
+
<CapabilityCard
|
|
59
|
+
capability={capability}
|
|
60
|
+
onOpen={() => openCapability(capability)}
|
|
61
|
+
/>
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
</section>
|
|
66
|
+
{selected ? (
|
|
67
|
+
<PromptDialog
|
|
68
|
+
key={openCount}
|
|
69
|
+
capability={selected}
|
|
70
|
+
open={open}
|
|
71
|
+
onOpenChange={setOpen}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
</PageContainer>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The first part of the page: development here happens by asking a coding agent, which the steps and sample show. */
|
|
79
|
+
function AgentIntro(): ReactElement {
|
|
80
|
+
const { t } = useTranslation();
|
|
81
|
+
const { copied, copy } = useCopy();
|
|
82
|
+
const example = t('home.example.text');
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<section className='grid gap-8 rounded-2xl border border-border bg-linear-to-br from-primary/5 via-card to-card p-6 md:p-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center'>
|
|
86
|
+
<div className='flex flex-col gap-6'>
|
|
87
|
+
<div className='flex flex-col gap-4'>
|
|
88
|
+
<p className='flex items-center gap-2 text-sm font-medium text-primary'>
|
|
89
|
+
<SparklesIcon className='size-4' aria-hidden />
|
|
90
|
+
{t('home.platform')}
|
|
91
|
+
</p>
|
|
92
|
+
<h1 className='font-heading text-3xl font-semibold tracking-[-0.035em] text-balance md:text-4xl'>
|
|
93
|
+
{t('home.title')}
|
|
94
|
+
</h1>
|
|
95
|
+
<p className='text-base leading-7 text-muted-foreground'>
|
|
96
|
+
{t('home.description')}
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
<ol className='flex flex-col gap-3'>
|
|
100
|
+
{STEPS.map((step, index) => (
|
|
101
|
+
<li key={step} className='flex gap-3'>
|
|
102
|
+
<span className='flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary'>
|
|
103
|
+
{index + 1}
|
|
104
|
+
</span>
|
|
105
|
+
<div className='flex flex-col gap-0.5'>
|
|
106
|
+
<span className='text-sm font-medium'>
|
|
107
|
+
{t(`home.steps.${step}.title`)}
|
|
108
|
+
</span>
|
|
109
|
+
<span className='text-sm text-muted-foreground'>
|
|
110
|
+
{t(`home.steps.${step}.description`)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</ol>
|
|
116
|
+
</div>
|
|
117
|
+
<figure className='flex flex-col overflow-hidden rounded-xl border border-primary/10 bg-card shadow-sm'>
|
|
118
|
+
<figcaption className='flex items-center justify-between gap-2 border-b border-primary/10 px-4 py-2.5'>
|
|
119
|
+
<span className='flex items-center gap-2 text-sm font-medium'>
|
|
120
|
+
<MessageSquareTextIcon
|
|
121
|
+
className='size-4 text-primary'
|
|
122
|
+
aria-hidden
|
|
123
|
+
/>
|
|
124
|
+
{t('home.example.title')}
|
|
125
|
+
</span>
|
|
126
|
+
<Button variant='ghost' size='sm' onClick={() => void copy(example)}>
|
|
127
|
+
{copied ? (
|
|
128
|
+
<CheckIcon data-icon='inline-start' />
|
|
129
|
+
) : (
|
|
130
|
+
<CopyIcon data-icon='inline-start' />
|
|
131
|
+
)}
|
|
132
|
+
{copied ? t('home.prompt.copiedShort') : t('home.prompt.copy')}
|
|
133
|
+
</Button>
|
|
134
|
+
</figcaption>
|
|
135
|
+
<p className='bg-primary/5 px-4 py-4 text-sm leading-7 whitespace-pre-line'>
|
|
136
|
+
{example}
|
|
137
|
+
</p>
|
|
138
|
+
<p className='border-t border-primary/10 px-4 py-2.5 text-xs text-muted-foreground'>
|
|
139
|
+
{t('home.example.hint')}
|
|
140
|
+
</p>
|
|
141
|
+
</figure>
|
|
142
|
+
</section>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function CapabilityCard({
|
|
147
|
+
capability,
|
|
148
|
+
onOpen,
|
|
149
|
+
}: {
|
|
150
|
+
readonly capability: Capability;
|
|
151
|
+
readonly onOpen: () => void;
|
|
152
|
+
}): ReactElement {
|
|
153
|
+
const { t } = useTranslation();
|
|
154
|
+
const prefix = `home.capabilities.${capability.id}`;
|
|
155
|
+
const Icon = capability.icon;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
type='button'
|
|
160
|
+
onClick={onOpen}
|
|
161
|
+
className='group flex w-full cursor-pointer rounded-xl text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50'
|
|
162
|
+
>
|
|
163
|
+
<Card className='w-full transition-[translate,box-shadow] duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md group-hover:ring-foreground/20 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0'>
|
|
164
|
+
<CardHeader className='gap-3'>
|
|
165
|
+
{/* The chip only deepens its tint on hover rather than inverting: swapping a light background and a dark icon
|
|
166
|
+
passes both through the same color halfway, where the icon vanishes for a moment and reads as a flicker. */}
|
|
167
|
+
<span className='flex size-9 items-center justify-center rounded-lg bg-primary/5 text-primary ring-1 ring-transparent transition-[background-color,box-shadow] duration-200 ease-out group-hover:bg-primary/10 group-hover:ring-primary/20 motion-reduce:transition-none'>
|
|
168
|
+
<Icon
|
|
169
|
+
className='size-4 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none'
|
|
170
|
+
aria-hidden
|
|
171
|
+
/>
|
|
172
|
+
</span>
|
|
173
|
+
<div className='flex flex-col gap-1'>
|
|
174
|
+
<CardTitle>{t(`${prefix}.title`)}</CardTitle>
|
|
175
|
+
<CardDescription className='leading-6'>
|
|
176
|
+
{t(`${prefix}.description`)}
|
|
177
|
+
</CardDescription>
|
|
178
|
+
</div>
|
|
179
|
+
</CardHeader>
|
|
180
|
+
<CardFooter className='mt-auto justify-between text-sm text-muted-foreground transition-colors duration-200 ease-out group-hover:text-foreground'>
|
|
181
|
+
{t('home.viewPrompts', { count: capability.prompts.length })}
|
|
182
|
+
<ArrowRightIcon
|
|
183
|
+
className='size-4 transition-transform duration-200 ease-out group-hover:translate-x-1 motion-reduce:transition-none'
|
|
184
|
+
aria-hidden
|
|
185
|
+
/>
|
|
186
|
+
</CardFooter>
|
|
187
|
+
</Card>
|
|
188
|
+
</button>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
+
import { CheckIcon, CopyIcon, RotateCcwIcon } from 'lucide-react';
|
|
3
|
+
import { type ReactElement, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Button } from '#components/ui/button';
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogClose,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from '#components/ui/dialog';
|
|
15
|
+
import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
|
|
16
|
+
import { Textarea } from '#components/ui/textarea';
|
|
17
|
+
|
|
18
|
+
import type { Capability } from './capabilities.js';
|
|
19
|
+
import { useCopy } from './use-copy.js';
|
|
20
|
+
|
|
21
|
+
export interface PromptDialogProps {
|
|
22
|
+
readonly capability: Capability;
|
|
23
|
+
readonly open: boolean;
|
|
24
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The prompts of one capability, ready to hand to a coding agent. Each prompt can be edited before copying. Edits are
|
|
29
|
+
* not saved: the page mounts a new dialog each time one is opened, so it starts from the default wording again.
|
|
30
|
+
*/
|
|
31
|
+
export function PromptDialog({
|
|
32
|
+
capability,
|
|
33
|
+
open,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
}: PromptDialogProps): ReactElement {
|
|
36
|
+
const { t } = useTranslation();
|
|
37
|
+
const { copied, copy } = useCopy();
|
|
38
|
+
const [current, setCurrent] = useState(capability.prompts[0]);
|
|
39
|
+
// Edited text per prompt id; a prompt without an entry shows its default wording.
|
|
40
|
+
const [edits, setEdits] = useState<Readonly<Record<string, string>>>({});
|
|
41
|
+
|
|
42
|
+
const prefix = `home.capabilities.${capability.id}`;
|
|
43
|
+
const defaultText = t(`${prefix}.prompts.${current}.text`);
|
|
44
|
+
const text = edits[current] ?? defaultText;
|
|
45
|
+
const Icon = capability.icon;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
49
|
+
<DialogContent className='gap-5 sm:max-w-2xl'>
|
|
50
|
+
<DialogHeader className='flex-row items-center gap-3 pr-8'>
|
|
51
|
+
<span className='flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/5 text-primary'>
|
|
52
|
+
<Icon className='size-5' aria-hidden />
|
|
53
|
+
</span>
|
|
54
|
+
<div className='flex min-w-0 flex-col gap-1.5'>
|
|
55
|
+
<DialogTitle>{t(`${prefix}.title`)}</DialogTitle>
|
|
56
|
+
<DialogDescription>{t(`${prefix}.description`)}</DialogDescription>
|
|
57
|
+
</div>
|
|
58
|
+
</DialogHeader>
|
|
59
|
+
<div className='flex flex-col gap-3'>
|
|
60
|
+
{capability.prompts.length > 1 ? (
|
|
61
|
+
<Tabs
|
|
62
|
+
value={current}
|
|
63
|
+
onValueChange={(value) => {
|
|
64
|
+
if (typeof value === 'string') setCurrent(value);
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<TabsList className='w-full'>
|
|
68
|
+
{capability.prompts.map((id) => (
|
|
69
|
+
<TabsTrigger key={id} value={id}>
|
|
70
|
+
{t(`${prefix}.prompts.${id}.label`)}
|
|
71
|
+
</TabsTrigger>
|
|
72
|
+
))}
|
|
73
|
+
</TabsList>
|
|
74
|
+
</Tabs>
|
|
75
|
+
) : null}
|
|
76
|
+
<Textarea
|
|
77
|
+
aria-label={t('home.prompt.label')}
|
|
78
|
+
className='field-sizing-fixed h-60 max-h-[50dvh] resize-none leading-6'
|
|
79
|
+
value={text}
|
|
80
|
+
onChange={(event) =>
|
|
81
|
+
setEdits((previous) => ({
|
|
82
|
+
...previous,
|
|
83
|
+
[current]: event.target.value,
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
/>
|
|
87
|
+
<p className='text-xs text-muted-foreground'>
|
|
88
|
+
{t('home.prompt.hint')}
|
|
89
|
+
</p>
|
|
90
|
+
</div>
|
|
91
|
+
<DialogFooter className='sm:items-center'>
|
|
92
|
+
<Button
|
|
93
|
+
variant='ghost'
|
|
94
|
+
className='sm:mr-auto'
|
|
95
|
+
disabled={text === defaultText}
|
|
96
|
+
onClick={() =>
|
|
97
|
+
setEdits((previous) => {
|
|
98
|
+
const rest = { ...previous };
|
|
99
|
+
delete rest[current];
|
|
100
|
+
return rest;
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
>
|
|
104
|
+
<RotateCcwIcon data-icon='inline-start' />
|
|
105
|
+
{t('home.prompt.reset')}
|
|
106
|
+
</Button>
|
|
107
|
+
<DialogClose render={<Button variant='outline' />}>
|
|
108
|
+
{t('actions.close')}
|
|
109
|
+
</DialogClose>
|
|
110
|
+
<Button disabled={text.trim() === ''} onClick={() => void copy(text)}>
|
|
111
|
+
{copied ? (
|
|
112
|
+
<CheckIcon data-icon='inline-start' />
|
|
113
|
+
) : (
|
|
114
|
+
<CopyIcon data-icon='inline-start' />
|
|
115
|
+
)}
|
|
116
|
+
{copied ? t('home.prompt.copiedShort') : t('home.prompt.copy')}
|
|
117
|
+
</Button>
|
|
118
|
+
</DialogFooter>
|
|
119
|
+
</DialogContent>
|
|
120
|
+
</Dialog>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useToaster } from '@nocobase/app-client';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface CopyState {
|
|
6
|
+
/** Whether the last copy succeeded within the past two seconds, for a button to show a check mark. */
|
|
7
|
+
readonly copied: boolean;
|
|
8
|
+
readonly copy: (text: string) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Copies text to the clipboard and reports the outcome in a toast. */
|
|
12
|
+
export function useCopy(): CopyState {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const toaster = useToaster();
|
|
15
|
+
const [copied, setCopied] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!copied) return;
|
|
19
|
+
const timer = setTimeout(() => setCopied(false), 2000);
|
|
20
|
+
return () => clearTimeout(timer);
|
|
21
|
+
}, [copied]);
|
|
22
|
+
|
|
23
|
+
const copy = useCallback(
|
|
24
|
+
async (text: string): Promise<void> => {
|
|
25
|
+
try {
|
|
26
|
+
await navigator.clipboard.writeText(text);
|
|
27
|
+
setCopied(true);
|
|
28
|
+
toaster.show({ type: 'success', title: t('home.prompt.copied') });
|
|
29
|
+
} catch {
|
|
30
|
+
// The Clipboard API is missing outside a secure context and rejects without permission.
|
|
31
|
+
toaster.show({ type: 'error', title: t('home.prompt.copyFailed') });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
[t, toaster],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return { copied, copy };
|
|
38
|
+
}
|
package/client/plugins.ts
CHANGED
|
@@ -15,6 +15,7 @@ import i18n from '@nocobase/app-plugin-i18n/client';
|
|
|
15
15
|
import notification from '@nocobase/app-plugin-notification/client';
|
|
16
16
|
import scheduler from '@nocobase/app-plugin-scheduler/client';
|
|
17
17
|
import file from '@nocobase/app-plugin-file/client';
|
|
18
|
+
import mail from '@nocobase/app-plugin-mail/client';
|
|
18
19
|
|
|
19
20
|
// Array order is contribution order. A plugin is enabled by appearing in this
|
|
20
21
|
// list; removing its entry and its import disables it.
|
|
@@ -32,6 +33,7 @@ const clientPlugins: AppClientPlugins = defineClientPlugins([
|
|
|
32
33
|
notification(),
|
|
33
34
|
file(),
|
|
34
35
|
scheduler(),
|
|
36
|
+
mail(),
|
|
35
37
|
]);
|
|
36
38
|
|
|
37
39
|
export default clientPlugins;
|
package/client/routes.ts
CHANGED
|
@@ -10,7 +10,7 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
10
10
|
// no permission change can leave a user signed in with nowhere to land.
|
|
11
11
|
authz: 'skip',
|
|
12
12
|
auth: 'required',
|
|
13
|
-
componentLoader: () => import('./pages/home.js'),
|
|
13
|
+
componentLoader: () => import('./pages/home/index.js'),
|
|
14
14
|
name: 'home',
|
|
15
15
|
navigation: { title: 'navigation.home', icon: Home },
|
|
16
16
|
path: '/',
|
package/config.example.yml
CHANGED
|
@@ -137,6 +137,11 @@ snowflake:
|
|
|
137
137
|
# scheduler:
|
|
138
138
|
# jobs: redis
|
|
139
139
|
|
|
140
|
+
# Mail is registered by default. Configure mailbox provider instances before connecting accounts.
|
|
141
|
+
# See .agents/skills/nocobase-app-plugin-mail/references/configuration-and-accounts.md.
|
|
142
|
+
mail:
|
|
143
|
+
providers: {}
|
|
144
|
+
|
|
140
145
|
# File logging is independent of terminal formatting.
|
|
141
146
|
logging:
|
|
142
147
|
level: info
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/app-template-default",
|
|
3
3
|
"displayName": "Default Template",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.61",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"prettier": "@nocobase/dev-config/prettier",
|
|
7
7
|
"engines": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"nocobase": {
|
|
11
11
|
"templateKind": "app",
|
|
12
|
-
"defaultTemplateVersion": "1.0.0-beta.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.61"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "2.0.0",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"tar": "^7.5.22",
|
|
78
78
|
"@nocobase/app-client": "^3.0.0-beta.0",
|
|
79
79
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.1",
|
|
80
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
80
|
+
"@nocobase/app-skills": "0.1.0-beta.29",
|
|
81
81
|
"@nocobase/app-testing": "0.1.0-beta.2",
|
|
82
82
|
"@nocobase/app-websocket": "^0.0.1",
|
|
83
83
|
"@nocobase/dev-config": "0.1.0-beta.21",
|
|
@@ -142,11 +142,12 @@
|
|
|
142
142
|
"better-auth": "1.7.5",
|
|
143
143
|
"hono": "^4.13.2",
|
|
144
144
|
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.14",
|
|
145
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.
|
|
145
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.4",
|
|
146
146
|
"@nocobase/app-plugin-authorization": "^1.0.0-beta.26",
|
|
147
147
|
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.12",
|
|
148
148
|
"@nocobase/app-plugin-file": "^1.0.0-beta.22",
|
|
149
149
|
"@nocobase/app-plugin-i18n": "^1.0.0-beta.14",
|
|
150
|
+
"@nocobase/app-plugin-mail": "^1.0.0-beta.10",
|
|
150
151
|
"@nocobase/app-plugin-notification": "^1.0.0-beta.25",
|
|
151
152
|
"@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.23",
|
|
152
153
|
"@nocobase/app-plugin-notification-providers": "^0.2.0-beta.10",
|
|
@@ -164,10 +165,11 @@
|
|
|
164
165
|
"@nocobase/authorization": "^1.0.0-beta.12",
|
|
165
166
|
"@nocobase/queue": "^0.1.0-beta.8",
|
|
166
167
|
"@nocobase/jobs": "^0.1.0-beta.2",
|
|
168
|
+
"@nocobase/lifecycle": "^0.1.0-beta.0",
|
|
167
169
|
"@nocobase/i18n": "^1.0.0-beta.6",
|
|
168
170
|
"@nocobase/caching": "^0.1.0-beta.2",
|
|
169
171
|
"@nocobase/repository-input": "^0.1.0-beta.2",
|
|
170
|
-
"@nocobase/app-cli": "^1.0.0-beta.
|
|
172
|
+
"@nocobase/app-cli": "^1.0.0-beta.18"
|
|
171
173
|
},
|
|
172
174
|
"description": "NocoBase 3 application template with the standard product capabilities.",
|
|
173
175
|
"imports": {
|
package/server/config/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import auth from './auth.js';
|
|
6
6
|
import authorization from './authorization.js';
|
|
7
7
|
import notification from './notification.js';
|
|
8
|
+
import mail from './mail.js';
|
|
8
9
|
import secrets from './secrets.js';
|
|
9
10
|
import session from './session.js';
|
|
10
11
|
import server from './server.js';
|
|
@@ -26,6 +27,7 @@ const defaultConfigs: AppConfigFactory<{
|
|
|
26
27
|
auth: ReturnType<typeof auth>;
|
|
27
28
|
authorization: ReturnType<typeof authorization>;
|
|
28
29
|
notification: ReturnType<typeof notification>;
|
|
30
|
+
mail: ReturnType<typeof mail>;
|
|
29
31
|
secrets: ReturnType<typeof secrets>;
|
|
30
32
|
session: ReturnType<typeof session>;
|
|
31
33
|
server: ReturnType<typeof server>;
|
|
@@ -46,6 +48,7 @@ const defaultConfigs: AppConfigFactory<{
|
|
|
46
48
|
auth,
|
|
47
49
|
authorization,
|
|
48
50
|
notification,
|
|
51
|
+
mail,
|
|
49
52
|
secrets,
|
|
50
53
|
session,
|
|
51
54
|
server,
|
package/server/plugins.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@nocobase/app-server/plugins';
|
|
17
17
|
import scheduler from '@nocobase/app-plugin-scheduler/server';
|
|
18
18
|
import file from '@nocobase/app-plugin-file/server';
|
|
19
|
+
import mail from '@nocobase/app-plugin-mail/server';
|
|
19
20
|
|
|
20
21
|
const serverPlugins: AppServerPlugins = defineServerPlugins([
|
|
21
22
|
authentication,
|
|
@@ -32,6 +33,7 @@ const serverPlugins: AppServerPlugins = defineServerPlugins([
|
|
|
32
33
|
notificationProviders,
|
|
33
34
|
file,
|
|
34
35
|
scheduler,
|
|
36
|
+
mail,
|
|
35
37
|
]);
|
|
36
38
|
|
|
37
39
|
export default serverPlugins;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TestI18nProvider,
|
|
3
|
+
createTestI18nRuntime,
|
|
4
|
+
} from '@nocobase/i18n/testing';
|
|
5
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import enUS from '../../client/locales/en-US.js';
|
|
11
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
12
|
+
import HomePage from '../../client/pages/home/index.js';
|
|
13
|
+
import { CAPABILITIES } from '../../client/pages/home/capabilities.js';
|
|
14
|
+
|
|
15
|
+
const { toaster } = vi.hoisted(() => ({
|
|
16
|
+
toaster: { show: vi.fn(), close: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('@nocobase/app-client', async (original) => ({
|
|
20
|
+
...(await original<typeof import('@nocobase/app-client')>()),
|
|
21
|
+
useToaster: () => toaster,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Strict: a key the locale lacks fails the test instead of rendering as text.
|
|
25
|
+
const runtime = await createTestI18nRuntime({
|
|
26
|
+
application: {
|
|
27
|
+
namespace: '@nocobase/app-template-default',
|
|
28
|
+
resources: enUS,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function I18n({ children }: { readonly children: ReactNode }): ReactElement {
|
|
33
|
+
return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const writeText = vi.fn<(text: string) => Promise<void>>();
|
|
37
|
+
const clipboard = { fail: false };
|
|
38
|
+
|
|
39
|
+
/** `userEvent.setup()` installs a clipboard stub of its own, so the mock goes in after it. */
|
|
40
|
+
function setup(): ReturnType<typeof userEvent.setup> {
|
|
41
|
+
const user = userEvent.setup();
|
|
42
|
+
writeText
|
|
43
|
+
.mockReset()
|
|
44
|
+
.mockImplementation(() =>
|
|
45
|
+
clipboard.fail ? Promise.reject(new Error('denied')) : Promise.resolve(),
|
|
46
|
+
);
|
|
47
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
48
|
+
configurable: true,
|
|
49
|
+
value: { writeText },
|
|
50
|
+
});
|
|
51
|
+
return user;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('home page', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
toaster.show.mockReset();
|
|
57
|
+
clipboard.fail = false;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('tells people to build by asking their coding agent', () => {
|
|
61
|
+
render(<HomePage />, { wrapper: I18n });
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
screen.getByRole('heading', { level: 1, name: enUS.home.title }),
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText(enUS.home.steps.describe.title)).toBeVisible();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows one card per built-in capability', () => {
|
|
70
|
+
render(<HomePage />, { wrapper: I18n });
|
|
71
|
+
|
|
72
|
+
for (const { id } of CAPABILITIES) {
|
|
73
|
+
const title =
|
|
74
|
+
enUS.home.capabilities[id as keyof typeof enUS.home.capabilities].title;
|
|
75
|
+
expect(
|
|
76
|
+
screen.getByRole('button', { name: new RegExp(title) }),
|
|
77
|
+
).toBeInTheDocument();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('opens a capability, switches prompts, and copies the edited text', async () => {
|
|
82
|
+
const user = setup();
|
|
83
|
+
render(<HomePage />, { wrapper: I18n });
|
|
84
|
+
const scheduler = enUS.home.capabilities.scheduler;
|
|
85
|
+
|
|
86
|
+
await user.click(
|
|
87
|
+
screen.getByRole('button', { name: new RegExp(scheduler.title) }),
|
|
88
|
+
);
|
|
89
|
+
const dialog = await screen.findByRole('dialog');
|
|
90
|
+
const prompt = within(dialog).getByRole('textbox', {
|
|
91
|
+
name: enUS.home.prompt.label,
|
|
92
|
+
});
|
|
93
|
+
expect(prompt).toHaveValue(scheduler.prompts.reminder.text);
|
|
94
|
+
|
|
95
|
+
await user.click(
|
|
96
|
+
within(dialog).getByRole('tab', {
|
|
97
|
+
name: scheduler.prompts.weeklyReport.label,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(prompt).toHaveValue(scheduler.prompts.weeklyReport.text);
|
|
101
|
+
|
|
102
|
+
await user.clear(prompt);
|
|
103
|
+
await user.type(prompt, 'Every Friday');
|
|
104
|
+
await user.click(
|
|
105
|
+
within(dialog).getByRole('button', { name: enUS.home.prompt.copy }),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(writeText).toHaveBeenCalledWith('Every Friday');
|
|
109
|
+
expect(toaster.show).toHaveBeenCalledWith({
|
|
110
|
+
type: 'success',
|
|
111
|
+
title: enUS.home.prompt.copied,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await user.click(
|
|
115
|
+
within(dialog).getByRole('button', { name: enUS.home.prompt.reset }),
|
|
116
|
+
);
|
|
117
|
+
expect(prompt).toHaveValue(scheduler.prompts.weeklyReport.text);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('starts from the default prompt each time the dialog opens', async () => {
|
|
121
|
+
const user = setup();
|
|
122
|
+
render(<HomePage />, { wrapper: I18n });
|
|
123
|
+
const theme = enUS.home.capabilities.theme;
|
|
124
|
+
const card = screen.getByRole('button', { name: new RegExp(theme.title) });
|
|
125
|
+
|
|
126
|
+
await user.click(card);
|
|
127
|
+
const prompt = within(await screen.findByRole('dialog')).getByRole(
|
|
128
|
+
'textbox',
|
|
129
|
+
);
|
|
130
|
+
await user.type(prompt, ' Edited.');
|
|
131
|
+
await user.keyboard('{Escape}');
|
|
132
|
+
await waitFor(() =>
|
|
133
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await user.click(card);
|
|
137
|
+
expect(
|
|
138
|
+
within(await screen.findByRole('dialog')).getByRole('textbox'),
|
|
139
|
+
).toHaveValue(theme.prompts.changeTheme.text);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('reports a copy the browser refuses', async () => {
|
|
143
|
+
clipboard.fail = true;
|
|
144
|
+
const user = setup();
|
|
145
|
+
render(<HomePage />, { wrapper: I18n });
|
|
146
|
+
|
|
147
|
+
await user.click(
|
|
148
|
+
screen.getAllByRole('button', { name: enUS.home.prompt.copy })[0],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(toaster.show).toHaveBeenCalledWith({
|
|
152
|
+
type: 'error',
|
|
153
|
+
title: enUS.home.prompt.copyFailed,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('home page copy', () => {
|
|
159
|
+
it('translates every capability prompt into Chinese', () => {
|
|
160
|
+
for (const { id, prompts } of CAPABILITIES) {
|
|
161
|
+
const key = id as keyof typeof zhCN.home.capabilities;
|
|
162
|
+
for (const prompt of prompts) {
|
|
163
|
+
const zh = (
|
|
164
|
+
zhCN.home.capabilities[key].prompts as Record<
|
|
165
|
+
string,
|
|
166
|
+
{ label: string; text: string }
|
|
167
|
+
>
|
|
168
|
+
)[prompt];
|
|
169
|
+
expect(zh?.text, `${id}.${prompt}`).toBeTruthy();
|
|
170
|
+
expect(zh?.label, `${id}.${prompt}`).toBeTruthy();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|