From 5e6a62d9ad5c1e6137c25f0a8f22f8dc50156efb Mon Sep 17 00:00:00 2001 From: Naor Biton Date: Mon, 30 Sep 2019 22:42:06 +0800 Subject: [PATCH] feat: case floor plan pins editing (#881) * feat: case floor plan pins editing * feat: allow to add floor plan pins to a case after creation * fix: prevent saving same pins set as new floor plan comment * feat: refactor floor plan renderer/editor to component * fix: revert sync issue with associated mongo documents * feat: upload floor plan from case views; add pins to case after creation --- imports/api/base/associations-helper.js | 1 + imports/api/cases.js | 40 ++--- imports/api/comments.js | 74 ++++++++- imports/api/units.js | 7 +- .../actions/case-floor-plan-pins.actions.js | 15 ++ .../epics/change-case-floor-plan-pins.js | 16 ++ imports/state/root-epic.js | 4 +- imports/ui/case-wizard/case-wizard.jsx | 150 ++++-------------- imports/ui/case/case-details.jsx | 101 +++++++++++- imports/ui/case/case-messages.jsx | 46 +----- imports/ui/case/case.jsx | 3 +- imports/ui/components/floor-plan-editor.jsx | 148 +++++++++++++++++ imports/ui/components/floor-plan-uploader.jsx | 115 ++++++++++++++ imports/ui/unit/unit-overview-tab.jsx | 83 +--------- 14 files changed, 532 insertions(+), 271 deletions(-) create mode 100644 imports/state/actions/case-floor-plan-pins.actions.js create mode 100644 imports/state/epics/change-case-floor-plan-pins.js create mode 100644 imports/ui/components/floor-plan-editor.jsx create mode 100644 imports/ui/components/floor-plan-uploader.jsx diff --git a/imports/api/base/associations-helper.js b/imports/api/base/associations-helper.js index 49e3d028..2b3a8ff5 100644 --- a/imports/api/base/associations-helper.js +++ b/imports/api/base/associations-helper.js @@ -32,6 +32,7 @@ export const withDocs = ({ cursorMaker, collectionName }) => (publishedItem, add // TODO: Consider implementing "removed" for cases where docs are disassociated with an entity }) cursor.forEach(doc => { + subHandle.removed(collectionName, doc._id) // Resetting existing items in case they exist to avoid revert sync issue -nbiton addingFn(collectionName, doc._id, doc) }) diff --git a/imports/api/cases.js b/imports/api/cases.js index f4bd401d..57b812c0 100644 --- a/imports/api/cases.js +++ b/imports/api/cases.js @@ -17,6 +17,7 @@ PendingInvitations, findUnitRoleConflictErrors, TYPE_ASSIGNED } from './pending-invitations' +import { createFloorPlanComment } from './comments' export const collectionName = 'cases' export const caseServerFieldMapping = { @@ -198,33 +199,18 @@ export const createCase = ( } if (floorPlanPins) { - console.log({ unitItem }) - - const metaData = UnitMetaData.findOne({ bzId: unitItem.id }) - const lastFloorPlan = metaData.floorPlanUrls && metaData.floorPlanUrls.slice(-1)[0] - if (lastFloorPlan && !lastFloorPlan.disabled) { - const payload = { - comment: `[!floorPlan(${lastFloorPlan.id})]\n${floorPlanPins.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`).join(';')}`, - api_key: creatorUser.bugzillaCreds.apiKey - } - - try { - // Creating the comment - const createData = callAPI('post', `/rest/bug/${newCaseId}/comment`, payload, false, true) - if (createData.data.error) { - throw new Meteor.Error(createData.data.error) - } - } catch (e) { - logger.error({ - user: creatorUser._id, - method: `${collectionName}.insert`, - args: [params], - step: 'post /rest/bug/{id}/comment adding floor plan', - error: e - }) - throw new Meteor.Error(`API Error: ${e.response ? e.response.data.message : e.message}`) - } - } + createFloorPlanComment({ + unitBzId: unitItem.id, + caseId: newCaseId, + userApiKey: creatorUser.bugzillaCreds.apiKey, + errorLogParams: { + user: creatorUser._id, + method: `${collectionName}.insert`, + args: [params], + step: 'post /rest/bug/{id}/comment adding floor plan' + }, + floorPlanPins + }) } try { diff --git a/imports/api/comments.js b/imports/api/comments.js index fc3459d6..2b750543 100644 --- a/imports/api/comments.js +++ b/imports/api/comments.js @@ -5,6 +5,9 @@ import bugzillaApi from '../util/bugzilla-api' import publicationFactory from './base/rest-resource-factory' import { makeAssociationFactory, withUsers } from './base/associations-helper' import { logger } from '../util/logger' +import UnitMetaData from './unit-meta-data' +import { idUrlTemplate, factoryOptions as casesFactoryOptions, transformCaseForClient } from './cases' +import { serverHelpers } from './units' export const collectionName = 'comments' @@ -16,6 +19,35 @@ export const factoryOptions = { } } +const formatFloorPlanCommentText = (floorPlanId, floorPlanPins) => + `[!floorPlan(${floorPlanId})]\n${floorPlanPins.map(({ x, y }) => `${x.toFixed(1)},${y.toFixed(1)}`).join(';')}` + +export const createFloorPlanComment = ({ unitBzId, caseId, userApiKey, floorPlanPins, errorLogParams }) => { + const metaData = UnitMetaData.findOne({ bzId: unitBzId }) + const lastFloorPlan = metaData.floorPlanUrls && metaData.floorPlanUrls.slice(-1)[0] + if (lastFloorPlan && !lastFloorPlan.disabled) { + const payload = { + comment: formatFloorPlanCommentText(lastFloorPlan.id, floorPlanPins), + api_key: userApiKey + } + + try { + // Creating the comment + const createData = bugzillaApi.callAPI('post', `/rest/bug/${caseId}/comment`, payload, false, true) + if (createData.data.error) { + throw new Meteor.Error(createData.data.error) + } + return createData.data.id + } catch (e) { + logger.error({ + ...errorLogParams, + error: e + }) + throw new Meteor.Error(`API Error: ${e.response ? e.response.data.message : e.message}`) + } + } +} + export let publicationObj // Exported for testing purposes let FailedComments if (Meteor.isServer) { @@ -47,7 +79,7 @@ if (Meteor.isServer) { } Meteor.methods({ - 'comments.insert' (text, caseId) { + [`${collectionName}.insert`] (text, caseId) { check(text, String) check(caseId, Number) @@ -105,6 +137,46 @@ Meteor.methods({ throw new Meteor.Error(`API Error: ${e.response.data.message}`) } } + }, + [`${collectionName}.insertFloorPlan`] (caseId, floorPlanPins, floorPlanId) { + check(caseId, Number) + check(floorPlanPins, Array) + const currUser = Meteor.user() + if (Meteor.isClient) { + Comments.insert({ + id: Math.round(Math.random() * Number.MAX_VALUE), + creator: currUser.bugzillaCreds.login, + creation_time: (new Date()).toISOString(), + bug_id: caseId, + text: formatFloorPlanCommentText(floorPlanId, floorPlanPins) + }) + } + if (Meteor.isServer) { + const { callAPI } = bugzillaApi + const resp = callAPI('get', idUrlTemplate(caseId), {}, true, true) + const caseItem = transformCaseForClient(casesFactoryOptions.dataResolver(resp.data)[0]) + + const unitItem = serverHelpers.getAPIUnitByName(caseItem.selectedUnit) + + const commentId = createFloorPlanComment({ + unitBzId: unitItem.id, + userApiKey: currUser.bugzillaCreds.apiKey, + errorLogParams: { + user: Meteor.userId(), + method: `${collectionName}.insertFloorPlan`, + args: [caseId, floorPlanPins] + }, + caseId, + floorPlanPins + }) + + // Fetching the full comment object by the returned id from the creation operation + const commentData = callAPI( + 'get', `/rest/bug/comment/${commentId}`, { api_key: currUser.bugzillaCreds.apiKey }, false, true + ) + const newComment = commentData.data.comments[commentId.toString()] + publicationObj.handleAdded(newComment) + } } }) diff --git a/imports/api/units.js b/imports/api/units.js index 499dcfb6..766458c5 100644 --- a/imports/api/units.js +++ b/imports/api/units.js @@ -47,7 +47,12 @@ if (Meteor.isServer) { getAPIUnitByName (unitName, apiKey) { try { const requestUrl = `/rest/product?names=${encodeURIComponent(unitName)}` - const unitResult = callAPI('get', requestUrl, { api_key: apiKey }, false, true) + let unitResult + if (apiKey) { + unitResult = callAPI('get', requestUrl, { api_key: apiKey }, false, true) + } else { + unitResult = callAPI('get', requestUrl, {}, true, true) + } return unitResult.data.products[0] } catch (e) { // Pass through just to highlight this method can throw diff --git a/imports/state/actions/case-floor-plan-pins.actions.js b/imports/state/actions/case-floor-plan-pins.actions.js new file mode 100644 index 00000000..9a170a4e --- /dev/null +++ b/imports/state/actions/case-floor-plan-pins.actions.js @@ -0,0 +1,15 @@ +// @flow +export const CHANGE_FLOOR_PLAN_PINS = 'change_floor_plan_pins_for_case' + +type FloorPlanPins = Array<{ + x: number, + y: number +}> +export function changeFloorPlanPins (caseId: number, floorPlanPins: FloorPlanPins, floorPlanId: number) { + return { + type: CHANGE_FLOOR_PLAN_PINS, + caseId, + floorPlanPins, + floorPlanId + } +} diff --git a/imports/state/epics/change-case-floor-plan-pins.js b/imports/state/epics/change-case-floor-plan-pins.js new file mode 100644 index 00000000..7a1296ab --- /dev/null +++ b/imports/state/epics/change-case-floor-plan-pins.js @@ -0,0 +1,16 @@ +import { collectionName } from '../../api/comments' + +import fallibleMethodCaller from './base/fallible-method-caller' +import { genericErrorOccurred } from '../../ui/general-actions' +import { CHANGE_FLOOR_PLAN_PINS } from '../actions/case-floor-plan-pins.actions' + +export const changeCaseFloorPlanPins = fallibleMethodCaller({ + actionType: CHANGE_FLOOR_PLAN_PINS, + methodName: `${collectionName}.insertFloorPlan`, + argTranslator: ({ caseId, floorPlanPins, floorPlanId }) => [parseInt(caseId), floorPlanPins, floorPlanId], + actionGenerators: { + errorGen: (err, { caseId }) => genericErrorOccurred( + `Failed to update case's floor plan pins for case ${caseId} due to: "${err.error}"` + ) + } +}) diff --git a/imports/state/root-epic.js b/imports/state/root-epic.js index 6aa9f9e8..967719ec 100644 --- a/imports/state/root-epic.js +++ b/imports/state/root-epic.js @@ -38,6 +38,7 @@ import { editUnitMetaData } from './epics/edit-unit-meta-data' import { uploadUnitFloorPlan } from './epics/upload-unit-floor-plan' import { changeUnitFloorPlanUrl } from './epics/change-unit-floor-plan-url' import { disableUnitFloorPlan } from './epics/disable-unit-floor-plan' +import { changeCaseFloorPlanPins } from './epics/change-case-floor-plan-pins' export const rootEpic = combineEpics( createAttachment, @@ -78,5 +79,6 @@ export const rootEpic = combineEpics( editUnitMetaData, uploadUnitFloorPlan, changeUnitFloorPlanUrl, - disableUnitFloorPlan + disableUnitFloorPlan, + changeCaseFloorPlanPins ) diff --git a/imports/ui/case-wizard/case-wizard.jsx b/imports/ui/case-wizard/case-wizard.jsx index d2c64612..f65a88bd 100644 --- a/imports/ui/case-wizard/case-wizard.jsx +++ b/imports/ui/case-wizard/case-wizard.jsx @@ -8,11 +8,9 @@ import { withRouter } from 'react-router-dom' import TextField from 'material-ui/TextField' import SelectField from 'material-ui/SelectField' import MenuItem from 'material-ui/MenuItem' -import FontIcon from 'material-ui/FontIcon' import RaisedButton from 'material-ui/RaisedButton' import { RadioButton, RadioButtonGroup } from 'material-ui/RadioButton' import CircularProgress from 'material-ui/CircularProgress' -import randToken from 'rand-token' import CaseFieldValues, { collectionName as fieldValsCollName } from '../../api/case-field-values' import Reports, { collectionName as reportsCollName } from '../../api/reports' import UnitMetaData from '../../api/unit-meta-data' @@ -28,7 +26,6 @@ import { roleCanBeOccupantMatcher } from '../../util/matchers' import { emailValidator } from '../../util/validators' import InputRow from '../components/input-row' import { infoItemMembers } from '../util/static-info-rendering' -import { panZoomHandler } from '../util/pan-zoom-handler' import { textInputFloatingLabelStyle, @@ -38,12 +35,10 @@ import { controlLabelStyle } from '../components/form-controls.mui-styles' import { MarkerIcon } from '../components/generic-icons' +import FloorPlanEditor from '../components/floor-plan-editor' +import FloorPlanUploader from '../components/floor-plan-uploader' class CaseWizard extends Component { - floorPlanContainer = null - imageCurrDims = null - floorPlanPinMap = {} - constructor () { super(...arguments) this.state = { @@ -163,75 +158,6 @@ class CaseWizard extends Component { } } - handleFloorPlanLoaded = evt => { - const image = evt.target - const parent = this.floorPlanContainer - - const widthRatio = parent.offsetWidth / image.offsetWidth - const heightRatio = parent.offsetHeight / image.offsetHeight - - const imageScale = widthRatio > heightRatio ? heightRatio : widthRatio - - const initWidth = image.offsetWidth * imageScale - const initHeight = image.offsetHeight * imageScale - const activeFloorPlan = this.getActiveFloorPlan() - const initScale = initWidth / activeFloorPlan.dimensions.width - const imageCurrDims = this.imageCurrDims = { - x: (parent.offsetWidth / 2 - initWidth / 2), - y: (parent.offsetHeight / 2 - initHeight / 2), - width: initWidth, - height: initHeight, - currScale: 1, - initScale - } - - Object.assign(image.style, { - position: 'absolute', - left: imageCurrDims.x + 'px', - top: imageCurrDims.y + 'px', - width: imageCurrDims.width + 'px', - height: imageCurrDims.height + 'px' - }) - - panZoomHandler(parent, imageCurrDims, { - minZoom: 1, - maxZoom: 3 - }, { - applyTransform: ({ x, y, scale }) => { - Object.assign(image.style, { - left: x + 'px', - top: y + 'px', - width: initWidth * scale + 'px', - height: initHeight * scale + 'px' - }) - - const { floorPlanPins } = this.state - - floorPlanPins.forEach(obj => { - const el = this.floorPlanPinMap[obj.id] - - Object.assign(el.style, { - left: (x + (obj.x * scale * initScale) - 12) + 'px', - top: (y + (obj.y * scale * initScale) - 20) + 'px' - }) - }) - } - }) - } - - handleFloorPlanContainerClicked = evt => { - const boundingRect = this.floorPlanContainer.getBoundingClientRect() - const relMousePos = { x: evt.clientX - boundingRect.left, y: evt.clientY - boundingRect.top } - const markerObj = { - x: (relMousePos.x - this.imageCurrDims.x) / (this.imageCurrDims.currScale * this.imageCurrDims.initScale), - y: (relMousePos.y - this.imageCurrDims.y) / (this.imageCurrDims.currScale * this.imageCurrDims.initScale), - id: randToken.generate(12) - } - this.setState({ - floorPlanPins: this.state.floorPlanPins.concat([markerObj]) - }) - } - checkFormInvalid = () => { const { inputValues: { mandatory }, needsNewUser, newUserEmail } = this.state return ( @@ -277,7 +203,6 @@ class CaseWizard extends Component { const rolesToRender = this.filterRolesBasedOnOwnership() const activeFloorPlan = this.getActiveFloorPlan() - // const floorPlanUrl = activeFloorPlan && fitDimensions(activeFloorPlan.url, window.innerWidth - 32, 256) return (
dispatch(goBack())} /> @@ -379,44 +304,30 @@ class CaseWizard extends Component {
- {activeFloorPlan && ( -
-
- -
Pin on Floor plan
-
-
{ this.floorPlanContainer = el }} - > -
- Floor Plan Thumbnail -
- {floorPlanPins.map(pin => ( -
{ this.floorPlanPinMap[pin.id] = el }} style={{ - left: (this.imageCurrDims.x + (pin.x * this.imageCurrDims.currScale * this.imageCurrDims.initScale) - 12) + 'px', - top: (this.imageCurrDims.y + (pin.y * this.imageCurrDims.currScale * this.imageCurrDims.initScale) - 20) + 'px' - }} onClick={() => { - delete this.floorPlanPinMap[pin.id] - const modifiedList = floorPlanPins.filter(p => p.id !== pin.id) - this.setState({ - floorPlanPins: modifiedList - }) - }}> - room +
+
+ +
Pin on Floor plan
+
+
+ +
+
+ this.setState({ floorPlanPins: pins })} + isEditing + isMovable + />
- ))} -
-
- Double tap on the floorplan to specify the location in the unit. Swipe to pan. Pinch or spread with two fingers to zoom. Tap an existing marker to remove it. -
+
+ Double tap on the floorplan to specify the location in the unit. Swipe to pan. Pinch or spread with two fingers to zoom. Tap an existing marker to remove it. +
+
+
- )} +
{rolesToRender.length === 1 && rolesToRender[0].areYouDefAssignee ? (

@@ -509,16 +420,22 @@ CaseWizard.propTypes = { preferredUnitId: PropTypes.string, reportItem: PropTypes.object, userId: PropTypes.string, - availableRoles: PropTypes.array + availableRoles: PropTypes.array, + floorPlanUploadProcess: PropTypes.object } export default withRouter(connect( - ({ caseCreationState: { inProgress, error } }, props) => { + ({ caseCreationState: { inProgress, error }, unitFloorPlanUploadState }, props) => { const { unit } = parseQueryString(props.location.search) + const { unit: unitId } = parseQueryString(props.location.search) + const unitMeta = UnitMetaData.findOne({ bzId: parseInt(unitId) }) + const mongoId = unitMeta && unitMeta._id + const floorPlanUploadProcess = mongoId && unitFloorPlanUploadState.find(proc => proc.unitMongoId === mongoId) return { preferredUnitId: unit, inProgress, - error + error, + floorPlanUploadProcess } } )(createContainer( @@ -536,6 +453,7 @@ export default withRouter(connect( .map(name => Meteor.subscribe(`${fieldValsCollName}.fetchByName`, name)) .filter(handle => !handle.ready()).length > 0 const loadingReport = !!reportHandle && !reportHandle.ready() + return ({ isLoading: loadingUnitInfo || loadingUserEmail || loadingFieldValues || loadingReport, unitItem: unitHandle.ready() diff --git a/imports/ui/case/case-details.jsx b/imports/ui/case/case-details.jsx index ae48ae32..efd07afa 100644 --- a/imports/ui/case/case-details.jsx +++ b/imports/ui/case/case-details.jsx @@ -6,9 +6,9 @@ import { connect } from 'react-redux' import FontIcon from 'material-ui/FontIcon' import RaisedButton from 'material-ui/RaisedButton' import IconButton from 'material-ui/IconButton' -import { negate, flow } from 'lodash' +import { negate, flow, isEqual } from 'lodash' import moment from 'moment' -import { attachmentTextMatcher, placeholderEmailMatcher } from '../../util/matchers' +import { attachmentTextMatcher, floorPlanTextMatcher, placeholderEmailMatcher } from '../../util/matchers' import { userInfoItem } from '/imports/util/user.js' import { fillDimensions } from '../../util/cloudinary-transformations' import UsersSearchList from '../components/users-search-list' @@ -20,6 +20,10 @@ import { infoItemLabel, InfoItemContainer, InfoItemRow } from '../util/static-in import AddUserControlLine from '../components/add-user-control-line' import AssigneeSelectionList from '../components/assignee-selection-list' import CaseTargetAttrDialog from '../dialogs/case-target-attr-dialog' +import { changeFloorPlanPins } from '/imports/state/actions/case-floor-plan-pins.actions' +import randToken from 'rand-token' +import FloorPlanEditor from '../components/floor-plan-editor' +import FloorPlanUploader from '../components/floor-plan-uploader' const mediaItemsPadding = 4 // Corresponds with the classNames set to the media items const mediaItemRowCount = 3 @@ -63,8 +67,11 @@ const renderEditableTargetAttribute = ( class CaseDetails extends Component { audioRefs = {} + floorPlanPinMap = {} imageMediaContainer = null audioMediaContainer = null + imageCurrDims = null + constructor (props) { super(props) this.state = { @@ -77,7 +84,12 @@ class CaseDetails extends Component { audioDurations: {}, playingAudioId: null, computedAudioMediaItemWidth: 100, - computedImageMediaItemWidth: 100 + computedImageMediaItemWidth: 100, + isEditingPins: false, + floorPlanPins: [], + savedFloorPlanPins: [], + floorPlan: null, + isFloorPlanLoaded: false } } @@ -89,6 +101,7 @@ class CaseDetails extends Component { componentDidMount () { this.recalcMediaItemsWidth() + this.resolveFloorPlan(this.props.comments) } componentDidUpdate () { @@ -106,6 +119,36 @@ class CaseDetails extends Component { normalizedUnitUsers: this.normalizeUnitUsers() }) } + if (nextProps.comments !== this.props.comments) { + this.resolveFloorPlan(nextProps.comments) + } + } + + resolveFloorPlan = comments => { + const { unitMetaData } = this.props + const floorPlanComment = comments.slice().reverse().find(comment => floorPlanTextMatcher(comment.text)) + let activeFloorPlan, translatedPins + if (floorPlanComment) { + const { id, pins } = floorPlanTextMatcher(floorPlanComment.text) + const floorPlan = unitMetaData.floorPlanUrls && unitMetaData.floorPlanUrls.find(f => f.id === id) + activeFloorPlan = floorPlan && !floorPlan.disabled && floorPlan + translatedPins = pins.map(pin => ({ x: pin[0], y: pin[1], id: randToken.generate(12) })) + } else { + const floorPlan = unitMetaData.floorPlanUrls && unitMetaData.floorPlanUrls.slice(-1)[0] + activeFloorPlan = floorPlan && !floorPlan.disabled && floorPlan + } + if (!floorPlanComment || !activeFloorPlan) { + this.setState({ + floorPlanPins: [], + floorPlan: activeFloorPlan + }) + } else { + this.setState({ + floorPlanPins: translatedPins, + savedFloorPlanPins: translatedPins, + floorPlan: activeFloorPlan + }) + } } recalcMediaItemsWidth = () => { @@ -563,7 +606,7 @@ class CaseDetails extends Component { render () { const { caseItem, comments, unitItem, caseUserTypes, pendingInvitations, caseUsersState, userId, userBzLogin, caseFieldValues } = this.props - const { normalizedUnitUsers } = this.state + const { normalizedUnitUsers, floorPlanPins, floorPlan } = this.state let successfullyAddedUsers, addUsersError if (parseInt(caseUsersState.caseId) === parseInt(caseItem.id)) { successfullyAddedUsers = caseUsersState.added @@ -580,6 +623,7 @@ class CaseDetails extends Component { {this.renderStatusLine(caseItem, caseFieldValues)} {this.renderCategoriesLine(caseItem, caseFieldValues)} {this.renderPrioritySeverityLine(caseItem, caseFieldValues)} + {this.renderFloorPlan(floorPlan, floorPlanPins, unitItem)} {this.renderCreatedBy(caseUserTypes.creator)} {this.renderAssignedTo( caseUserTypes.assignee, normalizedUnitUsers, pendingInvitations, isUnitOwner, unitRoleType @@ -593,6 +637,52 @@ class CaseDetails extends Component {

) } + + saveFloorPlanPins = () => { + const { dispatch, caseItem } = this.props + const { floorPlanPins, floorPlan } = this.state + dispatch(changeFloorPlanPins(caseItem.id, floorPlanPins, floorPlan.id)) + } + + renderFloorPlan (floorPlan, pins, unitItem) { + const { isEditingPins } = this.state + return ( +
+
+
+ {infoItemLabel('Location on floor plan')} +
+ {floorPlan && ( + { + const { savedFloorPlanPins, floorPlanPins } = this.state + if (isEditingPins && !isEqual(savedFloorPlanPins, floorPlanPins)) { + this.saveFloorPlanPins() + } + this.setState({ isEditingPins: !isEditingPins }) + }}> + {isEditingPins + ? 'Save pins' + : pins.length ? 'Edit pins' : 'Add pins'} + + )} +
+
+ +
+ this.setState({ floorPlanPins: pins })} + /> +
+
+
+
+ ) + } + renderMediaSection (comments) { const { audioDurations, playingAudioId, computedAudioMediaItemWidth: audioSize, computedImageMediaItemWidth: imageSize @@ -704,7 +794,8 @@ CaseDetails.propTypes = { caseUsersState: PropTypes.object.isRequired, pendingInvitations: PropTypes.array, userBzLogin: PropTypes.string.isRequired, - caseFieldValues: PropTypes.object.isRequired + caseFieldValues: PropTypes.object.isRequired, + unitMetaData: PropTypes.object.isRequired } export default connect(() => ({}))(withRouter(CaseDetails)) diff --git a/imports/ui/case/case-messages.jsx b/imports/ui/case/case-messages.jsx index fca40068..f49cfe9d 100644 --- a/imports/ui/case/case-messages.jsx +++ b/imports/ui/case/case-messages.jsx @@ -34,6 +34,8 @@ import { } from '../components/form-controls.mui-styles' import ChatBotUI from './chatbot-ui' import ErrorDialog from '../dialogs/error-dialog' +import FloorPlanEditor from '../components/floor-plan-editor' +import randToken from 'rand-token' const messagePercentWidth = 0.6 // Corresponds with width/max-width set to the text and image message containers @@ -465,51 +467,13 @@ class CaseMessages extends Component { makeFloorPlanRenderer = ({ id, pins }) => { const { unitMetaData } = this.props const floorPlan = unitMetaData.floorPlanUrls.find(obj => obj.id === id) - const floorPlanPinMap = {} - const handleFloorPlanImageLoaded = evt => { - const image = evt.target - const parent = image.parentNode - - const widthRatio = parent.offsetWidth / image.offsetWidth - const heightRatio = parent.offsetHeight / image.offsetHeight - const imageScale = widthRatio > heightRatio ? heightRatio : widthRatio - - const initWidth = image.offsetWidth * imageScale - const initHeight = image.offsetHeight * imageScale - - const initScale = initWidth / floorPlan.dimensions.width - const imageX = (parent.offsetWidth / 2 - initWidth / 2) - const imageY = (parent.offsetHeight / 2 - initHeight / 2) - - Object.assign(image.style, { - position: 'absolute', - left: imageX + 'px', - top: imageY + 'px', - width: initWidth + 'px', - height: initHeight + 'px' - }) - - pins.forEach((pin, idx) => { - const pinEl = floorPlanPinMap[idx] - Object.assign(pinEl.style, { - left: (imageX + (pin[0] * initScale) - 12) + 'px', - top: (imageY + (pin[1] * initScale) - 20) + 'px' - }) - }) - } + const translatedPins = pins.map(pin => ({ x: pin[0], y: pin[1], id: randToken.generate(12) })) return ({ isSelf, text, creationTime }) => { return (
- {floorPlan && ( - {floorPlan.url} - )} - {pins.map((pin, idx) => ( -
{ floorPlanPinMap[idx] = el }}> - room -
- ))} +
{moment(creationTime).format('HH:mm')}
diff --git a/imports/ui/case/case.jsx b/imports/ui/case/case.jsx index b3871b69..512c5b2d 100644 --- a/imports/ui/case/case.jsx +++ b/imports/ui/case/case.jsx @@ -127,7 +127,8 @@ export class Case extends Component { caseUserTypes, pendingInvitations, caseUsersState, - caseFieldValues + caseFieldValues, + unitMetaData }} onRoleUsersInvited={userLogins => dispatch(addRoleUsers(userLogins, caseId))} onRoleUserRemoved={user => dispatch(removeRoleUser(user.login, caseId))} diff --git a/imports/ui/components/floor-plan-editor.jsx b/imports/ui/components/floor-plan-editor.jsx new file mode 100644 index 00000000..982d2333 --- /dev/null +++ b/imports/ui/components/floor-plan-editor.jsx @@ -0,0 +1,148 @@ +// @flow +/* global HTMLElement, SyntheticMouseEvent */ +import * as React from 'react' +import randToken from 'rand-token' +import { panZoomHandler } from '../util/pan-zoom-handler' +import FontIcon from 'material-ui/FontIcon' +type Pins = Array<{ + x: number, + y: number, + id: string +}> +type Props = { + isEditing?: boolean, + isMovable?: boolean, + pins: Pins, + floorPlan: { + url: string, + dimensions: { + width: number, + height: number + } + }, + onPinsChanged?: (pins: Pins) => void +} + +type State = { + isFloorPlanLoaded: boolean +} + +export default class FloorPlanEditor extends React.Component { + floorPlanContainer: ?HTMLElement = null + imageEl: ?HTMLElement = null + imageCurrDims = {} + floorPlanPinMap = {} + state = { + isFloorPlanLoaded: false + } + handleFloorPlanContainerClicked = (evt: SyntheticMouseEvent) => { + const { isEditing, pins, onPinsChanged } = this.props + if (isEditing && onPinsChanged && this.floorPlanContainer) { + const boundingRect = this.floorPlanContainer.getBoundingClientRect() + const relMousePos = { x: evt.clientX - boundingRect.left, y: evt.clientY - boundingRect.top } + const newPin = { + x: (relMousePos.x - this.imageCurrDims.x) / (this.imageCurrDims.currScale * this.imageCurrDims.initScale), + y: (relMousePos.y - this.imageCurrDims.y) / (this.imageCurrDims.currScale * this.imageCurrDims.initScale), + id: randToken.generate(12) + } + onPinsChanged(pins.concat([newPin])) + } + } + + handleFloorPlanImageLoaded = () => { + const { floorPlan, isMovable } = this.props + const image = this.imageEl + if (!image) return + const parent:HTMLElement = (image.parentElement:any) + + const parWidth = parent.offsetWidth + const parHeight = parent.offsetHeight + + const widthRatio = parWidth / image.offsetWidth + const heightRatio = parHeight / image.offsetHeight + const imageScale = widthRatio > heightRatio ? heightRatio : widthRatio + + const initWidth = image.offsetWidth * imageScale + const initHeight = image.offsetHeight * imageScale + + const initScale = initWidth / floorPlan.dimensions.width + const imageX = (parWidth / 2 - initWidth / 2) + const imageY = (parHeight / 2 - initHeight / 2) + const imageCurrDims = this.imageCurrDims = { + x: imageX, + y: imageY, + width: initWidth, + height: initHeight, + currScale: 1, + initScale + } + + Object.assign(image.style, { + position: 'absolute', + left: imageX + 'px', + top: imageY + 'px', + width: initWidth + 'px', + height: initHeight + 'px' + }) + + this.setState({ + isFloorPlanLoaded: true + }) + + if (isMovable) { + panZoomHandler(parent, imageCurrDims, { + minZoom: 1, + maxZoom: 3 + }, { + applyTransform: ({ x, y, scale }) => { + Object.assign(image.style, { + left: x + 'px', + top: y + 'px', + width: initWidth * scale + 'px', + height: initHeight * scale + 'px' + }) + + const { pins } = this.props + + pins.forEach(obj => { + const el = this.floorPlanPinMap[obj.id] + + Object.assign(el.style, { + left: (x + (obj.x * scale * initScale) - 12) + 'px', + top: (y + (obj.y * scale * initScale) - 20) + 'px' + }) + }) + } + }) + } + } + + render () { + const { floorPlan, pins, isEditing, onPinsChanged } = this.props + const { isFloorPlanLoaded } = this.state + return ( +
{ this.floorPlanContainer = el }}> + {floorPlan && ( + {floorPlan.url} { this.imageEl = ref }} + onLoad={() => this.handleFloorPlanImageLoaded()} + /> + )} + {isFloorPlanLoaded && pins.map(pin => ( +
{ this.floorPlanPinMap[pin.id] = el }} style={{ + left: (this.imageCurrDims.x + (pin.x * this.imageCurrDims.currScale * this.imageCurrDims.initScale) - 12) + 'px', + top: (this.imageCurrDims.y + (pin.y * this.imageCurrDims.currScale * this.imageCurrDims.initScale) - 20) + 'px' + }} onClick={isEditing && onPinsChanged && (() => { + delete this.floorPlanPinMap[pin.id] + const modifiedList = pins.filter(p => p.id !== pin.id) + onPinsChanged(modifiedList) + })}> + room +
+ ))} +
+ ) + } +} diff --git a/imports/ui/components/floor-plan-uploader.jsx b/imports/ui/components/floor-plan-uploader.jsx new file mode 100644 index 00000000..f86bfda5 --- /dev/null +++ b/imports/ui/components/floor-plan-uploader.jsx @@ -0,0 +1,115 @@ +// @flow +/* global File */ +import * as React from 'react' +import { connect } from 'react-redux' +import { createContainer } from 'meteor/react-meteor-data' +import RaisedButton from 'material-ui/RaisedButton' +import FontIcon from 'material-ui/FontIcon' +import type { Dimensions } from '/imports/state/actions/unit-floor-plan.actions' +import { fileInputReaderEventHandler } from '../util/dom-api' +import UploadPreloader from './upload-preloader' +import FileInput from './file-input' +import { UploadIcon } from './generic-icons' +import { disableFloorPlan, uploadFloorPlan } from '/imports/state/actions/unit-floor-plan.actions' + +type Process = { + preview: string, + dimensions: Dimensions, + file: File, + percent: number +} +type Props = { + uploadProcess: Process, + unitMetaData: { + _id: string, + floorPlanUrls: ?Array<{ + url: string, + disabled: ?boolean + }> + }, + dispatch: (action: any) => void, + children: ?React.Node +} + +class FloorPlanUploader extends React.Component { + render () { + const { uploadProcess, unitMetaData, dispatch, children } = this.props + const activeFloorPlan = unitMetaData.floorPlanUrls && + !unitMetaData.floorPlanUrls.slice(-1)[0].disabled && + unitMetaData.floorPlanUrls.slice(-1)[0] + const floorPlanUrl = uploadProcess ? uploadProcess.preview : activeFloorPlan && activeFloorPlan.url + + if (floorPlanUrl && (uploadProcess || !children)) { + return ( +
+
+ Floor Plan Thumbnail + {uploadProcess && ( + dispatch(uploadFloorPlan(unitMetaData._id, proc.preview, proc.file, proc.dimensions))} + /> + )} +
+ {!uploadProcess && ( +
+
+ + dispatch(uploadFloorPlan(unitMetaData._id, preview, file, dimensions)) + )}> +
+ +
+ Upload again +
+
+
+
+
+
+ dispatch(disableFloorPlan(unitMetaData._id))}> +
+ delete +
+ Remove floor plan +
+
+
+
+
+ )} +
+ ) + } else if (floorPlanUrl && children) { + return children + } else { + return ( + + dispatch(uploadFloorPlan(unitMetaData._id, preview, file, dimensions)) + )}> +
+ +
+ Upload floor plan +
+
+
+
+ ) + } + } +} + +export default connect(({ unitFloorPlanUploadState }, props: Props) => { + const uploadProcess = unitFloorPlanUploadState.find(process => process.unitMongoId === props.unitMetaData._id) + return { + uploadProcess + } +})(createContainer(() => ({}), FloorPlanUploader)) diff --git a/imports/ui/unit/unit-overview-tab.jsx b/imports/ui/unit/unit-overview-tab.jsx index 934520dd..6295c1c5 100644 --- a/imports/ui/unit/unit-overview-tab.jsx +++ b/imports/ui/unit/unit-overview-tab.jsx @@ -1,5 +1,4 @@ // @flow - import { Meteor } from 'meteor/meteor' import * as React from 'react' import { createContainer } from 'meteor/react-meteor-data' @@ -9,7 +8,6 @@ import IconButton from 'material-ui/IconButton' import FontIcon from 'material-ui/FontIcon' import { isEqual } from 'lodash' import AutoComplete from 'material-ui/AutoComplete' -import RaisedButton from 'material-ui/RaisedButton' import { removeCleared, removeFromUnit } from '../../state/actions/unit-invite.actions' import { editUnitMetaData } from '../../state/actions/unit-meta-data.actions' @@ -25,12 +23,7 @@ import { unitTypes } from '../../api/unit-meta-data' import MenuItem from 'material-ui/MenuItem' import SelectField from 'material-ui/SelectField' import { textInputFloatingLabelStyle, textInputUnderlineFocusStyle } from '../components/form-controls.mui-styles' -import FileInput from '../components/file-input' -import { UploadIcon } from '../components/generic-icons' -import { fileInputReaderEventHandler } from '../util/dom-api' -import { disableFloorPlan, uploadFloorPlan } from '../../state/actions/unit-floor-plan.actions' -import { fitDimensions } from '../../util/cloudinary-transformations' -import UploadPreloader from '../components/upload-preloader' +import FloorPlanUploader from '../components/floor-plan-uploader' type UnitUser = { login: string, @@ -195,21 +188,11 @@ class UnitOverviewTab extends React.Component { })) } render () { - const { unitItem, metaData, unitUsers, isOwner, currentUser, dispatch, isEditing, floorPlanUploadProcess } = this.props + const { unitItem, metaData, unitUsers, isOwner, currentUser, dispatch, isEditing } = this.props const { showRemovalConfirmation, userToRemove, countrySearchText, country, countryValidWarning, unitType } = this.state const ongoingRemoval = this.getOngoingRemoval(this.props, this.state) const unitName = metaData.displayName || unitItem.name const currLoginName = currentUser.bugzillaCreds.login - - let floorPlanUrl - if (floorPlanUploadProcess) { - floorPlanUrl = floorPlanUploadProcess.preview - } else if (metaData.floorPlanUrls) { - const lastPlanUrl = metaData.floorPlanUrls.slice(-1)[0] - if (!lastPlanUrl.disabled) { - floorPlanUrl = fitDimensions(lastPlanUrl.url, window.innerWidth - 32, 256) - } - } return ( (
@@ -257,65 +240,9 @@ class UnitOverviewTab extends React.Component {
{infoItemLabel('Floor plan')}
- {floorPlanUrl ? ( -
-
- Floor Plan Thumbnail - {floorPlanUploadProcess && ( - dispatch(uploadFloorPlan(metaData._id, proc.preview, proc.file, proc.dimensions))} - /> - )} -
- {!floorPlanUploadProcess && ( -
-
- - dispatch(uploadFloorPlan(metaData._id, preview, file, dimensions)) - )}> -
- -
- Upload again -
-
-
-
-
-
- dispatch(disableFloorPlan(metaData._id))}> -
- delete -
- Remove floor plan -
-
-
-
-
- )} -
- ) : ( - - dispatch(uploadFloorPlan(metaData._id, preview, file, dimensions)) - )}> -
- -
- Upload floor plan -
-
-
-
- )} +