diff --git a/kotoed-js/src/main/less/code.less b/kotoed-js/src/main/less/code.less index 1457748c..3e676cd2 100644 --- a/kotoed-js/src/main/less/code.less +++ b/kotoed-js/src/main/less/code.less @@ -61,11 +61,16 @@ } #code-review-app { + display: flex; + flex: 1 1 100%; +} + +.code-review-app-rows { flex: 1 1 auto; min-height: 0px; overflow: hidden; display: flex; - flex-flow: row; + flex-flow: column; } .code-review { @@ -76,6 +81,13 @@ border-top: 1px solid #ccc; } +.code-review-status-bar { + flex: 0 0 1.2em; + flex-flow: row; + border-top: 1px solid #ccc; +} + + #code-review-left { display: flex; flex: none; @@ -130,8 +142,11 @@ .lost-found-button-container { flex: 0 1 auto } +.diff-mode-button-container { + flex: 0 1 auto +} -.lost-found-button { +.review-bottom-button { width: 100% } diff --git a/kotoed-js/src/main/ts/code/actions.ts b/kotoed-js/src/main/ts/code/actions.ts index 8b5f875d..4a12dd1c 100644 --- a/kotoed-js/src/main/ts/code/actions.ts +++ b/kotoed-js/src/main/ts/code/actions.ts @@ -8,7 +8,16 @@ import { import { Comment, CommentsState, CommentState, FileComments, LineComments, ReviewComments } from "./state/comments"; -import {fetchDiff, fetchFile, fetchRootDir, File, FileDiffResult, FileType} from "./remote/code"; +import { + DiffBase, + fetchDiff, + fetchFile, + fetchRootDir, + File, + FileDiffResponse, + FileDiffResult, + FileType, updateDiffPreference +} from "./remote/code"; import {FileNotFoundError} from "./errors"; import {push} from "react-router-redux"; import {Dispatch} from "redux"; @@ -22,7 +31,7 @@ import { editComment as doEditComment } from "./remote/comments"; import {Capabilities, fetchCapabilities} from "./remote/capabilities"; -import {getFilePath, getNodePath} from "./util/filetree"; +import {applyDiffToFileTree, getFilePath, getNodePath} from "./util/filetree"; import {NodePath} from "./state/blueprintTree"; import {makeCodeReviewCodePath, makeCodeReviewLostFoundPath} from "../util/url"; import {DbRecordWrapper, isStatusFinal} from "../data/verification"; @@ -33,6 +42,7 @@ import {fetchAnnotations} from "./remote/annotations"; import {ReviewAnnotations} from "./state/annotations"; import {CommentTemplates, fetchCommentTemplates} from "./remote/templates"; import natsort from "natsort"; +import {pick, typedKeys} from "../util/common"; const actionCreator = actionCreatorFactory(); @@ -114,6 +124,18 @@ interface FormLockUnlockPayload { sourceline: number } +interface DiffBasePayload { + diffBase: DiffBase +} + +interface PersistPayload { + persist: boolean +} + +interface DiffResultPayload { + diff: FileDiffResult[] +} + // Local actions export const dirExpand = actionCreator('DIR_EXPAND'); export const dirCollapse = actionCreator('DIR_COLLAPSE'); @@ -132,9 +154,9 @@ export const submissionFetch = actionCreator.async('ROOT_FETCH'); +export const rootFetch = actionCreator.async('ROOT_FETCH'); export const fileLoad = actionCreator.async('FILE_LOAD'); -export const fileDiff = actionCreator.async('FILE_DIFF'); +export const diffFetch = actionCreator.async('DIFF_FETCH') // Annotation fetch actions export const annotationsFetch = actionCreator.async('ANNOTATION_FETCH'); @@ -217,36 +239,48 @@ const naturalSorter = natsort() const typeSorter = (a: File, b: File) => fileTypeDisplayOrder[a.type] - fileTypeDisplayOrder[b.type] export function fetchRootDirIfNeeded(payload: SubmissionPayload) { - return (dispatch: Dispatch, getState: () => CodeReviewState) => { - if (!getState().fileTreeState.loading) + return async (dispatch: Dispatch, getState: () => CodeReviewState) => { + const state = getState(); + if (!state.fileTreeState.loading) return Promise.resolve(); dispatch(rootFetch.started({ submissionId: payload.submissionId })); - return fetchRootDir(payload.submissionId).then((root) => { - const recursiveSorter = (node: File) => { - if (node.children == null) { - return - } - node.children.sort((a: File, b: File) => - typeSorter(a, b) || naturalSorter(a.name, b.name) - ) - for (let child of node.children) { - recursiveSorter(child) - } + const root = await fetchRootDir(payload.submissionId); + + const recursiveSorter = (node: File) => { + if (node.children == null) { + return } - recursiveSorter(root) - dispatch(rootFetch.done({ - params: { - submissionId: payload.submissionId - }, - result: { - root - } - })) - }); + node.children.sort((a: File, b: File) => + typeSorter(a, b) || naturalSorter(a.name, b.name) + ) + for (let child of node.children) { + recursiveSorter(child) + } + } + recursiveSorter(root) + + const diff = await fetchDiff(payload.submissionId, state.diffState.base); + + dispatch(rootFetch.done({ + params: { + submissionId: payload.submissionId + }, + result: { + root, + diff: diff.diff + } + })) + dispatch(diffFetch.done({ + params: { + submissionId: payload.submissionId, + diffBase: state.diffState.base + }, + result: diff + })) }; } @@ -310,8 +344,9 @@ export function setLostFoundPath(payload: SubmissionPayload) { export function loadFileToEditor(payload: FilePathPayload & SubmissionPayload) { - return (dispatch: Dispatch, getState: () => CodeReviewState) => { + return async (dispatch: Dispatch, getState: () => CodeReviewState) => { let {filename} = payload; + const state = getState(); dispatch(fileLoad.started({ submissionId: payload.submissionId, filename @@ -322,28 +357,38 @@ export function loadFileToEditor(payload: FilePathPayload & SubmissionPayload) { file: filename })(dispatch, getState); - fetchFile(payload.submissionId, filename).then(result => { - dispatch(fileLoad.done({ - params: { - submissionId: payload.submissionId, - filename - }, - result: { - value: result, - displayedComments: getState().commentsState.comments.get(filename, Map()), - } - })); - }); + const result = await fetchFile(payload.submissionId, filename); + dispatch(fileLoad.done({ + params: { + submissionId: payload.submissionId, + filename + }, + result: { + value: result, + displayedComments: state.commentsState.comments.get(filename, Map()), + } + })); - fetchDiff(payload.submissionId).then(result => { - dispatch(fileDiff.done({ - params: { - submissionId: payload.submissionId, - filename - }, - result: result.find((diff) => diff.toFile == filename) - })) - }); + } +} + +export function updateDiff(payload: SubmissionPayload & DiffBasePayload & PersistPayload) { + return async (dispatch: Dispatch, getState: () => CodeReviewState) => { + dispatch(diffFetch.started(payload)) + + const state = getState(); + const patchedType = payload.diffBase.type == "SUBMISSION_ID" ? "PREVIOUS_CLOSED" : payload.diffBase.type; + + if (payload.persist) { + updateDiffPreference(state.capabilitiesState.capabilities.principal, patchedType) + } + + const diff = await fetchDiff(payload.submissionId, payload.diffBase); + + dispatch(diffFetch.done({ + params: payload, + result: diff + })) } } diff --git a/kotoed-js/src/main/ts/code/components/CodeReview.tsx b/kotoed-js/src/main/ts/code/components/CodeReview.tsx index a2f3dd14..d295f93b 100644 --- a/kotoed-js/src/main/ts/code/components/CodeReview.tsx +++ b/kotoed-js/src/main/ts/code/components/CodeReview.tsx @@ -1,12 +1,23 @@ import * as React from "react"; -import {Button, Panel, Label} from "react-bootstrap"; +import { + Button, + Panel, + Label, + Modal, + Form, + FormGroup, + ControlLabel, + FormControl, + Radio, + SplitButton, MenuItem +} from "react-bootstrap"; import FileReview from "./FileReview"; import FileTree from "./FileTree"; import {Comment, FileComments, LostFoundComments as LostFoundCommentsState} from "../state/comments"; import {NodePath} from "../state/blueprintTree"; import {FileNode} from "../state/filetree"; -import {List} from "immutable"; +import {List, Map} from "immutable"; import {LostFoundComments} from "./LostFoundComments"; import {CommentAggregate} from "../remote/comments"; import {UNKNOWN_FILE, UNKNOWN_LINE} from "../remote/constants"; @@ -20,7 +31,14 @@ import AggregatesLabel from "../../views/AggregatesLabel"; import {FileForms, ReviewForms} from "../state/forms"; import {ReviewAnnotations} from "../state/annotations"; import {CommentTemplates} from "../remote/templates"; -import {FileDiffChange} from "../remote/code"; +import {DiffBase, DiffBaseType, FileDiffChange, FileDiffResult, RevisionInfo} from "../remote/code"; + +import "@fortawesome/fontawesome-free/less/fontawesome.less" +import "@fortawesome/fontawesome-free/less/solid.less" +import "@fortawesome/fontawesome-free/less/regular.less" +import {ChangeEvent} from "react"; +import {DiffState} from "../state/diff"; +import {Profile} from "../../data/denizen"; export interface CodeReviewProps { submissionId: number @@ -34,7 +52,6 @@ export interface CodeReviewProps { value: string file: string comments: FileComments - diff: Array } fileTree: { @@ -51,12 +68,15 @@ export interface CodeReviewProps { capabilities: { canPostComment: boolean + canViewTags: boolean whoAmI: string } forms: { forms: ReviewForms } + + diff: DiffState } interface CodeReviewPropsFromRouting { @@ -89,17 +109,55 @@ export interface CodeReviewCallbacks { lostFound: { onSelect: () => void } + + diff: { + onChangeDiffBase: (submissionId: number, diffBase: DiffBase, persist: boolean) => void + } } export type CodeReviewPropsAndCallbacks = CodeReviewProps & CodeReviewCallbacks -export default class CodeReview extends React.Component { +interface CodeReviewState { + showDiffModal: boolean + baseChoice: { + type: DiffBaseType + submissionId: string|null + } +} + +export default class CodeReview extends + React.Component { + + constructor(props: CodeReviewPropsAndCallbacks & CodeReviewPropsFromRouting, context: any) { + super(props, context); + this.state = { + showDiffModal: false, + baseChoice: this.baseToFormState(props.diff.base) + } + } + + + private baseToFormState(base: DiffBase) { + return { + type: base.type, + submissionId: base.submissionId && base.submissionId.toString() || null + } + } makeOriginalLinkOrUndefined = (comment: BaseCommentToRead) => { if (this.props.comments.makeOriginalLink && comment.submissionId !== this.props.submissionId) return this.props.comments.makeOriginalLink(comment) }; + getDiffForEditor = () => { + const fileDiff = this.props.diff.diff.get(this.props.editor.file); + if (!fileDiff) { + return [] + } + + return fileDiff.changes; + } + renderRightSide = () => { switch (this.props.show) { case "lost+found": @@ -118,7 +176,7 @@ export default class CodeReview extends React.Component { - return
-
- {this.renderFileTreeVeil()} -
- -
- + return
+
+
+ {this.renderFileTreeVeil()} +
+ +
+ +
+
+ +
+
+ {this.renderRightSide()} +
+ {this.renderModal()}
-
- {this.renderRightSide()} -
+ {this.renderStatusBar()}
}; shouldRenderReview = () => this.props.submission && this.props.submission.verificationData.status === "Processed"; + renderStatusBar = () => +
+ {this.renderDiffStatus()} +
+
+ + shrinkRev = (rev: string) => rev.substring(0, 10) + + renderSubLink = (id?: number) => { + if (!id) { + return undefined; + } + + return {" "}(Sub #{id}) + } + + renderSubText = (id?: number) => { + if (!id) { + return undefined; + } + return ` (Sub #${id})` + } + + renderDiffStatus = () => { + if (!this.props.diff.from || !this.props.diff.to) + return undefined; + + if (this.props.diff.from.revision === this.props.diff.to.revision) + return undefined; + + return
+ {"Showing diff "} + {this.shrinkRev(this.props.diff.from.revision)} + {this.renderSubLink(this.props.diff.from.submissionId)} + {" .. "} + {this.shrinkRev(this.props.diff.to.revision)} + {this.renderSubText(this.props.diff.to.submissionId)} +
+ } + + renderModal = () => + { + this.setState({ + showDiffModal: false, + baseChoice: this.baseToFormState(this.props.diff.base) + }) + }}> + + Settings + + + + Diff base + this.setState({ + ...this.state, + baseChoice: { + type: "COURSE_BASE", + submissionId: null + } + })}> + Course base revision + + {" "} + this.setState({ + ...this.state, + baseChoice: { + type: "PREVIOUS_CLOSED", + submissionId: null + } + })}> + Latest closed submission + + {this.props.capabilities.canViewTags && this.setState({ + ...this.state, + baseChoice: { + type: "PREVIOUS_CHECKED", + submissionId: null + } + })}> + Latest checked submission + } + this.setState({ + ...this.state, + baseChoice: { + type: "SUBMISSION_ID", + submissionId: this.state.baseChoice.submissionId + } + })}> + Specific submission + + + + {this.state.baseChoice.type == "SUBMISSION_ID" && + Submission Id + ) => { + this.setState({ + ...this.state, + baseChoice: { + type: "SUBMISSION_ID", + submissionId: e.target.value as string || "" + } + }) + }} + /> + + } + + + this.applyDiffPreference(false)}> + this.applyDiffPreference(true)}>Apply and save + + + + + applyDiffPreference = (persist: boolean) => { + this.props.diff.onChangeDiffBase( + this.props.submission!!.record.id, { + type: this.state.baseChoice.type, + submissionId: this.state.baseChoice.type == "SUBMISSION_ID" ? + parseInt(this.state.baseChoice.submissionId || "0") : + undefined + }, persist) + this.setState({ + showDiffModal: false + }) + } render() { if (!this.props.submission) { diff --git a/kotoed-js/src/main/ts/code/components/FileReview.tsx b/kotoed-js/src/main/ts/code/components/FileReview.tsx index 70deb2ef..7384e13b 100644 --- a/kotoed-js/src/main/ts/code/components/FileReview.tsx +++ b/kotoed-js/src/main/ts/code/components/FileReview.tsx @@ -346,6 +346,12 @@ export default class FileReview extends ComponentWithLoading { + for (let i = 0; i < this.editor.lineCount(); i++) { + this.editor.removeLineClass(toCmLine(i), "background", "mark-line-changed"); + } + } + renderDiffChange = (change: FileDiffChange) => { let lineNumber = change.to.start; for (const lineChange of change.lines) { @@ -493,6 +499,11 @@ export default class FileReview extends ComponentWithLoading
diff --git a/kotoed-js/src/main/ts/code/containers/CodeReviewContainer.tsx b/kotoed-js/src/main/ts/code/containers/CodeReviewContainer.tsx index fa597d9e..a794815e 100644 --- a/kotoed-js/src/main/ts/code/containers/CodeReviewContainer.tsx +++ b/kotoed-js/src/main/ts/code/containers/CodeReviewContainer.tsx @@ -9,7 +9,7 @@ import { dirCollapse, dirExpand, editComment, expandHiddenComments, fileSelect, loadCode, loadLostFound, postComment, resetExpandedForLine, setCommentState, - setCodePath, setLostFoundPath, resetExpandedForLostFound, unselectFile, emphasizeComment + setCodePath, setLostFoundPath, resetExpandedForLostFound, unselectFile, emphasizeComment, updateDiff } from "../actions"; import {CodeReviewState, ScrollTo} from "../state"; import {RouteComponentProps} from "react-router-dom"; @@ -42,7 +42,6 @@ const mapStateToProps = function(store: CodeReviewState, value: store.editorState.value, file: store.editorState.fileName, comments: store.commentsState.comments.get(store.editorState.fileName, FileComments()), - diff: store.editorState.diff }, fileTree: { loading: store.fileTreeState.loading || store.fileTreeState.aggregatesLoading || store.capabilitiesState.loading, @@ -56,11 +55,13 @@ const mapStateToProps = function(store: CodeReviewState, }, capabilities: { canPostComment: store.capabilitiesState.capabilities.permissions.postComment, + canViewTags: store.capabilitiesState.capabilities.permissions.tags, whoAmI: store.capabilitiesState.capabilities.principal.denizenId, }, forms: { forms: store.formState - } + }, + diff: {...store.diffState} } }; @@ -171,6 +172,17 @@ const mapDispatchToProps = function (dispatch: Dispatch, })); } }, + + diff: { + onChangeDiffBase: (submissionId, diffBase, persist) => { + dispatch(updateDiff({ + diffBase, + submissionId, + persist + })) + } + }, + onCodeRoute: (submissionId, filename) => { dispatch(loadCode({ submissionId, diff --git a/kotoed-js/src/main/ts/code/index.tsx b/kotoed-js/src/main/ts/code/index.tsx index 591cc865..197d80da 100644 --- a/kotoed-js/src/main/ts/code/index.tsx +++ b/kotoed-js/src/main/ts/code/index.tsx @@ -13,7 +13,13 @@ import CodeReviewContainer, { } from "./containers/CodeReviewContainer"; import { annotationsReducer, - capabilitiesReducer, commentsReducer, commentTemplateReducer, editorReducer, fileTreeReducer, formReducer, + capabilitiesReducer, + commentsReducer, + commentTemplateReducer, + diffReducer, + editorReducer, + fileTreeReducer, + formReducer, submissionReducer } from "./reducers"; @@ -37,6 +43,7 @@ export const store = createStore( capabilitiesState: capabilitiesReducer, submissionState: submissionReducer, formState: formReducer, + diffState: diffReducer, router: routerReducer }), applyMiddleware(routerMiddleware(history)), @@ -53,4 +60,4 @@ render( , - document.getElementById("code-review-app")); \ No newline at end of file + document.getElementById("code-review-app")); diff --git a/kotoed-js/src/main/ts/code/reducers.ts b/kotoed-js/src/main/ts/code/reducers.ts index 532aa112..58a9efd8 100644 --- a/kotoed-js/src/main/ts/code/reducers.ts +++ b/kotoed-js/src/main/ts/code/reducers.ts @@ -9,16 +9,16 @@ import { fileSelect, rootFetch, commentAggregatesFetch, aggregatesUpdate, capabilitiesFetch, hiddenCommentsExpand, expandedResetForFile, expandedResetForLine, commentEdit, fileUnselect, expandedResetForLostFound, commentEmphasize, - submissionFetch, annotationsFetch, commentTemplateFetch, fileDiff + submissionFetch, annotationsFetch, commentTemplateFetch, diffFetch } from "./actions"; import { ADD_DELTA, - addAggregates, CLOSE_DELTA, makeFileNode, OPEN_DELTA, registerAddComment, registerCloseComment, + addAggregates, applyDiffToFileTree, CLOSE_DELTA, makeFileNode, OPEN_DELTA, registerAddComment, registerCloseComment, registerOpenComment, updateAggregate } from "./util/filetree"; import {NodePath} from "./state/blueprintTree"; import {UNKNOWN_FILE, UNKNOWN_LINE} from "./remote/constants"; -import {List} from "immutable"; +import {List, Map} from "immutable"; import {DbRecordWrapper} from "../data/verification"; import {SubmissionToRead} from "../data/submission"; import {SubmissionState} from "./state/submission"; @@ -26,6 +26,9 @@ import {DEFAULT_FORM_STATE, FileForms, ReviewForms} from "./state/forms"; import {CodeAnnotationsState, ReviewAnnotations} from "./state/annotations"; import {CommentTemplateState} from "./state/templates"; import {CommentTemplates} from "./remote/templates"; +import {DiffBase, fetchDiff, FileDiffResult} from "./remote/code"; +import {DiffState} from "./state/diff"; +import {DiffModePreference} from "../data/denizen"; const initialFileTreeState: FileTreeState = { root: FileNode({ @@ -86,8 +89,14 @@ export const fileTreeReducer = (state: FileTreeState = initialFileTreeState, act } else if (isType(action, rootFetch.done)) { let newState = {...state}; newState.root = makeFileNode(action.payload.result.root); + // Do not copy the state since it has not been published yet + applyDiffToFileTree(newState.root, action.payload.result.diff, false); newState.loading = false; return newState; + } else if (isType(action, diffFetch.done)) { + let newState = {...state} + newState.root = FileNode(applyDiffToFileTree(state.root, action.payload.result.diff)) + return newState; } else if (isType(action, commentAggregatesFetch.done)) { let newState = {...state}; newState.root = addAggregates(newState.root, action.payload.result); @@ -128,15 +137,15 @@ export const fileTreeReducer = (state: FileTreeState = initialFileTreeState, act return state; }; -const defaultEditorState = { +const defaultEditorState: EditorState = { value: "", fileName: "", displayedComments: FileComments(), - mode: {}, loading: false, diff: [] }; + export const editorReducer = (state: EditorState = defaultEditorState, action: Action) => { if (isType(action, fileLoad.started)) { let newState = {...state}; @@ -148,16 +157,68 @@ export const editorReducer = (state: EditorState = defaultEditorState, action: A newState.fileName = action.payload.params.filename; newState.loading = false; return newState; - } else if (isType(action, fileDiff.done)) { - let diff = action.payload.result; - if (diff === undefined) return state; - let newState = {...state}; - newState.diff = diff.changes; - return newState; } return state; }; +const defaultDiffState: DiffState = { + diff: Map(), + loading: false, + base: { + type: "PREVIOUS_CLOSED" + } +} + +function fileDiffToMap(diff: Array) { + return Map().withMutations(mutable => { + for (const diffEntry of diff) { + mutable.set(diffEntry.toFile, diffEntry) + } + }) + +} + +export const diffReducer = (state: DiffState = defaultDiffState, action: Action): DiffState => { + if (isType(action, diffFetch.done)) { + return { + loading: false, + base: action.payload.params.diffBase, + diff: fileDiffToMap(action.payload.result.diff), + from: action.payload.result.from, + to: action.payload.result.to + } + } else if (isType(action, diffFetch.started)) { + return { + loading: true, + base: action.payload.diffBase, + diff: state.diff + } + } else if (isType(action, rootFetch.done)) { + return { + loading: false, + base: state.base, + diff: fileDiffToMap(action.payload.result.diff) + } + } else if (isType(action, capabilitiesFetch.done)) { + let diffModePreference: DiffModePreference + + if (!action.payload.result.permissions.tags && + action.payload.result.profile.diffModePreference == "PREVIOUS_CHECKED") { + diffModePreference = "PREVIOUS_CLOSED" + } else { + diffModePreference = action.payload.result.profile.diffModePreference; + } + + return { + ...state, + base: { + type: diffModePreference + } + } + } + return state +} + export const defaultCommentsState = { comments: ReviewComments(), lostFound: LostFoundComments(), @@ -329,9 +390,16 @@ export const defaultCapabilitiesState: CapabilitiesState = { clean: false, tags: false, klones: false + }, + profile: { + id: 0, + denizenId: "???", + diffModePreference: "PREVIOUS_CLOSED", + oauth: [] } }, loading: true, + }; export const capabilitiesReducer = (state: CapabilitiesState = defaultCapabilitiesState, action: Action) => { @@ -390,4 +458,4 @@ export const formReducer = (state: ReviewForms = defaultFormState, action: Actio } else { return state.setIn([sourcefile, sourceline], {processing}); } -}; \ No newline at end of file +}; diff --git a/kotoed-js/src/main/ts/code/remote/capabilities.ts b/kotoed-js/src/main/ts/code/remote/capabilities.ts index 7a43a8f2..02d4e45b 100644 --- a/kotoed-js/src/main/ts/code/remote/capabilities.ts +++ b/kotoed-js/src/main/ts/code/remote/capabilities.ts @@ -1,16 +1,19 @@ import axios from "axios" import {keysToCamelCase} from "../../util/stringCase"; import {Kotoed} from "../../util/kotoed-api"; -import {DenizenPrincipal} from "../../data/denizen"; +import {DenizenPrincipal, Profile, ProfileInfo} from "../../data/denizen"; import {fetchPermissions, SubmissionPermissions} from "../../submissionDetails/remote"; +import {sendAsync} from "../../views/components/common"; export interface Capabilities { principal: DenizenPrincipal permissions: SubmissionPermissions + profile: ProfileInfo } export async function fetchCapabilities(submissionId: number): Promise { let principalResp = await axios.get(Kotoed.UrlPattern.AuthHelpers.WhoAmI); let permissions = await fetchPermissions(submissionId); - return {principal: keysToCamelCase(principalResp.data), permissions} + let profile = await sendAsync(Kotoed.Address.Api.Denizen.Profile.Read, {id: principalResp.data.id}) + return {principal: keysToCamelCase(principalResp.data), permissions, profile} } diff --git a/kotoed-js/src/main/ts/code/remote/code.ts b/kotoed-js/src/main/ts/code/remote/code.ts index 182dfaca..39a3d303 100644 --- a/kotoed-js/src/main/ts/code/remote/code.ts +++ b/kotoed-js/src/main/ts/code/remote/code.ts @@ -1,16 +1,17 @@ import {sleep} from "../../util/common"; import {EventBusError} from "../../util/vertx"; -import {eventBus} from "../../eventBus"; import {ResponseWithStatus, SubmissionIdRequest} from "./common"; import {Kotoed} from "../../util/kotoed-api"; import {sendAsync} from "../../views/components/common"; +import {Generated} from "../../util/kotoed-generated"; +import {DenizenPrincipal, DiffModePreference} from "../../data/denizen"; +import Address = Kotoed.Address; export type FileType = "file" | "directory" export interface File { type: FileType; name: string, - changed: boolean, children?: Array } @@ -52,8 +53,21 @@ interface FileResponse extends ResponseWithStatus { contents: string } -interface FileDiffResponse extends ResponseWithStatus { +export interface RevisionInfo { + revision: string, submissionId?: number +} + +export interface FileDiffResponse extends ResponseWithStatus { diff: Array + from: RevisionInfo + to: RevisionInfo +} + + +export type DiffBaseType = 'SUBMISSION_ID' | 'PREVIOUS_CLOSED' | 'PREVIOUS_CHECKED' | 'COURSE_BASE'; +export interface DiffBase { + submissionId?: number, + type: DiffBaseType } type IsReadyRequest = SubmissionIdRequest @@ -98,14 +112,15 @@ export async function fetchFile(submissionId: number, return res.contents; } -export async function fetchDiff(submissionId: number): Promise> { +export async function fetchDiff(submissionId: number, + base: DiffBase): Promise { - let res = await repeatTillReady(() => { + return await repeatTillReady(() => { return sendAsync(Kotoed.Address.Api.Submission.Code.Diff, { - submissionId: submissionId + submissionId: submissionId, + base }); }); - return res.diff; } export async function waitTillReady(submissionId: number): Promise { @@ -115,3 +130,12 @@ export async function waitTillReady(submissionId: number): Promise { }) }); } + +export async function updateDiffPreference(principal: DenizenPrincipal, preference: DiffModePreference) { + return await sendAsync(Address.Api.Denizen.Profile.Update, { + id: principal.id, + denizenId: principal.denizenId, + diffModePreference: preference + }); + +} diff --git a/kotoed-js/src/main/ts/code/state/diff.ts b/kotoed-js/src/main/ts/code/state/diff.ts new file mode 100644 index 00000000..c809a3ff --- /dev/null +++ b/kotoed-js/src/main/ts/code/state/diff.ts @@ -0,0 +1,10 @@ +import {DiffBase, FileDiffResult, RevisionInfo} from "../remote/code"; +import {Map} from "immutable"; + +export interface DiffState { + loading: boolean + base: DiffBase + diff: Map + from?: RevisionInfo + to?: RevisionInfo +} diff --git a/kotoed-js/src/main/ts/code/state/editor.ts b/kotoed-js/src/main/ts/code/state/editor.ts index ae0298d7..6494357c 100644 --- a/kotoed-js/src/main/ts/code/state/editor.ts +++ b/kotoed-js/src/main/ts/code/state/editor.ts @@ -1,8 +1,10 @@ import {FileDiffChange} from "../remote/code"; +import {FileComments} from "./comments"; export interface EditorState { fileName: string value: string + displayedComments: FileComments, loading: boolean diff: Array } diff --git a/kotoed-js/src/main/ts/code/state/index.ts b/kotoed-js/src/main/ts/code/state/index.ts index 5201d3ba..b1165553 100644 --- a/kotoed-js/src/main/ts/code/state/index.ts +++ b/kotoed-js/src/main/ts/code/state/index.ts @@ -8,6 +8,9 @@ import {SubmissionState} from "./submission"; import {ReviewForms} from "./forms"; import {CodeAnnotationsState} from "./annotations"; import {CommentTemplateState} from "./templates"; +import {DiffBase, FileDiffResult} from "../remote/code"; +import {Map} from "immutable" +import {DiffState} from "./diff"; export interface CodeReviewState { fileTreeState: FileTreeState @@ -18,9 +21,10 @@ export interface CodeReviewState { capabilitiesState: CapabilitiesState submissionState: SubmissionState formState: ReviewForms + diffState: DiffState } export interface ScrollTo { line?: number commentId?: number -} \ No newline at end of file +} diff --git a/kotoed-js/src/main/ts/code/util/filetree.tsx b/kotoed-js/src/main/ts/code/util/filetree.tsx index 7c7bb6a1..ed767133 100644 --- a/kotoed-js/src/main/ts/code/util/filetree.tsx +++ b/kotoed-js/src/main/ts/code/util/filetree.tsx @@ -1,6 +1,6 @@ import * as React from "react" import {Button, Panel, Label, OverlayTrigger, Tooltip} from "react-bootstrap"; -import {File} from "../remote/code"; +import {File, FileDiffResult} from "../remote/code"; import {IconClasses, Intent, Spinner} from "@blueprintjs/core"; import {CommentAggregate, CommentAggregates} from "../remote/comments"; import {FileNode, FileNodeProps, FileTreeProps, LoadingNode} from "../state/filetree"; @@ -8,6 +8,8 @@ import {NodePath} from "../state/blueprintTree"; import {FileNotFoundError} from "../errors"; import {ICON} from "@blueprintjs/core/dist/common/classes"; import AggregatesLabel from "../../views/AggregatesLabel"; +import * as _ from "lodash"; +import {Set} from "immutable" export function makeLoadingNode(idGen: (() => number)): LoadingNode { return { @@ -23,17 +25,12 @@ export function makeFileTreeProps(file: File, idGen: (() => number)|null = null) let id = 0; let idGenF = idGen ? idGen : () => {return ++id;}; - let iconType = (file.changed) ? "changed " : ""; - let nodeClass = (file.changed) ? "pt-tree-node-changed" : ""; - let bpNode: FileNodeProps = { id: idGenF(), isExpanded: false, isSelected: false, label: file.name, hasCaret: file.type === "directory", - className: nodeClass, - iconName: iconType + (file.type == "file" ? IconClasses.DOCUMENT : IconClasses.FOLDER_CLOSE), childNodes: [], data: { kind: "file", @@ -54,6 +51,38 @@ export function makeFileTreeProps(file: File, idGen: (() => number)|null = null) return bpNode; } +export function changedFilesAndDirs(diff: Array): Set { + return Set().withMutations(mutable => { + for (const diffItem of diff) { + const fileNameChunks = diffItem.toFile.split("/") + for (let i = 0; i < fileNameChunks.length; i++) { + mutable.add(fileNameChunks.slice(0, i + 1).join("/")); + } + } + }); +} + +function applyDiffToFileTreeHelper(props: FileNodeProps, changedFiles: Set, basePath: string|null = null) { + const isChanged = basePath ? changedFiles.contains(basePath) : false; + const iconType = isChanged ? "changed " : ""; + const nodeClass = isChanged ? "pt-tree-node-changed" : "" + + props.className = nodeClass; + props.iconName = iconType + (props.data.type == "file" ? IconClasses.DOCUMENT : IconClasses.FOLDER_CLOSE); + + if (props.childNodes) { + for (const child of props.childNodes) { + applyDiffToFileTreeHelper(child, changedFiles, + basePath ? basePath + "/" + child.data.filename : child.data.filename) + } + } + return props; +} + +export function applyDiffToFileTree(props: FileNodeProps, diff: Array, clone: boolean = true) { + return applyDiffToFileTreeHelper(clone ? _.cloneDeep(props) : props, changedFilesAndDirs(diff)) +} + export function makeFileNode(file: File) { return FileNode(makeFileTreeProps(file)); } diff --git a/kotoed-js/src/main/ts/courses/remote.ts b/kotoed-js/src/main/ts/courses/remote.ts index be9a32c2..62007248 100644 --- a/kotoed-js/src/main/ts/courses/remote.ts +++ b/kotoed-js/src/main/ts/courses/remote.ts @@ -7,8 +7,9 @@ import {WithId} from "../data/common"; import {DbRecordWrapper} from "../data/verification"; import {CourseToRead} from "../data/course"; -interface RootPermissions { - createCourse: boolean +export interface RootPermissions { + createCourse: boolean, + tags: boolean } export async function fetchPermissions(): Promise { @@ -22,4 +23,4 @@ export async function fetchCourse(id: number): Promise) }; + bindRadio = (key: K) => (e: ChangeEvent) => { + this.setDenizen({ [key]: e.target.value } as Pick) + }; + onEmailChanged = (e: ChangeEvent) => { this.unsetError("badEmail"); if(e.target.value && !e.target.checkValidity()) this.setError("badEmail"); @@ -200,6 +208,56 @@ export class ProfileComponent extends ComponentWithLocalErrors; }; + renderDiffPreference = () => { + return
+ +
+
+ +
+
+ +
+ {this.props.permissions.tags &&
+ +
} +
+
; + }; + renderBody = () => { return
@@ -220,6 +278,7 @@ export class ProfileComponent extends ComponentWithLocalErrors
Save @@ -267,6 +326,7 @@ export class ProfileComponent extends ComponentWithLocalErrors { @@ -276,9 +336,10 @@ class ProfileWrapper extends React.Component<{}, ProfileWrapperState> { } loadDenizen = async () => { - let profile = + let denizen = await sendAsync(Kotoed.Address.Api.Denizen.Profile.Read, {id: userId}); - this.setState({denizen: profile}) + let permissions = await fetchPermissions(); + this.setState({denizen, permissions}) }; componentDidMount() { @@ -286,8 +347,8 @@ class ProfileWrapper extends React.Component<{}, ProfileWrapperState> { } render() { - return this.state.denizen ? - : ; + return this.state.denizen && this.state.permissions ? + : ; } } diff --git a/kotoed-js/src/main/ts/profile/index.tsx b/kotoed-js/src/main/ts/profile/index.tsx index d4d457cf..6fe82980 100644 --- a/kotoed-js/src/main/ts/profile/index.tsx +++ b/kotoed-js/src/main/ts/profile/index.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import {render} from "react-dom"; import "less/kotoed-bootstrap/bootstrap.less" -import {Denizen, WithDenizen} from "../data/denizen"; +import {Denizen, ProfileInfo, WithDenizen} from "../data/denizen"; import {Kotoed} from "../util/kotoed-api"; import {eventBus} from "../eventBus"; import {sendAsync} from "../views/components/common"; @@ -15,15 +15,6 @@ import SocialButton from "../login/components/SocialButton"; let params = Kotoed.UrlPattern.tryResolve(Kotoed.UrlPattern.Profile.Index, window.location.pathname) || new Map(); let userId = parseInt(params.get("id")) || -1; -interface ProfileInfo { - id: number - denizenId: string - email?: string - oauth: [string, string | null][] - firstName?: string - lastName?: string - group?: string -} interface ProfileComponentProps extends LoadingProperty { denizen?: ProfileInfo diff --git a/kotoed-js/src/main/ts/projects/create.tsx b/kotoed-js/src/main/ts/projects/create.tsx index 9b360acf..79f9c9d2 100644 --- a/kotoed-js/src/main/ts/projects/create.tsx +++ b/kotoed-js/src/main/ts/projects/create.tsx @@ -23,7 +23,6 @@ interface ProjectCreateState { showModal: boolean remoteError?: string name: string - repoType: string repoUrl: string } @@ -35,7 +34,6 @@ export class ProjectCreate extends ComponentWithLocalErrors) => { - this.setState({ - repoType: changeEvent.target.value as string - }); - }; handleEnter = (event: KeyboardEvent) => event.key === "Enter" && this.handleSubmit(); @@ -158,25 +150,6 @@ export class ProjectCreate extends ComponentWithLocalErrors - - Repo type - - Git - - {" "} - - Mercurial - - ; } -} \ No newline at end of file +} diff --git a/kotoed-js/webpack.config.base.ts b/kotoed-js/webpack.config.base.ts index e42e12ea..0979ceec 100644 --- a/kotoed-js/webpack.config.base.ts +++ b/kotoed-js/webpack.config.base.ts @@ -35,7 +35,6 @@ const config: webpack.Configuration = { context: srcMain, entry: { - hello: kotoedEntry("./ts/hello.ts"), profile: kotoedEntry("./ts/profile/index.tsx"), profileEdit: kotoedEntry("./ts/profile/edit.tsx"), login: kotoedEntry("./ts/login/index.tsx", false), diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/DenizenVerticle.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/DenizenVerticle.kt index 52a60863..0cde3953 100644 --- a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/DenizenVerticle.kt +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/DenizenVerticle.kt @@ -10,6 +10,7 @@ import org.jetbrains.research.kotoed.data.db.textSearch import org.jetbrains.research.kotoed.database.Tables import org.jetbrains.research.kotoed.database.Tables.DENIZEN_TEXT_SEARCH import org.jetbrains.research.kotoed.database.Tables.PROFILE +import org.jetbrains.research.kotoed.database.enums.DiffModePreference import org.jetbrains.research.kotoed.database.tables.records.DenizenRecord import org.jetbrains.research.kotoed.database.tables.records.OauthProfileRecord import org.jetbrains.research.kotoed.database.tables.records.ProfileRecord @@ -51,7 +52,8 @@ class DenizenVerticle: AbstractKotoedVerticle() { firstName = profile?.firstName, lastName = profile?.lastName, group = profile?.groupId, - emailNotifications = profile?.emailNotifications ?: false + emailNotifications = profile?.emailNotifications ?: false, + diffModePreference = profile?.diffModePreference ?: DiffModePreference.PREVIOUS_CLOSED ) } @@ -70,10 +72,11 @@ class DenizenVerticle: AbstractKotoedVerticle() { update.firstName?.let { firstName = it } update.lastName?.let { lastName = it } update.group?.let { groupId = it } - update.emailNotifications.let { emailNotifications = it } + update.emailNotifications?.let { emailNotifications = it } + update.diffModePreference?.let { diffModePreference = it } } - if(profile != null) { + if (profile != null) { dbUpdateAsync(newProf.apply{ id = profile.id}) } else { dbCreateAsync(newProf) diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/SubmissionCodeVerticle.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/SubmissionCodeVerticle.kt index 6a1a9920..b5ca8c79 100644 --- a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/SubmissionCodeVerticle.kt +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/api/SubmissionCodeVerticle.kt @@ -1,5 +1,7 @@ package org.jetbrains.research.kotoed.api +import io.vertx.core.json.JsonObject +import org.jetbrains.research.kotoed.data.api.Code import org.jetbrains.research.kotoed.data.api.Code.FileRecord import org.jetbrains.research.kotoed.data.api.Code.FileType.directory import org.jetbrains.research.kotoed.data.api.Code.FileType.file @@ -7,10 +9,13 @@ import org.jetbrains.research.kotoed.data.api.Code.ListResponse import org.jetbrains.research.kotoed.data.api.Code.Submission.RemoteRequest import org.jetbrains.research.kotoed.data.db.ComplexDatabaseQuery import org.jetbrains.research.kotoed.data.vcs.* +import org.jetbrains.research.kotoed.database.Tables import org.jetbrains.research.kotoed.database.enums.SubmissionState import org.jetbrains.research.kotoed.database.tables.records.CourseRecord import org.jetbrains.research.kotoed.database.tables.records.ProjectRecord import org.jetbrains.research.kotoed.database.tables.records.SubmissionRecord +import org.jetbrains.research.kotoed.database.tables.records.TagRecord +import org.jetbrains.research.kotoed.db.condition.lang.formatToQuery import org.jetbrains.research.kotoed.eventbus.Address import org.jetbrains.research.kotoed.util.* import org.jetbrains.research.kotoed.util.database.toRecord @@ -134,20 +139,43 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { suspend fun handleSubmissionCodeDiff(message: SubDiffRequest): SubDiffResponse { val submission: SubmissionRecord = dbFetchAsync(SubmissionRecord().apply { id = message.submissionId }) val repoInfo = getCommitInfo(submission) + val toRevInfo = Code.Submission.RevisionInfo(submission) + when (repoInfo.cloneStatus) { - CloneStatus.pending -> return SubDiffResponse(diff = emptyList(), status = repoInfo.cloneStatus) + CloneStatus.pending -> return SubDiffResponse( + diff = emptyList(), + status = repoInfo.cloneStatus, + from = toRevInfo, + to = toRevInfo + ) CloneStatus.failed -> throw NotFound("Repository not found") else -> { } } - val diff = submissionCodeDiff(submission, repoInfo) - return SubDiffResponse(diff = diff.contents, status = repoInfo.cloneStatus) + val baseRev = message.base.getBaseRev(submission) + val diff = when (baseRev) { + null -> DiffResponse(listOf()) + else -> sendJsonableAsync( + Address.Code.Diff, + DiffRequest( + uid = repoInfo.repo.uid, + from = baseRev.revision, + to = submission.revision + ) + ) + } + + return SubDiffResponse( + diff = diff.contents, + status = repoInfo.cloneStatus, + from = baseRev ?: toRevInfo, // Makes sense for an empty diff + to = toRevInfo + ) } // Feel da powa of Kotlin! private data class MutableCodeTree( private val data: MutableMap = mutableMapOf(), - var changed: Boolean = false ) : MutableMap by data { // it's over 9000! private val fileComparator = compareBy { it.type }.thenBy { it.name } @@ -157,31 +185,28 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { FileRecord( type = directory, name = "$name/${children[0].name}", - children = children[0].children, - changed = changed + children = children[0].children ) else this private fun Map.Entry.toFileRecord(): FileRecord = - if (value.isEmpty()) FileRecord(type = file, name = key, changed = value.changed) + if (value.isEmpty()) FileRecord(type = file, name = key) else FileRecord( type = directory, name = key, - children = value.map { it.toFileRecord() }.sortedWith(fileComparator), - changed = value.changed + children = value.map { it.toFileRecord() }.sortedWith(fileComparator) ).squash() fun toFileRecord() = FileRecord( type = directory, name = "", - children = map { it.toFileRecord() }.sortedWith(fileComparator), - changed = changed + children = map { it.toFileRecord() }.sortedWith(fileComparator) ) } - private fun buildCodeTree(files: List, changedFiles: List): FileRecord { + private fun buildCodeTree(files: List): FileRecord { val mutableCodeTree = MutableCodeTree() // this is not overly efficient, but who cares @@ -193,16 +218,6 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { } } - for (file in changedFiles) { - val path = file.split('/', '\\') - var current = mutableCodeTree - for (crumb in path) { - current.changed = true - current = current[crumb] ?: break // do not mark removed files or "/dev/null" - current.changed = true - } - } - return mutableCodeTree.toFileRecord() } @@ -227,8 +242,7 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { return ListResponse( root = buildCodeTree( - innerResp.files, - emptyList() + innerResp.files ), status = repoInfo.cloneStatus ) @@ -238,6 +252,7 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { suspend fun handleSubmissionCodeList(message: SubListRequest): ListResponse { val submission: SubmissionRecord = dbFetchAsync(SubmissionRecord().apply { id = message.submissionId }) val repoInfo = getCommitInfo(submission) + when (repoInfo.cloneStatus) { CloneStatus.pending -> return ListResponse(root = null, status = repoInfo.cloneStatus) CloneStatus.failed -> throw NotFound("Repository not found") @@ -252,64 +267,14 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { revision = repoInfo.revision ) ) - val diff = submissionCodeDiff(submission, repoInfo) return ListResponse( root = buildCodeTree( - innerResp.files, - diff.contents.flatMap { listOf(it.fromFile, it.toFile) } + innerResp.files ), status = repoInfo.cloneStatus ) } - private suspend fun submissionCodeDiff(submission: SubmissionRecord, repoInfo: CommitInfo): DiffResponse { - val closedSubs = dbFindAsync(SubmissionRecord().apply { - projectId = submission.projectId - state = SubmissionState.closed - }) - - val foundationSub = closedSubs.filter { - it.datetime < submission.datetime - }.sortedByDescending { it.datetime }.firstOrNull() - - var baseRev = foundationSub?.revision - - if (baseRev == null) { - val course: CourseRecord = - dbQueryAsync( - ComplexDatabaseQuery(ProjectRecord().apply { id = submission.projectId }).join("course") - ).first().getJsonObject("course").toRecord() - - baseRev = if (course.baseRevision != "") course.baseRevision else null - - if (baseRev != null) try { - run { - sendJsonableAsync( - Address.Code.List, - InnerListRequest( - uid = repoInfo.repo.uid, - revision = baseRev - ) - ) - } - } catch (ex: Exception) { - baseRev = null - } - } - - return when (baseRev) { - null -> DiffResponse(listOf()) - else -> sendJsonableAsync( - Address.Code.Diff, - DiffRequest( - uid = repoInfo.repo.uid, - from = baseRev, - to = submission.revision - ) - ) - } - } - @JsonableEventBusConsumerFor(Address.Api.Submission.Code.Date) suspend fun handleSubmissionCodeDate(message: SubReadRequest): BlameResponse { val submission: SubmissionRecord = dbFetchAsync(SubmissionRecord().apply { id = message.submissionId }) @@ -326,4 +291,95 @@ class SubmissionCodeVerticle : AbstractKotoedVerticle() { ) } + + private suspend fun SubDiffRequest.DiffBase.getBaseRev(submission: SubmissionRecord): Code.Submission.RevisionInfo? = + when (type) { + Code.Submission.DiffBaseType.SUBMISSION_ID -> dbFindAsync(SubmissionRecord().apply { + projectId = submission.projectId + id = submissionId + }).firstOrNull()?.revision?.let { + Code.Submission.RevisionInfo(it) + } + Code.Submission.DiffBaseType.PREVIOUS_CHECKED -> submission.getPreviousChecked() + Code.Submission.DiffBaseType.PREVIOUS_CLOSED -> submission.getPreviousClosed() + Code.Submission.DiffBaseType.COURSE_BASE -> submission.getCourseBaseRev() + } + private suspend fun SubmissionRecord.getLatestClosedSub(): SubmissionRecord? = + dbQueryAsync( + ComplexDatabaseQuery(Tables.SUBMISSION) + .filter("project_id == %s and state == %s and datetime < %s" + .formatToQuery(projectId, SubmissionState.closed, datetime)) + ) + + .asSequence() + .map { + it.toRecord() + } + .sortedByDescending { + it.datetime + } + .firstOrNull() + private suspend fun SubmissionRecord.getCourseBaseRev(): Code.Submission.RevisionInfo? = + dbQueryAsync( + ComplexDatabaseQuery(ProjectRecord().apply { id = projectId }).join("course") + ) + .first() + .getJsonObject("course") + .toRecord() + .let { + if (it.baseRevision != "") Code.Submission.RevisionInfo(it.baseRevision) else null + } + + private suspend fun SubmissionRecord.getPreviousChecked(): Code.Submission.RevisionInfo? { + val latestClosed = getLatestClosedSub() // We consider closed as checked here + val q = "project_id == %s " + + (latestClosed?.datetime?.let { "and datetime > %s" } ?: "") + val qArgs = sequence { + yield(projectId) + latestClosed?.datetime?.let { + yield(it) + } + }.toList().toTypedArray() + + val newerThanClosed = dbQueryAsync( + ComplexDatabaseQuery(Tables.SUBMISSION) + .rjoin(ComplexDatabaseQuery(Tables.SUBMISSION_TAG) + .join(Tables.TAG), "submission_id", "tags") + .filter(q.formatToQuery(*qArgs)) + ) + + val byId = newerThanClosed + .asSequence().map { + it.toRecord(SubmissionRecord::class) + }.associateBy { + it.id + } + + val tagsById = newerThanClosed + .asSequence() + .map { + it.getInteger("id") to it.getJsonArray("tags").asSequence().map { + it.uncheckedCast().getJsonObject("tag").toRecord().name + }.toSet() + } + .toMap() + + var current = this + do { + current = byId[current.parentSubmissionId] ?: return latestClosed?.let { + Code.Submission.RevisionInfo(it) + } + val tags = tagsById[current.id] ?: return latestClosed?.let { + Code.Submission.RevisionInfo(it) + } + if (CHECKED in tags) return Code.Submission.RevisionInfo(current) + } while(true) + + } + private suspend fun SubmissionRecord.getPreviousClosed(): Code.Submission.RevisionInfo? = + getLatestClosedSub()?.let { Code.Submission.RevisionInfo(it) } ?: getCourseBaseRev() + + companion object { + private const val CHECKED = "checked" + } } diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/data/api/Data.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/data/api/Data.kt index 0ea927fa..9b45c208 100644 --- a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/data/api/Data.kt +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/data/api/Data.kt @@ -7,15 +7,14 @@ import org.jetbrains.research.kotoed.data.vcs.CloneStatus import org.jetbrains.research.kotoed.database.enums.SubmissionCommentState import org.jetbrains.research.kotoed.database.tables.records.SubmissionCommentRecord import org.jetbrains.research.kotoed.database.tables.records.TagRecord -import org.jetbrains.research.kotoed.util.database.toJson import org.jooq.Record import java.util.* import org.jetbrains.research.kotoed.data.buildSystem.BuildCommand +import org.jetbrains.research.kotoed.database.enums.DiffModePreference import org.jetbrains.research.kotoed.database.tables.records.BuildTemplateRecord +import org.jetbrains.research.kotoed.database.tables.records.SubmissionRecord import org.jetbrains.research.kotoed.util.* -import ru.spbstu.ktuples.Tuple -import ru.spbstu.ktuples.Tuple2 enum class VerificationStatus { Unknown, @@ -89,8 +88,30 @@ object Code { val toLine: Int? = null) : Jsonable data class ReadResponse(val contents: String, val status: CloneStatus) : Jsonable data class ListRequest(val submissionId: Int) : Jsonable - data class DiffRequest(val submissionId: Int) : Jsonable - data class DiffResponse(val diff: List, val status: CloneStatus) : Jsonable + data class DiffRequest(val submissionId: Int, val base: DiffBase) : Jsonable { + class DiffBase(val type: DiffBaseType, val submissionId: Int? = null) : Jsonable { + init { + require((type == DiffBaseType.SUBMISSION_ID) == (submissionId != null)) + } + } + } + data class DiffResponse( + val diff: List, + val status: CloneStatus, + val from: RevisionInfo, + val to: RevisionInfo) : Jsonable + + data class RevisionInfo(val revision: String, val submissionId: Int? = null): Jsonable { + companion object { + operator fun invoke(revision: String) = RevisionInfo(revision) + operator fun invoke(sub: SubmissionRecord) = RevisionInfo(sub.revision, sub.id) + + } + } + + enum class DiffBaseType { + SUBMISSION_ID, PREVIOUS_CLOSED, PREVIOUS_CHECKED, COURSE_BASE + } } object Course { @@ -103,8 +124,7 @@ object Code { data class FileRecord( val type: FileType, val name: String, - val children: List? = null, - val changed: Boolean = false) : Jsonable { + val children: List? = null) : Jsonable { fun toFileSeq(): Sequence = when (type) { FileType.directory -> @@ -200,7 +220,8 @@ data class ProfileInfo( val firstName: String?, val lastName: String?, val group: String?, - val emailNotifications: Boolean + val emailNotifications: Boolean, + val diffModePreference: DiffModePreference ) : Jsonable data class PasswordChangeRequest( @@ -218,7 +239,8 @@ data class ProfileInfoUpdate( val firstName: String?, val lastName: String?, val group: String?, - val emailNotifications: Boolean + val emailNotifications: Boolean?, + val diffModePreference: DiffModePreference? ) : Jsonable enum class SubmissionCodeAnnotationSeverity { error, warning } diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/data/CodeReview.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/data/CodeReview.kt index b50ab5a9..69651a36 100644 --- a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/data/CodeReview.kt +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/data/CodeReview.kt @@ -4,7 +4,7 @@ import io.vertx.core.json.JsonObject import org.jetbrains.research.kotoed.util.Jsonable object Permissions { - data class Root(val createCourse: Boolean = false): Jsonable + data class Root(val createCourse: Boolean = false, val tags: Boolean = false): Jsonable data class Course(val createProject: Boolean = false, val editCourse: Boolean = false, val viewTags: Boolean = false): Jsonable diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Diff.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Diff.kt new file mode 100644 index 00000000..fbb1a287 --- /dev/null +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Diff.kt @@ -0,0 +1,28 @@ +package org.jetbrains.research.kotoed.web.eventbus.guardian + +import io.vertx.core.Vertx +import io.vertx.ext.web.handler.sockjs.BridgeEvent +import org.jetbrains.research.kotoed.data.api.Code +import org.jetbrains.research.kotoed.database.enums.SubmissionState +import org.jetbrains.research.kotoed.util.scope +import org.jetbrains.research.kotoed.web.auth.isProjectOwner +import org.jetbrains.research.kotoed.web.auth.isSubmissionOwner +import org.jetbrains.research.kotoed.web.eventbus.submissionByIdOrNull + +class ShouldNotRequestLastChecked( + val vertx: Vertx, +) : LoggingBridgeEventFilter() { + override suspend fun checkIsAllowed(be: BridgeEvent): Boolean { + val diffType = be.rawMessage + ?.getJsonObject("body") + ?.getJsonObject("base") + ?.getString("type") + ?: return false + + return diffType != Code.Submission.DiffBaseType.PREVIOUS_CHECKED.toString(); + } + + override fun toString(): String { + return "ShouldBeSubmissionOwner(vertx=$vertx)" + } +} diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Kotoed.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Kotoed.kt index 98627dc3..c22ce5b6 100644 --- a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Kotoed.kt +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/eventbus/guardian/Kotoed.kt @@ -38,6 +38,13 @@ fun ProjectOwnerOrTeacherForFilter(vertx: Vertx, path: String = "project_id"): B fun SubmissionOwnerOrTeacher(vertx: Vertx, path: String = "submission_id"): BridgeEventFilter = ShouldBeSubmissionOwner(vertx, path) or AuthorityRequired(Authority.Teacher) +fun DiffFilter(vertx: Vertx): BridgeEventFilter { + val shouldBeTeacher = AuthorityRequired(Authority.Teacher) + val ownerWithCondition = ShouldBeSubmissionOwner(vertx) and ShouldNotRequestLastChecked(vertx) + val subShouldBeReady = SubmissionReady(vertx) + return (shouldBeTeacher or ownerWithCondition) and subShouldBeReady +} + fun SubmissionOwnerOrTeacherForFilter(vertx: Vertx, path: String = "submission_id"): BridgeEventFilter = ShouldBeSubmissionOwnerForFilter(vertx, path) or AuthorityRequired(Authority.Teacher) @@ -97,8 +104,7 @@ fun kotoedPerAddressFilter(vertx: Vertx): PerAddress { (SubmissionOwnerOrTeacher(vertx) and SubmissionReady(vertx)), Address.Api.Submission.Code.Read to (SubmissionOwnerOrTeacher(vertx) and SubmissionReady(vertx)), - Address.Api.Submission.Code.Diff to - (SubmissionOwnerOrTeacher(vertx) and SubmissionReady(vertx)), + Address.Api.Submission.Code.Diff to DiffFilter(vertx), Address.Api.Submission.Comment.Create to (SubmissionOwnerOrTeacher(vertx) and SubmissionOpen(vertx)), diff --git a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/routers/AuthHelpers.kt b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/routers/AuthHelpers.kt index 9784a1f2..7a0da66d 100644 --- a/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/routers/AuthHelpers.kt +++ b/kotoed-server/src/main/kotlin/org/jetbrains/research/kotoed/web/routers/AuthHelpers.kt @@ -43,7 +43,8 @@ suspend fun handleRootPerms(context: RoutingContext) { context.response().end( Permissions.Root( - createCourse = isTeacher + createCourse = isTeacher, + tags = isTeacher ) ) } diff --git a/kotoed-server/src/main/resources/db/migration/V93__Diff_mode_preference.sql b/kotoed-server/src/main/resources/db/migration/V93__Diff_mode_preference.sql new file mode 100644 index 00000000..9d471b53 --- /dev/null +++ b/kotoed-server/src/main/resources/db/migration/V93__Diff_mode_preference.sql @@ -0,0 +1,2 @@ +CREATE TYPE diff_mode_preference AS ENUM ('PREVIOUS_CLOSED', 'PREVIOUS_CHECKED', 'COURSE_BASE'); +ALTER TABLE profile ADD COLUMN diff_mode_preference diff_mode_preference NOT NULL DEFAULT 'PREVIOUS_CLOSED';