diff --git a/package-lock.json b/package-lock.json
index 767178048..4dfb8d21b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,7 +50,7 @@
"slate-hyperscript": "^0.100.0",
"slate-react": "^0.111.0",
"source-map-explorer": "^2.5.3",
- "terraso-client-shared": "github:techmatters/terraso-client-shared#8cd860e",
+ "terraso-client-shared": "github:techmatters/terraso-client-shared#bec5d03dcaf0c47bc2e48661d35a4b73ed2b70fd",
"use-debounce": "^10.0.4",
"uuid": "^11.0.3",
"web-vitals": "^4.2.4",
@@ -30257,19 +30257,20 @@
},
"node_modules/terraso-backend": {
"version": "0.1.0",
- "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#b61c60caf963a400bffa8016cb6f894d5d9d72b4",
- "integrity": "sha512-rM6ZXO+dX5GT3QuERDJ5PW03O1buJdD7DOkFiMnKbvQobAT+L71NypMKsZrcfxi4+6gT2dYo1j/5EmkFQxVoJQ=="
+ "resolved": "git+ssh://git@github.com/techmatters/terraso-backend.git#29d558f773853df94973499ab7c9f8c97fc74659",
+ "integrity": "sha512-Hvl31X7cLIl8V0EFhVRzyxRLEVv6MYFEIW0oEGDLc1p8p9PzVB9I889nl4pUCna4rJz16PZH6ywFj1Y3hnRLGg=="
},
"node_modules/terraso-client-shared": {
"version": "0.1.0",
- "resolved": "git+ssh://git@github.com/techmatters/terraso-client-shared.git#8cd860ef31575487970a59a14cf6a700e1c156bf",
+ "resolved": "git+ssh://git@github.com/techmatters/terraso-client-shared.git#bec5d03dcaf0c47bc2e48661d35a4b73ed2b70fd",
+ "integrity": "sha512-CwEmZFMlLY0vq2wb1RR8EseEPj53gBGpLxHsHPlCfKUSogDKYCypdWXa9Sggn04C/Uufp5JjV6rUicuKRWglcQ==",
"dependencies": {
"@reduxjs/toolkit": "^1.9.7",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"react": "^18.3.1",
"react-redux": "^8.1.3",
- "terraso-backend": "github:techmatters/terraso-backend#0a4e249",
+ "terraso-backend": "github:techmatters/terraso-backend#29d558f773853df94973499ab7c9f8c97fc74659",
"uuid": "^10.0.0"
},
"engines": {
diff --git a/package.json b/package.json
index e77874193..4d7e21b12 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"slate-hyperscript": "^0.100.0",
"slate-react": "^0.111.0",
"source-map-explorer": "^2.5.3",
- "terraso-client-shared": "github:techmatters/terraso-client-shared#8cd860e",
+ "terraso-client-shared": "github:techmatters/terraso-client-shared#bec5d03dcaf0c47bc2e48661d35a4b73ed2b70fd",
"use-debounce": "^10.0.4",
"uuid": "^11.0.3",
"web-vitals": "^4.2.4",
diff --git a/src/gis/components/Map.js b/src/gis/components/Map.js
index 0196a18b6..eb01767a2 100644
--- a/src/gis/components/Map.js
+++ b/src/gis/components/Map.js
@@ -305,7 +305,7 @@ const Map = React.forwardRef((props, ref) => {
id,
mapStyle,
projection,
- initialLocation,
+ initialLocation: propsInitialLocation,
interactive = true,
hash = false,
attributionControl = true,
@@ -325,6 +325,7 @@ const Map = React.forwardRef((props, ref) => {
const { map, setMap } = useMap();
const mapContainer = useRef(null);
const [bounds] = useState(initialBounds);
+ const [initialLocation] = useState(propsInitialLocation);
useEffect(() => {
const validBounds = isValidBounds(bounds);
diff --git a/src/localization/locales/en-US.json b/src/localization/locales/en-US.json
index 2979f25eb..2b97ea3ad 100644
--- a/src/localization/locales/en-US.json
+++ b/src/localization/locales/en-US.json
@@ -887,11 +887,13 @@
"form_chapter_no_title_label": "Untitled",
"form_back_button": "Story Maps",
"form_byline": "By $t(user.full_name)",
- "form_share_button": "Invite",
- "form_preview_button": "Preview",
+ "form_share_button": "Invite editors",
+ "form_actions_button": "Actions",
+ "form_preview_button": "Preview draft",
+ "form_view_published_button": "View published Story Map",
"form_save_draft_button": "Save draft",
"form_publish_button": "Publish",
- "form_republish_button": "Save & Republish",
+ "form_republish_button": "Publish Changes",
"form_preview_close": "Exit Preview",
"form_preview_title": "You are previewing <1>{{title}}1>",
"form_preview_title_blank": "You are previewing",
@@ -977,6 +979,9 @@
"form_no_title_label": "Untitled",
"form_title_location_button": "Set Map Location",
"form_title_location_dialog_title": "Title",
+ "form_save_status_saving": "Saving...",
+ "form_save_status_saved": "Draft saved",
+ "form_save_status_error": "Save failed, trying to connect",
"update_story_map": "{{title}} story map has been updated.",
"update_story_map_published": "{{title}} story map has been published.",
"added_story_map": "{{title}} story map has been saved.",
diff --git a/src/storyMap/components/StoryMap.js b/src/storyMap/components/StoryMap.js
index bc5224375..ddb21d0c6 100644
--- a/src/storyMap/components/StoryMap.js
+++ b/src/storyMap/components/StoryMap.js
@@ -477,7 +477,9 @@ const Scroller = props => {
filtered.forEach(setLayerOpacity);
}
});
-
+ // This is needed for development due to resize observer issue
+ // should not be here on production
+ // scroller.disable();
setIsReady(true);
window.addEventListener('resize', scroller.resize);
diff --git a/src/storyMap/components/StoryMapForm/TopBar.js b/src/storyMap/components/StoryMapForm/TopBar.js
index 02ffce873..7e4c51e73 100644
--- a/src/storyMap/components/StoryMapForm/TopBar.js
+++ b/src/storyMap/components/StoryMapForm/TopBar.js
@@ -15,24 +15,84 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
-import React from 'react';
+import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
-import { Button, Divider, Grid, Typography } from '@mui/material';
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+import CheckIcon from '@mui/icons-material/Check';
+import ErrorIcon from '@mui/icons-material/Error';
+import SyncIcon from '@mui/icons-material/Sync';
+import {
+ Button,
+ Divider,
+ Grid,
+ Link,
+ Menu,
+ MenuItem,
+ Stack,
+ Typography,
+} from '@mui/material';
import RouterLink from 'common/components/RouterLink';
+import { generateStoryMapUrl } from 'storyMap/storyMapUtils';
import ShareDialog from './ShareDialog';
import { useStoryMapConfigContext } from './storyMapConfigContext';
import TopBarContainer from './TopBarContainer';
-const TopBar = props => {
+const SAVE_STATUS = {
+ saving: {
+ message: 'storyMap.form_save_status_saving',
+ Icon: SyncIcon,
+ },
+ saved: {
+ message: 'storyMap.form_save_status_saved',
+ Icon: CheckIcon,
+ },
+ error: {
+ message: 'storyMap.form_save_status_error',
+ Icon: ErrorIcon,
+ color: 'error.main',
+ },
+};
+
+const SaveStatus = props => {
const { t } = useTranslation();
- const { storyMap, config, setPreview } = useStoryMapConfigContext();
- const { onPublish, onSaveDraft } = props;
- const [openShareDialog, setOpenShareDialog] = React.useState(false);
+ const { requestStatus, isDirty } = props;
+ const { error } = requestStatus;
- const isPublished = storyMap?.isPublished;
+ const status = error ? 'error' : isDirty ? 'saving' : 'saved';
+ const Icon = SAVE_STATUS[status].Icon;
+ const message = SAVE_STATUS[status].message;
+ const color = SAVE_STATUS[status].color;
+
+ return (
+
+
+ {t(message)}
+
+ );
+};
+
+const ActionsMenu = () => {
+ const { t } = useTranslation();
+ const { storyMap, setPreview } = useStoryMapConfigContext();
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [openShareDialog, setOpenShareDialog] = useState(false);
+
+ const open = Boolean(anchorEl);
+
+ const handleClick = event => {
+ setAnchorEl(event.currentTarget);
+ };
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
return (
<>
@@ -42,77 +102,101 @@ const TopBar = props => {
onClose={() => setOpenShareDialog(false)}
/>
)}
-
-
- }
+ >
+ {t('storyMap.form_actions_button')}
+
+
-
-
- {config.title || t('storyMap.form_no_title_label')}
+ {t('storyMap.form_view_published_button')}
+
+ )}
+ {storyMap && }
+ {storyMap && (
+
+ )}
+
+ >
+ );
+};
+
+const TopBar = props => {
+ const { t } = useTranslation();
+ const { storyMap, config } = useStoryMapConfigContext();
+ const { onPublish, isDirty, requestStatus } = props;
+
+ const isPublished = storyMap?.isPublished;
+
+ return (
+
+
+
+
+
+ {t('storyMap.form_back_button')}
-
-
+
+
+
+ {config.title || t('storyMap.form_no_title_label')}
+
+
+
+
- {storyMap && (
- <>
-
-
- >
- )}
-
- {!isPublished && (
-
- )}
-
+
+
-
-
- >
+
+
+
);
};
diff --git a/src/storyMap/components/StoryMapForm/index.js b/src/storyMap/components/StoryMapForm/index.js
index 7f4982964..5fbeba7bc 100644
--- a/src/storyMap/components/StoryMapForm/index.js
+++ b/src/storyMap/components/StoryMapForm/index.js
@@ -19,10 +19,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import _ from 'lodash/fp';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
+import logger from 'terraso-client-shared/monitoring/logger';
+import { useDebounce } from 'use-debounce';
import { v4 as uuidv4 } from 'uuid';
import { Grid, useMediaQuery } from '@mui/material';
-import PageLoader from 'layout/PageLoader';
import { useAnalytics } from 'monitoring/analytics';
import NavigationBlockedDialog from 'navigation/components/NavigationBlockedDialog';
import { useNavigationBlocker } from 'navigation/navigationContext';
@@ -38,6 +39,8 @@ import TopBarPreview from './TopBarPreview';
import theme from 'theme';
+const AUTO_SAVE_DEBOUNCE = 3000;
+
const BASE_CHAPTER = {
alignment: 'left',
title: '',
@@ -90,7 +93,8 @@ const StoryMapForm = props => {
const { trackEvent } = useAnalytics();
const isSmall = useMediaQuery(theme.breakpoints.down('md'));
const { onPublish, onSaveDraft } = props;
- const { saving } = useSelector(_.get('storyMap.form'));
+ const requestStatus = useSelector(_.get('storyMap.form'));
+ const { error: saveError, saving } = requestStatus;
const {
storyMap,
config,
@@ -106,6 +110,34 @@ const StoryMapForm = props => {
const [currentStepId, setCurrentStepId] = useState();
const [scrollToChapter, setScrollToChapter] = useState();
+ const [autoSaveData, setAutoSaveData] = useState({
+ config,
+ mediaFiles,
+ isDirty,
+ });
+ const [autoSaveDataDebounced] = useDebounce(autoSaveData, AUTO_SAVE_DEBOUNCE);
+ useEffect(() => {
+ setAutoSaveData({
+ config,
+ mediaFiles,
+ isDirty,
+ saving,
+ saveError,
+ });
+ }, [config, mediaFiles, isDirty, saving, saveError]);
+
+ useEffect(() => {
+ const { config, mediaFiles, isDirty } = autoSaveDataDebounced;
+ if (!isDirty) {
+ return;
+ }
+ onSaveDraft(config, mediaFiles)
+ .then(saved)
+ .catch(error => {
+ logger.error('Error auto saving story map', error);
+ });
+ }, [autoSaveDataDebounced, onSaveDraft, saved]);
+
const isFirefox = useMemo(
() => navigator.userAgent.toLowerCase().indexOf('firefox') > -1,
[]
@@ -223,7 +255,7 @@ const StoryMapForm = props => {
}, [config, mediaFiles, onPublish, saved]);
const onSaveDraftWrapper = useCallback(() => {
- onSaveDraft(config, mediaFiles).then(saved);
+ return onSaveDraft(config, mediaFiles).then(saved);
}, [config, mediaFiles, onSaveDraft, saved]);
if (preview || isSmall) {
@@ -240,8 +272,12 @@ const StoryMapForm = props => {
onCancel={cancel}
/>
)}
- {saving && }
-
+
{
return id;
}, []);
+ const clearMediaFiles = useCallback(() => {
+ setMediaFiles({});
+ }, []);
+
const getMediaFile = useCallback(id => mediaFiles[id]?.content, [mediaFiles]);
const saved = useCallback(() => setIsDirty(false), []);
const setConfigWrapper = useCallback(
- newConfigSetter => {
+ (newConfigSetter, dirty = true) => {
setConfig(currentConfig => {
const newConfig =
typeof newConfigSetter === 'function'
@@ -66,7 +70,7 @@ export const StoryMapConfigContextProvider = props => {
dataLayers: _.pick(usedDataLayersIds, newConfig.dataLayers),
};
});
- setIsDirty(true);
+ setIsDirty(dirty);
},
[setConfig]
);
@@ -81,6 +85,7 @@ export const StoryMapConfigContextProvider = props => {
mediaFiles,
addMediaFile,
getMediaFile,
+ clearMediaFiles,
init,
saved,
isDirty,
@@ -92,6 +97,7 @@ export const StoryMapConfigContextProvider = props => {
mediaFiles,
addMediaFile,
getMediaFile,
+ clearMediaFiles,
init,
setConfigWrapper,
isDirty,
diff --git a/src/storyMap/components/StoryMapNew.js b/src/storyMap/components/StoryMapNew.js
index 319193189..87e966ddb 100644
--- a/src/storyMap/components/StoryMapNew.js
+++ b/src/storyMap/components/StoryMapNew.js
@@ -133,12 +133,12 @@ const StoryMapNew = () => {
}, [dispatch, navigate, trackEvent, saved]);
const save = useCallback(
- (config, mediaFiles, published) =>
+ (config, mediaFiles, publish) =>
dispatch(
addStoryMap({
storyMap: {
config,
- published,
+ publish,
},
files: mediaFiles,
})
@@ -149,7 +149,7 @@ const StoryMapNew = () => {
const storyMapId = _.get('payload.story_map_id', data);
const id = _.get('payload.id', data);
- setSaved({ id, slug, storyMapId, published });
+ setSaved({ id, slug, storyMapId, published: publish });
return;
}
return Promise.reject(data);
diff --git a/src/storyMap/components/StoryMapUpdate.js b/src/storyMap/components/StoryMapUpdate.js
index 881ce8d06..c1f661b7e 100644
--- a/src/storyMap/components/StoryMapUpdate.js
+++ b/src/storyMap/components/StoryMapUpdate.js
@@ -39,15 +39,18 @@ import {
} from 'storyMap/storyMapUtils';
import StoryMapForm from './StoryMapForm';
-import { StoryMapConfigContextProvider } from './StoryMapForm/storyMapConfigContext';
+import {
+ StoryMapConfigContextProvider,
+ useStoryMapConfigContext,
+} from './StoryMapForm/storyMapConfigContext';
const StoryMapUpdate = props => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { t } = useTranslation();
const { trackEvent } = useAnalytics();
- const { storyMap } = props;
const [saved, setSaved] = useState();
+ const { storyMap, setConfig, clearMediaFiles } = useStoryMapConfigContext();
useDocumentTitle(
t('storyMap.edit_document_title', {
@@ -83,18 +86,24 @@ const StoryMapUpdate = props => {
}
if (title !== storyMap?.title) {
- navigate(generateStoryMapEditUrl({ slug, storyMapId }));
+ window.history.pushState(
+ null,
+ t('storyMap.edit_document_title', {
+ name: _.get('title', storyMap),
+ }),
+ generateStoryMapEditUrl({ slug, storyMapId })
+ );
}
- }, [storyMap, navigate, trackEvent, saved]);
+ }, [storyMap, navigate, trackEvent, saved, t, dispatch]);
const save = useCallback(
- (config, mediaFiles, published) =>
+ (config, mediaFiles, publish) =>
dispatch(
updateStoryMap({
storyMap: {
id: storyMap?.id,
config,
- published,
+ publish,
},
files: mediaFiles,
})
@@ -105,19 +114,22 @@ const StoryMapUpdate = props => {
const storyMapId = _.get('payload.story_map_id', data);
const title = _.get('payload.title', data);
const id = _.get('payload.id', data);
+ const config = _.get('payload.configuration', data);
setSaved({
id,
title,
slug,
storyMapId,
- published,
+ published: publish,
});
+ clearMediaFiles();
+ setConfig(config, false);
return;
}
return Promise.reject(data);
}),
- [storyMap?.id, dispatch]
+ [storyMap?.id, dispatch, clearMediaFiles, setConfig]
);
const onPublish = useCallback(
(config, mediaFiles) => save(config, mediaFiles, true),
@@ -164,7 +176,7 @@ const ContextWrapper = props => {
baseConfig={storyMap.config}
storyMap={storyMap}
>
-
+
);
};
diff --git a/src/storyMap/storyMapFragments.js b/src/storyMap/storyMapFragments.js
index eadee1d20..af6f6f92e 100644
--- a/src/storyMap/storyMapFragments.js
+++ b/src/storyMap/storyMapFragments.js
@@ -35,6 +35,13 @@ export const storyMapFields = /* GraphQL */ `
}
`;
+export const storyMapPublishedFields = /* GraphQL */ `
+ fragment storyMapPublishedFields on StoryMapNode {
+ ...storyMapFields
+ publishedConfiguration
+ }
+`;
+
export const storyMapMetadataFields = /* GraphQL */ `
fragment storyMapMetadataFields on StoryMapNode {
id
diff --git a/src/storyMap/storyMapService.js b/src/storyMap/storyMapService.js
index 65273fcc8..ccdaf60d3 100644
--- a/src/storyMap/storyMapService.js
+++ b/src/storyMap/storyMapService.js
@@ -67,6 +67,36 @@ export const fetchSamples = (params, currentUser) => {
export const fetchStoryMap = ({ slug, storyMapId }) => {
const query = graphql(`
query fetchStoryMap($slug: String!, $storyMapId: String!) {
+ storyMaps(slug: $slug, storyMapId: $storyMapId) {
+ edges {
+ node {
+ ...storyMapPublishedFields
+ membershipList {
+ ...collaborationMemberships
+ ...accountCollaborationMembership
+ }
+ }
+ }
+ }
+ }
+ `);
+ return terrasoApi
+ .requestGraphQL(query, { slug, storyMapId })
+ .then(_.get('storyMaps.edges[0].node'))
+ .then(storyMap => storyMap || Promise.reject('not_found'))
+ .then(storyMap => ({
+ ..._.omit(['membershipList', 'configuration'], storyMap),
+ config: storyMap.publishedConfiguration
+ ? JSON.parse(storyMap.publishedConfiguration)
+ : JSON.parse(storyMap.configuration),
+ memberships: extractMemberships(storyMap.membershipList),
+ accountMembership: extractAccountMembership(storyMap.membershipList),
+ }));
+};
+
+export const fetchStoryMapForm = ({ slug, storyMapId }) => {
+ const query = graphql(`
+ query fetchStoryMapForm($slug: String!, $storyMapId: String!) {
storyMaps(slug: $slug, storyMapId: $storyMapId) {
edges {
node {
@@ -96,8 +126,9 @@ export const addStoryMap = async ({ storyMap, files }) => {
const path = '/story-map/add/';
const storyMapForm = new FormData();
- storyMapForm.append('title', _.getOr('', 'config.title', storyMap).trim());
- storyMapForm.append('is_published', storyMap.published);
+ const title = _.get('config.title', storyMap);
+ storyMapForm.append('title', _.isEmpty(title) ? 'Untitled' : title.trim()); // TODO translate
+ storyMapForm.append('publish', storyMap.publish);
storyMapForm.append('configuration', JSON.stringify(storyMap.config));
Object.keys(files).forEach((fileId, index) => {
const file = files[fileId].file;
@@ -118,7 +149,7 @@ export const updateStoryMap = async ({ storyMap, files }) => {
const storyMapForm = new FormData();
storyMapForm.append('id', storyMap.id);
storyMapForm.append('title', _.getOr('', 'config.title', storyMap).trim());
- storyMapForm.append('is_published', storyMap.published);
+ storyMapForm.append('publish', storyMap.publish);
storyMapForm.append('configuration', JSON.stringify(storyMap.config));
Object.keys(files).forEach((fileId, index) => {
const file = files[fileId].file;
diff --git a/src/storyMap/storyMapSlice.js b/src/storyMap/storyMapSlice.js
index ab9c0938e..81c9be094 100644
--- a/src/storyMap/storyMapSlice.js
+++ b/src/storyMap/storyMapSlice.js
@@ -26,6 +26,7 @@ const initialState = {
form: {
fetching: true,
saving: false,
+ error: false,
data: null,
},
view: {
@@ -65,45 +66,19 @@ export const fetchStoryMap = createAsyncThunk(
);
export const fetchStoryMapForm = createAsyncThunk(
'storyMap/fetchStoryMapForm',
- storyMapService.fetchStoryMap
+ storyMapService.fetchStoryMapForm
);
export const addStoryMap = createAsyncThunk(
'storyMap/addStoryMap',
storyMapService.addStoryMap,
- (storyMap, { storyMap: { config, published } }) => ({
- severity: 'success',
- content: 'storyMap.added_story_map',
- params: {
- title: config.title,
- context: published ? 'published' : 'draft',
- },
- }),
- true,
- ({ message, input }) =>
- _.set(
- 'params.context',
- _.get('storyMap.published', input) ? 'published' : 'draft',
- message
- )
+ null,
+ false
);
export const updateStoryMap = createAsyncThunk(
'storyMap/updateStoryMap',
storyMapService.updateStoryMap,
- (storyMap, { storyMap: { config, published } }) => ({
- severity: 'success',
- content: 'storyMap.update_story_map',
- params: {
- title: config.title,
- context: published ? 'published' : 'draft',
- },
- }),
- true,
- ({ message, input }) =>
- _.set(
- 'params.context',
- _.get('storyMap.published', input) ? 'published' : 'draft',
- message
- )
+ null,
+ false
);
export const deleteStoryMap = createAsyncThunk(
'storyMap/deleteStoryMap',
@@ -272,9 +247,18 @@ const storyMapSlice = createSlice({
builder.addCase(addStoryMap.rejected, _.set('form.saving', false));
builder.addCase(addStoryMap.fulfilled, _.set('form.saving', false));
- builder.addCase(updateStoryMap.pending, _.set('form.saving', true));
- builder.addCase(updateStoryMap.rejected, _.set('form.saving', false));
- builder.addCase(updateStoryMap.fulfilled, _.set('form.saving', false));
+ builder.addCase(
+ updateStoryMap.pending,
+ _.flow(_.set('form.saving', true), _.set('form.error', false))
+ );
+ builder.addCase(
+ updateStoryMap.rejected,
+ _.flow(_.set('form.saving', false), _.set('form.error', true))
+ );
+ builder.addCase(
+ updateStoryMap.fulfilled,
+ _.flow(_.set('form.saving', false), _.set('form.error', false))
+ );
builder.addCase(deleteStoryMap.pending, (state, action) =>
_.set(`delete.${action.meta.arg.storyMap.id}.deleting`, true, state)