mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #15415 from overleaf/td-scope-store-and-emitter-fixed
IDE scope store and emitter with fixed PDF URLs GitOrigin-RevId: 9d33bad8a006bb55714878332f78932538dd8921
This commit is contained in:
parent
21514418e5
commit
537673cdf6
23 changed files with 1009 additions and 38 deletions
|
@ -0,0 +1,14 @@
|
||||||
|
import { Project } from '../../../../../types/project'
|
||||||
|
import { PermissionsLevel } from '../types/permissions-level'
|
||||||
|
|
||||||
|
export type JoinProjectPayloadProject = Pick<
|
||||||
|
Project,
|
||||||
|
Exclude<keyof Project, ['rootDocId', 'publicAccessLevel']>
|
||||||
|
> & { rootDoc_id?: string; publicAccesLevel?: string }
|
||||||
|
|
||||||
|
export type JoinProjectPayload = {
|
||||||
|
permissionsLevel: PermissionsLevel
|
||||||
|
project: JoinProjectPayloadProject
|
||||||
|
protocolVersion: number
|
||||||
|
publicId: string
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
export type Socket = {
|
export type Socket = {
|
||||||
publicId: string
|
publicId: string
|
||||||
on(event: string, callback: (...data: any[]) => void): void
|
on(event: string, callback: (...data: any[]) => void): void
|
||||||
|
removeListener(event: string, callback: (...data: any[]) => void): void
|
||||||
emit(
|
emit(
|
||||||
event: string,
|
event: string,
|
||||||
arg0: any,
|
arg0: any,
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
FC,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
} from 'react'
|
||||||
|
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
|
||||||
|
import { IdeProvider } from '@/shared/context/ide-context'
|
||||||
|
import {
|
||||||
|
createIdeEventEmitter,
|
||||||
|
IdeEventEmitter,
|
||||||
|
} from '@/features/ide-react/create-ide-event-emitter'
|
||||||
|
import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload'
|
||||||
|
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||||
|
import { getMockIde } from '@/shared/context/mock/mock-ide'
|
||||||
|
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
|
||||||
|
|
||||||
|
type IdeReactContextValue = {
|
||||||
|
projectId: string
|
||||||
|
eventEmitter: IdeEventEmitter
|
||||||
|
}
|
||||||
|
|
||||||
|
const IdeReactContext = createContext<IdeReactContextValue | null>(null)
|
||||||
|
|
||||||
|
function populateIdeReactScope(store: ReactScopeValueStore) {
|
||||||
|
store.set('sync_tex_error', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateProjectScope(store: ReactScopeValueStore) {
|
||||||
|
store.allowNonExistentPath('project', true)
|
||||||
|
store.set('permissionsLevel', 'readOnly')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReactScopeValueStore() {
|
||||||
|
const scopeStore = new ReactScopeValueStore()
|
||||||
|
|
||||||
|
// Populate the scope value store with default values that will be used by
|
||||||
|
// nested contexts that refer to scope values. The ideal would be to leave
|
||||||
|
// initialization of store values up to the nested context, which would keep
|
||||||
|
// initialization code together with the context and would only populate
|
||||||
|
// necessary values in the store, but this is simpler for now
|
||||||
|
populateIdeReactScope(scopeStore)
|
||||||
|
populateProjectScope(scopeStore)
|
||||||
|
|
||||||
|
return scopeStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = window.project_id
|
||||||
|
|
||||||
|
export const IdeReactProvider: FC = ({ children }) => {
|
||||||
|
const [scopeStore] = useState(createReactScopeValueStore)
|
||||||
|
const [eventEmitter] = useState(createIdeEventEmitter)
|
||||||
|
const [scopeEventEmitter] = useState(
|
||||||
|
() => new ReactScopeEventEmitter(eventEmitter)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { socket } = useConnectionContext()
|
||||||
|
|
||||||
|
// Fire project:joined event
|
||||||
|
useEffect(() => {
|
||||||
|
function handleJoinProjectResponse({
|
||||||
|
project,
|
||||||
|
permissionsLevel,
|
||||||
|
}: JoinProjectPayload) {
|
||||||
|
eventEmitter.emit('project:joined', { project, permissionsLevel })
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('joinProjectResponse', handleJoinProjectResponse)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
|
||||||
|
}
|
||||||
|
}, [socket, eventEmitter])
|
||||||
|
|
||||||
|
// Populate scope values when joining project, then fire project:joined event
|
||||||
|
useEffect(() => {
|
||||||
|
function handleJoinProjectResponse({
|
||||||
|
project,
|
||||||
|
permissionsLevel,
|
||||||
|
}: JoinProjectPayload) {
|
||||||
|
scopeStore.set('project', { rootDoc_id: null, ...project })
|
||||||
|
scopeStore.set('permissionsLevel', permissionsLevel)
|
||||||
|
// Make watchers update immediately
|
||||||
|
scopeStore.flushUpdates()
|
||||||
|
eventEmitter.emit('project:joined', { project, permissionsLevel })
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('joinProjectResponse', handleJoinProjectResponse)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
|
||||||
|
}
|
||||||
|
}, [socket, eventEmitter, scopeStore])
|
||||||
|
|
||||||
|
const ide = useMemo(() => {
|
||||||
|
return {
|
||||||
|
...getMockIde(),
|
||||||
|
socket,
|
||||||
|
}
|
||||||
|
}, [socket])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
eventEmitter,
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
[eventEmitter]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IdeReactContext.Provider value={value}>
|
||||||
|
<IdeProvider
|
||||||
|
ide={ide}
|
||||||
|
scopeStore={scopeStore}
|
||||||
|
scopeEventEmitter={scopeEventEmitter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</IdeProvider>
|
||||||
|
</IdeReactContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIdeReactContext(): IdeReactContextValue {
|
||||||
|
const context = useContext(IdeReactContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useIdeReactContext is only available inside IdeReactProvider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
|
@ -1,6 +1,17 @@
|
||||||
import { ConnectionProvider } from './connection-context'
|
import { ConnectionProvider } from './connection-context'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
|
import { IdeReactProvider } from '@/features/ide-react/context/ide-react-context'
|
||||||
|
import { ProjectProvider } from '@/shared/context/project-context'
|
||||||
|
import { UserProvider } from '@/shared/context/user-context'
|
||||||
|
|
||||||
export const ReactContextRoot: FC = ({ children }) => {
|
export const ReactContextRoot: FC = ({ children }) => {
|
||||||
return <ConnectionProvider>{children}</ConnectionProvider>
|
return (
|
||||||
|
<ConnectionProvider>
|
||||||
|
<IdeReactProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<ProjectProvider>{children}</ProjectProvider>
|
||||||
|
</UserProvider>
|
||||||
|
</IdeReactProvider>
|
||||||
|
</ConnectionProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Emitter } from 'strict-event-emitter'
|
||||||
|
import { Project } from '../../../../types/project'
|
||||||
|
import { PermissionsLevel } from '@/features/ide-react/types/permissions-level'
|
||||||
|
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
|
||||||
|
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
|
||||||
|
|
||||||
|
export type IdeEvents = {
|
||||||
|
'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }]
|
||||||
|
|
||||||
|
'editor:gotoOffset': [gotoOffset: number]
|
||||||
|
'editor:gotoLine': [options: GotoLineOptions]
|
||||||
|
'outline-toggled': [isOpen: boolean]
|
||||||
|
'cursor:editor:update': [position: CursorPosition]
|
||||||
|
'cursor:editor:syncToPdf': []
|
||||||
|
'scroll:editor:update': []
|
||||||
|
'comment:start_adding': []
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IdeEventEmitter = Emitter<IdeEvents>
|
||||||
|
|
||||||
|
export function createIdeEventEmitter(): IdeEventEmitter {
|
||||||
|
return new Emitter<IdeEvents>()
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import {
|
||||||
|
ScopeEventEmitter,
|
||||||
|
ScopeEventName,
|
||||||
|
} from '../../../../../types/ide/scope-event-emitter'
|
||||||
|
import { Scope } from '../../../../../types/angular/scope'
|
||||||
|
|
||||||
|
export class AngularScopeEventEmitter implements ScopeEventEmitter {
|
||||||
|
// eslint-disable-next-line no-useless-constructor
|
||||||
|
constructor(readonly $scope: Scope) {}
|
||||||
|
|
||||||
|
emit(eventName: ScopeEventName, broadcast: boolean, ...detail: unknown[]) {
|
||||||
|
if (broadcast) {
|
||||||
|
this.$scope.$broadcast(eventName, ...detail)
|
||||||
|
} else {
|
||||||
|
this.$scope.$emit(eventName, ...detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventName: ScopeEventName, listener: (...args: unknown[]) => void) {
|
||||||
|
return this.$scope.$on(eventName, listener)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {
|
||||||
|
ScopeEventEmitter,
|
||||||
|
ScopeEventName,
|
||||||
|
} from '../../../../../types/ide/scope-event-emitter'
|
||||||
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
|
export class ReactScopeEventEmitter implements ScopeEventEmitter {
|
||||||
|
// eslint-disable-next-line no-useless-constructor
|
||||||
|
constructor(private readonly eventEmitter: EventEmitter) {}
|
||||||
|
|
||||||
|
emit(eventName: ScopeEventName, broadcast: boolean, ...detail: unknown[]) {
|
||||||
|
this.eventEmitter.emit(eventName, ...detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventName: ScopeEventName, listener: (...args: unknown[]) => void) {
|
||||||
|
// A listener attached via useScopeEventListener expects an event as the
|
||||||
|
// first parameter. We don't have one, so just provide an empty object
|
||||||
|
const wrappedListener = (...detail: unknown[]) => {
|
||||||
|
listener({}, ...detail)
|
||||||
|
}
|
||||||
|
this.eventEmitter.on(eventName, wrappedListener)
|
||||||
|
return () => {
|
||||||
|
this.eventEmitter.off(eventName, wrappedListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { ScopeValueStore } from '../../../../../types/ide/scope-value-store'
|
||||||
|
import { Scope } from '../../../../../types/angular/scope'
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
export class AngularScopeValueStore implements ScopeValueStore {
|
||||||
|
// eslint-disable-next-line no-useless-constructor
|
||||||
|
constructor(readonly $scope: Scope) {}
|
||||||
|
|
||||||
|
get(path: string) {
|
||||||
|
return _.get(this.$scope, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
set(path: string, value: unknown): void {
|
||||||
|
this.$scope.$applyAsync(() => _.set(this.$scope, path, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch<T>(
|
||||||
|
path: string,
|
||||||
|
callback: (newValue: T) => void,
|
||||||
|
deep: boolean
|
||||||
|
): () => void {
|
||||||
|
return this.$scope.$watch(
|
||||||
|
path,
|
||||||
|
(newValue: T) => callback(deep ? _.cloneDeep(newValue) : newValue),
|
||||||
|
deep
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,330 @@
|
||||||
|
import { ScopeValueStore } from '../../../../../types/ide/scope-value-store'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import customLocalStorage from '../../../infrastructure/local-storage'
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
|
||||||
|
const NOT_FOUND = Symbol('not found')
|
||||||
|
|
||||||
|
type Watcher<T> = {
|
||||||
|
removed: boolean
|
||||||
|
callback: (value: T) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value that has been set
|
||||||
|
type ScopeValueStoreValue<T = any> = {
|
||||||
|
value?: T
|
||||||
|
watchers: Watcher<T>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatcherUpdate<T = any> = {
|
||||||
|
path: string
|
||||||
|
value: T
|
||||||
|
watchers: Watcher<T>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type NonExistentValue = {
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllowedNonExistentPath = {
|
||||||
|
path: string
|
||||||
|
deep: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Persister = {
|
||||||
|
localStorageKey: string
|
||||||
|
toPersisted?: (value: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is object {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!('length' in value && typeof value.length === 'number' && value.length > 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ancestorPaths(path: string) {
|
||||||
|
const ancestors: string[] = []
|
||||||
|
let currentPath = path
|
||||||
|
let lastPathSeparatorPos: number
|
||||||
|
while ((lastPathSeparatorPos = currentPath.lastIndexOf('.')) !== -1) {
|
||||||
|
currentPath = currentPath.slice(0, lastPathSeparatorPos)
|
||||||
|
ancestors.push(currentPath)
|
||||||
|
}
|
||||||
|
return ancestors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store scope values in a simple map
|
||||||
|
export class ReactScopeValueStore implements ScopeValueStore {
|
||||||
|
private readonly items = new Map<string, ScopeValueStoreValue>()
|
||||||
|
private readonly persisters: Map<string, Persister> = new Map()
|
||||||
|
|
||||||
|
private watcherUpdates: WatcherUpdate[] = []
|
||||||
|
private watcherUpdateTimer: number | null = null
|
||||||
|
private allowedNonExistentPaths: AllowedNonExistentPath[] = []
|
||||||
|
|
||||||
|
private nonExistentPathAllowed(path: string) {
|
||||||
|
return this.allowedNonExistentPaths.some(allowedPath => {
|
||||||
|
return (
|
||||||
|
allowedPath.path === path ||
|
||||||
|
(allowedPath.deep && path.startsWith(allowedPath.path + '.'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an item for a path. Attempt to get a value for the item from its
|
||||||
|
// ancestors, if there are any.
|
||||||
|
private findInAncestors(path: string): ScopeValueStoreValue {
|
||||||
|
// Populate value from the nested property ancestors, if possible
|
||||||
|
for (const ancestorPath of ancestorPaths(path)) {
|
||||||
|
const ancestorItem = this.items.get(ancestorPath)
|
||||||
|
if (
|
||||||
|
ancestorItem &&
|
||||||
|
'value' in ancestorItem &&
|
||||||
|
isObject(ancestorItem.value)
|
||||||
|
) {
|
||||||
|
const pathRelativeToAncestor = path.slice(ancestorPath.length + 1)
|
||||||
|
const ancestorValue = _.get(ancestorItem.value, pathRelativeToAncestor)
|
||||||
|
if (ancestorValue !== NOT_FOUND) {
|
||||||
|
return { value: ancestorValue, watchers: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { watchers: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private getItem<T>(path: string): ScopeValueStoreValue<T> | NonExistentValue {
|
||||||
|
const item = this.items.get(path) || this.findInAncestors(path)
|
||||||
|
if (!('value' in item)) {
|
||||||
|
if (this.nonExistentPathAllowed(path)) {
|
||||||
|
debugConsole.log(
|
||||||
|
`No value found for key '${path}'. This is allowed because the path is in allowedNonExistentPaths`
|
||||||
|
)
|
||||||
|
return { value: undefined }
|
||||||
|
} else {
|
||||||
|
throw new Error(`No value found for key '${path}'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private reassembleObjectValue(path: string, value: Record<string, any>) {
|
||||||
|
const newValue: Record<string, any> = { ...value }
|
||||||
|
const pathPrefix = path + '.'
|
||||||
|
for (const [key, item] of this.items.entries()) {
|
||||||
|
if (key.startsWith(pathPrefix)) {
|
||||||
|
const propName = key.slice(pathPrefix.length)
|
||||||
|
if (propName.indexOf('.') === -1 && 'value' in item) {
|
||||||
|
newValue[propName] = item.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
flushUpdates() {
|
||||||
|
if (this.watcherUpdateTimer) {
|
||||||
|
window.clearTimeout(this.watcherUpdateTimer)
|
||||||
|
this.watcherUpdateTimer = null
|
||||||
|
}
|
||||||
|
// Clone watcherUpdates in case a watcher creates new watcherUpdates
|
||||||
|
const watcherUpdates = [...this.watcherUpdates]
|
||||||
|
this.watcherUpdates = []
|
||||||
|
for (const { value, watchers } of watcherUpdates) {
|
||||||
|
for (const watcher of watchers) {
|
||||||
|
if (!watcher.removed) {
|
||||||
|
watcher.callback.call(null, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleWatcherUpdate<T>(
|
||||||
|
path: string,
|
||||||
|
value: T,
|
||||||
|
watchers: Watcher<T>[]
|
||||||
|
) {
|
||||||
|
// Make a copy of the watchers so that any watcher added before this update
|
||||||
|
// runs is not triggered
|
||||||
|
const update: WatcherUpdate = {
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
watchers: [...watchers],
|
||||||
|
}
|
||||||
|
this.watcherUpdates.push(update)
|
||||||
|
if (!this.watcherUpdateTimer) {
|
||||||
|
this.watcherUpdateTimer = window.setTimeout(() => {
|
||||||
|
this.watcherUpdateTimer = null
|
||||||
|
this.flushUpdates()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(path: string) {
|
||||||
|
return this.getItem<T>(path).value
|
||||||
|
}
|
||||||
|
|
||||||
|
private setValue<T>(path: string, value: T): void {
|
||||||
|
let item = this.items.get(path)
|
||||||
|
if (item === undefined) {
|
||||||
|
item = { value, watchers: [] }
|
||||||
|
this.items.set(path, item)
|
||||||
|
} else if (!('value' in item)) {
|
||||||
|
item = { ...item, value }
|
||||||
|
this.items.set(path, item)
|
||||||
|
} else if (item.value === value) {
|
||||||
|
// Don't update and trigger watchers if the value hasn't changed
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
item.value = value
|
||||||
|
}
|
||||||
|
this.scheduleWatcherUpdate<T>(path, value, item.watchers)
|
||||||
|
|
||||||
|
// Persist to local storage, if configured to do so
|
||||||
|
const persister = this.persisters.get(path)
|
||||||
|
if (persister) {
|
||||||
|
customLocalStorage.setItem(
|
||||||
|
persister.localStorageKey,
|
||||||
|
persister.toPersisted?.(value) || value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setValueAndDescendants<T>(path: string, value: T): void {
|
||||||
|
this.setValue(path, value)
|
||||||
|
|
||||||
|
// Set nested values non-recursively, only updating existing items
|
||||||
|
if (isObject(value)) {
|
||||||
|
const pathPrefix = path + '.'
|
||||||
|
for (const [nestedPath, existingItem] of this.items.entries()) {
|
||||||
|
if (nestedPath.startsWith(pathPrefix)) {
|
||||||
|
const newValue = _.get(
|
||||||
|
value,
|
||||||
|
nestedPath.slice(pathPrefix.length),
|
||||||
|
NOT_FOUND
|
||||||
|
)
|
||||||
|
// Only update a nested value if it has changed
|
||||||
|
if (
|
||||||
|
newValue !== NOT_FOUND &&
|
||||||
|
(!('value' in existingItem) || newValue !== existingItem.value)
|
||||||
|
) {
|
||||||
|
this.setValue(nestedPath, newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete nested items corresponding to properties that do not exist in
|
||||||
|
// the new object
|
||||||
|
const pathsToDelete: string[] = []
|
||||||
|
const newPropNames = new Set(Object.keys(value))
|
||||||
|
for (const path of this.items.keys()) {
|
||||||
|
if (path.startsWith(pathPrefix)) {
|
||||||
|
const propName = path.slice(pathPrefix.length).split('.', 1)[0]
|
||||||
|
if (!newPropNames.has(propName)) {
|
||||||
|
pathsToDelete.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const path of pathsToDelete) {
|
||||||
|
this.items.delete(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(path: string, value: unknown): void {
|
||||||
|
this.setValueAndDescendants(path, value)
|
||||||
|
|
||||||
|
// Reassemble ancestors. For example, if the path is x.y.z, x.y and x have
|
||||||
|
// now changed too and must be updated
|
||||||
|
for (const ancestorPath of ancestorPaths(path)) {
|
||||||
|
const ancestorItem = this.items.get(ancestorPath)
|
||||||
|
if (ancestorItem && 'value' in ancestorItem) {
|
||||||
|
ancestorItem.value = this.reassembleObjectValue(
|
||||||
|
ancestorPath,
|
||||||
|
ancestorItem.value
|
||||||
|
)
|
||||||
|
this.scheduleWatcherUpdate(
|
||||||
|
ancestorPath,
|
||||||
|
ancestorItem.value,
|
||||||
|
ancestorItem.watchers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes in a scope value. The value does not need to exist yet.
|
||||||
|
// Watchers are batched and called asynchronously to avoid chained state
|
||||||
|
// watcherUpdates, which result in warnings from React (see
|
||||||
|
// https://github.com/facebook/react/issues/18178)
|
||||||
|
watch<T>(path: string, callback: Watcher<T>['callback']): () => void {
|
||||||
|
let item = this.items.get(path)
|
||||||
|
if (!item) {
|
||||||
|
item = this.findInAncestors(path)
|
||||||
|
this.items.set(path, item)
|
||||||
|
}
|
||||||
|
const watchers = item.watchers
|
||||||
|
const watcher = { removed: false, callback }
|
||||||
|
item.watchers.push(watcher)
|
||||||
|
|
||||||
|
// Schedule watcher immediately. This is to work around the fact that there
|
||||||
|
// is a delay between getting an initial value and adding a watcher in
|
||||||
|
// useScopeValue, during which the value could change without being
|
||||||
|
// observed
|
||||||
|
if ('value' in item) {
|
||||||
|
this.scheduleWatcherUpdate<T>(path, item.value, [watcher])
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Add a flag to the watcher so that it can be ignored if the watcher is
|
||||||
|
// removed in the interval between observing a change and being called
|
||||||
|
watcher.removed = true
|
||||||
|
_.pull(watchers, watcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted<Value, PersistedValue>(
|
||||||
|
path: string,
|
||||||
|
fallbackValue: Value,
|
||||||
|
localStorageKey: string,
|
||||||
|
converter?: {
|
||||||
|
toPersisted: (value: Value) => PersistedValue
|
||||||
|
fromPersisted: (persisted: PersistedValue) => Value
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const persistedValue = customLocalStorage.getItem(
|
||||||
|
localStorageKey
|
||||||
|
) as PersistedValue | null
|
||||||
|
|
||||||
|
let value: Value = fallbackValue
|
||||||
|
if (persistedValue !== null) {
|
||||||
|
value = converter
|
||||||
|
? converter.fromPersisted(persistedValue)
|
||||||
|
: (persistedValue as Value)
|
||||||
|
}
|
||||||
|
this.set(path, value)
|
||||||
|
|
||||||
|
// Don't persist the value until set() is called
|
||||||
|
this.persisters.set(path, {
|
||||||
|
localStorageKey,
|
||||||
|
toPersisted: converter?.toPersisted as Persister['toPersisted'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
allowNonExistentPath(path: string, deep = false) {
|
||||||
|
this.allowedNonExistentPaths.push({ path, deep })
|
||||||
|
}
|
||||||
|
|
||||||
|
// For debugging
|
||||||
|
dump() {
|
||||||
|
const entries = []
|
||||||
|
for (const [path, item] of this.items.entries()) {
|
||||||
|
entries.push({
|
||||||
|
path,
|
||||||
|
value: 'value' in item ? item.value : '[not set]',
|
||||||
|
watcherCount: item.watchers.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type CursorPosition = {
|
||||||
|
row: number
|
||||||
|
column: number
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface GotoLineOptions {
|
||||||
|
gotoLine: number
|
||||||
|
gotoColumn?: number
|
||||||
|
syncToPdf?: boolean
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export type PermissionsLevel = 'owner' | 'readAndWrite' | 'readOnly'
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { FC, useState } from 'react'
|
||||||
|
import { AngularScopeValueStore } from '@/features/ide-react/scope-value-store/angular-scope-value-store'
|
||||||
|
import { AngularScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/angular-scope-event-emitter'
|
||||||
|
import { Ide, IdeProvider } from '@/shared/context/ide-context'
|
||||||
|
import { getMockIde } from '@/shared/context/mock/mock-ide'
|
||||||
|
|
||||||
|
export const IdeAngularProvider: FC<{ ide?: Ide }> = ({ ide, children }) => {
|
||||||
|
const [ideValue] = useState(() => ide || getMockIde())
|
||||||
|
const [scopeStore] = useState(
|
||||||
|
() => new AngularScopeValueStore(ideValue.$scope)
|
||||||
|
)
|
||||||
|
const [scopeEventEmitter] = useState(
|
||||||
|
() => new AngularScopeEventEmitter(ideValue.$scope)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IdeProvider
|
||||||
|
ide={ideValue}
|
||||||
|
scopeStore={scopeStore}
|
||||||
|
scopeEventEmitter={scopeEventEmitter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</IdeProvider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,20 +1,41 @@
|
||||||
import { createContext, FC, useContext, useState } from 'react'
|
import { createContext, FC, useContext, useMemo } from 'react'
|
||||||
import { getMockIde } from './mock/mock-ide'
|
import { ScopeValueStore } from '../../../../types/ide/scope-value-store'
|
||||||
|
import { Scope } from '../../../../types/angular/scope'
|
||||||
|
import getMeta from '@/utils/meta'
|
||||||
|
import { ScopeEventEmitter } from '../../../../types/ide/scope-event-emitter'
|
||||||
|
|
||||||
type Ide = {
|
export type Ide = {
|
||||||
[key: string]: any // TODO: define the rest of the `ide` and `$scope` properties
|
[key: string]: any // TODO: define the rest of the `ide` and `$scope` properties
|
||||||
$scope: Record<string, any>
|
$scope: Scope
|
||||||
}
|
}
|
||||||
|
|
||||||
const IdeContext = createContext<Ide | null>(null)
|
type IdeContextValue = Ide & {
|
||||||
|
isReactIde: boolean
|
||||||
|
scopeStore: ScopeValueStore
|
||||||
|
scopeEventEmitter: ScopeEventEmitter
|
||||||
|
}
|
||||||
|
|
||||||
export const IdeProvider: FC<{ ide: Ide }> = ({ ide, children }) => {
|
const IdeContext = createContext<IdeContextValue | undefined>(undefined)
|
||||||
const [value] = useState(() => ide || getMockIde())
|
const isReactIde: boolean = getMeta('ol-idePageReact')
|
||||||
|
|
||||||
|
export const IdeProvider: FC<{
|
||||||
|
ide: Ide
|
||||||
|
scopeStore: ScopeValueStore
|
||||||
|
scopeEventEmitter: ScopeEventEmitter
|
||||||
|
}> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
|
||||||
|
const value = useMemo<IdeContextValue>(() => {
|
||||||
|
return {
|
||||||
|
...ide,
|
||||||
|
isReactIde,
|
||||||
|
scopeStore,
|
||||||
|
scopeEventEmitter,
|
||||||
|
}
|
||||||
|
}, [ide, scopeStore, scopeEventEmitter])
|
||||||
|
|
||||||
return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
|
return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIdeContext(): Ide {
|
export function useIdeContext(): IdeContextValue {
|
||||||
const context = useContext(IdeContext)
|
const context = useContext(IdeContext)
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import PropTypes from 'prop-types'
|
||||||
import createSharedContext from 'react2angular-shared-context'
|
import createSharedContext from 'react2angular-shared-context'
|
||||||
|
|
||||||
import { UserProvider } from './user-context'
|
import { UserProvider } from './user-context'
|
||||||
import { IdeProvider } from './ide-context'
|
import { IdeAngularProvider } from './ide-angular-provider'
|
||||||
import { EditorProvider } from './editor-context'
|
import { EditorProvider } from './editor-context'
|
||||||
import { LocalCompileProvider } from './local-compile-context'
|
import { LocalCompileProvider } from './local-compile-context'
|
||||||
import { DetachCompileProvider } from './detach-compile-context'
|
import { DetachCompileProvider } from './detach-compile-context'
|
||||||
|
@ -17,7 +17,7 @@ import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/pro
|
||||||
export function ContextRoot({ children, ide }) {
|
export function ContextRoot({ children, ide }) {
|
||||||
return (
|
return (
|
||||||
<SplitTestProvider>
|
<SplitTestProvider>
|
||||||
<IdeProvider ide={ide}>
|
<IdeAngularProvider ide={ide}>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<FileTreeDataProvider>
|
<FileTreeDataProvider>
|
||||||
|
@ -37,7 +37,7 @@ export function ContextRoot({ children, ide }) {
|
||||||
</FileTreeDataProvider>
|
</FileTreeDataProvider>
|
||||||
</ProjectProvider>
|
</ProjectProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</IdeProvider>
|
</IdeAngularProvider>
|
||||||
</SplitTestProvider>
|
</SplitTestProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useIdeContext } from '../context/ide-context'
|
import { useIdeContext } from '../context/ide-context'
|
||||||
|
import { ScopeEventName } from '../../../../types/ide/scope-event-emitter'
|
||||||
|
|
||||||
export default function useScopeEventEmitter(
|
export default function useScopeEventEmitter(
|
||||||
eventName: string,
|
eventName: ScopeEventName,
|
||||||
broadcast = true
|
broadcast = true
|
||||||
) {
|
) {
|
||||||
const { $scope } = useIdeContext()
|
const { scopeEventEmitter } = useIdeContext()
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(...detail: unknown[]) => {
|
(...detail: unknown[]) => {
|
||||||
if (broadcast) {
|
scopeEventEmitter.emit(eventName, broadcast, ...detail)
|
||||||
$scope.$broadcast(eventName, ...detail)
|
|
||||||
} else {
|
|
||||||
$scope.$emit(eventName, ...detail)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[$scope, eventName, broadcast]
|
[scopeEventEmitter, eventName, broadcast]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useIdeContext } from '../context/ide-context'
|
import { useIdeContext } from '../context/ide-context'
|
||||||
|
import { ScopeEventName } from '../../../../types/ide/scope-event-emitter'
|
||||||
|
|
||||||
export default function useScopeEventListener(
|
export default function useScopeEventListener(
|
||||||
eventName: string,
|
eventName: ScopeEventName,
|
||||||
listener: (...args: unknown[]) => void
|
listener: (...args: unknown[]) => void
|
||||||
) {
|
) {
|
||||||
const { $scope } = useIdeContext()
|
const { scopeEventEmitter } = useIdeContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return $scope.$on(eventName, listener)
|
return scopeEventEmitter.on(eventName, listener)
|
||||||
}, [$scope, eventName, listener])
|
}, [scopeEventEmitter, eventName, listener])
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,38 +12,39 @@ import { useIdeContext } from '../context/ide-context'
|
||||||
* Binds a property in an Angular scope making it accessible in a React
|
* Binds a property in an Angular scope making it accessible in a React
|
||||||
* component. The interface is compatible with React.useState(), including
|
* component. The interface is compatible with React.useState(), including
|
||||||
* the option of passing a function to the setter.
|
* the option of passing a function to the setter.
|
||||||
|
*
|
||||||
|
* The generic type is not an actual guarantee because the value for a path is
|
||||||
|
* returned as undefined when there is nothing in the scope store for that path.
|
||||||
*/
|
*/
|
||||||
export default function useScopeValue<T = any>(
|
export default function useScopeValue<T = any>(
|
||||||
path: string, // dot '.' path of a property in the Angular scope
|
path: string, // dot '.' path of a property in the Angular scope
|
||||||
deep = false
|
deep = false
|
||||||
): [T, Dispatch<SetStateAction<T>>] {
|
): [T, Dispatch<SetStateAction<T>>] {
|
||||||
const { $scope } = useIdeContext()
|
const { scopeStore } = useIdeContext()
|
||||||
|
|
||||||
const [value, setValue] = useState<T>(() => _.get($scope, path))
|
const [value, setValue] = useState<T>(() => scopeStore.get(path))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return $scope.$watch(
|
return scopeStore.watch<T>(
|
||||||
path,
|
path,
|
||||||
(newValue: T) => {
|
(newValue: T) => {
|
||||||
setValue(() => {
|
// NOTE: this is deliberately wrapped in a function,
|
||||||
// NOTE: this is deliberately wrapped in a function,
|
// to avoid calling setValue directly with a value that's a function
|
||||||
// to avoid calling setValue directly with a value that's a function
|
setValue(() => newValue)
|
||||||
return deep ? _.cloneDeep(newValue) : newValue
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
deep
|
deep
|
||||||
)
|
)
|
||||||
}, [path, $scope, deep])
|
}, [path, scopeStore, deep])
|
||||||
|
|
||||||
const scopeSetter = useCallback(
|
const scopeSetter = useCallback(
|
||||||
(newValue: SetStateAction<T>) => {
|
(newValue: SetStateAction<T>) => {
|
||||||
setValue(val => {
|
setValue(val => {
|
||||||
const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue
|
const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue
|
||||||
$scope.$applyAsync(() => _.set($scope, path, actualNewValue))
|
scopeStore.set(path, actualNewValue)
|
||||||
return actualNewValue
|
return actualNewValue
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[path, $scope]
|
[path, scopeStore]
|
||||||
)
|
)
|
||||||
|
|
||||||
return [value, scopeSetter]
|
return [value, scopeSetter]
|
||||||
|
|
|
@ -0,0 +1,299 @@
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
|
||||||
|
import sinon from 'sinon'
|
||||||
|
import customLocalStorage from '@/infrastructure/local-storage'
|
||||||
|
|
||||||
|
function waitForWatchers(callback: () => void) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
callback()
|
||||||
|
window.setTimeout(resolve, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReactScopeValueStore', function () {
|
||||||
|
it('can set and retrieve a value', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
const retrieved = store.get('test')
|
||||||
|
expect(retrieved).to.equal('wombat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can overwrite a value', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
store.set('test', 'not a wombat')
|
||||||
|
const retrieved = store.get('test')
|
||||||
|
expect(retrieved).to.equal('not a wombat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can overwrite a nested value', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { prop: 'wombat' })
|
||||||
|
store.set('test.prop', 'not a wombat')
|
||||||
|
const retrieved = store.get('test.prop')
|
||||||
|
expect(retrieved).to.equal('not a wombat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws an error when retrieving an unknown value', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
expect(() => store.get('test')).to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can watch a value', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('changing', 'one')
|
||||||
|
store.set('fixed', 'one')
|
||||||
|
const changingItemWatcher = sinon.stub()
|
||||||
|
const fixedItemWatcher = sinon.stub()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.watch('changing', changingItemWatcher)
|
||||||
|
store.watch('fixed', fixedItemWatcher)
|
||||||
|
})
|
||||||
|
expect(changingItemWatcher).to.have.been.calledWith('one')
|
||||||
|
expect(fixedItemWatcher).to.have.been.calledWith('one')
|
||||||
|
changingItemWatcher.reset()
|
||||||
|
fixedItemWatcher.reset()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('changing', 'two')
|
||||||
|
})
|
||||||
|
expect(changingItemWatcher).to.have.been.calledWith('two')
|
||||||
|
expect(fixedItemWatcher).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows synchronous watcher updates', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
store.watch('test', watcher)
|
||||||
|
store.set('test', 'not a wombat')
|
||||||
|
expect(watcher).not.to.have.been.called
|
||||||
|
store.flushUpdates()
|
||||||
|
expect(watcher).to.have.been.calledWith('not a wombat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes a watcher', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
const removeWatcher = store.watch('test', watcher)
|
||||||
|
store.flushUpdates()
|
||||||
|
watcher.reset()
|
||||||
|
removeWatcher()
|
||||||
|
store.set('test', 'not a wombat')
|
||||||
|
store.flushUpdates()
|
||||||
|
expect(watcher).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call a watcher removed between observing change and being called', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
const removeWatcher = store.watch('test', watcher)
|
||||||
|
store.flushUpdates()
|
||||||
|
watcher.reset()
|
||||||
|
store.set('test', 'not a wombat')
|
||||||
|
removeWatcher()
|
||||||
|
store.flushUpdates()
|
||||||
|
expect(watcher).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not trigger watcher on setting to an identical value', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.watch('test', watcher)
|
||||||
|
})
|
||||||
|
expect(watcher).to.have.been.calledWith('wombat')
|
||||||
|
watcher.reset()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
})
|
||||||
|
expect(watcher).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can watch a value before it has been set', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
store.watch('test', watcher)
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('test', 'wombat')
|
||||||
|
})
|
||||||
|
expect(watcher).to.have.been.calledWith('wombat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws an error when watching an unknown value', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
expect(() => store.watch('test', () => {})).to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets nested value if watched', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { nested: 'one' })
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
store.watch('test.nested', watcher)
|
||||||
|
const retrieved = store.get('test.nested')
|
||||||
|
expect(retrieved).to.equal('one')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not set nested value if not watched', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { nested: 'one' })
|
||||||
|
expect(() => store.get('test.nested')).to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can watch a nested value', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { nested: 'one' })
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
store.watch('test.nested', watcher)
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('test', { nested: 'two' })
|
||||||
|
})
|
||||||
|
expect(watcher).to.have.been.calledWith('two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can watch a deeply nested value', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
|
||||||
|
const watcher = sinon.stub()
|
||||||
|
store.watch('test.levelOne.levelTwo.levelThree', watcher)
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('test', { levelOne: { levelTwo: { levelThree: 'two' } } })
|
||||||
|
})
|
||||||
|
expect(watcher).to.have.been.calledWith('two')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not inform nested value watcher when nested value does not change', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { nestedOne: 'one', nestedTwo: 'one' })
|
||||||
|
const nestedOneWatcher = sinon.stub()
|
||||||
|
const nestedTwoWatcher = sinon.stub()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.watch('test.nestedOne', nestedOneWatcher)
|
||||||
|
store.watch('test.nestedTwo', nestedTwoWatcher)
|
||||||
|
})
|
||||||
|
nestedOneWatcher.reset()
|
||||||
|
nestedTwoWatcher.reset()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('test', { nestedOne: 'two', nestedTwo: 'one' })
|
||||||
|
})
|
||||||
|
expect(nestedOneWatcher).to.have.been.calledWith('two')
|
||||||
|
expect(nestedTwoWatcher).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes nested values that no longer exist', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
|
||||||
|
store.set('test', { levelOne: { different: 'wombat' } })
|
||||||
|
const retrieved = store.get('test.levelOne.different')
|
||||||
|
expect(retrieved).to.equal('wombat')
|
||||||
|
expect(() => store.get('test.levelOne.levelTwo')).to.throw
|
||||||
|
expect(() => store.get('test.levelOne.levelTwo.levelThree')).to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw for allowed non-existent path', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.allowNonExistentPath('wombat')
|
||||||
|
store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
|
||||||
|
store.set('test', { levelOne: { different: 'wombat' } })
|
||||||
|
expect(() => store.get('test')).not.to.throw
|
||||||
|
expect(store.get('wombat')).to.equal(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw for deep allowed non-existent path', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.allowNonExistentPath('wombat', true)
|
||||||
|
expect(() => store.get('wombat')).not.to.throw
|
||||||
|
expect(() => store.get('wombat.nested')).not.to.throw
|
||||||
|
expect(() => store.get('wombat.really.very.nested')).not.to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for nested value in non-deep allowed non-existent path', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.allowNonExistentPath('wombat', false)
|
||||||
|
expect(() => store.get('wombat.nested')).to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for ancestor of allowed non-existent path', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.allowNonExistentPath('wombat.nested', true)
|
||||||
|
expect(() => store.get('wombat.really.very.nested')).not.to.throw
|
||||||
|
expect(() => store.get('wombat')).to.throw
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates ancestors', async function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
const testValue = {
|
||||||
|
prop1: {
|
||||||
|
subProp: 'wombat',
|
||||||
|
},
|
||||||
|
prop2: {
|
||||||
|
subProp: 'wombat',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store.set('test', testValue)
|
||||||
|
const rootWatcher = sinon.stub()
|
||||||
|
const prop1Watcher = sinon.stub()
|
||||||
|
const subPropWatcher = sinon.stub()
|
||||||
|
const prop2Watcher = sinon.stub()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.watch('test', rootWatcher)
|
||||||
|
store.watch('test.prop1', prop1Watcher)
|
||||||
|
store.watch('test.prop1.subProp', subPropWatcher)
|
||||||
|
store.watch('test.prop2', prop2Watcher)
|
||||||
|
})
|
||||||
|
rootWatcher.reset()
|
||||||
|
prop1Watcher.reset()
|
||||||
|
subPropWatcher.reset()
|
||||||
|
prop2Watcher.reset()
|
||||||
|
await waitForWatchers(() => {
|
||||||
|
store.set('test.prop1.subProp', 'picard')
|
||||||
|
})
|
||||||
|
expect(store.get('test')).to.deep.equal({
|
||||||
|
prop1: {
|
||||||
|
subProp: 'picard',
|
||||||
|
},
|
||||||
|
prop2: {
|
||||||
|
subProp: 'wombat',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(store.get('test.prop2')).to.equal(testValue.prop2)
|
||||||
|
expect(rootWatcher).to.have.been.called
|
||||||
|
expect(prop1Watcher).to.have.been.called
|
||||||
|
expect(subPropWatcher).to.have.been.called
|
||||||
|
expect(prop2Watcher).not.to.have.been.called
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('persistence', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
customLocalStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists string to local storage', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.persisted('test-path', 'fallback value', 'test-storage-key')
|
||||||
|
expect(store.get('test-path')).to.equal('fallback value')
|
||||||
|
store.set('test-path', 'new value')
|
||||||
|
expect(customLocalStorage.getItem('test-storage-key')).to.equal(
|
||||||
|
'new value'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't persist string to local storage until set() is called", function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.persisted('test-path', 'fallback value', 'test-storage-key')
|
||||||
|
expect(customLocalStorage.getItem('test-storage-key')).to.equal(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts persisted value', function () {
|
||||||
|
const store = new ReactScopeValueStore()
|
||||||
|
store.persisted('test-path', false, 'test-storage-key', {
|
||||||
|
toPersisted: value => (value ? 'on' : 'off'),
|
||||||
|
fromPersisted: persistedValue => persistedValue === 'on',
|
||||||
|
})
|
||||||
|
store.set('test-path', true)
|
||||||
|
expect(customLocalStorage.getItem('test-storage-key')).to.equal('on')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -3,7 +3,7 @@
|
||||||
import sinon from 'sinon'
|
import sinon from 'sinon'
|
||||||
import { get } from 'lodash'
|
import { get } from 'lodash'
|
||||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||||
import { IdeProvider } from '@/shared/context/ide-context'
|
import { IdeAngularProvider } from '@/shared/context/ide-angular-provider'
|
||||||
import { UserProvider } from '@/shared/context/user-context'
|
import { UserProvider } from '@/shared/context/user-context'
|
||||||
import { ProjectProvider } from '@/shared/context/project-context'
|
import { ProjectProvider } from '@/shared/context/project-context'
|
||||||
import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context'
|
import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context'
|
||||||
|
@ -112,7 +112,7 @@ export function EditorProviders({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitTestProvider>
|
<SplitTestProvider>
|
||||||
<IdeProvider ide={window._ide}>
|
<IdeAngularProvider ide={window._ide}>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<FileTreeDataProvider>
|
<FileTreeDataProvider>
|
||||||
|
@ -132,7 +132,7 @@ export function EditorProviders({
|
||||||
</FileTreeDataProvider>
|
</FileTreeDataProvider>
|
||||||
</ProjectProvider>
|
</ProjectProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</IdeProvider>
|
</IdeAngularProvider>
|
||||||
</SplitTestProvider>
|
</SplitTestProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
1
services/web/types/angular/scope.ts
Normal file
1
services/web/types/angular/scope.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type Scope = Record<string, any>
|
15
services/web/types/ide/scope-event-emitter.ts
Normal file
15
services/web/types/ide/scope-event-emitter.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
|
||||||
|
|
||||||
|
export type ScopeEventName = keyof IdeEvents
|
||||||
|
|
||||||
|
export interface ScopeEventEmitter {
|
||||||
|
emit: (
|
||||||
|
eventName: ScopeEventName,
|
||||||
|
broadcast: boolean,
|
||||||
|
...detail: unknown[]
|
||||||
|
) => void
|
||||||
|
on: (
|
||||||
|
eventName: ScopeEventName,
|
||||||
|
listener: (...args: unknown[]) => void
|
||||||
|
) => () => void
|
||||||
|
}
|
11
services/web/types/ide/scope-value-store.ts
Normal file
11
services/web/types/ide/scope-value-store.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export interface ScopeValueStore {
|
||||||
|
// We can't make this generic because get() can always return undefined if
|
||||||
|
// there is no entry for the path
|
||||||
|
get: (path: string) => any
|
||||||
|
set: (path: string, value: unknown) => void
|
||||||
|
watch: <T>(
|
||||||
|
path: string,
|
||||||
|
callback: (newValue: T) => void,
|
||||||
|
deep: boolean
|
||||||
|
) => () => void
|
||||||
|
}
|
Loading…
Reference in a new issue