Skip to content

Commit

Permalink
Merge pull request #360 from concord-consortium/188182907-extra-docum…
Browse files Browse the repository at this point in the history
…entation

dirty tracking doc and a code trace
  • Loading branch information
scytacki authored Nov 5, 2024
2 parents 77a6ad9 + 88e3f2a commit a8dec0a
Show file tree
Hide file tree
Showing 2 changed files with 164 additions and 0 deletions.
44 changes: 44 additions & 0 deletions doc/dirty-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
The CFM tracks if the document is "dirty". This means the document has been modified since the last time it was saved. When the document is "dirty":
- an "UNSAVED" badge is displayed in the title bar
- when the user tries to close the window, a dialog will be shown asking if they want to save first.
- if autosave is enabled, the document will be saved if it is dirty

The CFM only marks the document dirty itself in a few cases. It is mainly the responsibility of the client to tell the CFM when the document is dirty. This is done using `client.dirty()`.

When the document is saved by a provider this dirty state is reset to false. However there are a few caveats to this approach.

If the user changes the document while the CFM is saving it, the revision of the file that is saved might already be out of date when the save completes. However the CFM doesn't take that into account and will reset the dirty state to false. The CFM doesn't reset the dirty state at the beginning of the save because the save might fail. Because of this, these intermediate changes during save could be lost. Clients can handle this case by listening to the `fileSaved` event and then comparing the content that was successfully saved to the current content. If current content is different, then mark the document dirty again with `client.dirty()`.

If the provider has switched from storing wrapped to unwrapped documents or vice versa, the CFM will identify this and mark the document as "dirty" immediately after opening it. This handled by the `requiresConversion` method.

The client might need to modify a document right after opening. If this is automatic without user interaction, the client might **not** want these changes to make the document "dirty". If autosaving is disabled the "UNSAVED" badge will be shown even though the user hasn't changed anything themselves. If autosaving is enabled the user might see the "UNSAVED" badge flash and disappear. Additionally when the saving of document is an indication of student progress, this initial immediate saving will make it appear the user has changed the document when really they have not. Because it is the responsibility of the client to tell the CFM when the content is dirty these cases can be handled by just not marking the document dirty.

## Autosave
When autosave is enabled by passing the `autoSaveInterval` configuration option, the CFM will try to autosave on this interval. If the document is not "dirty" then the autosave will do nothing. So when autosave is used, it is important that the client calls `client.dirty()` otherwise the document will not be auto saved.

When the autosave loop sees the document is dirty, it will request the content from the client using the `getContent` event and then save the result.

## Shared doc info
The CFM normally includes the shared doc info in the original document that is being shared. This is so the author of the original document can update the shared document when they re-open the original document.

Some providers allow the user to not include this shared doc info in the saved file. This is useful if you want to send a document to someone but you don't want them to be able update the shared document.

The shared doc info is related to the dirty state of the document because the CFM needs to know to save the original document once this shared doc info has been added to it. When the document is shared or unshared the CFM will mark the document as dirty. This is one case where the CFM will actually mark the document dirty itself.

This is further complicated by the unwrapped option in the CFM. When the client configures the CFM to store the content unwrapped, it is the responsibility of the client to store the shared doc info inside of its content. Clients using the unwrapped option do this by listening for the `sharedFile` event from the CFM. After updating the content a client should set the dirty state so the CFM knows it should save the client's updated content.

## Wrapped vs Unwrapped files
The CFM has an option to either store the content from the client directly or to wrap this content with CFM specific metadata. CODAP chooses to not wrap the file content. This means that CODAP has to maintain its own version of at least the shared metadata inside of its content. CODAP stores this shared data at `metadata.shared`.

When the CFM saves a file without the shared doc info and is running in unwrapped mode, it looks for this `metadata.shared` section and doesn't save it.

When a CODAP document is opened, the CODAP client listens for the `openedFile` event and then reads the shared doc info out of the `metadata.shared`. And it then returns this shared doc info in the call back of `openedFile`, this way the CFM knows what to put in the sharing dialog for the document.

## Document names
TODO: this section needs more work.

When the document is renamed, its dirty state is unmodified. So in essence the name is not considered part of the content. However some providers do look for the name in the content, so there is probably some path where the document renaming causes the content to be updated.

In some cases the document name is used as the file name. For example in google drive and local file saving.

When the document is loaded by the s3-provider, lara-provider, or the legacy document-store-provider it looks in the content in several places to figure out the name. This includes looking in the CODAP locations where the name is saved.
120 changes: 120 additions & 0 deletions doc/local-open-trace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
This is an example of how the UI of a provider interacts with the document model. It is the code path that supports a user opening a local file. This traces the path through many functions from when the `openFileDialog` is called to when the `window.title` is set to the name of the file.

