Compare commits
17 Commits
cddeef027b
...
1c5db5bfa5
Author | SHA1 | Date | |
---|---|---|---|
1c5db5bfa5 | |||
2541733c71 | |||
4d6fd9ecc4 | |||
818f31c220 | |||
34e4862c88 | |||
0d074113f1 | |||
0730955403 | |||
75035ec1f8 | |||
923fc4eaa0 | |||
ea7dab68ae | |||
8b9e5e54af | |||
a4a2b95dc7 | |||
9fdb6a8d24 | |||
fc6b707405 | |||
9efe860f26 | |||
af13d68c48 | |||
3cb1e7dede |
|
@ -6,7 +6,6 @@
|
||||||
# (2) Comment in and adjust the values as needed.
|
# (2) Comment in and adjust the values as needed.
|
||||||
|
|
||||||
# VITE_IS_ONLINE=true
|
# VITE_IS_ONLINE=true
|
||||||
# VITE_WORKBOX_DEBUG=false
|
|
||||||
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
|
||||||
# SENTRY_ORG=vikunja
|
# SENTRY_ORG=vikunja
|
||||||
# SENTRY_PROJECT=frontend-oss
|
# SENTRY_PROJECT=frontend-oss
|
||||||
|
|
|
@ -5,6 +5,19 @@ import {ProjectFactory} from '../../factories/project'
|
||||||
import {TaskFactory} from '../../factories/task'
|
import {TaskFactory} from '../../factories/task'
|
||||||
import {prepareProjects} from './prepareProjects'
|
import {prepareProjects} from './prepareProjects'
|
||||||
|
|
||||||
|
function createSingleTaskInBucket(count = 1, attrs = {}) {
|
||||||
|
const projects = ProjectFactory.create(1)
|
||||||
|
const buckets = BucketFactory.create(2, {
|
||||||
|
project_id: projects[0].id,
|
||||||
|
})
|
||||||
|
const tasks = TaskFactory.create(count, {
|
||||||
|
project_id: projects[0].id,
|
||||||
|
bucket_id: buckets[0].id,
|
||||||
|
...attrs,
|
||||||
|
})
|
||||||
|
return tasks[0]
|
||||||
|
}
|
||||||
|
|
||||||
describe('Project View Kanban', () => {
|
describe('Project View Kanban', () => {
|
||||||
createFakeUserAndLogin()
|
createFakeUserAndLogin()
|
||||||
prepareProjects()
|
prepareProjects()
|
||||||
|
@ -207,15 +220,7 @@ describe('Project View Kanban', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should remove a task from the board when deleting it', () => {
|
it('Should remove a task from the board when deleting it', () => {
|
||||||
const projects = ProjectFactory.create(1)
|
const task = createSingleTaskInBucket(5)
|
||||||
const buckets = BucketFactory.create(2, {
|
|
||||||
project_id: projects[0].id,
|
|
||||||
})
|
|
||||||
const tasks = TaskFactory.create(5, {
|
|
||||||
project_id: 1,
|
|
||||||
bucket_id: buckets[0].id,
|
|
||||||
})
|
|
||||||
const task = tasks[0]
|
|
||||||
cy.visit('/projects/1/kanban')
|
cy.visit('/projects/1/kanban')
|
||||||
|
|
||||||
cy.get('.kanban .bucket .tasks .task')
|
cy.get('.kanban .bucket .tasks .task')
|
||||||
|
@ -238,4 +243,43 @@ describe('Project View Kanban', () => {
|
||||||
cy.get('.kanban .bucket .tasks')
|
cy.get('.kanban .bucket .tasks')
|
||||||
.should('not.contain', task.title)
|
.should('not.contain', task.title)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should show a task description icon if the task has a description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||||
|
const task = createSingleTaskInBucket(1, {
|
||||||
|
description: 'Lorem Ipsum',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has an empty description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||||
|
const task = createSingleTaskInBucket(1, {
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/buckets**').as('loadTasks')
|
||||||
|
const task = createSingleTaskInBucket(1, {
|
||||||
|
description: '<p></p>',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit(`/projects/${task.project_id}/kanban`)
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.bucket .tasks .task .footer .icon svg')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
})
|
})
|
|
@ -36,7 +36,7 @@ function uploadAttachmentAndVerify(taskId: number) {
|
||||||
cy.get('.task-view .action-buttons .button')
|
cy.get('.task-view .action-buttons .button')
|
||||||
.contains('Add Attachments')
|
.contains('Add Attachments')
|
||||||
.click()
|
.click()
|
||||||
cy.get('input[type=file]', {timeout: 1000})
|
cy.get('input[type=file]#files', {timeout: 1000})
|
||||||
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
|
||||||
cy.wait('@uploadAttachment')
|
cy.wait('@uploadAttachment')
|
||||||
|
|
||||||
|
@ -112,10 +112,50 @@ describe('Task', () => {
|
||||||
.should('contain', 'Favorites')
|
.should('contain', 'Favorites')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should show a task description icon if the task has a description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
description: 'Lorem Ipsum',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit('/projects/1/list')
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .project-task-icon')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has an empty description', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit('/projects/1/list')
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .project-task-icon')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not show a task description icon if the task has a description containing only an empty p tag', () => {
|
||||||
|
cy.intercept(Cypress.env('API_URL') + '/projects/1/tasks**').as('loadTasks')
|
||||||
|
TaskFactory.create(1, {
|
||||||
|
description: '<p></p>',
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.visit('/projects/1/list')
|
||||||
|
cy.wait('@loadTasks')
|
||||||
|
|
||||||
|
cy.get('.tasks .task .project-task-icon')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
describe('Task Detail View', () => {
|
describe('Task Detail View', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TaskCommentFactory.truncate()
|
TaskCommentFactory.truncate()
|
||||||
LabelTaskFactory.truncate()
|
LabelTaskFactory.truncate()
|
||||||
|
TaskAttachmentFactory.truncate()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Shows all task details', () => {
|
it('Shows all task details', () => {
|
||||||
|
@ -213,6 +253,45 @@ describe('Task', () => {
|
||||||
.should('exist')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Shows an empty editor when the description of a task is empty', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||||
|
.should('have.attr', 'data-placeholder')
|
||||||
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
|
.should('not.exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows a preview editor when the description of a task is not empty', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: 'Lorem Ipsum dolor sit amet',
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||||
|
.should('not.have.attr', 'data-placeholder')
|
||||||
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows a preview editor when the description of a task contains html', () => {
|
||||||
|
const tasks = TaskFactory.create(1, {
|
||||||
|
id: 1,
|
||||||
|
description: '<p>Lorem Ipsum dolor sit amet</p>',
|
||||||
|
})
|
||||||
|
cy.visit(`/tasks/${tasks[0].id}`)
|
||||||
|
|
||||||
|
cy.get('.task-view .details.content.description .tiptap.ProseMirror p')
|
||||||
|
.should('not.have.attr', 'data-placeholder')
|
||||||
|
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||||
|
.should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
it('Can add a new comment', () => {
|
it('Can add a new comment', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -692,7 +771,7 @@ describe('Task', () => {
|
||||||
.should('exist')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Can check items off a checklist', () => {
|
it.only('Can check items off a checklist', () => {
|
||||||
const tasks = TaskFactory.create(1, {
|
const tasks = TaskFactory.create(1, {
|
||||||
id: 1,
|
id: 1,
|
||||||
description: `
|
description: `
|
||||||
|
@ -761,7 +840,7 @@ describe('Task', () => {
|
||||||
.should('exist')
|
.should('exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it.only('Should render an image from attachment', async () => {
|
it('Should render an image from attachment', async () => {
|
||||||
|
|
||||||
TaskAttachmentFactory.truncate()
|
TaskAttachmentFactory.truncate()
|
||||||
|
|
||||||
|
|
1
env.d.ts
vendored
1
env.d.ts
vendored
|
@ -25,7 +25,6 @@ interface ImportMetaEnv {
|
||||||
readonly SENTRY_ORG?: string
|
readonly SENTRY_ORG?: string
|
||||||
readonly SENTRY_PROJECT?: string
|
readonly SENTRY_PROJECT?: string
|
||||||
|
|
||||||
readonly VITE_WORKBOX_DEBUG?: boolean
|
|
||||||
readonly VITE_IS_ONLINE: boolean
|
readonly VITE_IS_ONLINE: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
28
package.json
28
package.json
|
@ -52,7 +52,7 @@
|
||||||
"@github/hotkey": "2.3.1",
|
"@github/hotkey": "2.3.1",
|
||||||
"@infectoone/vue-ganttastic": "2.2.0",
|
"@infectoone/vue-ganttastic": "2.2.0",
|
||||||
"@intlify/unplugin-vue-i18n": "1.5.0",
|
"@intlify/unplugin-vue-i18n": "1.5.0",
|
||||||
"@kyvg/vue3-notification": "3.1.0",
|
"@kyvg/vue3-notification": "3.1.1",
|
||||||
"@sentry/tracing": "7.85.0",
|
"@sentry/tracing": "7.85.0",
|
||||||
"@sentry/vue": "7.85.0",
|
"@sentry/vue": "7.85.0",
|
||||||
"@tiptap/core": "2.1.13",
|
"@tiptap/core": "2.1.13",
|
||||||
|
@ -136,23 +136,23 @@
|
||||||
"@types/is-touch-device": "1.0.2",
|
"@types/is-touch-device": "1.0.2",
|
||||||
"@types/lodash.debounce": "4.0.9",
|
"@types/lodash.debounce": "4.0.9",
|
||||||
"@types/marked": "5.0.2",
|
"@types/marked": "5.0.2",
|
||||||
"@types/node": "20.10.3",
|
"@types/node": "20.10.4",
|
||||||
"@types/postcss-preset-env": "7.7.0",
|
"@types/postcss-preset-env": "7.7.0",
|
||||||
"@types/sortablejs": "1.15.7",
|
"@types/sortablejs": "1.15.7",
|
||||||
"@typescript-eslint/eslint-plugin": "6.13.2",
|
"@typescript-eslint/eslint-plugin": "6.14.0",
|
||||||
"@typescript-eslint/parser": "6.13.2",
|
"@typescript-eslint/parser": "6.14.0",
|
||||||
"@vitejs/plugin-legacy": "5.2.0",
|
"@vitejs/plugin-legacy": "5.2.0",
|
||||||
"@vitejs/plugin-vue": "4.5.1",
|
"@vitejs/plugin-vue": "4.5.2",
|
||||||
"@vue/eslint-config-typescript": "12.0.0",
|
"@vue/eslint-config-typescript": "12.0.0",
|
||||||
"@vue/test-utils": "2.4.3",
|
"@vue/test-utils": "2.4.3",
|
||||||
"@vue/tsconfig": "0.4.0",
|
"@vue/tsconfig": "0.4.0",
|
||||||
"autoprefixer": "10.4.16",
|
"autoprefixer": "10.4.16",
|
||||||
"browserslist": "4.22.2",
|
"browserslist": "4.22.2",
|
||||||
"caniuse-lite": "1.0.30001566",
|
"caniuse-lite": "1.0.30001570",
|
||||||
"css-has-pseudo": "6.0.0",
|
"css-has-pseudo": "6.0.0",
|
||||||
"csstype": "3.1.2",
|
"csstype": "3.1.3",
|
||||||
"cypress": "13.6.1",
|
"cypress": "13.6.1",
|
||||||
"esbuild": "0.19.8",
|
"esbuild": "0.19.9",
|
||||||
"eslint": "8.55.0",
|
"eslint": "8.55.0",
|
||||||
"eslint-plugin-vue": "9.19.2",
|
"eslint-plugin-vue": "9.19.2",
|
||||||
"happy-dom": "12.10.3",
|
"happy-dom": "12.10.3",
|
||||||
|
@ -162,17 +162,17 @@
|
||||||
"postcss-easings": "4.0.0",
|
"postcss-easings": "4.0.0",
|
||||||
"postcss-focus-within": "8.0.0",
|
"postcss-focus-within": "8.0.0",
|
||||||
"postcss-preset-env": "9.3.0",
|
"postcss-preset-env": "9.3.0",
|
||||||
"rollup": "4.6.1",
|
"rollup": "4.9.0",
|
||||||
"rollup-plugin-visualizer": "5.10.0",
|
"rollup-plugin-visualizer": "5.11.0",
|
||||||
"sass": "1.69.5",
|
"sass": "1.69.5",
|
||||||
"start-server-and-test": "2.0.3",
|
"start-server-and-test": "2.0.3",
|
||||||
"typescript": "5.3.2",
|
"typescript": "5.3.3",
|
||||||
"vite": "5.0.6",
|
"vite": "5.0.8",
|
||||||
"vite-plugin-inject-preload": "1.3.3",
|
"vite-plugin-inject-preload": "1.3.3",
|
||||||
"vite-plugin-pwa": "0.17.3",
|
"vite-plugin-pwa": "0.17.4",
|
||||||
"vite-plugin-sentry": "1.3.0",
|
"vite-plugin-sentry": "1.3.0",
|
||||||
"vite-svg-loader": "5.1.0",
|
"vite-svg-loader": "5.1.0",
|
||||||
"vitest": "1.0.1",
|
"vitest": "1.0.4",
|
||||||
"vue-tsc": "1.8.25",
|
"vue-tsc": "1.8.25",
|
||||||
"wait-on": "7.2.0",
|
"wait-on": "7.2.0",
|
||||||
"workbox-cli": "7.0.0"
|
"workbox-cli": "7.0.0"
|
||||||
|
|
629
pnpm-lock.yaml
629
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -47,7 +47,6 @@
|
||||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
|
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<ProjectSettingsDropdown
|
<ProjectSettingsDropdown
|
||||||
v-if="project.id > 0"
|
|
||||||
class="menu-list-dropdown"
|
class="menu-list-dropdown"
|
||||||
:project="project"
|
:project="project"
|
||||||
:level="level"
|
:level="level"
|
||||||
|
@ -58,7 +57,6 @@
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
</ProjectSettingsDropdown>
|
</ProjectSettingsDropdown>
|
||||||
<span class="list-setting-spacer" v-else></span>
|
|
||||||
</div>
|
</div>
|
||||||
<ProjectsNavigation
|
<ProjectsNavigation
|
||||||
v-if="canNestDeeper && childProjectsOpen && canCollapse"
|
v-if="canNestDeeper && childProjectsOpen && canCollapse"
|
||||||
|
|
|
@ -118,7 +118,6 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||||
import {refDebounced} from '@vueuse/core'
|
|
||||||
|
|
||||||
import EditorToolbar from './EditorToolbar.vue'
|
import EditorToolbar from './EditorToolbar.vue'
|
||||||
|
|
||||||
|
@ -173,6 +172,7 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
||||||
import {eventToHotkeyString} from '@github/hotkey'
|
import {eventToHotkeyString} from '@github/hotkey'
|
||||||
import {mergeAttributes} from '@tiptap/core'
|
import {mergeAttributes} from '@tiptap/core'
|
||||||
import {createRandomID} from '@/helpers/randomId'
|
import {createRandomID} from '@/helpers/randomId'
|
||||||
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
|
|
||||||
const tiptapInstanceRef = ref<HTMLInputElement | null>(null)
|
const tiptapInstanceRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
@ -200,7 +200,9 @@ const CustomTableCell = TableCell.extend({
|
||||||
})
|
})
|
||||||
|
|
||||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||||
const loadedAttachments = ref<{ [key: CacheKey]: string }>({})
|
const loadedAttachments = ref<{
|
||||||
|
[key: CacheKey]: string
|
||||||
|
}>({})
|
||||||
|
|
||||||
const CustomImage = Image.extend({
|
const CustomImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
|
@ -272,7 +274,6 @@ const {
|
||||||
showSave = false,
|
showSave = false,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
editShortcut = '',
|
editShortcut = '',
|
||||||
initialMode = 'edit',
|
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
modelValue: string,
|
modelValue: string,
|
||||||
uploadCallback?: UploadCallback,
|
uploadCallback?: UploadCallback,
|
||||||
|
@ -281,13 +282,11 @@ const {
|
||||||
showSave?: boolean,
|
showSave?: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
editShortcut?: string,
|
editShortcut?: string,
|
||||||
initialMode?: Mode,
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'save'])
|
const emit = defineEmits(['update:modelValue', 'save'])
|
||||||
|
|
||||||
const inputHTML = ref('')
|
const internalMode = ref<Mode>('edit')
|
||||||
const internalMode = ref<Mode>(initialMode)
|
|
||||||
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
@ -359,14 +358,28 @@ const editor = useEditor({
|
||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
nested: true,
|
nested: true,
|
||||||
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
||||||
if (isEditEnabled) {
|
if (!isEditEnabled) {
|
||||||
node.attrs.checked = checked
|
return false
|
||||||
inputHTML.value = editor.value?.getHTML()
|
|
||||||
bubbleSave()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
// The following is a workaround for this bug:
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/4521
|
||||||
|
// https://github.com/ueberdosis/tiptap/issues/3676
|
||||||
|
|
||||||
|
editor.value!.state.doc.descendants((subnode, pos) => {
|
||||||
|
if (node.eq(subnode)) {
|
||||||
|
const {tr} = editor.value!.state
|
||||||
|
tr.setNodeMarkup(pos, undefined, {
|
||||||
|
...node.attrs,
|
||||||
|
checked,
|
||||||
|
})
|
||||||
|
editor.value!.view.dispatch(tr)
|
||||||
|
bubbleSave()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
@ -376,52 +389,55 @@ const editor = useEditor({
|
||||||
BubbleMenu,
|
BubbleMenu,
|
||||||
],
|
],
|
||||||
onUpdate: () => {
|
onUpdate: () => {
|
||||||
inputHTML.value = editor.value!.getHTML()
|
bubbleNow()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => modelValue,
|
|
||||||
value => {
|
|
||||||
inputHTML.value = value
|
|
||||||
|
|
||||||
if (!editor?.value) return
|
|
||||||
|
|
||||||
if (editor.value.getHTML() === value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.value.commands.setContent(value, false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const debouncedInputHTML = refDebounced(inputHTML, 1000)
|
|
||||||
watch(debouncedInputHTML, () => bubbleNow())
|
|
||||||
|
|
||||||
function bubbleNow() {
|
|
||||||
emit('update:modelValue', inputHTML.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bubbleSave() {
|
|
||||||
bubbleNow()
|
|
||||||
emit('save', inputHTML.value)
|
|
||||||
if (isEditing.value) {
|
|
||||||
internalMode.value = 'preview'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEdit() {
|
|
||||||
internalMode.value = 'edit'
|
|
||||||
editor.value?.commands.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => isEditing.value,
|
() => isEditing.value,
|
||||||
() => {
|
() => {
|
||||||
editor.value?.setEditable(isEditing.value)
|
editor.value?.setEditable(isEditing.value)
|
||||||
},
|
},
|
||||||
|
{immediate: true},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => modelValue,
|
||||||
|
value => {
|
||||||
|
if (!editor?.value) return
|
||||||
|
|
||||||
|
if (editor.value.getHTML() === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setModeAndValue(value)
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
|
function bubbleNow() {
|
||||||
|
if (editor.value?.getHTML() === modelValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', editor.value?.getHTML())
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubbleSave() {
|
||||||
|
bubbleNow()
|
||||||
|
emit('save', editor.value?.getHTML())
|
||||||
|
if (isEditing.value) {
|
||||||
|
internalMode.value = 'preview'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEdit(focus: boolean = true) {
|
||||||
|
internalMode.value = 'edit'
|
||||||
|
if (focus) {
|
||||||
|
editor.value?.commands.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => editor.value?.destroy())
|
onBeforeUnmount(() => editor.value?.destroy())
|
||||||
|
|
||||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
@ -491,15 +507,17 @@ function setLink() {
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
internalMode.value = initialMode
|
|
||||||
nextTick(() => {
|
|
||||||
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
|
|
||||||
input?.addEventListener('paste', handleImagePaste)
|
|
||||||
})
|
|
||||||
if (editShortcut !== '') {
|
if (editShortcut !== '') {
|
||||||
document.addEventListener('keydown', setFocusToEditor)
|
document.addEventListener('keydown', setFocusToEditor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0]
|
||||||
|
input?.addEventListener('paste', handleImagePaste)
|
||||||
|
|
||||||
|
setModeAndValue(modelValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
@ -512,6 +530,11 @@ onBeforeUnmount(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function setModeAndValue(value: string) {
|
||||||
|
internalMode.value = isEditorContentEmpty(value) ? 'edit' : 'preview'
|
||||||
|
editor.value?.commands.setContent(value, false)
|
||||||
|
}
|
||||||
|
|
||||||
function handleImagePaste(event) {
|
function handleImagePaste(event) {
|
||||||
if (event?.clipboardData?.items?.length === 0) {
|
if (event?.clipboardData?.items?.length === 0) {
|
||||||
return
|
return
|
||||||
|
@ -521,7 +544,6 @@ function handleImagePaste(event) {
|
||||||
|
|
||||||
const image = event.clipboardData.items[0]
|
const image = event.clipboardData.items[0]
|
||||||
if (image.kind === 'file' && image.type.startsWith('image/')) {
|
if (image.kind === 'file' && image.type.startsWith('image/')) {
|
||||||
console.log('img', image.getAsFile())
|
|
||||||
uploadAndInsertFiles([image.getAsFile()])
|
uploadAndInsertFiles([image.getAsFile()])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -538,7 +560,7 @@ function setFocusToEditor(event) {
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (initialMode === 'preview' && isEditEnabled && !isEditing.value) {
|
if (!isEditing.value && isEditEnabled) {
|
||||||
internalMode.value = 'edit'
|
internalMode.value = 'edit'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -149,13 +149,15 @@ async function addTask() {
|
||||||
await Promise.all(newTasks)
|
await Promise.all(newTasks)
|
||||||
|
|
||||||
const taskRelationService = new TaskRelationService()
|
const taskRelationService = new TaskRelationService()
|
||||||
|
const allParentTasks = tasksToCreate.filter(t => t.parent !== null).map(t => t.parent)
|
||||||
const relations = tasksToCreate.map(async t => {
|
const relations = tasksToCreate.map(async t => {
|
||||||
const createdTask = createdTasks[t.title]
|
const createdTask = createdTasks[t.title]
|
||||||
if (typeof createdTask === 'undefined') {
|
if (typeof createdTask === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.parent === null) {
|
const isParent = allParentTasks.includes(t.title)
|
||||||
|
if (t.parent === null && !isParent) {
|
||||||
emit('taskAdded', createdTask)
|
emit('taskAdded', createdTask)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -171,10 +173,19 @@ async function addTask() {
|
||||||
relationKind: RELATION_KIND.PARENTTASK,
|
relationKind: RELATION_KIND.PARENTTASK,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
|
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [{
|
||||||
|
...createdParentTask,
|
||||||
|
relatedTasks: {}, // To avoid endless references
|
||||||
|
}]
|
||||||
// we're only emitting here so that the relation shows up in the project
|
// we're only emitting here so that the relation shows up in the project
|
||||||
emit('taskAdded', createdTask)
|
emit('taskAdded', createdTask)
|
||||||
|
|
||||||
|
createdParentTask.relatedTasks[RELATION_KIND.SUBTASK] = [{
|
||||||
|
...createdTask,
|
||||||
|
relatedTasks: {}, // To avoid endless references
|
||||||
|
}]
|
||||||
|
emit('taskAdded', createdParentTask)
|
||||||
|
|
||||||
return rel
|
return rel
|
||||||
})
|
})
|
||||||
await Promise.all(relations)
|
await Promise.all(relations)
|
||||||
|
|
|
@ -26,7 +26,6 @@
|
||||||
v-model="description"
|
v-model="description"
|
||||||
@update:model-value="saveWithDelay"
|
@update:model-value="saveWithDelay"
|
||||||
@save="save"
|
@save="save"
|
||||||
:initial-mode="isEditorContentEmpty(description) ? 'edit' : 'preview'"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -39,7 +38,6 @@ import Editor from '@/components/input/AsyncEditor'
|
||||||
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
|
||||||
|
|
||||||
type AttachmentUploadFunction = (file: File, onSuccess: (attachmentUrl: string) => void) => Promise<string>
|
type AttachmentUploadFunction = (file: File, onSuccess: (attachmentUrl: string) => void) => Promise<string>
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
'is-loading': loadingInternal || loading,
|
'is-loading': loadingInternal || loading,
|
||||||
'draggable': !(loadingInternal || loading),
|
'draggable': !(loadingInternal || loading),
|
||||||
'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color),
|
'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color),
|
||||||
|
'has-custom-background-color': color !== TASK_DEFAULT_COLOR ? color : undefined,
|
||||||
}"
|
}"
|
||||||
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : undefined}"
|
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : undefined}"
|
||||||
@click.exact="openTaskDetail()"
|
@click.exact="openTaskDetail()"
|
||||||
|
@ -48,7 +49,10 @@
|
||||||
</progress>
|
</progress>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<labels :labels="task.labels"/>
|
<labels :labels="task.labels"/>
|
||||||
<priority-label :priority="task.priority" :done="task.done" class="is-inline-flex is-align-items-center"/>
|
<priority-label
|
||||||
|
:priority="task.priority"
|
||||||
|
:done="task.done"
|
||||||
|
class="is-inline-flex is-align-items-center"/>
|
||||||
<assignee-list
|
<assignee-list
|
||||||
v-if="task.assignees.length > 0"
|
v-if="task.assignees.length > 0"
|
||||||
:assignees="task.assignees"
|
:assignees="task.assignees"
|
||||||
|
@ -60,7 +64,7 @@
|
||||||
<span class="icon" v-if="task.attachments.length > 0">
|
<span class="icon" v-if="task.attachments.length > 0">
|
||||||
<icon icon="paperclip"/>
|
<icon icon="paperclip"/>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="task.description" class="icon">
|
<span v-if="!isEditorContentEmpty(task.description)" class="icon">
|
||||||
<icon icon="align-left"/>
|
<icon icon="align-left"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="icon" v-if="task.repeatAfter.amount > 0">
|
<span class="icon" v-if="task.repeatAfter.amount > 0">
|
||||||
|
@ -91,6 +95,7 @@ import {useTaskStore} from '@/stores/tasks'
|
||||||
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {playPopSound} from '@/helpers/playPop'
|
import {playPopSound} from '@/helpers/playPop'
|
||||||
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
@ -113,7 +118,7 @@ async function toggleTaskDone(task: ITask) {
|
||||||
...task,
|
...task,
|
||||||
done: !task.done,
|
done: !task.done,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (updatedTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
|
if (updatedTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
|
||||||
playPopSound()
|
playPopSound()
|
||||||
}
|
}
|
||||||
|
@ -279,6 +284,16 @@ $task-background: var(--white);
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.has-custom-background-color {
|
||||||
|
color: hsl(215, 27.9%, 16.9%); // copied from grey-800 to avoid different values in dark mode
|
||||||
|
|
||||||
|
.footer .icon,
|
||||||
|
.due-date,
|
||||||
|
.priority-label {
|
||||||
|
background: hsl(220, 13%, 91%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.has-light-text {
|
&.has-light-text {
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
|
|
||||||
|
|
|
@ -93,7 +93,7 @@
|
||||||
<span class="project-task-icon" v-if="task.attachments.length > 0">
|
<span class="project-task-icon" v-if="task.attachments.length > 0">
|
||||||
<icon icon="paperclip"/>
|
<icon icon="paperclip"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="project-task-icon" v-if="task.description">
|
<span class="project-task-icon" v-if="!isEditorContentEmpty(task.description)">
|
||||||
<icon icon="align-left"/>
|
<icon icon="align-left"/>
|
||||||
</span>
|
</span>
|
||||||
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0">
|
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0">
|
||||||
|
@ -184,6 +184,7 @@ import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
|
||||||
import {useIntervalFn} from '@vueuse/core'
|
import {useIntervalFn} from '@vueuse/core'
|
||||||
import {playPopSound} from '@/helpers/playPop'
|
import {playPopSound} from '@/helpers/playPop'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
theTask,
|
theTask,
|
||||||
|
|
|
@ -11,7 +11,6 @@ const workboxVersion = 'v7.0.0'
|
||||||
importScripts(`${fullBaseUrl}workbox-${workboxVersion}/workbox-sw.js`)
|
importScripts(`${fullBaseUrl}workbox-${workboxVersion}/workbox-sw.js`)
|
||||||
workbox.setConfig({
|
workbox.setConfig({
|
||||||
modulePathPrefix: `${fullBaseUrl}workbox-${workboxVersion}`,
|
modulePathPrefix: `${fullBaseUrl}workbox-${workboxVersion}`,
|
||||||
debug: Boolean(import.meta.env.VITE_WORKBOX_DEBUG),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
import { precacheAndRoute } from 'workbox-precaching'
|
import { precacheAndRoute } from 'workbox-precaching'
|
||||||
|
|
|
@ -175,7 +175,11 @@ const tasks = ref<ITask[]>([])
|
||||||
watch(
|
watch(
|
||||||
allTasks,
|
allTasks,
|
||||||
() => {
|
() => {
|
||||||
tasks.value = [...allTasks.value].filter(t => typeof t.relatedTasks?.parenttask === 'undefined')
|
tasks.value = [...allTasks.value]
|
||||||
|
if (projectId < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tasks.value = tasks.value.filter(t => typeof t.relatedTasks?.parenttask === 'undefined')
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -241,9 +245,9 @@ function updateTaskList(task: ITask) {
|
||||||
loadTasks()
|
loadTasks()
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tasks.value = [
|
allTasks.value = [
|
||||||
task,
|
task,
|
||||||
...tasks.value,
|
...allTasks.value,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
'is-modal': isModal,
|
'is-modal': isModal,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="task-view">
|
<!-- Removing everything until the task is loaded to prevent empty initialization of other components -->
|
||||||
|
<div class="task-view" v-if="visible">
|
||||||
<Heading
|
<Heading
|
||||||
:task="task"
|
:task="task"
|
||||||
@update:task="Object.assign(task, $event)"
|
@update:task="Object.assign(task, $event)"
|
||||||
|
@ -605,7 +606,8 @@ watch(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Object.assign(task.value, await taskService.get({id}))
|
const loaded = await taskService.get({id})
|
||||||
|
Object.assign(task.value, loaded)
|
||||||
attachmentStore.set(task.value.attachments)
|
attachmentStore.set(task.value.attachments)
|
||||||
taskColor.value = task.value.hexColor
|
taskColor.value = task.value.hexColor
|
||||||
setActiveFields()
|
setActiveFields()
|
||||||
|
|
Reference in New Issue
Block a user