From da67123da7df463537d3d469b5e051f361e33852 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 08:12:19 +0000 Subject: [PATCH 1/8] test --- test | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test diff --git a/test b/test new file mode 100644 index 00000000..e69de29b From 8ba30d8f0f5470cb9fd9164fed30363e00d5394f Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 14:59:25 +0000 Subject: [PATCH 2/8] added workspace name --- .../SaveWorkspaceModal/SaveWorkspaceModal.tsx | 113 ++++++++++++++++++ .../workspace/SaveWorkspaceModal/index.ts | 1 + .../features/workspace/Workspace/index.ts | 2 +- web/src/components/layout/Header/Header.tsx | 48 +++++++- web/src/store/workspace/actions.ts | 11 ++ web/src/store/workspace/dispatchers/files.ts | 20 ++++ web/src/store/workspace/reducers.ts | 3 + web/src/store/workspace/state.ts | 7 ++ 8 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx create mode 100644 web/src/components/features/workspace/SaveWorkspaceModal/index.ts diff --git a/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx b/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx new file mode 100644 index 00000000..80c572e4 --- /dev/null +++ b/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react' +import { Stack, TextField, DefaultButton, PrimaryButton, DefaultSpacing, type IStackTokens } from '@fluentui/react' + +import { Dialog } from '~/components/elements/modals/Dialog' +import { DialogActions } from '~/components/elements/modals/DialogActions' + +interface Props { + isOpen: boolean, + workspaceName: string | undefined, + nameValidator?: (name: string) => string | undefined, + onClose: (name?: string) => void +} + +const verticalStackTokens: IStackTokens = { + childrenGap: DefaultSpacing.s1, +} + +const validateName = (value?: string, validatorFn?: Props['nameValidator']): string | undefined => { + value = value?.trim(); + + if (!value?.length) { + return 'Workspace name is required' + } + + return validatorFn?.(value) +} + +const modalStyles = { + main: { + maxWidth: 480, + }, +} + +export const SaveWorkspaceModal: React.FC = ({ isOpen, onClose, nameValidator, workspaceName }) => { + const [name, setName] = useState(workspaceName) + const [isDirty, setIsDirty] = useState(false) + const [errorMessage, setErrorMessage] = useState(undefined) + + const isValid = !errorMessage?.length + + useEffect(() => { + if (isOpen) { + return + } + setIsDirty(false) + setErrorMessage('Empty workspace name') + }, [isOpen]) + + useEffect(() => { + const errMsg = validateName(name, nameValidator) + setErrorMessage(errMsg) + }, [name, nameValidator]) + + return ( + { + onClose() + }} + > + + + Enter workspace name: + + + { + setName(value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && isValid) { + onClose(name) + } else { + setIsDirty(true) + } + }} + onGetErrorMessage={(value) => { + if (!isDirty) { + return undefined + } + + setIsDirty(true) + return errorMessage + }} + /> + + + + { + onClose() + }} + /> + { + onClose(name) + }} + disabled={!isValid} + /> + + + + + ) +} diff --git a/web/src/components/features/workspace/SaveWorkspaceModal/index.ts b/web/src/components/features/workspace/SaveWorkspaceModal/index.ts new file mode 100644 index 00000000..3eb5073f --- /dev/null +++ b/web/src/components/features/workspace/SaveWorkspaceModal/index.ts @@ -0,0 +1 @@ +export { SaveWorkspaceModal } from '../SaveWorkspaceModal/SaveWorkspaceModal'; \ No newline at end of file diff --git a/web/src/components/features/workspace/Workspace/index.ts b/web/src/components/features/workspace/Workspace/index.ts index 3e077425..53cc1aae 100644 --- a/web/src/components/features/workspace/Workspace/index.ts +++ b/web/src/components/features/workspace/Workspace/index.ts @@ -1 +1 @@ -export * from './Workspace' +export * from './Workspace' \ No newline at end of file diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index 440e53c4..ee3e5063 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -16,7 +16,9 @@ import { dispatchFormatFile, dispatchLoadSnippet, dispatchLoadSnippetFromSource, + dispatchSaveWorkspaceState, dispatchShareSnippet, + dispatchUpdateWorkspaceName, } from '~/store/workspace/dispatchers' import { connect, @@ -29,16 +31,20 @@ import { } from '~/store' import './Header.css' +import { saveWorkspaceState } from '~/store/workspace/config' +import { SaveWorkspaceModal } from '~/components/features/workspace/SaveWorkspaceModal' /** * Unique class name for share button to use as popover target. */ const BTN_SHARE_CLASSNAME = 'Header__btn--share' + interface HeaderState { showSettings?: boolean showAbout?: boolean showExamples?: boolean + showSaveWorkspace?: boolean, loading?: boolean goVersions?: VersionsInfo } @@ -48,7 +54,8 @@ interface StateProps { loading?: boolean running?: boolean sharedSnippetName?: string | null - hideThemeToggle?: boolean + hideThemeToggle?: boolean, + workspaceName?: string } interface Props extends StateProps { @@ -63,6 +70,7 @@ class HeaderContainer extends ThemeableComponent { showSettings: false, showAbout: false, loading: false, + showSaveWorkspace: false } } @@ -104,6 +112,24 @@ class HeaderContainer extends ThemeableComponent { this.props.dispatch(runFileDispatcher) }, }, + { + key: 'load', + text: 'Load', + iconProps: { iconName: 'Upload', style: { transform: 'rotate(90deg)' } }, + disabled: this.isDisabled, + onClick: () => { + //this.props.dispatch(dispatchShareSnippet()) + }, + }, + { + key: 'save', + text: 'Save', + iconProps: { iconName: 'Save' }, + disabled: this.isDisabled, + onClick: () => { + this.setState({ showSaveWorkspace: true }); + }, + }, { key: 'share', text: 'Share', @@ -190,6 +216,14 @@ class HeaderContainer extends ThemeableComponent { ] } + private onSaveWorkspaceClose(name: string | undefined) { + if(name) { + this.props.dispatch(dispatchUpdateWorkspaceName(name)); + } + + this.setState({ showSaveWorkspace: false}); + } + private onSettingsClose(changes: SettingsChanges) { if (changes.monaco) { // Update monaco state if some of its settings were changed @@ -218,7 +252,7 @@ class HeaderContainer extends ThemeableComponent { } render() { - const { showAbout, showSettings, showExamples } = this.state + const { showAbout, showSettings, showExamples, showSaveWorkspace } = this.state const { sharedSnippetName } = this.props return (
@@ -254,15 +288,23 @@ class HeaderContainer extends ThemeableComponent { onDismiss={() => this.setState({ showExamples: false })} onSelect={(s) => this.onSnippetSelected(s)} /> + + this.onSaveWorkspaceClose(args) + } + workspaceName={this.props.workspaceName} + />
) } } -export const Header = connect(({ settings, status, ui }) => ({ +export const Header = connect(({ settings, status, ui, workspace }) => ({ darkMode: settings.darkMode, loading: status?.loading, running: status?.running, hideThemeToggle: settings.useSystemTheme, sharedSnippetName: ui?.shareCreated ? ui?.snippetId : undefined, + workspaceName: workspace.name }))(HeaderContainer) diff --git a/web/src/store/workspace/actions.ts b/web/src/store/workspace/actions.ts index f75db2a4..a7ba7c5e 100644 --- a/web/src/store/workspace/actions.ts +++ b/web/src/store/workspace/actions.ts @@ -40,6 +40,11 @@ export enum WorkspaceAction { * Indicates that current file tab changed. */ SELECT_FILE = 'WORKSPACE_SELECT_FILE', + + /** + * Indicates that workspace name was changed. + */ + UPDATE_WORKSPACE_NAME = 'WORKSPACE_UPDATE_NAME', } export type BulkFileUpdatePayload = Record @@ -63,3 +68,9 @@ export interface SnippetLoadPayload { error?: string | null files?: WorkspaceState['files'] } + +// NOTE: not sure if this is the correct location for this definition +export const updateWorkspaceNameAction = (name: string) => ({ + type: WorkspaceAction.UPDATE_WORKSPACE_NAME, + payload: name, +}); \ No newline at end of file diff --git a/web/src/store/workspace/dispatchers/files.ts b/web/src/store/workspace/dispatchers/files.ts index 8896bf32..b3368967 100644 --- a/web/src/store/workspace/dispatchers/files.ts +++ b/web/src/store/workspace/dispatchers/files.ts @@ -39,6 +39,26 @@ const fileNamesFromState = (getState: StateProvider) => { return workspace.files ? Object.keys(workspace.files) : [] } +////////////////////////////////////////////////////////////// + +import { updateWorkspaceNameAction } from '../actions' + +export const dispatchUpdateWorkspaceName = (name: string) => (dispatch: DispatchFn) => { + dispatch(updateWorkspaceNameAction(name)) +} + + +export const dispatchSaveWorkspaceState = () => async (dispatch: DispatchFn, getState: StateProvider) => { + const s = getState(); + const { + workspace: { snippet, ...wp }, + } = getState(); + + console.log(s); +} + +////////////////////////////////////////////////////////////// + /** * Reads and imports files to a workspace. * File names are deduplicated. diff --git a/web/src/store/workspace/reducers.ts b/web/src/store/workspace/reducers.ts index aa15bcb6..56548ed2 100644 --- a/web/src/store/workspace/reducers.ts +++ b/web/src/store/workspace/reducers.ts @@ -114,6 +114,9 @@ export const reducers = mapByAction( [WorkspaceAction.WORKSPACE_IMPORT]: (_: WorkspaceState, { payload }: Action) => ({ ...payload, }), + [WorkspaceAction.UPDATE_WORKSPACE_NAME]: (s: WorkspaceState, { payload }: Action) => { + return { ...s, name: payload }; + } }, initialWorkspaceState, ) diff --git a/web/src/store/workspace/state.ts b/web/src/store/workspace/state.ts index 43be49e0..6793b052 100644 --- a/web/src/store/workspace/state.ts +++ b/web/src/store/workspace/state.ts @@ -48,6 +48,13 @@ export interface WorkspaceState { * Key-value pair of file names and their content. */ files?: Record + + /** + * Name of the workspace. + * + * Empty if not set or no snippet is loaded. + */ + name?: string } export const initialWorkspaceState: WorkspaceState = { From 663e300e1f2b127e256b30cda018fd93cbec1a71 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 15:41:05 +0000 Subject: [PATCH 3/8] reset workspace name when snippet has been loaded --- .../SaveWorkspaceModal/SaveWorkspaceModal.tsx | 11 +++++++---- web/src/components/layout/Header/Header.tsx | 19 +++++++++++-------- web/src/store/workspace/reducers.ts | 9 ++++++--- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx b/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx index 80c572e4..7893778a 100644 --- a/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx +++ b/web/src/components/features/workspace/SaveWorkspaceModal/SaveWorkspaceModal.tsx @@ -35,22 +35,25 @@ export const SaveWorkspaceModal: React.FC = ({ isOpen, onClose, nameValid const [name, setName] = useState(workspaceName) const [isDirty, setIsDirty] = useState(false) const [errorMessage, setErrorMessage] = useState(undefined) - - const isValid = !errorMessage?.length - + const [isValid, setIsValid] = useState(false) + useEffect(() => { if (isOpen) { return } setIsDirty(false) - setErrorMessage('Empty workspace name') }, [isOpen]) useEffect(() => { const errMsg = validateName(name, nameValidator) setErrorMessage(errMsg) + setIsValid(!errMsg) }, [name, nameValidator]) + useEffect(() => { + setName(workspaceName) + }, [workspaceName]) + return ( { } } -export const Header = connect(({ settings, status, ui, workspace }) => ({ - darkMode: settings.darkMode, - loading: status?.loading, - running: status?.running, - hideThemeToggle: settings.useSystemTheme, - sharedSnippetName: ui?.shareCreated ? ui?.snippetId : undefined, - workspaceName: workspace.name -}))(HeaderContainer) +export const Header = connect(({ settings, status, ui, workspace }) => { + const s = { + darkMode: settings.darkMode, + loading: status?.loading, + running: status?.running, + hideThemeToggle: settings.useSystemTheme, + sharedSnippetName: ui?.shareCreated ? ui?.snippetId : undefined, + workspaceName: workspace.name + }; + return s; +})(HeaderContainer) diff --git a/web/src/store/workspace/reducers.ts b/web/src/store/workspace/reducers.ts index 56548ed2..8f0d764a 100644 --- a/web/src/store/workspace/reducers.ts +++ b/web/src/store/workspace/reducers.ts @@ -92,6 +92,7 @@ export const reducers = mapByAction( _: WorkspaceState, { payload: { id, error, files } }: Action, ) => { + if (error || !files) { return { selectedFile: null, @@ -111,9 +112,11 @@ export const reducers = mapByAction( files, } }, - [WorkspaceAction.WORKSPACE_IMPORT]: (_: WorkspaceState, { payload }: Action) => ({ - ...payload, - }), + [WorkspaceAction.WORKSPACE_IMPORT]: (_: WorkspaceState, { payload }: Action) => { + return { + ...payload, + } + }, [WorkspaceAction.UPDATE_WORKSPACE_NAME]: (s: WorkspaceState, { payload }: Action) => { return { ...s, name: payload }; } From cbf89896c173449ee89c74f8760fa8462d8dcb49 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 15:50:36 +0000 Subject: [PATCH 4/8] add name to auto save --- web/src/store/workspace/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/store/workspace/config.ts b/web/src/store/workspace/config.ts index 486aa918..9804956d 100644 --- a/web/src/store/workspace/config.ts +++ b/web/src/store/workspace/config.ts @@ -23,12 +23,12 @@ export const loadWorkspaceState = (): WorkspaceState => sanitizeState(config.get const sanitizeState = (state: WorkspaceState) => { // Skip current snippet URL. - const { selectedFile, files } = state + const { selectedFile, files, name } = state if (!files) { // Save defaults if ws is empty. return defaultWorkspace } - return { selectedFile, files } + return { selectedFile, files, name } } From 626275e77adf54fdcb281aa27c168b193a8baa08 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 17:14:02 +0000 Subject: [PATCH 5/8] indexedDB added --- web/src/components/layout/Header/Header.tsx | 43 +++++------ web/src/store/db/db.ts | 81 ++++++++++++++++++++ web/src/store/workspace/actions.ts | 17 +++- web/src/store/workspace/dispatchers/files.ts | 36 +++++---- web/src/store/workspace/reducers.ts | 2 +- 5 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 web/src/store/db/db.ts diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index 3b177653..933d8ee8 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -16,7 +16,7 @@ import { dispatchFormatFile, dispatchLoadSnippet, dispatchLoadSnippetFromSource, - dispatchSaveWorkspaceState, + dispatchSaveWorkspace, dispatchShareSnippet, dispatchUpdateWorkspaceName, } from '~/store/workspace/dispatchers' @@ -31,7 +31,7 @@ import { } from '~/store' import './Header.css' -import { saveWorkspaceState } from '~/store/workspace/config' + import { SaveWorkspaceModal } from '~/components/features/workspace/SaveWorkspaceModal' /** @@ -39,12 +39,11 @@ import { SaveWorkspaceModal } from '~/components/features/workspace/SaveWorkspac */ const BTN_SHARE_CLASSNAME = 'Header__btn--share' - interface HeaderState { showSettings?: boolean showAbout?: boolean showExamples?: boolean - showSaveWorkspace?: boolean, + showSaveWorkspace?: boolean loading?: boolean goVersions?: VersionsInfo } @@ -54,7 +53,7 @@ interface StateProps { loading?: boolean running?: boolean sharedSnippetName?: string | null - hideThemeToggle?: boolean, + hideThemeToggle?: boolean workspaceName?: string } @@ -70,7 +69,7 @@ class HeaderContainer extends ThemeableComponent { showSettings: false, showAbout: false, loading: false, - showSaveWorkspace: false + showSaveWorkspace: false, } } @@ -127,7 +126,7 @@ class HeaderContainer extends ThemeableComponent { iconProps: { iconName: 'Save' }, disabled: this.isDisabled, onClick: () => { - this.setState({ showSaveWorkspace: true }); + this.setState({ showSaveWorkspace: true }) }, }, { @@ -217,11 +216,12 @@ class HeaderContainer extends ThemeableComponent { } private onSaveWorkspaceClose(name: string | undefined) { - if(name) { - this.props.dispatch(dispatchUpdateWorkspaceName(name)); + if (name) { + this.props.dispatch(dispatchSaveWorkspace(name)) + // this.props.dispatch(dispatchUpdateWorkspaceName(name)) } - this.setState({ showSaveWorkspace: false}); + this.setState({ showSaveWorkspace: false }) } private onSettingsClose(changes: SettingsChanges) { @@ -290,9 +290,7 @@ class HeaderContainer extends ThemeableComponent { /> - this.onSaveWorkspaceClose(args) - } + onClose={(args) => this.onSaveWorkspaceClose(args)} workspaceName={this.props.workspaceName} /> @@ -300,14 +298,11 @@ class HeaderContainer extends ThemeableComponent { } } -export const Header = connect(({ settings, status, ui, workspace }) => { - const s = { - darkMode: settings.darkMode, - loading: status?.loading, - running: status?.running, - hideThemeToggle: settings.useSystemTheme, - sharedSnippetName: ui?.shareCreated ? ui?.snippetId : undefined, - workspaceName: workspace.name - }; - return s; -})(HeaderContainer) +export const Header = connect(({ settings, status, ui, workspace }) => ({ + darkMode: settings.darkMode, + loading: status?.loading, + running: status?.running, + hideThemeToggle: settings.useSystemTheme, + sharedSnippetName: ui?.shareCreated ? ui?.snippetId : undefined, + workspaceName: workspace.name, +}))(HeaderContainer) diff --git a/web/src/store/db/db.ts b/web/src/store/db/db.ts new file mode 100644 index 00000000..1d9af57a --- /dev/null +++ b/web/src/store/db/db.ts @@ -0,0 +1,81 @@ +import { WorkspaceState } from "../workspace/state" + +const DB_NAME = 'go-playground'; +const WORKSPACE_STORE_NAME = 'workspaces'; +const DB_VERSION = 1; + +class PlaygroundDB { + private db: IDBDatabase | null = null; + + constructor() { + this.init(); + } + + private init() { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(WORKSPACE_STORE_NAME)) { + db.createObjectStore(WORKSPACE_STORE_NAME, { keyPath: 'name' }); + } + }; + + request.onsuccess = (event) => { + this.db = (event.target as IDBOpenDBRequest).result; + }; + + request.onerror = (event) => { + console.error('Database error:', (event.target as IDBOpenDBRequest).error); + }; + } + + private getObjectStore(mode: IDBTransactionMode): IDBObjectStore { + if (!this.db) { + throw new Error('Database is not initialized'); + } + const transaction = this.db.transaction(WORKSPACE_STORE_NAME, mode); + return transaction.objectStore(WORKSPACE_STORE_NAME); + } + + public async saveWorkspace(workspace: WorkspaceState): Promise { + if (!workspace.name || !workspace.selectedFile || !workspace.files) { + throw new Error('Workspace must have a name, selectedFile, and files'); + } + + const { snippet, ...workspaceToSave } = workspace; + + return new Promise((resolve, reject) => { + const store = this.getObjectStore('readwrite'); + const request = store.put(workspaceToSave); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + public async getWorkspaceByName(name: string): Promise { + return new Promise((resolve, reject) => { + const store = this.getObjectStore('readonly'); + const request = store.get(name); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + public async getAllWorkspaces(): Promise[]> { + return new Promise((resolve, reject) => { + const store = this.getObjectStore('readonly'); + const request = store.getAllKeys(); + + request.onsuccess = () => { + const keys = request.result as string[]; + resolve(keys.map((name) => ({ name }))); + }; + request.onerror = () => reject(request.error); + }); + } +} + +export const db = new PlaygroundDB(); \ No newline at end of file diff --git a/web/src/store/workspace/actions.ts b/web/src/store/workspace/actions.ts index a7ba7c5e..e62384c5 100644 --- a/web/src/store/workspace/actions.ts +++ b/web/src/store/workspace/actions.ts @@ -44,7 +44,17 @@ export enum WorkspaceAction { /** * Indicates that workspace name was changed. */ - UPDATE_WORKSPACE_NAME = 'WORKSPACE_UPDATE_NAME', + UPDATE_NAME = 'WORKSPACE_UPDATE_NAME', + + /** + * Save workspace to storage. + */ + SAVE = 'WORKSPACE_SAVE', + + /** + * Load workspace from storage. + */ + LOAD = 'WORKSPACE_LOAD', } export type BulkFileUpdatePayload = Record @@ -71,6 +81,7 @@ export interface SnippetLoadPayload { // NOTE: not sure if this is the correct location for this definition export const updateWorkspaceNameAction = (name: string) => ({ - type: WorkspaceAction.UPDATE_WORKSPACE_NAME, + type: WorkspaceAction.UPDATE_NAME, payload: name, -}); \ No newline at end of file +}); + diff --git a/web/src/store/workspace/dispatchers/files.ts b/web/src/store/workspace/dispatchers/files.ts index b3368967..3da21430 100644 --- a/web/src/store/workspace/dispatchers/files.ts +++ b/web/src/store/workspace/dispatchers/files.ts @@ -25,10 +25,6 @@ const scheduleAutosave = (getState: StateProvider) => { const { workspace: { snippet, ...wp }, } = getState() - if (snippet) { - // abort autosave when loaded external snippet. - return - } saveWorkspaceState(wp) }, AUTOSAVE_INTERVAL) @@ -39,25 +35,36 @@ const fileNamesFromState = (getState: StateProvider) => { return workspace.files ? Object.keys(workspace.files) : [] } -////////////////////////////////////////////////////////////// - import { updateWorkspaceNameAction } from '../actions' +import { db } from '~/store/db/db'; export const dispatchUpdateWorkspaceName = (name: string) => (dispatch: DispatchFn) => { dispatch(updateWorkspaceNameAction(name)) } - -export const dispatchSaveWorkspaceState = () => async (dispatch: DispatchFn, getState: StateProvider) => { +export const dispatchSaveWorkspace = (name: string) => async (dispatch: DispatchFn, getState: StateProvider) => { const s = getState(); - const { - workspace: { snippet, ...wp }, - } = getState(); + const { workspace } = s; - console.log(s); -} + workspace.name = name; + + try { + await db.saveWorkspace(workspace); + dispatch(updateWorkspaceNameAction(name)); + scheduleAutosave(getState); + } catch (err) { + dispatch( + newAddNotificationAction({ + id: newNotificationId(), + type: NotificationType.Error, + title: 'Failed to save workspace', + description: `${err}`, + canDismiss: true, + }), + ) + } -////////////////////////////////////////////////////////////// +}; /** * Reads and imports files to a workspace. @@ -173,3 +180,4 @@ export const newFileSelectAction = (filename: string): Action => ({ }) export const dispatchResetWorkspace = dispatchImportSource(defaultFiles) + diff --git a/web/src/store/workspace/reducers.ts b/web/src/store/workspace/reducers.ts index 8f0d764a..00c207cc 100644 --- a/web/src/store/workspace/reducers.ts +++ b/web/src/store/workspace/reducers.ts @@ -117,7 +117,7 @@ export const reducers = mapByAction( ...payload, } }, - [WorkspaceAction.UPDATE_WORKSPACE_NAME]: (s: WorkspaceState, { payload }: Action) => { + [WorkspaceAction.UPDATE_NAME]: (s: WorkspaceState, { payload }: Action) => { return { ...s, name: payload }; } }, From d13b910904a7332ad4e8d1bc4c81f8cff03db16b Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 18:38:30 +0000 Subject: [PATCH 6/8] Load workspace modal --- .../LoadWorkspaceModal/LoadWorkspaceModal.tsx | 83 +++++++++++++++++++ .../workspace/LoadWorkspaceModal/index.ts | 1 + web/src/components/layout/Header/Header.tsx | 24 +++++- web/src/store/db/index.ts | 1 + web/src/store/workspace/dispatchers/files.ts | 22 +++++ 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx create mode 100644 web/src/components/features/workspace/LoadWorkspaceModal/index.ts create mode 100644 web/src/store/db/index.ts diff --git a/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx b/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx new file mode 100644 index 00000000..f2a10d0c --- /dev/null +++ b/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx @@ -0,0 +1,83 @@ +import { + DefaultButton, + DefaultSpacing, + getScreenSelector, + IconButton, + IStackItemProps, + mergeStyles, + Stack, + useTheme, +} from '@fluentui/react' +import React, { useEffect } from 'react' +import { Dialog } from '~/components/elements/modals/Dialog' +import { db } from '~/store/db' + +interface Props { + isOpen?: boolean + onDismiss?: () => void + onSelect?: (workspace: string) => void +} + +export const LoadWorkspaceModal: React.FC = ({ isOpen, onDismiss, onSelect }) => { + const { semanticColors } = useTheme() + + const modalStyles = { + main: { + maxWidth: 840, + }, + } + + const [names, setNames] = React.useState([]) + + useEffect(() => { + setNames([]) + + if (isOpen) { + db.getAllWorkspaces().then((workspaces) => { + if (workspaces) { + setNames(workspaces.map((workspace) => workspace.name as string)) + } + }) + } + }, [isOpen]) + + return ( + + + {names.map((name) => ( + + + + onSelect?.(name)} + styles={{ + root: { width: '100%' }, + textContainer: { textTransform: 'capitalize', textAlign: 'left' }, + }} + /> + + + {}} /> + + + + ))} + + + ) +} diff --git a/web/src/components/features/workspace/LoadWorkspaceModal/index.ts b/web/src/components/features/workspace/LoadWorkspaceModal/index.ts new file mode 100644 index 00000000..da32178b --- /dev/null +++ b/web/src/components/features/workspace/LoadWorkspaceModal/index.ts @@ -0,0 +1 @@ +export { LoadWorkspaceModal } from './LoadWorkspaceModal'; \ No newline at end of file diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index 933d8ee8..8b88abfb 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -16,6 +16,7 @@ import { dispatchFormatFile, dispatchLoadSnippet, dispatchLoadSnippetFromSource, + dispatchLoadWorkspace, dispatchSaveWorkspace, dispatchShareSnippet, dispatchUpdateWorkspaceName, @@ -33,6 +34,7 @@ import { import './Header.css' import { SaveWorkspaceModal } from '~/components/features/workspace/SaveWorkspaceModal' +import { LoadWorkspaceModal } from '~/components/features/workspace/LoadWorkspaceModal' /** * Unique class name for share button to use as popover target. @@ -43,6 +45,7 @@ interface HeaderState { showSettings?: boolean showAbout?: boolean showExamples?: boolean + showLoadWorkspace?: boolean showSaveWorkspace?: boolean loading?: boolean goVersions?: VersionsInfo @@ -70,6 +73,7 @@ class HeaderContainer extends ThemeableComponent { showAbout: false, loading: false, showSaveWorkspace: false, + showLoadWorkspace: false, } } @@ -117,7 +121,7 @@ class HeaderContainer extends ThemeableComponent { iconProps: { iconName: 'Upload', style: { transform: 'rotate(90deg)' } }, disabled: this.isDisabled, onClick: () => { - //this.props.dispatch(dispatchShareSnippet()) + this.setState({ showLoadWorkspace: true }) }, }, { @@ -215,10 +219,17 @@ class HeaderContainer extends ThemeableComponent { ] } + private onLoadWorkspaceClose(name: string | undefined) { + if (name) { + this.props.dispatch(dispatchLoadWorkspace(name)) + } + + this.setState({ showLoadWorkspace: false }) + } + private onSaveWorkspaceClose(name: string | undefined) { if (name) { this.props.dispatch(dispatchSaveWorkspace(name)) - // this.props.dispatch(dispatchUpdateWorkspaceName(name)) } this.setState({ showSaveWorkspace: false }) @@ -293,6 +304,15 @@ class HeaderContainer extends ThemeableComponent { onClose={(args) => this.onSaveWorkspaceClose(args)} workspaceName={this.props.workspaceName} /> + { + this.onLoadWorkspaceClose(undefined) + }} + onSelect={(x: string) => { + this.onLoadWorkspaceClose(x) + }} + /> ) } diff --git a/web/src/store/db/index.ts b/web/src/store/db/index.ts new file mode 100644 index 00000000..21623732 --- /dev/null +++ b/web/src/store/db/index.ts @@ -0,0 +1 @@ +export * from './db'; \ No newline at end of file diff --git a/web/src/store/workspace/dispatchers/files.ts b/web/src/store/workspace/dispatchers/files.ts index 3da21430..f5932ad1 100644 --- a/web/src/store/workspace/dispatchers/files.ts +++ b/web/src/store/workspace/dispatchers/files.ts @@ -63,9 +63,31 @@ export const dispatchSaveWorkspace = (name: string) => async (dispatch: Dispatch }), ) } +}; +export const dispatchLoadWorkspace = (name: string) => async (dispatch: DispatchFn) => { + try { + const workspace = await db.getWorkspaceByName(name); + if (workspace) { + dispatch({ + type: WorkspaceAction.WORKSPACE_IMPORT, + payload: workspace, + }); + } + } catch (err) { + dispatch( + newAddNotificationAction({ + id: newNotificationId(), + type: NotificationType.Error, + title: 'Failed to load workspace', + description: `${err}`, + canDismiss: true, + }), + ); + } }; + /** * Reads and imports files to a workspace. * File names are deduplicated. From 98632674599b3764b4d0fc779f78700401fb508f Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 18:57:12 +0000 Subject: [PATCH 7/8] Load workspace modal styling --- .../LoadWorkspaceModal/LoadWorkspaceModal.tsx | 74 +++++++++++-------- web/src/components/layout/Header/Header.tsx | 7 ++ web/src/store/db/db.ts | 10 +++ web/src/store/workspace/dispatchers/files.ts | 15 ++++ 4 files changed, 74 insertions(+), 32 deletions(-) diff --git a/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx b/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx index f2a10d0c..2c67ec2b 100644 --- a/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx +++ b/web/src/components/features/workspace/LoadWorkspaceModal/LoadWorkspaceModal.tsx @@ -44,39 +44,49 @@ export const LoadWorkspaceModal: React.FC = ({ isOpen, onDismiss, onSelec return ( - {names.map((name) => ( - - No workspaces found + ) : ( + names.map((name) => ( + - - onSelect?.(name)} - styles={{ - root: { width: '100%' }, - textContainer: { textTransform: 'capitalize', textAlign: 'left' }, - }} - /> - - - {}} /> - - - - ))} + + + onSelect?.(name)} + styles={{ + root: { width: '100%', borderColor: semanticColors.variantBorder }, + textContainer: { textTransform: 'capitalize', textAlign: 'left' }, + }} + /> + + + { + await db.deleteWorkspace(name) + setNames(names.filter((n) => n !== name)) + }} + /> + + + + )) + )} ) diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index 8b88abfb..49c8b774 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -13,6 +13,7 @@ import { SharePopup } from '~/components/utils/SharePopup' import { dispatchTerminalSettingsChange } from '~/store/terminal' import { + dispatchDeleteWorkspace, dispatchFormatFile, dispatchLoadSnippet, dispatchLoadSnippetFromSource, @@ -227,6 +228,12 @@ class HeaderContainer extends ThemeableComponent { this.setState({ showLoadWorkspace: false }) } + private onWorkspaceDelete(name: string | undefined) { + if (name) { + this.props.dispatch(dispatchDeleteWorkspace(name)) + } + } + private onSaveWorkspaceClose(name: string | undefined) { if (name) { this.props.dispatch(dispatchSaveWorkspace(name)) diff --git a/web/src/store/db/db.ts b/web/src/store/db/db.ts index 1d9af57a..a38c1265 100644 --- a/web/src/store/db/db.ts +++ b/web/src/store/db/db.ts @@ -76,6 +76,16 @@ class PlaygroundDB { request.onerror = () => reject(request.error); }); } + + public async deleteWorkspace(name: string): Promise { + return new Promise((resolve, reject) => { + const store = this.getObjectStore('readwrite'); + const request = store.delete(name); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } } export const db = new PlaygroundDB(); \ No newline at end of file diff --git a/web/src/store/workspace/dispatchers/files.ts b/web/src/store/workspace/dispatchers/files.ts index f5932ad1..06b28eb1 100644 --- a/web/src/store/workspace/dispatchers/files.ts +++ b/web/src/store/workspace/dispatchers/files.ts @@ -87,6 +87,21 @@ export const dispatchLoadWorkspace = (name: string) => async (dispatch: Dispatch } }; +export const dispatchDeleteWorkspace = (name: string) => async (dispatch: DispatchFn) => { + try { + await db.deleteWorkspace(name); + } catch (err) { + dispatch( + newAddNotificationAction({ + id: newNotificationId(), + type: NotificationType.Error, + title: 'Failed to delete workspace', + description: `${err}`, + canDismiss: true, + }), + ); + } +} /** * Reads and imports files to a workspace. From af96aeb39947038e634a6bc31e9ba2e8236ffd4c Mon Sep 17 00:00:00 2001 From: null Date: Mon, 5 Aug 2024 19:08:28 +0000 Subject: [PATCH 8/8] add timestamp and sorting --- web/src/store/db/db.ts | 10 +++++++--- web/src/store/workspace/state.ts | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/store/db/db.ts b/web/src/store/db/db.ts index a38c1265..9ac5d52e 100644 --- a/web/src/store/db/db.ts +++ b/web/src/store/db/db.ts @@ -44,6 +44,7 @@ class PlaygroundDB { } const { snippet, ...workspaceToSave } = workspace; + workspaceToSave.timestamp = Date.now(); return new Promise((resolve, reject) => { const store = this.getObjectStore('readwrite'); @@ -67,11 +68,14 @@ class PlaygroundDB { public async getAllWorkspaces(): Promise[]> { return new Promise((resolve, reject) => { const store = this.getObjectStore('readonly'); - const request = store.getAllKeys(); + const request = store.getAll(); request.onsuccess = () => { - const keys = request.result as string[]; - resolve(keys.map((name) => ({ name }))); + const workspaces = request.result as WorkspaceState[]; + workspaces.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); // Sort by timestamp in descending order + resolve( + workspaces.map((workspace) => ({ name: workspace.name })) + ); }; request.onerror = () => reject(request.error); }); diff --git a/web/src/store/workspace/state.ts b/web/src/store/workspace/state.ts index 6793b052..9fcbb6ec 100644 --- a/web/src/store/workspace/state.ts +++ b/web/src/store/workspace/state.ts @@ -55,6 +55,11 @@ export interface WorkspaceState { * Empty if not set or no snippet is loaded. */ name?: string + + /** + * Timestamp of the last workspace save. + */ + timestamp?: number } export const initialWorkspaceState: WorkspaceState = {