## Summary
- it starts out in `CloudFileManagerClient`
- `CloudFileManagerClient` fires an event that is handled by `AppView`
- a state change in `AppView` then causes `ProviderTabbedDialog` to render
- `ProviderTabbedDialog` gets the `LocalFileListTab` component from `LocalFileProvider`
- `LocalFileListTab` shows a file input element
- the input event handling saves the browser file object into `metadata.providerData.file` and calls back into `CloudFileManagerClient#openFile`
- this then goes into `LocalFileFileProvider#load` to read the content from browser file object and create the CloudContent.
- then a callback goes back into `CloudFileManagerClient` to finishing the opening logic

## Details

### `CloudFileManagerClient#openFileDialog(callback: OpenSaveCallback = null)`
- if `!this.state.dirty`
- call
```javascript
this._ui.openFileDialog((metadata: CloudMetadata) => {
return this.openFile(metadata, callback)
})
```
- Note: this is the definition of the callback that is eventually called by `LocalFileTabListView#openFile`

### `CloudFileManagerUI#openFileDialog(callback: UIEventCallback)`
- calls `_showProviderDialog('openFile', (tr('~DIALOG.OPEN')), callback)`

### `CloudFileManagerUI#_showProviderDialog(action: string, title: string, callback: UIEventCallback, data?: any)`
- fires an 'showProviderDialog' event with:
`this.listenerCallback(new CloudFileManagerUIEvent('showProviderDialog', { action, title, callback, data }))`

### `AppView#componentDidMount`
- adds a listener to `props.client._ui`
- when `event.type` is 'showProviderDialog'
- call `setState({providerDialog: event.data})`

### `AppView#render`
- when `state.providerDialog` is truthy
- call `renderDialogs`

### `AppView#renderDialogs`
- when `state.providerDialog` is truthy
- return `ProviderTabbedDialog({client: this.props.client, dialog: this.state.providerDialog, close: this.closeDialogs})`

### `ProviderTabbedDialog#render`
- when `props.dialog.action` is 'openFile' it sets:
- `capability='list'`
- `TabComponent=FileDialogTab`
- gets a filteredTabComponent with `provider.filterTabComponent(capability, TabComponent)`
- renders this component with:
```javascript
filteredTabComponent({
client: this.props.client,
dialog: this.props.dialog,
close: this.props.close,
provider
})
```

### `LocalFileProvider#filterTabComponent(capability: ECapabilities, defaultComponent: React.Component)`
If the capability is 'list' then the `LocalFileListTab` component is returned.

### `LocalFileTabListView#render`
`<input type='file' onChange={this.changed}>`

### `LocalFileTabListView#changed(e: any)`
makes sure there is only one file being opened
calls `openFile(e.target.files[0], 'select')`

### `LocalFileTabListView#openFile(file: any, via: any)`
constructs a CloudMetadata with:
```
{
name: file.name.split('.')[0],
type: CloudMetadata.File,
parent: null,
provider: this.props.provider,
providerData: {
file
}
}
```
Then calls `props.dialog.callback(metadata, via)`, this callback is defined up in `CloudFileManagerClient#openFileDialog`

### `CloudFileManagerClient#openFile(metadata: CloudMetadata, callback: OpenSaveCallback = null)`
- checks that the provider can load this file
- fires `willOpenFile` event
- calls `metadata.provider.load(metadata, ...)`
- The `...` represents an inline callback which is described below
- The `callback` argument to `openFile` comes from `CloudFileManagerClient#openFileDialog`

### `LocalFileProvider#load(metadata: CloudMetadata, callback: ProviderLoadCallback)`
- reads the content from the `metadata.providerData.file`
- creates a CloudContent from the result
- passes this CloudContent to: `callback(null, content)`

### `CloudFileManagerClient#openFile<anonymous callback>(err: string | null, content: any)`
- closes the current file
- calls `_filterLoadedContent(content)`
- calls `_fileOpened(content, metadata, {openedContent: content.clone()}, this._getHashParams(metadata))`
- calls the `callback` that was an argument to `CloudFileManagerClient#openFile` this callback comes from `CloudFileManagerClient#openFileDialog`
- calls `metadata.provider.fileOpened(content, metadata)`

### `CloudFileManagerClient#_fileOpened(content: any, metadata: CloudMetadata, additionalState?: any, hashParams: string = null)`
- calls `_updateState(content, metadata, additionalState, hashParams)`
- fires 'openedFile' event with `{content: content.getClientContent()}` with an inline callback
- TODO: describe the inline callback

### `CloudFileManagerClient#_updateState(content: any, metadata: CloudMetadata, additionalState: Partial<IClientState> = {}, hashParams: string = null)`
- sets the window title to `metadata.name`
- updates the window.location.hash wth the passed in hashParams. In this case of a local file there probably are no hashParams, but I haven't verified that.
- saves the content as `currentContent` and metadata as `metadata` in state

### `ProviderInterface#fileOpened`
- the `LocalFileProvider` doesn't define `fileOpened` so this parent definition is used
- the function doesn't do anything.

# Questions


0 comments on commit a8dec0a

Please sign in to comment.