diff --git a/client/src/api/boards.js b/client/src/api/boards.js index c804b4ded..b1db0ea03 100755 --- a/client/src/api/boards.js +++ b/client/src/api/boards.js @@ -7,14 +7,25 @@ import http from './http'; import socket from './socket'; import { transformCard } from './cards'; import { transformAttachment } from './attachments'; +import { getAccessToken } from '../utils/access-token-storage'; +import Config from '../constants/Config'; /* Actions */ const createBoard = (projectId, data, headers) => socket.post(`/projects/${projectId}/boards`, data, headers); -const createBoardWithImport = (projectId, data, requestId, headers) => - http.post(`/projects/${projectId}/boards?requestId=${requestId}`, data, headers); +const createBoardWithImport = (projectId, data, requestId, headers) => { + const { importMapping, ...formDataFields } = data; + + let url = `/projects/${projectId}/boards?requestId=${requestId}`; + if (importMapping) { + url += `&importMapping=${encodeURIComponent(importMapping)}`; + } + + return http.post(url, formDataFields, headers); +} + const getBoard = (id, subscribe, headers) => socket @@ -32,10 +43,35 @@ const updateBoard = (id, data, headers) => socket.patch(`/boards/${id}`, data, h const deleteBoard = (id, headers) => socket.delete(`/boards/${id}`, undefined, headers); +const previewFocalboardImport = (file) => { + const accessToken = getAccessToken(); + + const formData = new FormData(); + formData.append('file', file); + + return fetch(`${Config.SERVER_BASE_URL}/api/focalboard/preview`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + credentials: 'include', + }) + .then((response) => { + if (!response.ok) { + return response.json().then((body) => { + throw body; + }); + } + return response.json(); + }); +}; + export default { createBoard, createBoardWithImport, getBoard, updateBoard, deleteBoard, + previewFocalboardImport, }; diff --git a/client/src/api/http.js b/client/src/api/http.js index 4d5ceca1a..88bd9493d 100755 --- a/client/src/api/http.js +++ b/client/src/api/http.js @@ -10,13 +10,13 @@ const http = {}; // TODO: add all methods ['GET', 'POST', 'DELETE'].forEach((method) => { http[method.toLowerCase()] = (url, data, headers) => { - const formData = - data && - Object.keys(data).reduce((result, key) => { - result.append(key, data[key]); - return result; - }, new FormData()); + const formData = + data && + Object.keys(data).reduce((result, key) => { + result.append(key, data[key]); + return result; + }, new FormData()); return fetch(`${Config.SERVER_BASE_URL}/api${url}`, { method, diff --git a/client/src/components/boards/AddBoardStep/AddBoardStep.jsx b/client/src/components/boards/AddBoardStep/AddBoardStep.jsx index de859a9b5..a6e7a4837 100755 --- a/client/src/components/boards/AddBoardStep/AddBoardStep.jsx +++ b/client/src/components/boards/AddBoardStep/AddBoardStep.jsx @@ -22,6 +22,11 @@ const StepTypes = { IMPORT: 'IMPORT', }; +const IMPORT_TYPE_ICONS = { + trello: 'trello', + focalboard: 'file', +}; + const AddBoardStep = React.memo(({ onClose }) => { const dispatch = useDispatch(); const [t] = useTranslation(); @@ -47,6 +52,17 @@ const AddBoardStep = React.memo(({ onClose }) => { return; } + // Transform import data for API + if (cleanData.import) { + const { type, file, mapping } = cleanData.import; + cleanData.import = { + type, + file, + // Include mapping for Focalboard imports + ...(mapping && { mapping }), + }; + } + dispatch(entryActions.createBoardInCurrentProject(cleanData)); onClose(); }, [onClose, dispatch, data, nameFieldRef]); @@ -56,6 +72,7 @@ const AddBoardStep = React.memo(({ onClose }) => { setData((prevData) => ({ ...prevData, import: nextImport, + name: nextImport.boardTitle || prevData.name, })); }, [setData], @@ -84,6 +101,8 @@ const AddBoardStep = React.memo(({ onClose }) => { return ; } + const importIconName = data.import ? (IMPORT_TYPE_ICONS[data.import.type] || 'file') : 'arrow down'; + return ( <> @@ -111,7 +130,7 @@ const AddBoardStep = React.memo(({ onClose }) => { onClick={handleImportClick} > {data.import ? data.import.file.name : t('action.import')} diff --git a/client/src/components/boards/AddBoardStep/FocalboardMappingStep.jsx b/client/src/components/boards/AddBoardStep/FocalboardMappingStep.jsx new file mode 100644 index 000000000..f098a41f3 --- /dev/null +++ b/client/src/components/boards/AddBoardStep/FocalboardMappingStep.jsx @@ -0,0 +1,367 @@ +/*! + * Copyright (c) 2024 PLANKA Software GmbH + * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md + */ + +import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import { Button, Checkbox, Dropdown, Form, Loader, Message } from 'semantic-ui-react'; +import { Popup } from '../../../lib/custom-ui'; + +import api from '../../../api'; + +import styles from './FocalboardMappingStep.module.scss'; + +// Property types that can be used for each mapping +const COLUMN_TYPES = ['select']; +const LABEL_TYPES = ['select', 'multiSelect']; +const DUE_DATE_TYPES = ['date']; + +// Supported custom field types (stored as strings in Planka) +const SUPPORTED_CUSTOM_FIELD_TYPES = ['text', 'url', 'number', 'email', 'phone', 'checkbox']; + +// All custom field types to show (excluding column/label types) +const ALL_CUSTOM_FIELD_TYPES = [ + 'text', 'url', 'number', 'email', 'phone', 'checkbox', 'date', + 'person', 'multiPerson', 'file', 'createdTime', 'createdBy', 'updatedTime', 'updatedBy', +]; + +const FocalboardMappingStep = React.memo(({ file, onSelect, onBack }) => { + const [t] = useTranslation(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [previewData, setPreviewData] = useState(null); + + // User selections + const [useBoardTitle, setUseBoardTitle] = useState(true); + const [columnPropertyId, setColumnPropertyId] = useState(null); + const [labelPropertyId, setLabelPropertyId] = useState(null); + const [dueDatePropertyId, setDueDatePropertyId] = useState(null); + const [customFieldPropertyIds, setCustomFieldPropertyIds] = useState([]); + + // Fetch preview data on mount + useEffect(() => { + let cancelled = false; + + const fetchPreview = async () => { + setIsLoading(true); + setError(null); + + try { + const data = await api.previewFocalboardImport(file); + + if (cancelled) return; + + setPreviewData(data); + + // Auto-select column property from Kanban view + const kanbanView = data.views.find((v) => v.type === 'board'); + if (kanbanView?.groupById) { + setColumnPropertyId(kanbanView.groupById); + } + } catch (err) { + if (cancelled) return; + setError(err.message || 'Failed to parse file'); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + fetchPreview(); + + return () => { + cancelled = true; + }; + }, [file]); + + // Filter properties by type for each dropdown + const columnOptions = useMemo(() => { + if (!previewData) return []; + return previewData.properties + .filter((p) => COLUMN_TYPES.includes(p.type)) + .map((p) => ({ + key: p.id, + value: p.id, + text: `${p.name} (${p.options?.length || 0} options)`, + })); + }, [previewData]); + + const labelOptions = useMemo(() => { + if (!previewData) return []; + return [ + { key: 'none', value: '', text: t('common.none') }, + ...previewData.properties + .filter((p) => LABEL_TYPES.includes(p.type)) + .map((p) => ({ + key: p.id, + value: p.id, + text: `${p.name} (${p.options?.length || 0} options)`, + })), + ]; + }, [previewData, t]); + + const dueDateOptions = useMemo(() => { + if (!previewData) return []; + return [ + { key: 'none', value: '', text: t('common.none') }, + ...previewData.properties + .filter((p) => DUE_DATE_TYPES.includes(p.type)) + .map((p) => ({ + key: p.id, + value: p.id, + text: p.name, + })), + ]; + }, [previewData, t]); + + // Get all custom field properties (excluding those used for columns/labels) + const customFieldProperties = useMemo(() => { + if (!previewData) return []; + return previewData.properties.filter((p) => + ALL_CUSTOM_FIELD_TYPES.includes(p.type) && + !COLUMN_TYPES.includes(p.type) && + !LABEL_TYPES.includes(p.type) + ); + }, [previewData]); + + // Get only supported custom field IDs (for select all) + const supportedCustomFieldIds = useMemo(() => { + return customFieldProperties + .filter((p) => SUPPORTED_CUSTOM_FIELD_TYPES.includes(p.type)) + .map((p) => p.id); + }, [customFieldProperties]); + + // Check if a property type is supported + const isTypeSupported = useCallback((type) => { + return SUPPORTED_CUSTOM_FIELD_TYPES.includes(type); + }, []); + + // Handlers + const handleUseBoardTitleChange = useCallback(() => { + setUseBoardTitle((prev) => !prev); + }, []); + + const handleColumnChange = useCallback((_, { value }) => { + setColumnPropertyId(value); + }, []); + + const handleLabelChange = useCallback((_, { value }) => { + setLabelPropertyId(value); + }, []); + + const handleDueDateChange = useCallback((_, { value }) => { + setDueDatePropertyId(value); + }, []); + + const handleCustomFieldToggle = useCallback((propertyId, isSupported) => { + // Only allow toggling supported types + if (!isSupported) return; + + setCustomFieldPropertyIds((prev) => + prev.includes(propertyId) + ? prev.filter((id) => id !== propertyId) + : [...prev, propertyId] + ); + }, []); + + const handleSelectAllCustomFields = useCallback(() => { + setCustomFieldPropertyIds(supportedCustomFieldIds); + }, [supportedCustomFieldIds]); + + const handleDeselectAllCustomFields = useCallback(() => { + setCustomFieldPropertyIds([]); + }, []); + + const handleSubmit = useCallback(() => { + if (!columnPropertyId) return; + + // Just call onSelect - ImportStep will handle navigation + onSelect({ + type: 'focalboard', + file, + boardTitle: useBoardTitle ? previewData.board.title : null, + mapping: { + columnPropertyId, + labelPropertyId: labelPropertyId || undefined, + dueDatePropertyId: dueDatePropertyId || undefined, + customFieldPropertyIds: customFieldPropertyIds.length > 0 ? customFieldPropertyIds : undefined, + }, + }); + }, [file, useBoardTitle, previewData, columnPropertyId, labelPropertyId, dueDatePropertyId, customFieldPropertyIds, onSelect]); + + // Loading state + if (isLoading) { + return ( + <> + + {t('common.focalboardImport', { context: 'title' })} + + +
+ +

{t('common.parsingFile')}

+
+
+ + ); + } + + // Error state + if (error) { + return ( + <> + + {t('common.focalboardImport', { context: 'title' })} + + + + {t('common.error')} +

{error}

+
+
+ + ); + } + + return ( + <> + + {t('common.focalboardImport', { context: 'title' })} + + +
+
+ + + {previewData.board.title} + +
+ + {t('common.cardsCount', { count: previewData.stats.totalCards })} + +
+ +
+ {/* Columns (required) */} +
+
+ {t('common.columns')} * +
+ +
+ + {/* Labels (optional) */} +
+
{t('common.labels')}
+ +
+ + {/* Due Date (optional) */} +
+
{t('common.byDueDate')}
+ +
+ + {/* Custom Fields (optional, multiple) */} + {customFieldProperties.length > 0 && ( +
+
+ {t('common.customFields_title')} +
+ + +
+
+
+ {customFieldProperties.map((property) => { + const supported = isTypeSupported(property.type); + return ( +
+ + + {property.name} + + + {supported ? property.type : t('common.unsupported')} + + + } + onChange={() => handleCustomFieldToggle(property.id, supported)} + /> +
+ ); + })} +
+
+ )} + +