Skip to content

Commit 5eb1278

Browse files
committed
Merge workspace templates
2 parents f7ea555 + c7c4a83 commit 5eb1278

41 files changed

Lines changed: 4164 additions & 1843 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/openapi.json

Lines changed: 1052 additions & 853 deletions
Large diffs are not rendered by default.

api/openapi.yaml

Lines changed: 975 additions & 848 deletions
Large diffs are not rendered by default.

frontend/src/lib/api/workspaces.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const workspaces = {
1818
}),
1919
getStatuses: (id) => fetchAPI(`/workspaces/${id}/statuses`),
2020
getItemTypes: (id) => fetchAPI(`/workspaces/${id}/item-types`),
21+
getTemplates: () => fetchAPI('/workspace-templates'),
2122
// Allowed status transitions for every (item_type_id, status_id) pair in the
2223
// workspace, keyed "<itemTypeId>:<statusId>". One request replaces the
2324
// board's per-pair /items/{id}/available-status-transitions preload.

frontend/src/lib/dialogs/CreateModal.svelte

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,15 @@
8787
let workspaceFormData = $state({
8888
name: '',
8989
key: '',
90-
description: ''
90+
description: '',
91+
template_workspace_id: null
9192
});
9293
94+
let workspaceTemplateOptions = $state([]);
95+
let workspaceTemplatesLoading = $state(false);
96+
let workspaceTemplatesError = $state(null);
97+
let workspaceTemplatesLoaded = $state(false);
98+
9399
let collectionFormData = $state({
94100
name: '',
95101
description: '',
@@ -147,7 +153,8 @@
147153
workspaceFormData = {
148154
name: '',
149155
key: '',
150-
description: ''
156+
description: '',
157+
template_workspace_id: null
151158
};
152159
153160
collectionFormData = {
@@ -167,6 +174,23 @@
167174
}
168175
}
169176
177+
async function loadWorkspaceTemplates() {
178+
if (workspaceTemplatesLoaded || workspaceTemplatesLoading) return;
179+
workspaceTemplatesLoading = true;
180+
workspaceTemplatesError = null;
181+
try {
182+
const templates = await api.workspaces.getTemplates();
183+
workspaceTemplateOptions = Array.isArray(templates) ? templates : [];
184+
workspaceTemplatesLoaded = true;
185+
} catch (error) {
186+
console.error('Failed to load workspace templates:', error);
187+
workspaceTemplateOptions = [];
188+
workspaceTemplatesError = error?.message || String(error);
189+
} finally {
190+
workspaceTemplatesLoading = false;
191+
}
192+
}
193+
170194
async function uploadPendingDescriptionImages(itemId, description) {
171195
if (workItemFormStore.pendingDescriptionImages.length === 0) {
172196
return description;
@@ -247,14 +271,18 @@
247271
navigate('/milestones');
248272
close();
249273
} else if (selectedType === 'workspace') {
250-
const result = await api.workspaces.create({
274+
const payload = {
251275
name: workspaceFormData.name,
252276
key: workspaceFormData.key,
253277
description: workspaceFormData.description || '',
254278
icon: 'Package',
255279
color: '#3b82f6',
256280
active: true
257-
});
281+
};
282+
if (workspaceFormData.template_workspace_id) {
283+
payload.template_workspace_id = workspaceFormData.template_workspace_id;
284+
}
285+
const result = await api.workspaces.create(payload);
258286
259287
window.dispatchEvent(new CustomEvent('refresh-workspaces'));
260288
if (!skipNavigate) {
@@ -318,6 +346,18 @@
318346
}
319347
});
320348
349+
// Load template summaries when the workspace form opens for a user who may
350+
// create workspaces. Blank creation works without them.
351+
$effect(() => {
352+
if (
353+
isOpen &&
354+
selectedType === 'workspace' &&
355+
($permissionStore.userPermissionKeys?.has('workspace.create') || $isSystemAdmin)
356+
) {
357+
loadWorkspaceTemplates();
358+
}
359+
});
360+
321361
// Sync selectedType when initialType prop changes (e.g. before modal opens)
322362
$effect(() => {
323363
selectedType = initialType;
@@ -507,6 +547,9 @@
507547
<WorkspaceForm
508548
bind:this={workspaceFormRef}
509549
bind:formData={workspaceFormData}
550+
templates={workspaceTemplateOptions}
551+
templatesLoading={workspaceTemplatesLoading}
552+
templatesError={workspaceTemplatesError}
510553
bind:nameInputRef={nameInputRef}
511554
/>
512555
{:else if selectedType === 'collection'}

frontend/src/lib/features/collections/CollectionList.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@
439439
<!-- Table Body -->
440440
<div>
441441
{#each filteredItems as item (item.id)}
442-
<div class="px-4 py-3 list-row transition-colors" style="border-top: 1px solid var(--ds-border);" data-item-row data-item-id={item.id}>
442+
<div class="px-4 py-3 list-row transition-colors" style="border-top: 1px solid var(--ds-border);" data-item-row data-item-id={item.id} data-testid={`workspace-item-row-${item.id}`}>
443443
<LazyRender>
444444
{#snippet children()}
445445
<div

frontend/src/lib/features/collections/ListCellRenderer.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@
141141
</div>
142142
{/if}
143143
{/if}
144-
<div class="flex-1 min-w-0">
144+
<div class="flex-1 min-w-0" data-testid={`workspace-item-title-${item.id}`}>
145145
{#if canEdit}
146146
<InlineFieldEditor
147147
{item}

frontend/src/lib/forms/WorkspaceForm.svelte

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22
import { t } from '../stores/i18n.svelte.js';
33
import MilkdownEditor from '../editors/LazyMilkdownEditor.svelte';
44
import Input from '../components/Input.svelte';
5+
import ChipPicker from '../pickers/ChipPicker.svelte';
6+
import Spinner from '../components/Spinner.svelte';
7+
import { LayoutTemplate } from '@lucide/svelte';
58
69
let {
710
formData = $bindable({
811
name: '',
912
key: '',
10-
description: ''
13+
description: '',
14+
template_workspace_id: null
1115
}),
16+
templates = [],
17+
templatesLoading = false,
18+
templatesError = null,
1219
nameInputRef = $bindable(null)
1320
} = $props();
1421
@@ -34,6 +41,14 @@
3441
formData.key = e.target.value.toUpperCase();
3542
}
3643
44+
let templatePickerItems = $derived.by(() => {
45+
if (templates.length === 0) return [];
46+
return [
47+
{ id: null, name: t('createModal.workspaceTemplateBlank') },
48+
...templates
49+
];
50+
});
51+
3752
export function validate() {
3853
return formData.name.trim() !== '' && formData.key.trim() !== '';
3954
}
@@ -43,15 +58,17 @@
4358
name: formData.name,
4459
key: formData.key,
4560
description: formData.description || '',
46-
active: true
61+
active: true,
62+
template_workspace_id: formData.template_workspace_id ?? null
4763
};
4864
}
4965
5066
export function reset() {
5167
formData = {
5268
name: '',
5369
key: '',
54-
description: ''
70+
description: '',
71+
template_workspace_id: null
5572
};
5673
keyManuallyEdited = false;
5774
}
@@ -87,6 +104,60 @@
87104
placeholder={t('createModal.workspaceKeyPlaceholder')}
88105
/>
89106
107+
<!-- Template picker: blank workspace is the default and preserves the
108+
legacy creation flow. -->
109+
{#if templatesLoading}
110+
<div
111+
data-testid="workspace-template-loading"
112+
class="flex items-center gap-2 text-sm px-2 py-1.5 rounded"
113+
style="color: var(--ds-text-subtle);"
114+
>
115+
<Spinner size="sm" />
116+
<span>{t('createModal.workspaceTemplateLoading')}</span>
117+
</div>
118+
{:else if templatesError}
119+
<div
120+
data-testid="workspace-template-error"
121+
class="text-sm px-2 py-1.5 rounded"
122+
style="color: var(--ds-text-danger, #dc2626);"
123+
>
124+
{t('createModal.workspaceTemplateError')}
125+
</div>
126+
{:else if templatePickerItems.length > 0}
127+
<div data-testid="workspace-template-section" class="pt-1">
128+
<ChipPicker
129+
value={formData.template_workspace_id}
130+
items={templatePickerItems}
131+
getValue={(tpl) => tpl.id}
132+
getLabel={(tpl) => tpl.name}
133+
icon={LayoutTemplate}
134+
placeholder={t('createModal.workspaceTemplate')}
135+
searchable={true}
136+
searchFields={['name']}
137+
testId="workspace-template-picker"
138+
onSelect={(tpl) => {
139+
formData.template_workspace_id = tpl?.id ?? null;
140+
}}
141+
>
142+
{#snippet itemSnippet({ item })}
143+
<LayoutTemplate size={14} style="color: var(--ds-text-subtle); flex-shrink: 0;" />
144+
<span class="truncate">{item.name}</span>
145+
{#if item.id !== null && item.id !== undefined}
146+
<span
147+
class="text-xs ml-auto pl-2 flex-shrink-0"
148+
style="color: var(--ds-text-subtle);"
149+
>
150+
{t('createModal.workspaceTemplateMeta', {
151+
templates: item.template_count ?? 0,
152+
items: item.item_count ?? 0
153+
})}
154+
</span>
155+
{/if}
156+
{/snippet}
157+
</ChipPicker>
158+
</div>
159+
{/if}
160+
90161
<!-- Description -->
91162
<div class="min-h-[60px]">
92163
<MilkdownEditor

frontend/src/lib/locales/ar/misc.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,11 @@ export default {
741741
workspaceName: 'اسم {type}',
742742
workspaceKeyPlaceholder: 'مفتاح مساحة العمل (مثال: PROJ، TEAM)',
743743
addDescription: 'إضافة وصف...',
744+
workspaceTemplate: 'قالب',
745+
workspaceTemplateBlank: 'مساحة عمل فارغة',
746+
workspaceTemplateLoading: 'جارٍ تحميل القوالب...',
747+
workspaceTemplateError: 'فشل تحميل قوالب مساحات العمل',
748+
workspaceTemplateMeta: '{templates} قوالب · {items} عناصر',
744749
// Field labels
745750
type: 'النوع',
746751
priority: 'الأولوية',

frontend/src/lib/locales/ar/workspace.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export default {
33
title: 'مساحات العمل',
44
subtitle: 'إدارة مساحات العمل والمشاريع',
55
workspace: 'مساحة عمل',
6+
template: 'قالب',
67
workspaces_one: '{count} مساحة عمل',
78
workspaces_other: '{count} مساحات عمل',
89
createWorkspace: 'إنشاء مساحة عمل',
@@ -595,6 +596,9 @@ export default {
595596
failedToUploadAvatar: 'فشل رفع الصورة الرمزية: {error}',
596597
enableInternalComments: 'تفعيل التعليقات الداخلية',
597598
enableInternalCommentsHint: 'السماح بتحديد التعليقات كملاحظات داخلية على جميع العناصر، وليس فقط طلبات البوابة.',
599+
availableAsTemplate: 'متاح كقالب',
600+
availableAsTemplateHelp:
601+
'يمكن لمنشئي مساحات العمل بدء مساحة جديدة من نسخة لمجموعة الإعدادات وقوالب عناصر العمل والعناصر الحالية لهذه المساحة. لا يتم نسخ الأعضاء أو السجل أو الملفات أبدًا.',
598602
},
599603
lookAndFeel: {
600604
title: 'المظهر والتصميم',

frontend/src/lib/locales/de/misc.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,11 @@ export default {
493493
workspaceName: '{type}-Name',
494494
workspaceKeyPlaceholder: 'Arbeitsbereichs-Schlüssel (z.B. PROJ, TEAM)',
495495
addDescription: 'Beschreibung hinzufügen...',
496+
workspaceTemplate: 'Vorlage',
497+
workspaceTemplateBlank: 'Leerer Arbeitsbereich',
498+
workspaceTemplateLoading: 'Vorlagen werden geladen...',
499+
workspaceTemplateError: 'Arbeitsbereich-Vorlagen konnten nicht geladen werden',
500+
workspaceTemplateMeta: '{templates} Vorlagen · {items} Elemente',
496501
type: 'Typ',
497502
priority: 'Priorität',
498503
noPriority: 'Keine Priorität',

0 commit comments

Comments
 (0)