diff --git a/react-common/components/controls/Accordion/Accordion.tsx b/react-common/components/controls/Accordion/Accordion.tsx new file mode 100644 index 000000000000..15ae70e29d33 --- /dev/null +++ b/react-common/components/controls/Accordion/Accordion.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { ContainerProps, classList, fireClickOnEnter } from "../../util"; +import { useId } from "../../../hooks/useId"; +import { AccordionProvider, clearExpanded, setExpanded, useAccordionDispatch, useAccordionState } from "./context"; + +export interface AccordionProps extends ContainerProps { + children?: React.ReactElement[] | React.ReactElement; +} + +export interface AccordionItemProps extends ContainerProps { + children?: [React.ReactElement, React.ReactElement]; + noChevron?: boolean; +} + +export interface AccordionHeaderProps extends ContainerProps { +} + +export interface AccordionPanelProps extends ContainerProps { +} + +export const Accordion = (props: AccordionProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + } = props; + + return ( + +
+ {children} +
+
+ ); +}; + +export const AccordionItem = (props: AccordionItemProps) => { + const { + children, + id, + className, + ariaLabel, + ariaHidden, + ariaDescribedBy, + role, + noChevron + } = props; + + const { expanded } = useAccordionState(); + const dispatch = useAccordionDispatch(); + + const panelId = useId(); + const mappedChildren = React.Children.toArray(children); + const isExpanded = expanded === panelId; + + const onHeaderClick = React.useCallback(() => { + if (isExpanded) { + dispatch(clearExpanded()); + } + else { + dispatch(setExpanded(panelId)); + } + }, [isExpanded]); + + return ( +
+ +
+ {isExpanded && mappedChildren[1]} +
+
+ ); +} + +export const AccordionHeader = (props: AccordionHeaderProps) => { + const { + id, + className, + ariaLabel, + children, + } = props; + + return ( +
+ {children} +
+ ); +} + +export const AccordionPanel = (props: AccordionPanelProps) => { + const { + id, + className, + ariaLabel, + children, + } = props; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/react-common/components/controls/Accordion/context.tsx b/react-common/components/controls/Accordion/context.tsx new file mode 100644 index 000000000000..8bde33951a30 --- /dev/null +++ b/react-common/components/controls/Accordion/context.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; + +interface AccordionState { + expanded?: string; +} + +const AccordionStateContext = React.createContext(null); +const AccordionDispatchContext = React.createContext<(action: Action) => void>(null); + +export const AccordionProvider = ({ children }: React.PropsWithChildren<{}>) => { + const [state, dispatch] = React.useReducer( + accordionReducer, + {} + ); + + return ( + + + {children} + + + ) +} + +type SetExpanded = { + type: "SET_EXPANDED"; + id: string; +}; + +type ClearExpanded = { + type: "CLEAR_EXPANDED"; +}; + +type Action = SetExpanded | ClearExpanded; + +export const setExpanded = (id: string): SetExpanded => ( + { + type: "SET_EXPANDED", + id + } +); + +export const clearExpanded = (): ClearExpanded => ( + { + type: "CLEAR_EXPANDED" + } +); + +export function useAccordionState() { + return React.useContext(AccordionStateContext) +} + +export function useAccordionDispatch() { + return React.useContext(AccordionDispatchContext); +} + +function accordionReducer(state: AccordionState, action: Action): AccordionState { + switch (action.type) { + case "SET_EXPANDED": + return { + ...state, + expanded: action.id + }; + case "CLEAR_EXPANDED": + return { + ...state, + expanded: undefined + }; + } +} \ No newline at end of file diff --git a/react-common/components/controls/Accordion/index.tsx b/react-common/components/controls/Accordion/index.tsx new file mode 100644 index 000000000000..4a07731cffc8 --- /dev/null +++ b/react-common/components/controls/Accordion/index.tsx @@ -0,0 +1,10 @@ +import { Accordion as AccordionControl, AccordionHeader, AccordionItem, AccordionPanel } from "./Accordion"; + +export const Accordion = Object.assign( + AccordionControl, + { + Header: AccordionHeader, + Item: AccordionItem, + Panel: AccordionPanel + } +); \ No newline at end of file diff --git a/react-common/hooks/useId.ts b/react-common/hooks/useId.ts new file mode 100644 index 000000000000..0a76a0693681 --- /dev/null +++ b/react-common/hooks/useId.ts @@ -0,0 +1,5 @@ +import * as React from "react"; + +export function useId(): string { + return React.useMemo(() => pxt.Util.guidGen(), []); +} \ No newline at end of file diff --git a/react-common/styles/controls/Accordion.less b/react-common/styles/controls/Accordion.less new file mode 100644 index 000000000000..5795aa8ae3ef --- /dev/null +++ b/react-common/styles/controls/Accordion.less @@ -0,0 +1,32 @@ +.common-accordion-header-outer { + cursor: pointer; + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + outline: inherit; + text-align: inherit; + width: 100%; + + & > div { + display: flex; + flex-direction: row; + width: 100%; + } + + .common-accordion-chevron { + display: flex; + flex-direction: row; + align-items: center; + width: 2rem; + } + + .common-accordion-header-content { + flex-grow: 1; + } +} + +.common-accordion-header-outer:focus-visible { + outline: @buttonFocusOutlineLightBackground; +} \ No newline at end of file diff --git a/react-common/styles/react-common.less b/react-common/styles/react-common.less index a48b66630f7d..87e9debe8bdb 100644 --- a/react-common/styles/react-common.less +++ b/react-common/styles/react-common.less @@ -24,6 +24,7 @@ @import "controls/Tree.less"; @import "controls/VerticalResizeContainer.less"; @import "controls/VerticalSlider.less"; +@import "controls/Accordion.less"; @import "./react-common-variables.less"; @import "fontawesome-free/less/solid.less"; diff --git a/theme/github.less b/theme/github.less index dd25bece5a4a..5c8143ff3485 100644 --- a/theme/github.less +++ b/theme/github.less @@ -113,6 +113,63 @@ } } +.history-zone { + .commit-day { + padding-top: 1rem; + padding-bottom: 1rem; + } + + .commit-day:first-child { + padding-top: 0; + } + + .commit-day:last-child { + padding-bottom: 0; + } + + .commit-day-header { + display: flex; + flex-direction: row; + align-items: center; + } + + .commit-day-label { + font-size: 1.28571429em; + font-weight: 700; + margin-right: 0.5rem; + margin-top: 0.2rem; + } + + .commit-time { + margin: 0.5rem 0; + font-size: 1rem; + line-height: 1rem; + color: rgba(0, 0, 0, .6); + } + + .commit-view { + border-top: 1px solid rgba(34, 36, 38, .15); + padding-top: 1rem; + padding-bottom: 1rem; + position: relative; + + .restore-button { + position: absolute; + right: 0; + top: 1.5rem; + z-index: 1; + } + } + + .commit-view:first-child { + border-top: none; + } + + .commit-view:last-child { + padding-bottom: 0; + } +} + // inverted style .inverted-theme { #githubEditor { diff --git a/webapp/src/coretsx.tsx b/webapp/src/coretsx.tsx index 95fb695f7b68..68695f36515d 100644 --- a/webapp/src/coretsx.tsx +++ b/webapp/src/coretsx.tsx @@ -164,7 +164,7 @@ export class CoreDialog extends React.Component - {!!inputError &&
{inputError}
} + {!!inputError &&
{inputError}
} } {options.jsx} {!!options.jsxd && options.jsxd()} diff --git a/webapp/src/gitjson.tsx b/webapp/src/gitjson.tsx index 655762b0849a..6a43e0a61313 100644 --- a/webapp/src/gitjson.tsx +++ b/webapp/src/gitjson.tsx @@ -18,6 +18,9 @@ import * as pxtblockly from "../../pxtblocks"; import IProjectView = pxt.editor.IProjectView; import UserInfo = pxt.editor.UserInfo; +import { Accordion } from "../../react-common/components/controls/Accordion"; +import { Button } from "../../react-common/components/controls/Button" + const MAX_COMMIT_DESCRIPTION_LENGTH = 70; interface DiffFile { @@ -1421,7 +1424,7 @@ class ReleaseZone extends sui.StatelessUIElement { const pagesBuilding = pages && pages.status == "building"; const inverted = !!pxt.appTarget.appTheme.invertedGitHub; return
-
{lf("Release zone")}
+

{lf("Release zone")}

{!needsCommit && !tag &&
{ const inverted = !!pxt.appTarget.appTheme.invertedGitHub; return
-
{lf("Extension zone")}
+

{lf("Extension zone")}

) => void; } -interface CommitViewState { - diffFiles?: DiffFile[]; - loading?: boolean; +const CommitView = (props: CommitViewProps) => { + const { commit } = props; + const date = new Date(Date.parse(commit.author.date)); + + return ( + + +
+ {date.toLocaleTimeString()} +
+
{commit.message}
+
+ + + +
+ ); } -class CommitView extends sui.UIElement { - constructor(props: CommitViewProps) { - super(props); - this.handleRestore = this.handleRestore.bind(this); - } +const CommitDiffView = (props: CommitViewProps) => { + const { parent, githubId, commit } = props; + const [diffFiles, setDiffFiles] = React.useState(); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + if (loading) return () => {}; + + setLoading(true); + let cancelled = false; + (async () => { + try { + const commitData = await pxt.github.getCommitAsync(githubId.slug, commit.sha); + if (cancelled) return; + const diffs = await computeDiffAsync(commitData, githubId); + if (cancelled) return; + setDiffFiles(diffs); + } + finally { + setLoading(false); + } + })(); - private loadDiffFilesAsync() { - // load commit and compute markdown - const { githubId, commit } = this.props; - this.setState({ loading: true }); - pxt.github.getCommitAsync(githubId.slug, commit.sha) - .then(cmt => this.computeDiffAsync(cmt)) - .then(dfs => this.setState({ diffFiles: dfs })) - .finally(() => this.setState({ loading: false })) - } + return () => cancelled = true; + }, [commit.sha, githubId.slug]); - private computeDiffAsync(commit: pxt.github.Commit): Promise { - const { githubId } = this.props; - const files = pkg.mainEditorPkg().sortedFiles(); - const oldFiles: pxt.Map = {}; - - return Promise.all( - files.map(p => { - const path = p.name; - const oldEnt = pxt.github.lookupFile(githubId, commit, path); - if (!oldEnt) return Promise.resolve(); - return pxt.github.downloadTextAsync(githubId.fullName, commit.sha, path) - .then(content => { oldFiles[path] = content; }); - })) - .then(() => files.map(p => { - const path = p.name; - const oldContent = oldFiles[path]; - const isBlocks = /\.blocks$/.test(path); - const newContent = p.publishedContent(); - const hasChanges = oldContent !== newContent; - if (!hasChanges) return undefined; - const df: DiffFile = { - file: p, - name: p.name, - gitFile: oldContent, - editorFile: newContent - } - if (isBlocks && pxtblockly.needsDecompiledDiff(oldContent, newContent)) { - const vpn = p.getVirtualFileName(pxt.JAVASCRIPT_PROJECT_NAME); - const virtualNewFile = files.find(ff => ff.name == vpn); - const virtualOldContent = oldFiles[vpn]; - if (virtualNewFile && virtualOldContent) { - df.tsEditorFile = virtualNewFile.publishedContent(); - df.tsGitFile = virtualOldContent; - } - } - return df; - })).then(diffs => diffs.filter(df => !!df)); - } + const onRestoreClick = React.useCallback(async () => { + pxt.tickEvent("github.restore", undefined, { interactiveConsent: true }); - handleRestore(e: React.MouseEvent) { - e.stopPropagation(); - pxt.tickEvent("github.restore", undefined, { interactiveConsent: true }) - const { commit } = this.props; - core.confirmAsync({ + const response = await core.confirmAsync({ header: lf("Would you like to restore this commit?"), body: lf("You will restore your project to the point in time when this commit was made. Don't worry, you can undo this action by restoring to the previous commit."), agreeLbl: lf("Restore"), agreeClass: "green", - }).then(r => { - if (!r) return; - core.showLoading("github.restore", lf("restoring commit...")) - workspace.restoreCommitAsync(this.props.parent.props.parent.state.header, commit) - .then(() => { - data.invalidate("gh-commits:*"); - return this.props.parent.props.parent.reloadHeaderAsync(); - }) - .finally(() => core.hideLoading("github.restore")) - }) - return false; - } + }); - renderCore() { - const { parent, commit, expanded, onClick } = this.props; - const { diffFiles, loading } = this.state; - const date = new Date(Date.parse(commit.author.date)); + if (!response) return; - if (expanded && !diffFiles && !loading) - this.loadDiffFilesAsync(); + core.showLoading("github.restore", lf("restoring commit...")); - return
-
- {expanded && } -
- {date.toLocaleTimeString()} -
-
{commit.message}
- {expanded && diffFiles &&
{lf("Comparing selected commit with local files")}
} - {expanded && diffFiles && } -
-
+ try { + await workspace.restoreCommitAsync(parent.props.parent.state.header, commit); + data.invalidate("gh-commits:*"); + await parent.props.parent.reloadHeaderAsync(); + } + finally { + core.hideLoading("github.restore"); + } + }, [commit, githubId, parent]); + + return ( + <> +