diff --git a/frontend/src/components/home/Navigation.vue b/frontend/src/components/home/Navigation.vue index b657f9eaa..24c9ba7b2 100644 --- a/frontend/src/components/home/Navigation.vue +++ b/frontend/src/components/home/Navigation.vue @@ -49,6 +49,16 @@ {{ $t('project.projects') }} +
  • + + + + + {{ $t('project.template.title') }} + +
  • {{ $t('menu.duplicate') }} + + {{ $t('project.template.saveAsTemplate') }} + props.project?.id === authStore.settings.defaultProjectId) + +const {t} = useI18n({useScope: 'global'}) + +async function saveAsTemplate() { + const templateService = new ProjectTemplateService() + const response = await templateService.create({projectId: props.project.id}) + if (response.project) { + projectStore.setProject(response.project) + } + await projectStore.loadAllProjects() + success({message: t('project.template.saveAsTemplateSuccess')}) +} diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index a48d793ac..31ec626ee 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -395,6 +395,15 @@ "text": "Select a parent project which should hold the duplicated project:", "success": "The project was successfully duplicated." }, + "template": { + "title": "Templates", + "saveAsTemplate": "Save as Template", + "saveAsTemplateSuccess": "Project saved as template successfully.", + "useTemplate": "Use a template", + "selectTemplate": "Select a template\u2026", + "createFromTemplate": "Creating project from template\u2026", + "none": "You don't have any templates yet. Save a project as a template to get started." + }, "edit": { "header": "Edit This Project", "title": "Edit \"{project}\"", diff --git a/frontend/src/modelTypes/IProject.ts b/frontend/src/modelTypes/IProject.ts index 555b35a97..c7623d854 100644 --- a/frontend/src/modelTypes/IProject.ts +++ b/frontend/src/modelTypes/IProject.ts @@ -12,6 +12,7 @@ export interface IProject extends IAbstract { owner: IUser tasks: ITask[] isArchived: boolean + isTemplate: boolean hexColor: string identifier: string backgroundInformation: unknown | null // FIXME: improve type diff --git a/frontend/src/models/project.ts b/frontend/src/models/project.ts index 53b75a9b8..7f0b6cad9 100644 --- a/frontend/src/models/project.ts +++ b/frontend/src/models/project.ts @@ -17,6 +17,7 @@ export default class ProjectModel extends AbstractModel implements IPr owner: IUser = UserModel tasks: ITask[] = [] isArchived = false + isTemplate = false hexColor = '' identifier = '' backgroundInformation: unknown | null = null diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cc693cc48..030dc12d1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -242,6 +242,11 @@ const router = createRouter({ name: 'projects.index', component: () => import('@/views/project/ListProjects.vue'), }, + { + path: '/templates', + name: 'templates.index', + component: () => import('@/views/project/ListTemplates.vue'), + }, { path: '/projects/new', name: 'project.create', diff --git a/frontend/src/services/projectTemplateService.ts b/frontend/src/services/projectTemplateService.ts new file mode 100644 index 000000000..a61c8b887 --- /dev/null +++ b/frontend/src/services/projectTemplateService.ts @@ -0,0 +1,9 @@ +import AbstractService from './abstractService' + +export default class ProjectTemplateService extends AbstractService { + constructor() { + super({ + create: '/projects/{projectId}/template', + }) + } +} diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 37110100e..5c4f15480 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -37,14 +37,17 @@ export const useProjectStore = defineStore('project', () => { } const notArchivedRootProjects = computed(() => projectsArray.value - .filter(p => !p.isArchived && p.id > 0 && ( + .filter(p => !p.isArchived && !p.isTemplate && p.id > 0 && ( p.parentProjectId === 0 || isOrphanedSubProject(p) ))) const favoriteProjects = computed(() => projectsArray.value - .filter(p => !p.isArchived && p.isFavorite)) + .filter(p => !p.isArchived && !p.isTemplate && p.isFavorite)) const savedFilterProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && p.id < -1)) const hasProjects = computed(() => projectsArray.value.length > 0) + const templateProjects = computed(() => projectsArray.value + .filter(p => !p.isArchived && p.isTemplate)) + const hasTemplates = computed(() => templateProjects.value.length > 0) const getChildProjects = computed(() => { return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id) @@ -261,15 +264,15 @@ export const useProjectStore = defineStore('project', () => { let page = 1 try { do { - const newProjects = await projectService.getAll({}, {is_archived: true, expand: 'permissions'}, page) as IProject[] + const newProjects = await projectService.getAll({}, {is_archived: true, is_template: true, expand: 'permissions'}, page) as IProject[] loadedProjects.push(...newProjects) page++ } while (page <= projectService.totalPages) - + } finally { cancel() } - + projects.value = {} setProjects(loadedProjects) @@ -329,6 +332,8 @@ export const useProjectStore = defineStore('project', () => { favoriteProjects: readonly(favoriteProjects), hasProjects: readonly(hasProjects), savedFilterProjects: readonly(savedFilterProjects), + templateProjects: readonly(templateProjects), + hasTemplates: readonly(hasTemplates), getChildProjects, isOrphanedSubProject, diff --git a/frontend/src/views/project/ListTemplates.vue b/frontend/src/views/project/ListTemplates.vue new file mode 100644 index 000000000..f051c7b85 --- /dev/null +++ b/frontend/src/views/project/ListTemplates.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/views/project/NewProject.vue b/frontend/src/views/project/NewProject.vue index 452533191..2f017f487 100644 --- a/frontend/src/views/project/NewProject.vue +++ b/frontend/src/views/project/NewProject.vue @@ -5,6 +5,18 @@ :primary-disabled="project.title === ''" @create="createProject()" > + + +