From 2e3976393ea82720eccc3c5d4b79d560291ebcad Mon Sep 17 00:00:00 2001 From: WANJIN Date: Mon, 23 Dec 2024 13:43:01 +0900 Subject: [PATCH 01/40] refactor: separate mention functionality from comment input component (#5277) * feat(authenticator): initialize task management template on sign-in Signed-off-by: Wanjin Noh * refactor: separate mention functionality from comment input component Signed-off-by: Wanjin Noh --------- Signed-off-by: Wanjin Noh --- .../ops-flow/components/BoardTaskComment.vue | 327 +++-------------- .../ops-flow/composables/use-mention.ts | 329 ++++++++++++++++++ 2 files changed, 374 insertions(+), 282 deletions(-) create mode 100644 apps/web/src/services/ops-flow/composables/use-mention.ts diff --git a/apps/web/src/services/ops-flow/components/BoardTaskComment.vue b/apps/web/src/services/ops-flow/components/BoardTaskComment.vue index 4e90e6151a..9f83e11eb8 100644 --- a/apps/web/src/services/ops-flow/components/BoardTaskComment.vue +++ b/apps/web/src/services/ops-flow/components/BoardTaskComment.vue @@ -1,13 +1,10 @@ @@ -110,39 +225,99 @@ onBeforeMount(() => { @delete="emit('delete')" />
-
- - +
+ +
+ +
-
+
(); const emit = defineEmits<{(event: 'update:fields', value: TaskField[]): void; @@ -27,7 +28,7 @@ const emit = defineEmits<{(event: 'update:fields', value: TaskField[]): void; const taskFieldMetadataStore = useTaskFieldMetadataStore(); const taskFieldMetadataStoreGetters = taskFieldMetadataStore.getters; -const draggableFields = computed({ +const draggableFields = computed({ get() { return props.fields; }, @@ -37,14 +38,14 @@ const draggableFields = computed({ }); const visibleFieldDeleteModal = ref(false); -const fieldDeleteTarget = ref<{ field: TaskField, index: number } | undefined>(); -const handleFieldDelete = (field: TaskField, idx: number) => { +const fieldDeleteTarget = ref<{ field: MutableTaskField, index: number } | undefined>(); +const handleFieldDelete = (field: MutableTaskField, idx: number) => { if (!props.originFields) { // if it is creation mode - emit('remove-field', field.field_id, idx); + emit('remove-field', field._field_id, idx); return; } - if (!props.originFields.find((f) => f.field_id === field.field_id)) { // if it is newly added field - emit('remove-field', field.field_id, idx); + if (!props.originFields.find((f) => f.field_id === field._field_id)) { // if it is newly added field + emit('remove-field', field._field_id, idx); return; } fieldDeleteTarget.value = { field, index: idx }; @@ -57,7 +58,7 @@ const handleFieldDeleteConfirm = () => { return; } visibleFieldDeleteModal.value = false; - emit('remove-field', fieldDeleteTarget.value.field.field_id, fieldDeleteTarget.value.index); + emit('remove-field', fieldDeleteTarget.value.field._field_id, fieldDeleteTarget.value.index); fieldDeleteTarget.value = undefined; }; @@ -71,6 +72,7 @@ const handleFieldDeleteConfirm = () => {
@@ -81,7 +83,7 @@ const handleFieldDeleteConfirm = () => { class="flex flex-col gap-2" >
@@ -92,9 +94,10 @@ const handleFieldDeleteConfirm = () => {
diff --git a/apps/web/src/services/ops-flow/task-fields-configuration/components/DropdownTaskFieldEnumForm.vue b/apps/web/src/services/ops-flow/task-fields-configuration/components/DropdownTaskFieldEnumForm.vue index d3ea8c5419..5d213f0fa2 100644 --- a/apps/web/src/services/ops-flow/task-fields-configuration/components/DropdownTaskFieldEnumForm.vue +++ b/apps/web/src/services/ops-flow/task-fields-configuration/components/DropdownTaskFieldEnumForm.vue @@ -1,5 +1,5 @@ - +
diff --git a/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts b/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts index 0fe3282db2..2d97235cc1 100644 --- a/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts +++ b/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts @@ -40,7 +40,6 @@ export const useTaskFieldMetadataStore = defineStore('task-field-metadata', () = taskFieldTypeMetadataMap.value.DROPDOWN, taskFieldTypeMetadataMap.value.DATE, taskFieldTypeMetadataMap.value.PROJECT, - taskFieldTypeMetadataMap.value.SERVICE_ACCOUNT, taskFieldTypeMetadataMap.value.ASSET, ]); const defaultFields = computed(() => [ diff --git a/apps/web/src/services/ops-flow/task-fields-form/field-templates/AssetTaskField.vue b/apps/web/src/services/ops-flow/task-fields-form/field-templates/AssetTaskField.vue index 6335fc70a6..63cf4b08c7 100644 --- a/apps/web/src/services/ops-flow/task-fields-form/field-templates/AssetTaskField.vue +++ b/apps/web/src/services/ops-flow/task-fields-form/field-templates/AssetTaskField.vue @@ -89,7 +89,6 @@ const handleUpdateSelected = (stepIdx: number, selected: DataSelectorItem[]) => no-spacing >
- {{ fieldValue.value ? fieldValue.value.join(', ') : '' }}
Date: Thu, 2 Jan 2025 22:33:29 +0900 Subject: [PATCH 30/40] fix(task-field-metadata): integrate task field metadata store and update mappings Signed-off-by: Wanjin Noh --- .../ops-flow/components/TaskProgressEventView.vue | 9 ++++++++- .../ops-flow/stores/task-content-form-store.ts | 14 +++++++------- .../constants/default-field-constant.ts | 4 ++-- .../stores/use-task-field-metadata-store.ts | 8 +++++--- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/web/src/services/ops-flow/components/TaskProgressEventView.vue b/apps/web/src/services/ops-flow/components/TaskProgressEventView.vue index 88eb09a819..ea3092f589 100644 --- a/apps/web/src/services/ops-flow/components/TaskProgressEventView.vue +++ b/apps/web/src/services/ops-flow/components/TaskProgressEventView.vue @@ -10,6 +10,9 @@ import { useTimezoneDate } from '@/common/composables/timezone-date'; import { TASK_STATUS_LABELS } from '@/services/ops-flow/constants/task-status-label-constant'; import { useTaskTypeStore } from '@/services/ops-flow/stores/task-type-store'; +import { + useTaskFieldMetadataStore, +} from '@/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store'; const props = withDefaults(defineProps<{ eventType: EventType; @@ -22,6 +25,7 @@ const props = withDefaults(defineProps<{ }); const taskTypeStore = useTaskTypeStore(); +const taskFieldMetadataStore = useTaskFieldMetadataStore(); const fields = asyncComputed(async () => { if (!props.taskTypeId) return []; @@ -38,6 +42,9 @@ const fields = asyncComputed(async () => { }, [], { lazy: true }); const fieldNameMap = computed(() => { const map: Record = {}; + taskFieldMetadataStore.getters.allDefaultFields.forEach((field) => { + map[field.field_id] = field.name; + }); fields.value.forEach((field) => { map[field.field_id] = field.name; }); @@ -59,7 +66,7 @@ const { getTimezoneDate } = useTimezoneDate();
- {{ $t('OPSFLOW.TASK_BOARD.FIELD') }}: {{ fieldNameMap[d.updated_field] }}
+ {{ $t('OPSFLOW.TASK_BOARD.FIELD') }}: {{ fieldNameMap[d.updated_field] ?? d.updated_field }}
{{ $t('OPSFLOW.TASK_BOARD.CONTENT') }}: {{ d.updated_content }}
diff --git a/apps/web/src/services/ops-flow/stores/task-content-form-store.ts b/apps/web/src/services/ops-flow/stores/task-content-form-store.ts index 341ce3293f..0bf9b61e6d 100644 --- a/apps/web/src/services/ops-flow/stores/task-content-form-store.ts +++ b/apps/web/src/services/ops-flow/stores/task-content-form-store.ts @@ -198,9 +198,9 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { state.statusId = task.status_id; state.assignee = task.assignee; state.defaultData = { - title: task.name, - description: task.description, - project: task.project_id ? [task.project_id] : undefined, + [DEFAULT_FIELD_ID_MAP.title]: task.name, + [DEFAULT_FIELD_ID_MAP.description]: task.description, + [DEFAULT_FIELD_ID_MAP.project]: task.project_id ? [task.project_id] : undefined, }; state.data = task.data ?? {}; state.defaultDataValidationMap = {}; @@ -222,13 +222,13 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { state.createTaskLoading = true; state.originTask = await taskAPI.create({ task_type_id: state.currentTaskType.task_type_id, - name: state.defaultData.title, + name: state.defaultData[DEFAULT_FIELD_ID_MAP.title], status_id: state.statusId as string, - description: state.defaultData.description || undefined, + description: state.defaultData[DEFAULT_FIELD_ID_MAP.description] || undefined, assignee: state.assignee || undefined, data: isEmpty(state.data) ? undefined : state.data, files: state.files.map((f) => f.file_id), - project_id: state.defaultData.project?.[0], + project_id: state.defaultData[DEFAULT_FIELD_ID_MAP.project]?.[0], resource_group: state.currentTaskType.scope, }); showSuccessMessage(i18n.t('OPSFLOW.ALT_S_CREATE_TARGET', { target: taskManagementTemplateStore.templates.task }), ''); @@ -245,7 +245,7 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { if (!state.originTask) throw new Error('Origin task is not defined'); await taskAPI.update({ task_id: state.originTask.task_id, - name: state.defaultData.title, + name: state.defaultData[DEFAULT_FIELD_ID_MAP.title], }); showSuccessMessage(i18n.t('OPSFLOW.ALT_S_UPDATE_TARGET', { target: taskManagementTemplateStore.templates.task }), ''); return true; diff --git a/apps/web/src/services/ops-flow/task-fields-configuration/constants/default-field-constant.ts b/apps/web/src/services/ops-flow/task-fields-configuration/constants/default-field-constant.ts index 16e81e9961..a53c99f321 100644 --- a/apps/web/src/services/ops-flow/task-fields-configuration/constants/default-field-constant.ts +++ b/apps/web/src/services/ops-flow/task-fields-configuration/constants/default-field-constant.ts @@ -1,7 +1,7 @@ export const DEFAULT_FIELD_ID_MAP = { - title: 'title', + title: 'name', description: 'description', - project: 'project', + project: 'project_id', } as const; export const MULTI_SELECTION_FIELD_TYPES = [ 'DROPDOWN', diff --git a/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts b/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts index 2d97235cc1..361723108a 100644 --- a/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts +++ b/apps/web/src/services/ops-flow/task-fields-configuration/stores/use-task-field-metadata-store.ts @@ -17,6 +17,7 @@ interface UseTaskFieldMetadataGetters { taskFieldTypeMetadataList: ComputedRef; workspaceScopeDefaultFields: ComputedRef; projectScopeDefaultFields: ComputedRef; + allDefaultFields: ComputedRef; } export const useTaskFieldMetadataStore = defineStore('task-field-metadata', () => { const taskManagementTemplateStore = useTaskManagementTemplateStore(); @@ -42,7 +43,7 @@ export const useTaskFieldMetadataStore = defineStore('task-field-metadata', () = taskFieldTypeMetadataMap.value.PROJECT, taskFieldTypeMetadataMap.value.ASSET, ]); - const defaultFields = computed(() => [ + const allDefaultFields = computed(() => [ { field_id: DEFAULT_FIELD_ID_MAP.title, name: i18n.t('OPSFLOW.TITLE') as string, @@ -76,12 +77,13 @@ export const useTaskFieldMetadataStore = defineStore('task-field-metadata', () = }, }, ] as TaskField[]); - const workspaceScopeDefaultFields = computed(() => defaultFields.value.filter((field) => field.field_id !== DEFAULT_FIELD_ID_MAP.project)); + const workspaceScopeDefaultFields = computed(() => allDefaultFields.value.filter((field) => field.field_id !== DEFAULT_FIELD_ID_MAP.project)); const getters: UseTaskFieldMetadataGetters = { taskFieldTypeMetadataMap, taskFieldTypeMetadataList, workspaceScopeDefaultFields, - projectScopeDefaultFields: defaultFields, + projectScopeDefaultFields: allDefaultFields, + allDefaultFields, }; return { From 63bd53ba22111ded83f17cf513e03fa7fbb987fd Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 01:09:55 +0900 Subject: [PATCH 31/40] feat(space-connector): add restClient for simplified API method access Signed-off-by: Wanjin Noh --- .../core-lib/src/space-connector/index.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/core-lib/src/space-connector/index.ts b/packages/core-lib/src/space-connector/index.ts index b5f28a910e..8215433cc4 100644 --- a/packages/core-lib/src/space-connector/index.ts +++ b/packages/core-lib/src/space-connector/index.ts @@ -1,4 +1,4 @@ -import type { InternalAxiosRequestConfig, CreateAxiosDefaults } from 'axios'; +import type { InternalAxiosRequestConfig, CreateAxiosDefaults, Axios } from 'axios'; import axios from 'axios'; import type { CustomAxiosRequestConfig } from 'axios-auth-refresh/dist/utils'; import { camelCase } from 'lodash'; @@ -31,6 +31,12 @@ interface ApiHandler { (params?: Params, config?: CustomAxiosRequestConfig): Promise; [key: string]: ApiHandler; } +interface RestApiHandler { + post: Axios['post']; + get: Axios['get']; + put: Axios['put']; + delete: Axios['delete']; +} const DEFAULT_MOCK_CONFIG: MockRequestConfig = Object.freeze({ mockMode: false }); export class SpaceConnector { private static instance: SpaceConnector; @@ -47,6 +53,8 @@ export class SpaceConnector { private _clientV2: any = {}; + private _restClient: RestApiHandler; + private static mockConfig: MockConfig; private static authConfig: AuthConfig; @@ -69,9 +77,15 @@ export class SpaceConnector { SpaceConnector.isDevMode = devConfig?.enabled ?? false; this.tokenApi = tokenApi; this.serviceApi = new ServiceAPI(endpoints[0], this.tokenApi, apiSettings[0]); - this.serviceApiV2 = new ServiceAPI(endpoints[1], this.tokenApi, apiSettings[1]); + const serviceApiV2 = new ServiceAPI(endpoints[1], this.tokenApi, apiSettings[1]); + this.serviceApiV2 = serviceApiV2; this.afterCallApiMap = afterCallApiMap; - this.setApiTokenCheckInterval(); + this._restClient = { + post: this.serviceApi.instance.post, + get: this.serviceApi.instance.get, + put: this.serviceApi.instance.put, + delete: this.serviceApi.instance.delete, + }; } private setApiTokenCheckInterval() { @@ -112,6 +126,13 @@ export class SpaceConnector { throw new Error('Not initialized client V2!'); } + static get restClient(): RestApiHandler { + if (SpaceConnector.instance) { + return SpaceConnector.instance._restClient; + } + throw new Error('Not initialized restClient!'); + } + static setToken(accessToken: string, refreshToken?: string): void { SpaceConnector.instance.tokenApi.setToken(accessToken, refreshToken); SpaceConnector.instance.setApiTokenCheckInterval(); From 6c31377db8bfe27f731b1319cbb213d43ef0f673 Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 01:15:50 +0900 Subject: [PATCH 32/40] feat(task-content-form): replace file model with file IDs for uploads Signed-off-by: Wanjin Noh --- .../stores/task-content-form-store.ts | 26 ++++++++++--------- .../task-fields-form/TaskFieldsForm.vue | 8 +++--- .../field-templates/ParagraphTaskField.vue | 12 +++------ .../types/task-field-form-type.ts | 2 +- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/web/src/services/ops-flow/stores/task-content-form-store.ts b/apps/web/src/services/ops-flow/stores/task-content-form-store.ts index 0bf9b61e6d..37a062f287 100644 --- a/apps/web/src/services/ops-flow/stores/task-content-form-store.ts +++ b/apps/web/src/services/ops-flow/stores/task-content-form-store.ts @@ -5,7 +5,6 @@ import { defineStore } from 'pinia'; import { APIError } from '@cloudforet/core-lib/space-connector/error'; -import type { FileModel } from '@/schema/file-manager/model'; import type { TaskField } from '@/schema/opsflow/_types/task-field-type'; import type { TaskCategoryModel } from '@/schema/opsflow/task-category/model'; import type { TaskTypeModel } from '@/schema/opsflow/task-type/model'; @@ -45,7 +44,7 @@ interface UseTaskContentFormStoreState { // task type field form data: Record; dataValidationMap: Record; - files: FileModel[]; + fileIds: string[]; // for file upload // overall mode: 'create'|'view'; hasUnsavedChanges: boolean; @@ -82,7 +81,7 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { // task type field form data: {}, dataValidationMap: {}, - files: [], + fileIds: [], // overall mode: 'create', hasUnsavedChanges: false, @@ -155,9 +154,10 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { } else { state.currentTaskType = undefined; } - state.data = {}; - state.dataValidationMap = {}; - state.files = []; + if (!state.originTask) { + state.data = {}; + state.dataValidationMap = {}; + } }, setStatusId(statusId?: string) { state.statusId = statusId; @@ -186,9 +186,9 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { setFieldValidation(fieldId: string, isValid: boolean) { state.dataValidationMap = { ...state.dataValidationMap, [fieldId]: isValid }; }, - setFiles(files: FileModel[]) { - state.hasUnsavedChanges = !isEqual(state.files, files); - state.files = files; + setFileIds(fileIds: string[]) { + state.hasUnsavedChanges = !isEqual(state.fileIds, fileIds); + state.fileIds = fileIds; }, // overall setCurrentTask(task: TaskModel) { @@ -203,6 +203,7 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { [DEFAULT_FIELD_ID_MAP.project]: task.project_id ? [task.project_id] : undefined, }; state.data = task.data ?? {}; + state.fileIds = task.files?.map((f) => f.file_id) ?? []; state.defaultDataValidationMap = {}; state.dataValidationMap = {}; }, @@ -211,7 +212,7 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { state.defaultDataValidationMap = {}; state.data = {}; state.dataValidationMap = {}; - state.files = []; + state.fileIds = []; }, setMode(mode: 'create'|'view') { state.mode = mode; @@ -227,7 +228,7 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { description: state.defaultData[DEFAULT_FIELD_ID_MAP.description] || undefined, assignee: state.assignee || undefined, data: isEmpty(state.data) ? undefined : state.data, - files: state.files.map((f) => f.file_id), + files: state.fileIds, project_id: state.defaultData[DEFAULT_FIELD_ID_MAP.project]?.[0], resource_group: state.currentTaskType.scope, }); @@ -243,11 +244,12 @@ export const useTaskContentFormStore = defineStore('task-content-form', () => { async updateTask() { try { if (!state.originTask) throw new Error('Origin task is not defined'); - await taskAPI.update({ + state.originTask = await taskAPI.update({ task_id: state.originTask.task_id, name: state.defaultData[DEFAULT_FIELD_ID_MAP.title], }); showSuccessMessage(i18n.t('OPSFLOW.ALT_S_UPDATE_TARGET', { target: taskManagementTemplateStore.templates.task }), ''); + state.hasUnsavedChanges = false; return true; } catch (e) { ErrorHandler.handleRequestError(e, i18n.t('OPSFLOW.ALT_E_UPDATE_TARGET', { target: taskManagementTemplateStore.templates.task })); diff --git a/apps/web/src/services/ops-flow/task-fields-form/TaskFieldsForm.vue b/apps/web/src/services/ops-flow/task-fields-form/TaskFieldsForm.vue index 70246e5e1c..683d856cc1 100644 --- a/apps/web/src/services/ops-flow/task-fields-form/TaskFieldsForm.vue +++ b/apps/web/src/services/ops-flow/task-fields-form/TaskFieldsForm.vue @@ -41,9 +41,9 @@ onUnmounted(() => { :field="field" :value="taskContentFormState.defaultData[field.field_id]" :readonly="taskContentFormState.mode === 'view' ? !(taskContentFormGetters.isEditable && isEditableFieldInViewMode(field.field_id)) : false" - :files="taskContentFormState.files" + :files="taskContentFormState.originTask?.files" @update:value="taskContentFormStore.setDefaultFieldData(field.field_id, $event)" - @update:files="taskContentFormStore.setFiles" + @update:file-ids="taskContentFormStore.setFileIds" @update:is-valid="taskContentFormStore.setDefaultFieldValidation(field.field_id, $event)" /> @@ -53,9 +53,9 @@ onUnmounted(() => { :field="field" :value="taskContentFormState.data[field.field_id]" :readonly="taskContentFormState.mode === 'view'" - :files="taskContentFormState.files" + :files="taskContentFormState.originTask?.files" @update:value="taskContentFormStore.setFieldData(field.field_id, $event)" - @update:files="taskContentFormStore.setFiles" + @update:file-ids="taskContentFormStore.setFileIds" @update:is-valid="taskContentFormStore.setFieldValidation(field.field_id, $event)" />
diff --git a/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue b/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue index 603ba73349..39809d84b5 100644 --- a/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue +++ b/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue @@ -7,10 +7,8 @@ import { PFieldGroup, } from '@cloudforet/mirinae'; -import type { FileModel } from '@/schema/file-manager/model'; import type { ParagraphTaskField } from '@/schema/opsflow/_types/task-field-type'; -import type { Attachment } from '@/common/components/editor/extensions/image/type'; import TextEditor from '@/common/components/editor/TextEditor.vue'; import TextEditorViewer from '@/common/components/editor/TextEditorViewer.vue'; import { useFileAttachments } from '@/common/composables/file-attachments'; @@ -36,12 +34,10 @@ const { const { fileUploader } = useFileUploader(); const { attachments } = useFileAttachments(toRef(props, 'files')); -const handleUpdateAttachments = (newAttachments: Attachment[]) => { +const handleUpdateAttachmentIds = (attachmentIds: string[]) => { const originFileIds = props.files.map((f) => f.file_id); - const newFileIds = newAttachments.map((a) => a.fileId); - if (isEqual(originFileIds, newFileIds)) return; - const files = newAttachments.map((a) => a.data as FileModel); - emit('update:files', files); + if (isEqual(originFileIds, attachmentIds)) return; + emit('update:file-ids', attachmentIds); }; @@ -68,7 +64,7 @@ const handleUpdateAttachments = (newAttachments: Attachment[]) => { :invalid="isInvalid" content-type="markdown" @update:value="updateFieldValue" - @update:attachments="handleUpdateAttachments" + @update:attachment-ids="handleUpdateAttachmentIds" /> diff --git a/apps/web/src/services/ops-flow/task-fields-form/types/task-field-form-type.ts b/apps/web/src/services/ops-flow/task-fields-form/types/task-field-form-type.ts index e1570f989b..a455108d4f 100644 --- a/apps/web/src/services/ops-flow/task-fields-form/types/task-field-form-type.ts +++ b/apps/web/src/services/ops-flow/task-fields-form/types/task-field-form-type.ts @@ -9,6 +9,6 @@ export interface TaskFieldFormProps { } export interface TaskFieldFormEmits { (event: 'update:value', value: TValue): void; - (event: 'update:files', value: FileModel[]): void; + (event: 'update:file-ids', value: string[]): void; (event: 'update:is-valid', value: boolean): void; } From 6bd0caa86524d097e32dc85b124b5038bd8b3a06 Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 01:17:51 +0900 Subject: [PATCH 33/40] feat(image): simplify attachment handling by removing unnecessary data fields Signed-off-by: Wanjin Noh --- .../src/common/components/editor/TextEditor.vue | 14 ++++++-------- .../components/editor/extensions/image/helper.ts | 12 ++++++------ .../components/editor/extensions/image/index.ts | 4 ++-- .../editor/extensions/image/plugins/drop-image.ts | 8 +++----- .../components/editor/extensions/image/type.ts | 5 ++--- 5 files changed, 19 insertions(+), 24 deletions(-) diff --git a/apps/web/src/common/components/editor/TextEditor.vue b/apps/web/src/common/components/editor/TextEditor.vue index fc75834091..ec12a0950d 100644 --- a/apps/web/src/common/components/editor/TextEditor.vue +++ b/apps/web/src/common/components/editor/TextEditor.vue @@ -15,7 +15,7 @@ import { Editor, EditorContent } from '@tiptap/vue-2'; import { Markdown } from 'tiptap-markdown'; import { createImageExtension } from '@/common/components/editor/extensions/image'; -import { getAttachments, setAttachmentsToContents } from '@/common/components/editor/extensions/image/helper'; +import { getAttachmentIds, setAttachmentsToContents } from '@/common/components/editor/extensions/image/helper'; import type { Attachment, ImageUploader } from '@/common/components/editor/extensions/image/type'; import MenuBar from '@/common/components/editor/MenuBar.vue'; @@ -23,8 +23,8 @@ import { loadMonospaceFonts } from '@/styles/fonts'; interface Props { value?: string; - imageUploader?: ImageUploader; - attachments?: Attachment[]; + imageUploader?: ImageUploader; + attachments?: Attachment[]; invalid?: boolean; placeholder?: string; contentType?: 'html'|'markdown'; @@ -40,15 +40,13 @@ const props = withDefaults(defineProps(), { showUndoRedoButtons: true, }); const emit = defineEmits<{(e: 'update:value', value: string): void; - (e: 'update:attachments', attachments: Attachment[]): void; + (e: 'update:attachment-ids', attachmentIds: string[]): void; }>(); loadMonospaceFonts(); const editor = shallowRef(null); -const imgFileDataMap = new Map(); - const getExtensions = (): AnyExtension[] => { const extensions: AnyExtension[] = [ StarterKit.configure({ @@ -82,7 +80,7 @@ const getExtensions = (): AnyExtension[] => { // add image extension if imageUploader is provided if (props.imageUploader) { - extensions.push(createImageExtension(props.imageUploader, imgFileDataMap)); + extensions.push(createImageExtension(props.imageUploader)); } return extensions; }; @@ -100,7 +98,7 @@ onMounted(() => { content = editor.value.storage.markdown.getMarkdown() ?? ''; } emit('update:value', content); - emit('update:attachments', getAttachments(editor.value, imgFileDataMap)); + emit('update:attachment-ids', getAttachmentIds(editor.value)); }, }); }); diff --git a/apps/web/src/common/components/editor/extensions/image/helper.ts b/apps/web/src/common/components/editor/extensions/image/helper.ts index ef32730d9a..c3c3109e08 100644 --- a/apps/web/src/common/components/editor/extensions/image/helper.ts +++ b/apps/web/src/common/components/editor/extensions/image/helper.ts @@ -5,23 +5,23 @@ import type { Attachment } from '@/common/components/editor/extensions/image/typ // such as

export const emptyHtmlRegExp = /<[^/>][^>]*><\/[^>]+>/; -export const getAttachments = (editor: Editor, imgFileDataMap: Map): Attachment[] => { +export const getAttachmentIds = (editor: Editor): string[] => { const contentsEl = editor.contentComponent?.$el; if (!contentsEl) return []; const imageElements = contentsEl.getElementsByTagName('img'); return Array.from(imageElements) .reduce((results, imageElement) => { const fileId = imageElement.getAttribute('file-id'); - const downloadUrl = imageElement.getAttribute('src'); - if (fileId && downloadUrl) { - results.push({ fileId, downloadUrl, data: imgFileDataMap.get(fileId) }); + const src = imageElement.getAttribute('src'); + if (fileId && src) { + results.push(fileId); } return results; - }, [] as Attachment[]); + }, [] as string[]); }; -export const setAttachmentsToContents = (contents: string, attachments: Attachment[]): string => { +export const setAttachmentsToContents = (contents: string, attachments: Attachment[]): string => { if (attachments.length === 0) return contents; const contentsEl = document.createElement('div'); diff --git a/apps/web/src/common/components/editor/extensions/image/index.ts b/apps/web/src/common/components/editor/extensions/image/index.ts index 31b413bf5d..6974d0d9da 100644 --- a/apps/web/src/common/components/editor/extensions/image/index.ts +++ b/apps/web/src/common/components/editor/extensions/image/index.ts @@ -14,7 +14,7 @@ import { dropImagePlugin } from './plugins/drop-image'; */ const IMAGE_INPUT_REGEX = /!\[(.+|:?)\]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; -export const createImageExtension = (uploadFn: ImageUploader, imgFileDataMap: Map) => Node.create({ +export const createImageExtension = (uploadFn: ImageUploader) => Node.create({ name: 'image', inline: true, group: 'inline', @@ -74,6 +74,6 @@ export const createImageExtension = (uploadFn: ImageUploader, imgFileDataMa ]; }, addProseMirrorPlugins() { - return [dropImagePlugin(uploadFn, imgFileDataMap)]; + return [dropImagePlugin(uploadFn)]; }, }); diff --git a/apps/web/src/common/components/editor/extensions/image/plugins/drop-image.ts b/apps/web/src/common/components/editor/extensions/image/plugins/drop-image.ts index 7eafbeee0f..c7a3a14d4d 100644 --- a/apps/web/src/common/components/editor/extensions/image/plugins/drop-image.ts +++ b/apps/web/src/common/components/editor/extensions/image/plugins/drop-image.ts @@ -7,7 +7,7 @@ const LOADING_IMAGE_NODE = { 'data-loading': true, style: 'max-width: 2rem; max-height: 2rem;', }; -export const dropImagePlugin = (upload: ImageUploader, imgFileDataMap: Map) => new Plugin({ +export const dropImagePlugin = (upload: ImageUploader) => new Plugin({ props: { handleDOMEvents: { paste: (view, _event: Event) => { @@ -29,8 +29,7 @@ export const dropImagePlugin = (upload: ImageUploader, imgFileDataMap: Map< view.dispatch(loadingTransaction); // upload and replace the loading node with the uploaded image node - upload(image).then(({ downloadUrl, fileId, data }) => { - if (data) imgFileDataMap.set(fileId, data); + upload(image).then(({ downloadUrl, fileId }) => { const node = schema.nodes.image.create({ src: downloadUrl, 'file-id': fileId, @@ -88,8 +87,7 @@ export const dropImagePlugin = (upload: ImageUploader, imgFileDataMap: Map< view.dispatch(loadingTransaction); // upload and replace the loading node with the uploaded image node - const { downloadUrl, fileId, data } = await upload(image); - if (data) imgFileDataMap.set(fileId, data); + const { downloadUrl, fileId } = await upload(image); const node = schema.nodes.image.create({ src: downloadUrl, 'file-id': fileId, diff --git a/apps/web/src/common/components/editor/extensions/image/type.ts b/apps/web/src/common/components/editor/extensions/image/type.ts index 508a47caa3..2851d550a2 100644 --- a/apps/web/src/common/components/editor/extensions/image/type.ts +++ b/apps/web/src/common/components/editor/extensions/image/type.ts @@ -1,7 +1,6 @@ -export interface Attachment { +export interface Attachment { downloadUrl: string; fileId: string; - data?: Data; } -export type ImageUploader = (image: File) => Promise>; +export type ImageUploader = (image: File) => Promise; From 0446fbb32a7ed83715b6c07802a946afad7cb6a3 Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 11:34:58 +0900 Subject: [PATCH 34/40] refactor(space-connector): update rest client to use serviceApiV2 instance Signed-off-by: Wanjin Noh --- packages/core-lib/src/space-connector/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core-lib/src/space-connector/index.ts b/packages/core-lib/src/space-connector/index.ts index 8215433cc4..05b3349906 100644 --- a/packages/core-lib/src/space-connector/index.ts +++ b/packages/core-lib/src/space-connector/index.ts @@ -81,10 +81,10 @@ export class SpaceConnector { this.serviceApiV2 = serviceApiV2; this.afterCallApiMap = afterCallApiMap; this._restClient = { - post: this.serviceApi.instance.post, - get: this.serviceApi.instance.get, - put: this.serviceApi.instance.put, - delete: this.serviceApi.instance.delete, + post: serviceApiV2.instance.post, + get: serviceApiV2.instance.get, + put: serviceApiV2.instance.put, + delete: serviceApiV2.instance.delete, }; } From eefba2cf87d105e97a5af98c8074fdad284e42b3 Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 11:45:26 +0900 Subject: [PATCH 35/40] feat(file-attachments): enhance file attachment handling with resource group logic Signed-off-by: Wanjin Noh --- .../composables/file-attachments/index.ts | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/web/src/common/composables/file-attachments/index.ts b/apps/web/src/common/composables/file-attachments/index.ts index 52ebff90f6..3bde5da7f9 100644 --- a/apps/web/src/common/composables/file-attachments/index.ts +++ b/apps/web/src/common/composables/file-attachments/index.ts @@ -1,32 +1,31 @@ import { computedAsync } from '@vueuse/core'; import type { ComputedRef, Ref } from 'vue'; +import { computed } from 'vue'; +import type { ResourceGroupType } from '@/schema/_common/type'; import type { FileModel } from '@/schema/file-manager/model'; -import { getUploadedFile } from '@/lib/file-manager'; +import { useAppContextStore } from '@/store/app-context/app-context-store'; + +import { getFileDownloadUrl } from '@/lib/file-manager'; import type { Attachment } from '@/common/components/editor/extensions/image/type'; export const useFileAttachments = (files: ComputedRef| Ref) => { - const noImage = `${window.location.origin}/images/no-image.png`; + const appContextStore = useAppContextStore(); + const appContextGetters = appContextStore.getters; + const resourceGroup = computed>(() => { + if (appContextGetters.isAdminMode) return 'DOMAIN'; + return 'WORKSPACE'; + }); - const attachments = computedAsync[]>(async (): Promise[]> => { + const attachments = computedAsync(async (): Promise => { if (files.value.length === 0) return []; - const results = await Promise.allSettled(files.value.map((file) => { - if (file.download_url) return Promise.resolve(file); - return getUploadedFile(file.file_id); + return files.value.map((file, idx) => ({ + downloadUrl: getFileDownloadUrl(file.file_id, resourceGroup.value), + fileId: files.value[idx].file_id, })); - return results.map((result, idx) => { - if (result.status === 'fulfilled') { - return { - downloadUrl: result.value.download_url ?? noImage, - fileId: result.value.file_id, - data: result.value, - }; - } - return { downloadUrl: noImage, fileId: files.value[idx].file_id, data: files.value[idx] }; - }); }); return { From e3662b6a330e2c974784262c86516e41c9b2ccd0 Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 11:45:38 +0900 Subject: [PATCH 36/40] refactor: simplify restClient type and initialization in SpaceConnector Signed-off-by: Wanjin Noh --- packages/core-lib/src/space-connector/index.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/core-lib/src/space-connector/index.ts b/packages/core-lib/src/space-connector/index.ts index 05b3349906..086bde9642 100644 --- a/packages/core-lib/src/space-connector/index.ts +++ b/packages/core-lib/src/space-connector/index.ts @@ -31,12 +31,6 @@ interface ApiHandler { (params?: Params, config?: CustomAxiosRequestConfig): Promise; [key: string]: ApiHandler; } -interface RestApiHandler { - post: Axios['post']; - get: Axios['get']; - put: Axios['put']; - delete: Axios['delete']; -} const DEFAULT_MOCK_CONFIG: MockRequestConfig = Object.freeze({ mockMode: false }); export class SpaceConnector { private static instance: SpaceConnector; @@ -53,7 +47,7 @@ export class SpaceConnector { private _clientV2: any = {}; - private _restClient: RestApiHandler; + private _restClient: Axios; private static mockConfig: MockConfig; @@ -80,12 +74,7 @@ export class SpaceConnector { const serviceApiV2 = new ServiceAPI(endpoints[1], this.tokenApi, apiSettings[1]); this.serviceApiV2 = serviceApiV2; this.afterCallApiMap = afterCallApiMap; - this._restClient = { - post: serviceApiV2.instance.post, - get: serviceApiV2.instance.get, - put: serviceApiV2.instance.put, - delete: serviceApiV2.instance.delete, - }; + this._restClient = serviceApiV2.instance; } private setApiTokenCheckInterval() { @@ -126,7 +115,7 @@ export class SpaceConnector { throw new Error('Not initialized client V2!'); } - static get restClient(): RestApiHandler { + static get restClient(): Axios { if (SpaceConnector.instance) { return SpaceConnector.instance._restClient; } From 5da19fd923e5c5a72d59d221d59d8c7da5bfe86c Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 12:51:09 +0900 Subject: [PATCH 37/40] feat(file-manager): simplify file upload process and enhance resource handling Signed-off-by: Wanjin Noh --- .../composables/file-attachments/index.ts | 5 +- .../common/composables/file-uploader/index.ts | 11 +-- apps/web/src/lib/file-manager/index.ts | 74 +++++++++---------- 3 files changed, 38 insertions(+), 52 deletions(-) diff --git a/apps/web/src/common/composables/file-attachments/index.ts b/apps/web/src/common/composables/file-attachments/index.ts index 3bde5da7f9..53af0990a5 100644 --- a/apps/web/src/common/composables/file-attachments/index.ts +++ b/apps/web/src/common/composables/file-attachments/index.ts @@ -1,4 +1,3 @@ -import { computedAsync } from '@vueuse/core'; import type { ComputedRef, Ref } from 'vue'; import { computed } from 'vue'; @@ -11,7 +10,7 @@ import { getFileDownloadUrl } from '@/lib/file-manager'; import type { Attachment } from '@/common/components/editor/extensions/image/type'; -export const useFileAttachments = (files: ComputedRef| Ref) => { +export const useFileAttachments = (files: ComputedRef|Ref) => { const appContextStore = useAppContextStore(); const appContextGetters = appContextStore.getters; const resourceGroup = computed>(() => { @@ -19,7 +18,7 @@ export const useFileAttachments = (files: ComputedRef| Ref(async (): Promise => { + const attachments = computed(() => { if (files.value.length === 0) return []; return files.value.map((file, idx) => ({ diff --git a/apps/web/src/common/composables/file-uploader/index.ts b/apps/web/src/common/composables/file-uploader/index.ts index 6810771bf9..a865323e64 100644 --- a/apps/web/src/common/composables/file-uploader/index.ts +++ b/apps/web/src/common/composables/file-uploader/index.ts @@ -3,8 +3,6 @@ import { computed } from 'vue'; import type { ResourceGroupType } from '@/schema/_common/type'; import { useAppContextStore } from '@/store/app-context/app-context-store'; -import { useUserWorkspaceStore } from '@/store/app-context/workspace/user-workspace-store'; -import { useDomainStore } from '@/store/domain/domain-store'; import { uploadFileAndGetFileInfo } from '@/lib/file-manager'; @@ -12,20 +10,13 @@ import { uploadFileAndGetFileInfo } from '@/lib/file-manager'; export const useFileUploader = () => { const appContextStore = useAppContextStore(); const appContextGetters = appContextStore.getters; - const userWorkspaceStore = useUserWorkspaceStore(); - const workspaceGetters = userWorkspaceStore.getters; - const domainStore = useDomainStore(); const resourceGroup = computed>(() => { if (appContextGetters.isAdminMode) return 'DOMAIN'; return 'WORKSPACE'; }); - const domainIdOrWorkspaceId = computed(() => { - if (appContextGetters.isAdminMode) return domainStore.state.domainId; - return workspaceGetters.currentWorkspaceId as string; - }); return { fileUploader(file: File) { - return uploadFileAndGetFileInfo(file, resourceGroup.value, domainIdOrWorkspaceId.value); + return uploadFileAndGetFileInfo(file, resourceGroup.value); }, }; }; diff --git a/apps/web/src/lib/file-manager/index.ts b/apps/web/src/lib/file-manager/index.ts index f6842b0a4a..9bc7824424 100644 --- a/apps/web/src/lib/file-manager/index.ts +++ b/apps/web/src/lib/file-manager/index.ts @@ -1,61 +1,56 @@ -import axios from 'axios'; - import { SpaceConnector } from '@cloudforet/core-lib/space-connector'; import type { ResourceGroupType } from '@/schema/_common/type'; -import type { FileAddParameters } from '@/schema/file-manager/api-verbs/add'; -import type { - FileGetDownloadUrlParameters, -} from '@/schema/file-manager/api-verbs/get-download-url'; import type { FileModel } from '@/schema/file-manager/model'; import type { Attachment } from '@/common/components/editor/extensions/image/type'; import ErrorHandler from '@/common/composables/error/errorHandler'; -type FileManagerResourceGroupType = Extract; +type FileManagerResourceGroupType = Extract; -interface UploadedFile extends FileModel { - upload_url: string; - upload_options: object; -} -const getUploadInfo = async (file: File, resourceGroup: FileManagerResourceGroupType, domainOrWorkspaceId: string): Promise => { - const params: FileAddParameters = { - name: file.name, - resource_group: resourceGroup, - }; - if (resourceGroup === 'DOMAIN') params.domain_id = domainOrWorkspaceId; - else if (resourceGroup === 'WORKSPACE') params.workspace_id = domainOrWorkspaceId; - const result = await SpaceConnector.clientV2.fileManager.file.add(params); - if (!result.upload_url || !result.upload_options) throw new Error('[File Manager] No upload info in response of add file api'); - return result as UploadedFile; -}; -const uploadFile = async (uploadUrl: string, options: object, file: File) => { +const uploadFile = async (file: File, resourceGroup: FileManagerResourceGroupType): Promise => { const formData = new FormData(); - Object.keys(options).forEach((key) => { - formData.append(key, options[key]); + formData.append('files', file); + + let resourceGroupPath: string; + if (resourceGroup === 'DOMAIN') { + resourceGroupPath = 'domain'; + } else if (resourceGroup === 'WORKSPACE') { + resourceGroupPath = 'workspace'; + } else if (resourceGroup === 'USER') { + resourceGroupPath = 'user'; + } else { resourceGroupPath = 'public'; } + + const response = await SpaceConnector.restClient.post(`/files/${resourceGroupPath}/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, }); - formData.append('file', file); - await axios.post(uploadUrl, formData); + return response.data; }; -export const getUploadedFile = async (fileId: string): Promise => { - const result = await SpaceConnector.clientV2.fileManager.file.getDownloadUrl({ - file_id: fileId, - }); - if (!result.download_url) throw new Error('[File Manager] No download url in response of update file state api'); - return result; +export const getFileDownloadUrl = (fileId: string, resourceGroup: FileManagerResourceGroupType): string => { + const baseUri = SpaceConnector.restClient.getUri(); + + let resourceGroupPath: string; + if (resourceGroup === 'DOMAIN') { + resourceGroupPath = 'domain'; + } else if (resourceGroup === 'WORKSPACE') { + resourceGroupPath = 'workspace'; + } else if (resourceGroup === 'USER') { + resourceGroupPath = 'user'; + } else { resourceGroupPath = 'public'; } + + return `${baseUri}/files/${resourceGroupPath}/${fileId}?token=${SpaceConnector.getAccessToken()}`; }; -export const uploadFileAndGetFileInfo = async (file: File, resourceGroup: FileManagerResourceGroupType, domainOrWorkspaceId: string): Promise> => { +export const uploadFileAndGetFileInfo = async (file: File, resourceGroup: FileManagerResourceGroupType): Promise => { try { - const uploadInfo = await getUploadInfo(file, resourceGroup, domainOrWorkspaceId); - await uploadFile(uploadInfo.upload_url, uploadInfo.upload_options, file); - const fileModel = await getUploadedFile(uploadInfo.file_id); + const fileModel = await uploadFile(file, resourceGroup); return { - downloadUrl: fileModel.download_url as string, + downloadUrl: getFileDownloadUrl(fileModel.file_id, resourceGroup), fileId: fileModel.file_id, - data: fileModel, }; } catch (e) { ErrorHandler.handleError(e); @@ -65,3 +60,4 @@ export const uploadFileAndGetFileInfo = async (file: File, resourceGroup: FileMa }; } }; + From 59c0dcca2caf7ebc2646d46149cae1d946827aa3 Mon Sep 17 00:00:00 2001 From: Wanjin Noh Date: Fri, 3 Jan 2025 12:51:20 +0900 Subject: [PATCH 38/40] feat(editor-content-transformer): add content transformation for editor inputs Signed-off-by: Wanjin Noh --- .../editor-content-transformer/index.ts | 51 +++++++++++++++++++ .../field-templates/ParagraphTaskField.vue | 14 +++-- 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/common/composables/editor-content-transformer/index.ts diff --git a/apps/web/src/common/composables/editor-content-transformer/index.ts b/apps/web/src/common/composables/editor-content-transformer/index.ts new file mode 100644 index 0000000000..a4355e481c --- /dev/null +++ b/apps/web/src/common/composables/editor-content-transformer/index.ts @@ -0,0 +1,51 @@ +import type { ComputedRef, Ref } from 'vue'; +import { computed } from 'vue'; + +import { SpaceConnector } from '@cloudforet/core-lib/space-connector'; + +export const useEditorContentTransformer = (op?: { + value: ComputedRef|Ref; + contentType?: 'html'|'markdown'; +}) => { + const { value, contentType = 'html' } = op || {}; + const baseUri = SpaceConnector.restClient.getUri(); + + const replaceImageUrl = (url: string): string => { + const pattern = new RegExp(`${baseUri}/files/[^/]+/(file-[^?]+)\\?token=.*`); + const match = url.match(pattern); + if (match && match[1]) { + return `{${match[1]}}`; // Extract only the fileId and return it in the format {fileId}. + } + return url; + }; + + const transformHtmlContent = (content: string): string => { + const imagePattern = /]*src="([^"]+)"[^>]*>/g; + return content.replace(imagePattern, (match, url) => { + const newUrl = replaceImageUrl(url); + return match.replace(url, newUrl); + }); + }; + const transformMarkdownContent = (content: string): string => { + const markdownImagePattern = /!\[([^\]]*)\]\(([^)]+)\)/g; + return content.replace(markdownImagePattern, (match, altText, url) => { + const newUrl = replaceImageUrl(url); + return `![${altText}](${newUrl})`; + }); + }; + + const transformEditorContent = (content: string): string => { + if (contentType === 'markdown') { + return transformMarkdownContent(content); + } + return transformHtmlContent(content); + }; + + + const transformedEditorContent = computed(() => transformEditorContent(value ? value.value : '')); + + return { + transformedEditorContent, + transformEditorContent, + }; +}; diff --git a/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue b/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue index 39809d84b5..6e42a92f6e 100644 --- a/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue +++ b/apps/web/src/services/ops-flow/task-fields-form/field-templates/ParagraphTaskField.vue @@ -1,5 +1,5 @@