Add possibility to load WIP in-app tutorial from local file (#6603)

This commit is contained in:
AlexandreS
2024-05-31 10:44:24 +02:00
committed by GitHub
parent eb256b3412
commit 96e5d6966a
14 changed files with 317 additions and 136 deletions

View File

@@ -1,47 +1,19 @@
// @flow
import optionalRequire from '../../Utils/OptionalRequire';
const fs = optionalRequire('fs');
const remote = optionalRequire('@electron/remote');
const dialog = remote ? remote.dialog : null;
const readJSONFile = (filepath: string): Promise<Object> => {
if (!fs) return Promise.reject('Filesystem is not supported.');
return new Promise((resolve, reject) => {
fs.readFile(filepath, { encoding: 'utf8' }, (err, data) => {
if (err) return reject(err);
try {
const dataObject = JSON.parse(data);
return resolve(dataObject);
} catch (ex) {
return reject(filepath + ' is a corrupted/malformed file.');
}
});
});
};
import { openFilePicker, readJSONFile } from '../../Utils/FileSystem';
export default class LocalEventsFunctionsExtensionOpener {
static chooseEventsFunctionExtensionFile = (): Promise<?string> => {
if (!dialog) return Promise.reject('Not supported');
const browserWindow = remote.getCurrentWindow();
return dialog
.showOpenDialog(browserWindow, {
title: 'Import an extension in the project',
properties: ['openFile'],
message: 'Choose an extension file to import (.json file)',
filters: [
{
name: 'GDevelop 5 "events based" extension',
extensions: ['json'],
},
],
})
.then(({ filePaths }) => {
if (!filePaths || !filePaths.length) return null;
return filePaths[0];
});
return openFilePicker({
title: 'Import an extension in the project',
properties: ['openFile'],
message: 'Choose an extension file to import (.json file)',
filters: [
{
name: 'GDevelop 5 "events based" extension',
extensions: ['json'],
},
],
}).then(filePath => filePath);
};
static readEventsFunctionExtensionFile = (

View File

@@ -21,6 +21,7 @@ export type InAppTutorialState = {|
startProjectData: { [key: string]: string },
inAppTutorialsFetchingError: string | null,
fetchInAppTutorials: () => Promise<void>,
onLoadInAppTutorialFromLocalFile: () => Promise<void>,
|};
export const initialInAppTutorialState: InAppTutorialState = {
@@ -33,6 +34,7 @@ export const initialInAppTutorialState: InAppTutorialState = {
startProjectData: {},
inAppTutorialsFetchingError: null,
fetchInAppTutorials: async () => {},
onLoadInAppTutorialFromLocalFile: async () => {},
};
const InAppTutorialContext = React.createContext<InAppTutorialState>(

View File

@@ -9,6 +9,11 @@ import {
type InAppTutorial,
} from '../Utils/GDevelopServices/InAppTutorial';
import { IN_APP_TUTORIALS_FETCH_TIMEOUT } from '../Utils/GlobalFetchTimeouts';
import optionalRequire from '../Utils/OptionalRequire';
import Window from '../Utils/Window';
import { openFilePicker, readJSONFile } from '../Utils/FileSystem';
import { checkInAppTutorialFileJsonSchema } from './SchemaChecker';
const electron = optionalRequire('electron');
type Props = {| children: React.Node |};
@@ -35,27 +40,30 @@ const InAppTutorialProvider = (props: Props) => {
[inAppTutorialShortHeaders]
);
const startTutorial = async ({
tutorialId,
initialStepIndex,
initialProjectData,
}: {|
tutorialId: string,
initialStepIndex: number,
initialProjectData: { [key: string]: string },
|}) => {
if (!inAppTutorialShortHeaders) return;
const startTutorial = React.useCallback(
async ({
tutorialId,
initialStepIndex,
initialProjectData,
}: {|
tutorialId: string,
initialStepIndex: number,
initialProjectData: { [key: string]: string },
|}) => {
if (!inAppTutorialShortHeaders) return;
const inAppTutorialShortHeader = getInAppTutorialShortHeader(tutorialId);
const inAppTutorialShortHeader = getInAppTutorialShortHeader(tutorialId);
if (!inAppTutorialShortHeader) return;
if (!inAppTutorialShortHeader) return;
const inAppTutorial = await fetchInAppTutorial(inAppTutorialShortHeader);
setStartStepIndex(initialStepIndex);
setStartProjectData(initialProjectData);
setTutorial(inAppTutorial);
setCurrentlyRunningInAppTutorial(tutorialId);
};
const inAppTutorial = await fetchInAppTutorial(inAppTutorialShortHeader);
setStartStepIndex(initialStepIndex);
setStartProjectData(initialProjectData);
setTutorial(inAppTutorial);
setCurrentlyRunningInAppTutorial(tutorialId);
},
[getInAppTutorialShortHeader, inAppTutorialShortHeaders]
);
const endTutorial = () => {
setTutorial(null);
@@ -73,6 +81,46 @@ const InAppTutorialProvider = (props: Props) => {
}
}, []);
const onLoadInAppTutorialFromLocalFile = React.useCallback(
async () => {
if (!electron) {
Window.showMessageBox(
'This option is available on the desktop app only.'
);
return;
}
const filePath = await openFilePicker({
title: 'Open a guided lesson',
properties: ['openFile'],
message: 'Choose a guided lesson (.json file)',
filters: [{ name: 'GDevelop 5 in-app tutorial', extensions: ['json'] }],
});
const guidedLesson = await readJSONFile(filePath);
const errors = checkInAppTutorialFileJsonSchema(guidedLesson);
if (errors.length) {
console.error(
"Guided lesson file doesn't respect the format. See errors:",
errors
);
Window.showMessageBox(
"Guided lesson file doesn't respect the format. Check developer console for details."
);
return;
}
if (guidedLesson.initialTemplateUrl) {
console.warn(
'Starting tutorial from file. The tutorial has the field initialTemplateUrl set so make sure the project is already open in the editor.'
);
}
startTutorial({
tutorialId: guidedLesson.id,
initialProjectData: guidedLesson.initialProjectData || {},
initialStepIndex: 0,
});
},
[startTutorial]
);
// Preload the in-app tutorial short headers when the app loads.
React.useEffect(
() => {
@@ -97,6 +145,7 @@ const InAppTutorialProvider = (props: Props) => {
startStepIndex,
inAppTutorialsFetchingError: fetchingError,
fetchInAppTutorials: loadInAppTutorials,
onLoadInAppTutorialFromLocalFile,
}}
>
{props.children}

View File

@@ -0,0 +1,75 @@
// @flow
const editorIdentifiers = [
'Scene',
'EventsSheet',
'Home',
'ExternalEvents',
'ExternalLayout',
'Extension',
'Resources',
];
const topLevelKeys = [
'id',
'flow',
'editorSwitches',
'endDialog',
'availableLocales',
];
const flowStepKeys = [
'elementToHighlightId',
'id',
'isTriggerFlickering',
'isCheckpoint',
'deprecated',
'nextStepTrigger',
'shortcuts',
'dialog',
'mapProjectData',
'tooltip',
'skippable',
'isOnClosableDialog',
];
export const checkInAppTutorialFileJsonSchema = (object: Object) => {
const errors = [];
topLevelKeys.forEach(topLevelKey => {
if (!object[topLevelKey]) {
errors.push(`Top level key ${topLevelKey} missing in file.`);
}
});
if (object.editorSwitches) {
Object.keys(object.editorSwitches).forEach(editorSwitchKey => {
const editorSwitch = object.editorSwitches[editorSwitchKey];
if (!editorSwitch.editor) {
errors.push(
`Editor switch ${editorSwitchKey} doesn't have editor key.`
);
}
if (!editorIdentifiers.includes(editorSwitch.editor)) {
errors.push(
`Editor switch ${editorSwitchKey} with identifier ${
editorSwitch.editor
} unknown.`
);
}
});
}
if (object.flow) {
object.flow.forEach((step, index) => {
const stepKeys = Object.keys(step);
const unknownStepKeys = stepKeys.filter(
stepKey => !flowStepKeys.includes(stepKey)
);
if (unknownStepKeys.length) {
errors.push(
`Flow step with id ${step.id ||
'unknown'} at index ${index} has unknown key(s) ${unknownStepKeys.join(
','
)})`
);
}
});
}
return errors;
};

View File

@@ -18,7 +18,7 @@ import {
type WindowSizeType,
} from '../../../../UI/Responsive/ResponsiveWindowMeasurer';
import Text from '../../../../UI/Text';
import { Column, Spacer } from '../../../../UI/Grid';
import { Column, Line, Spacer } from '../../../../UI/Grid';
import { type Tutorial } from '../../../../Utils/GDevelopServices/Tutorial';
import { type SubscriptionPlanWithPricingSystems } from '../../../../Utils/GDevelopServices/Usage';
import { CardWidget } from '../CardWidget';
@@ -35,6 +35,8 @@ import PlaceholderLoader from '../../../../UI/PlaceholderLoader';
import PromotionsSlideshow from '../../../../Promotions/PromotionsSlideshow';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import { EarnBadges } from './EarnBadges';
import FlatButton from '../../../../UI/FlatButton';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
const styles = {
textTutorialContent: {
@@ -198,7 +200,13 @@ const RecommendationList = ({
achievements,
} = authenticatedUser;
const { tutorials } = React.useContext(TutorialContext);
const { getTutorialProgress } = React.useContext(PreferencesContext);
const {
getTutorialProgress,
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
const [
selectedTutorial,
@@ -276,9 +284,17 @@ const RecommendationList = ({
items.push(
<SectionRow key="guided-lessons">
<Text size="section-title" noMargin>
<Trans>Build game mechanics</Trans>
</Text>
<Line justifyContent="space-between" noMargin>
<Text size="section-title" noMargin>
<Trans>Build game mechanics</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
/>
)}
</Line>
<GuidedLessons
selectInAppTutorial={selectInAppTutorial}
lessonsIds={guidedLessonsIds}

View File

@@ -33,6 +33,8 @@ import AuthenticatedUserContext from '../../../../Profile/AuthenticatedUserConte
import { type Limits } from '../../../../Utils/GDevelopServices/Usage';
import { PrivateTutorialViewDialog } from '../../../../AssetStore/PrivateTutorials/PrivateTutorialViewDialog';
import { EducationCard } from './EducationCard';
import InAppTutorialContext from '../../../../InAppTutorial/InAppTutorialContext';
import PreferencesContext from '../../../Preferences/PreferencesContext';
const useStyles = makeStyles({
tile: {
@@ -144,6 +146,12 @@ const MainPage = ({
selectInAppTutorial,
}: Props) => {
const { limits } = React.useContext(AuthenticatedUserContext);
const { onLoadInAppTutorialFromLocalFile } = React.useContext(
InAppTutorialContext
);
const {
values: { showInAppTutorialDeveloperMode },
} = React.useContext(PreferencesContext);
const classes = useStyles();
const {
windowSize,
@@ -223,9 +231,17 @@ const MainPage = ({
</Line>
</SectionRow>
<SectionRow>
<Text noMargin size="section-title">
<Trans>Guided lessons</Trans>
</Text>
<Line justifyContent="space-between" noMargin>
<Text noMargin size="section-title">
<Trans>Guided lessons</Trans>
</Text>
{showInAppTutorialDeveloperMode && (
<FlatButton
label={<Trans>Load local lesson</Trans>}
onClick={onLoadInAppTutorialFromLocalFile}
/>
)}
</Line>
<GuidedLessons selectInAppTutorial={selectInAppTutorial} />
</SectionRow>
<>

View File

@@ -212,6 +212,7 @@ export type PreferencesValues = {|
showCommunityExtensions: boolean,
showGetStartedSectionByDefault: boolean,
showEventBasedObjectsEditor: boolean,
showInAppTutorialDeveloperMode: boolean,
showDeprecatedInstructionWarning: boolean,
openDiagnosticReportAutomatically: boolean,
use3DEditor: boolean,
@@ -289,6 +290,7 @@ export type Preferences = {|
setShowGetStartedSectionByDefault: (enabled: boolean) => void,
setShowEventBasedObjectsEditor: (enabled: boolean) => void,
getShowEventBasedObjectsEditor: () => boolean,
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
getOpenDiagnosticReportAutomatically: () => boolean,
setShowDeprecatedInstructionWarning: (enabled: boolean) => void,
@@ -358,6 +360,7 @@ export const initialPreferences = {
showCommunityExtensions: false,
showGetStartedSectionByDefault: true,
showEventBasedObjectsEditor: false,
showInAppTutorialDeveloperMode: false,
openDiagnosticReportAutomatically: true,
showDeprecatedInstructionWarning: false,
use3DEditor: isWebGLSupported(),
@@ -419,6 +422,7 @@ export const initialPreferences = {
setShowGetStartedSectionByDefault: (enabled: boolean) => {},
setShowEventBasedObjectsEditor: (enabled: boolean) => {},
getShowEventBasedObjectsEditor: () => false,
setShowInAppTutorialDeveloperMode: (enabled: boolean) => {},
setShowDeprecatedInstructionWarning: (enabled: boolean) => {},
getOpenDiagnosticReportAutomatically: () => true,
setOpenDiagnosticReportAutomatically: (enabled: boolean) => {},

View File

@@ -66,6 +66,7 @@ const PreferencesDialog = ({ i18n, onClose }: Props) => {
setEventsSheetCancelInlineParameter,
setShowCommunityExtensions,
setShowEventBasedObjectsEditor,
setShowInAppTutorialDeveloperMode,
setOpenDiagnosticReportAutomatically,
setShowDeprecatedInstructionWarning,
setUse3DEditor,
@@ -358,16 +359,6 @@ const PreferencesDialog = ({ i18n, onClose }: Props) => {
</Trans>
}
/>
<Toggle
onToggle={(e, check) => setShowEventBasedObjectsEditor(check)}
toggled={values.showEventBasedObjectsEditor}
labelPosition="right"
label={
<Trans>
Show custom objects in the extension editor (experimental)
</Trans>
}
/>
{!!electron && (
<Toggle
onToggle={(e, check) =>
@@ -455,18 +446,46 @@ const PreferencesDialog = ({ i18n, onClose }: Props) => {
</ColumnStackLayout>
</>
)}
<Text size="block-title">
<Trans>Contributor options</Trans>
</Text>
<Toggle
onToggle={(e, check) => setShowEventBasedObjectsEditor(check)}
toggled={values.showEventBasedObjectsEditor}
labelPosition="right"
label={
<Trans>
Show custom objects in the extension editor (experimental)
</Trans>
}
/>
<Toggle
onToggle={(e, check) => setShowInAppTutorialDeveloperMode(check)}
toggled={values.showInAppTutorialDeveloperMode}
labelPosition="right"
label={
<Trans>
Show button to load guided lesson from file and test it.
</Trans>
}
/>
{Window.isDev() && (
<Toggle
onToggle={(e, check) => setUseGDJSDevelopmentWatcher(check)}
toggled={values.useGDJSDevelopmentWatcher}
labelPosition="right"
label={
<Trans>
Watch changes in game engine (GDJS) sources and auto import
them (dev only)
</Trans>
}
/>
<>
<Text size="block-title">
<Trans>Developer options</Trans>
</Text>
<Toggle
onToggle={(e, check) => setUseGDJSDevelopmentWatcher(check)}
toggled={values.useGDJSDevelopmentWatcher}
labelPosition="right"
label={
<Trans>
Watch changes in game engine (GDJS) sources and auto import
them (dev only)
</Trans>
}
/>
</>
)}
</ColumnStackLayout>
)}

View File

@@ -157,6 +157,9 @@ export default class PreferencesProvider extends React.Component<Props, State> {
getShowEventBasedObjectsEditor: this._getShowEventBasedObjectsEditor.bind(
this
),
setShowInAppTutorialDeveloperMode: this._setShowInAppTutorialDeveloperMode.bind(
this
),
setOpenDiagnosticReportAutomatically: this._setOpenDiagnosticReportAutomatically.bind(
this
),
@@ -446,6 +449,18 @@ export default class PreferencesProvider extends React.Component<Props, State> {
return this.state.values.showEventBasedObjectsEditor;
}
_setShowInAppTutorialDeveloperMode(showInAppTutorialDeveloperMode: boolean) {
this.setState(
state => ({
values: {
...state.values,
showInAppTutorialDeveloperMode,
},
}),
() => this._persistValuesToLocalStorage(this.state)
);
}
_setOpenDiagnosticReportAutomatically(
openDiagnosticReportAutomatically: boolean
) {

View File

@@ -2,44 +2,18 @@
import optionalRequire from '../../Utils/OptionalRequire';
import { type FileMetadata } from '../index';
import { unsplit } from '../../Utils/ObjectSplitter';
import { openFilePicker, readJSONFile } from '../../Utils/FileSystem';
const fs = optionalRequire('fs');
const path = optionalRequire('path');
const remote = optionalRequire('@electron/remote');
const dialog = remote ? remote.dialog : null;
const readJSONFile = (filepath: string): Promise<Object> => {
if (!fs) return Promise.reject('Filesystem is not supported.');
return new Promise((resolve, reject) => {
fs.readFile(filepath, { encoding: 'utf8' }, (err, data) => {
if (err) return reject(err);
try {
const dataObject = JSON.parse(data);
return resolve(dataObject);
} catch (ex) {
return reject(filepath + ' is a corrupted/malformed file.');
}
});
});
};
export const onOpenWithPicker = (): Promise<?FileMetadata> => {
if (!dialog) return Promise.reject('Not supported');
const browserWindow = remote.getCurrentWindow();
return dialog
.showOpenDialog(browserWindow, {
title: 'Open a project',
properties: ['openFile'],
message:
'If you want to open your GDevelop 4 project, be sure to save it as a .json file',
filters: [{ name: 'GDevelop 5 project', extensions: ['json'] }],
})
.then(({ filePaths }) => {
if (!filePaths || !filePaths.length) return null;
return { fileIdentifier: filePaths[0] };
});
return openFilePicker({
title: 'Open a project',
properties: ['openFile'],
message:
'If you want to open your GDevelop 4 project, be sure to save it as a .json file',
filters: [{ name: 'GDevelop 5 project', extensions: ['json'] }],
}).then(filePath => (filePath ? { fileIdentifier: filePath } : null));
};
export const onOpen = (

View File

@@ -0,0 +1,45 @@
// @flow
import optionalRequire from './OptionalRequire';
const fs = optionalRequire('fs');
const fsPromises = fs ? fs.promises : null;
const remote = optionalRequire('@electron/remote');
const dialog = remote ? remote.dialog : null;
export const readJSONFile = async (filepath: string): Promise<Object> => {
if (!fsPromises) throw new Error('Filesystem is not supported.');
try {
const data = await fsPromises.readFile(filepath, { encoding: 'utf8' });
const dataObject = JSON.parse(data);
return dataObject;
} catch (ex) {
throw new Error(filepath + ' is a corrupted/malformed file.');
}
};
export const openFilePicker = ({
title,
properties,
message,
filters,
}: {|
title: string,
properties: string[],
message: string,
filters: {| name: string, extensions: string[] |}[],
|}) => {
if (!dialog) return Promise.reject('Not supported');
const browserWindow = remote.getCurrentWindow();
return dialog
.showOpenDialog(browserWindow, {
title,
properties,
message,
filters,
})
.then(({ filePaths }) => {
if (!filePaths || !filePaths.length) return null;
return filePaths[0];
});
};

View File

@@ -6,8 +6,7 @@ import optionalRequire from '../OptionalRequire';
import { type MessageDescriptor } from '../i18n/MessageDescriptor.flow';
import { type MessageByLocale } from '../i18n/MessageByLocale';
import Window from '../Window';
const fs = optionalRequire('fs');
const fsPromises = fs ? fs.promises : null;
import { readJSONFile } from '../FileSystem';
const path = optionalRequire('path');
const remote = optionalRequire('@electron/remote');
const app = remote ? remote.app : null;
@@ -140,18 +139,6 @@ export type InAppTutorial = {|
availableLocales?: Array<string>,
|};
const readJSONFile = async (filepath: string): Promise<Object> => {
if (!fsPromises) throw new Error('Filesystem is not supported.');
try {
const data = await fsPromises.readFile(filepath, { encoding: 'utf8' });
const dataObject = JSON.parse(data);
return dataObject;
} catch (ex) {
throw new Error(filepath + ' is a corrupted/malformed file.');
}
};
const fetchLocalFileIfDesktop = async (filename: string): Promise<?Object> => {
const shouldRetrieveTutorialsLocally = !!remote && !Window.isDev();
if (!shouldRetrieveTutorialsLocally) return null;
@@ -163,7 +150,8 @@ const fetchLocalFileIfDesktop = async (filename: string): Promise<?Object> => {
const filePath = path.join(
appPath,
'..', // If on dev env, replace with '../../app/resources' to test.
`inAppTutorials/${filename}.json`
'inAppTutorials',
`${filename}.json`
);
const data = await readJSONFile(filePath);
return data;

View File

@@ -33,6 +33,9 @@ const inAppTutorialDecorator: StoryDecorator = (Story, context) => {
fetchInAppTutorials: async () => {
action('Fetch in app tutorials')();
},
onLoadInAppTutorialFromLocalFile: async () => {
action('load in app tutorial from local file')();
},
}}
>
<Story />

View File

@@ -107,6 +107,9 @@ const WrappedHomePage = ({
fetchInAppTutorials: async () => {
action('fetch tutorials')();
},
onLoadInAppTutorialFromLocalFile: async () => {
action('load in app tutorial from local file')();
},
}}
>
<AuthenticatedUserContext.Provider value={user}>