Minimal working version (#1)

* cra boilerplate

* Dummy chrome extension

* eslint and working url popup

* content script and dummy messaging

* replace .obj diffs with dummy test

* comment and in-order multiple type support

* get pull api data from url

* README title and desc

* api/elements match with filename check

* github token signin signout

* manifest v3, service request for CORS

* working kittycad api in service worker

* First real background message

* Clean up,  better types

* Fix settings

* multiservice settings

* Tweaks

* WIP: download file

* Working downloads and kittycad conversion

* Inject react, add three dependencies

* Working stl canvas

* primer for github-like style

* Loading before model

* diff colors

* colorMode auto

* Popup clean up

* clean up

* Working loading

* Logos

* Add GitHub CI

* Working test

* yarn test in ci

* Little tweak

* Update README

* component tests

* Better test

* Clean up

* UserCard test

* working caddiff test

* Note

* Rename App to Settings

* storage test

* Clean up

* Clean up content script

* further content cleanup

* Fix test

* Little tweaks to modelview

* More tests and testing

* Regex fix

* LFS file download test

* prettier config from kittycad/website

* Little tweaks

* comment

* log level

* Tweaks

* README update

* more prettier

* comment

* Irrelevant comment

* No .vscode and readme update

* Remove .vscode

* Package.json update after vscode removal
This commit is contained in:
Pierre Jacquier
2023-03-02 04:35:07 -05:00
committed by GitHub
parent 730452db19
commit fd44076a18
40 changed files with 48589 additions and 0 deletions

130
src/chrome/background.ts Normal file
View File

@ -0,0 +1,130 @@
import { Client, users } from '@kittycad/lib'
import { Octokit } from '@octokit/rest'
import {
KittycadUser,
Message,
MessageGetFileDiff,
MessageGetGithubPullFilesData,
MessageIds,
MessageResponse,
MessageSaveToken,
} from './types'
import {
getStorageGithubToken,
getStorageKittycadToken,
setStorageGithubToken,
setStorageKittycadToken,
} from './storage'
import { getFileDiff } from './diff'
let github: Octokit
let kittycad: Client
async function initGithubApi() {
try {
github = new Octokit({ auth: await getStorageGithubToken() })
const octokitResponse = await github.rest.users.getAuthenticated()
console.log(`Logged in on github.com as ${octokitResponse.data.login}`)
} catch (e) {
console.log('Couldnt initiate the github api client')
}
}
async function initKittycadApi() {
try {
kittycad = new Client(await getStorageKittycadToken())
const kittycadResponse = await users.get_user_self({ client: kittycad })
console.log(
`Logged in on kittycad.io as ${
(kittycadResponse as KittycadUser).email
}`
)
} catch (e) {
console.log("Couldn't initiate the kittycad api client")
}
}
async function saveGithubTokenAndReload(token: string): Promise<void> {
await setStorageGithubToken(token)
await initGithubApi()
}
async function saveKittycadTokenAndReload(token: string): Promise<void> {
await setStorageKittycadToken(token)
await initKittycadApi()
}
;(async () => {
await initKittycadApi()
await initGithubApi()
})()
chrome.runtime.onMessage.addListener(
(
message: Message,
sender: chrome.runtime.MessageSender,
sendResponse: (response: MessageResponse) => void
) => {
console.log(`Received ${message.id} from ${sender.id}`)
if (message.id === MessageIds.GetGithubPullFiles) {
const { owner, repo, pull } =
message.data as MessageGetGithubPullFilesData
github.rest.pulls
.listFiles({ owner, repo, pull_number: pull })
.then(r => sendResponse(r.data))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetGithubPull) {
const { owner, repo, pull } =
message.data as MessageGetGithubPullFilesData
github.rest.pulls
.get({ owner, repo, pull_number: pull })
.then(r => sendResponse(r.data))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetGithubUser) {
github.rest.users
.getAuthenticated()
.then(r => sendResponse(r.data))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetKittycadUser) {
users
.get_user_self({ client: kittycad })
.then(r => sendResponse(r as KittycadUser))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.SaveGithubToken) {
const { token } = message.data as MessageSaveToken
saveGithubTokenAndReload(token)
.then(() => sendResponse({ token }))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.SaveKittycadToken) {
const { token } = message.data as MessageSaveToken
saveKittycadTokenAndReload(token)
.then(() => sendResponse({ token }))
.catch(e => sendResponse(e))
return true
}
if (message.id === MessageIds.GetFileDiff) {
const { owner, repo, sha, parentSha, file } =
message.data as MessageGetFileDiff
getFileDiff(github, kittycad, owner, repo, sha, parentSha, file)
.then(r => sendResponse(r))
.catch(e => sendResponse(e))
return true
}
}
)

67
src/chrome/content.ts Normal file
View File

@ -0,0 +1,67 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { CadDiff } from '../components/CadDiff'
import { Loading } from '../components/Loading'
import { isFilenameSupported } from './diff'
import { DiffEntry, FileDiff, Message, MessageIds, Pull } from './types'
import {
getGithubUrlParams,
getWebPullElements,
getInjectablePullElements,
} from './web'
// https://github.com/OctoLinker/injection
// maintained by octolinker, makes sure pages that are loaded through pjax are available for injection
// no ts support
const gitHubInjection = require('github-injection')
async function injectPullDiff(
owner: string,
repo: string,
pull: number,
document: Document
) {
const allApiFiles = await chrome.runtime.sendMessage<Message, DiffEntry[]>({
id: MessageIds.GetGithubPullFiles,
data: { owner, repo, pull },
})
const apiFiles = allApiFiles.filter(f => isFilenameSupported(f.filename))
console.log(`Found ${apiFiles.length} supported files with the API`)
const elements = getWebPullElements(document)
console.log(`Found ${elements.length} elements in the web page`)
const injectableElements = getInjectablePullElements(elements, apiFiles)
for (const { element } of injectableElements) {
createRoot(element).render(React.createElement(Loading))
}
const pullData = await chrome.runtime.sendMessage<Message, Pull>({
id: MessageIds.GetGithubPull,
data: { owner, repo, pull },
})
const sha = pullData.head.sha
const parentSha = pullData.base.sha
for (const { element, file } of injectableElements) {
const fileDiff = await chrome.runtime.sendMessage<Message, FileDiff>({
id: MessageIds.GetFileDiff,
data: { owner, repo, sha, parentSha, file },
})
createRoot(element).render(React.createElement(CadDiff, fileDiff))
}
}
gitHubInjection(async () => {
const params = getGithubUrlParams(window.location.href)
if (!params) {
console.log("URL doesn't match pull request pattern.")
return
}
const { owner, repo, pull } = params
console.log('Found pull request diff URL', owner, repo, pull)
try {
await injectPullDiff(owner, repo, pull, window.document)
} catch (e) {
console.error(e)
}
})

38
src/chrome/diff.test.ts Normal file
View File

@ -0,0 +1,38 @@
import { Octokit } from '@octokit/rest'
import { downloadFile, isFilenameSupported } from './diff'
it('checks if the filename has a supported extension', () => {
expect(isFilenameSupported('noextension')).toBe(false)
expect(isFilenameSupported('unsupported.txt')).toBe(false)
expect(isFilenameSupported('supported.obj')).toBe(true)
})
describe('Function downloadFile', () => {
it('downloads a public regular github file', async () => {
const github = new Octokit()
// https://github.com/KittyCAD/kittycad.ts/blob/0c61ffe45d8b2c72b3d98600e9c50a8a404226b9/example.obj
const response = await downloadFile(
github,
'KittyCAD',
'kittycad.ts',
'0c61ffe45d8b2c72b3d98600e9c50a8a404226b9',
'example.obj'
)
// TODO: add hash validation or something like that
expect(response).toHaveLength(37077)
})
it('downloads a public LFS github file', async () => {
const github = new Octokit()
// https://github.com/pierremtb/SwGitExample/be3e3897450f28b4166fa1039db06e7d0351dc9b/main/Part1.SLDPRT
const response = await downloadFile(
github,
'pierremtb',
'SwGitExample',
'be3e3897450f28b4166fa1039db06e7d0351dc9b',
'Part1.SLDPRT'
)
// TODO: add hash validation or something like that
expect(response).toHaveLength(70702)
})
})

134
src/chrome/diff.ts Normal file
View File

@ -0,0 +1,134 @@
import { Octokit } from '@octokit/rest'
import { Client, file } from '@kittycad/lib'
import { ContentFile, DiffEntry, FileDiff } from './types'
import {
FileExportFormat_type,
FileImportFormat_type,
} from '@kittycad/lib/dist/types/src/models'
// TODO: check if we could get that from the library
export const supportedSrcFormats = new Set([
'dae',
'dxf',
'fbx',
'obj',
'step',
'stl',
'svg',
])
export function isFilenameSupported(filename: string) {
const parts = filename.split('.')
if (parts.length <= 1) {
return false
}
return supportedSrcFormats.has(parts.pop()!)
}
export async function downloadFile(
octokit: Octokit,
owner: string,
repo: string,
ref: string,
path: string
): Promise<string> {
// First get some info on the blob with the Contents api
const content = await octokit.rest.repos.getContent({
owner,
repo,
path,
ref,
request: { cache: 'reload' }, // download_url provides a token that seems very short-lived
})
const contentFile = content.data as ContentFile
if (!contentFile.download_url) {
throw Error(`No download URL associated with ${path} at ${ref}`)
}
// Then actually use the download_url (that supports LFS files and has a token) to write the file
console.log(`Downloading ${contentFile.download_url}...`)
const response = await fetch(contentFile.download_url)
if (!response.ok) throw response
return await response.text()
}
async function convert(
client: Client,
body: string,
srcFormat: string,
outputFormat = 'stl'
) {
// TODO: think about the best output format for visual diff injection, now defaults to STL
const response = await file.create_file_conversion({
client,
body,
src_format: srcFormat as FileImportFormat_type,
output_format: outputFormat as FileExportFormat_type,
})
if ('error_code' in response) throw response
const { status, id, output } = response
console.log(`File conversion id: ${id}`)
console.log(`File conversion status: ${status}`)
return output
}
export async function getFileDiff(
github: Octokit,
kittycad: Client,
owner: string,
repo: string,
sha: string,
parentSha: string,
file: DiffEntry
): Promise<FileDiff> {
const { filename, status } = file
const extension = filename.split('.').pop()
if (!extension || !supportedSrcFormats.has(extension)) {
throw Error(
`Unsupported extension. Given ${extension}, was expecting ${supportedSrcFormats.values()}`
)
}
if (status === 'modified') {
const beforeBlob = await downloadFile(
github,
owner,
repo,
sha,
filename
)
const before = await convert(kittycad, beforeBlob, extension)
const afterBlob = await downloadFile(
github,
owner,
repo,
parentSha,
filename
)
const after = await convert(kittycad, afterBlob, extension)
return { before, after }
}
if (status === 'added') {
const blob = await downloadFile(github, owner, repo, sha, filename)
const after = await convert(kittycad, blob, extension)
return { after }
}
if (status === 'removed') {
const blob = await downloadFile(
github,
owner,
repo,
parentSha,
filename
)
const before = await convert(kittycad, blob, extension)
return { before }
}
throw Error(`Unsupported status: ${status}`)
}

View File

@ -0,0 +1,28 @@
import {
getStorageGithubToken,
getStorageKittycadToken,
setStorageGithubToken,
setStorageKittycadToken,
} from './storage'
it('saves github token to storage', async () => {
await setStorageGithubToken('token')
expect(chrome.storage.local.set).toHaveBeenCalledWith({ gtk: 'token' })
})
it('reads github token from storage', () => {
getStorageGithubToken()
expect(chrome.storage.local.get).toHaveBeenCalled()
// TODO: improve
})
it('saves kittycad token to storage', async () => {
await setStorageKittycadToken('token')
expect(chrome.storage.local.set).toHaveBeenCalledWith({ ktk: 'token' })
})
it('reads kittycad token from storage', () => {
getStorageKittycadToken()
expect(chrome.storage.local.get).toHaveBeenCalled()
// TODO: improve
})

36
src/chrome/storage.ts Normal file
View File

@ -0,0 +1,36 @@
enum TokenKeys {
Github = 'gtk',
Kittycad = 'ktk',
}
function setStorage(key: TokenKeys, value: string): Promise<void> {
return chrome.storage.local.set({ [key]: value })
}
function getStorage(key: TokenKeys): Promise<string> {
return new Promise<string>((resolve, reject) => {
chrome.storage.local.get([key], result => {
if (result && result[key]) {
resolve(result[key])
} else {
reject('Empty token')
}
})
})
}
export function setStorageGithubToken(token: string): Promise<void> {
return setStorage(TokenKeys.Github, token)
}
export function getStorageGithubToken(): Promise<string> {
return getStorage(TokenKeys.Github)
}
export function setStorageKittycadToken(token: string): Promise<void> {
return setStorage(TokenKeys.Kittycad, token)
}
export function getStorageKittycadToken(): Promise<string> {
return getStorage(TokenKeys.Kittycad)
}

63
src/chrome/types.ts Normal file
View File

@ -0,0 +1,63 @@
import { User_type } from '@kittycad/lib/dist/types/src/models'
import { components } from '@octokit/openapi-types'
// kittycad
export type KittycadUser = User_type
// octokit
export type DiffEntry = components['schemas']['diff-entry']
export type ContentFile = components['schemas']['content-file']
export type User = components['schemas']['simple-user']
export type Pull = components['schemas']['pull-request']
// chrome extension
export type FileDiff = {
before?: string
after?: string
}
export enum MessageIds {
GetGithubPullFiles = 'GetPullFiles',
GetGithubUser = 'GetGitHubUser',
SaveGithubToken = 'SaveGitHubToken',
SaveKittycadToken = 'SaveKittyCadToken',
GetKittycadUser = 'GetKittyCadUser',
GetFileDiff = 'GetFileDiff',
GetGithubPull = 'GetGithubPull',
}
export type MessageGetGithubPullFilesData = {
owner: string
repo: string
pull: number
}
export type MessageGetFileDiff = {
owner: string
repo: string
sha: string
parentSha: string
file: DiffEntry
}
export type MessageSaveToken = {
token: string
}
export type Message = {
id: MessageIds
data?: MessageGetGithubPullFilesData | MessageSaveToken | MessageGetFileDiff
}
export type MessageResponse =
| DiffEntry[]
| Pull
| User
| KittycadUser
| MessageSaveToken
| FileDiff
| Error
| void

90
src/chrome/web.test.ts Normal file
View File

@ -0,0 +1,90 @@
import { DiffEntry } from './types'
import {
getElementFilename,
getGithubUrlParams,
getInjectablePullElements,
getWebPullElements,
} from './web'
const githubPullHtmlSnippet = `
<div class="file js-file js-details-container js-targetable-element show-inline-notes Details Details--on open js-tagsearch-file" data-file-type=".obj">
<div class="file-header d-flex flex-md-row flex-column flex-md-items-center file-header--expandable js-file-header js-skip-tagsearch sticky-file-header js-position-sticky js-position-sticky-stacked">
<div class="file-info flex-auto min-width-0 mb-md-0 mb-2">
<span class="Truncate">
<a title="samples/file_center_of_mass/output.obj" class="Link--primary Truncate-text" href="#diff-5f8df244900f6383db3354c02b8a984a044b272e6bfe4cacc1ec8d4892ad3e21">samples/file_center_of_mass/output.obj</a>
</span>
</div>
</div>
<div class="js-file-content Details-content--hidden position-relative">
<div class="data highlight empty">
Git LFS file not shown
</div>
</div>
</div>
` // from https://github.com/KittyCAD/litterbox/pull/95/files
const parser = new DOMParser()
const githubPullHtmlDocument = parser.parseFromString(
githubPullHtmlSnippet,
'text/html'
)
const githubPullFilesSample: DiffEntry[] = [
{
sha: '2f35d962a711bea7a8bf57481b8717f7dedbe1c5',
filename: 'samples/file_center_of_mass/output.obj',
status: 'modified',
additions: 2,
deletions: 2,
changes: 4,
blob_url:
'https://github.com/KittyCAD/litterbox/blob/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.obj',
raw_url:
'https://github.com/KittyCAD/litterbox/raw/11510a02d8294cac5943b8ebdc416170f5b738b5/samples%2Ffile_center_of_mass%2Foutput.obj',
contents_url:
'https://api.github.com/repos/KittyCAD/litterbox/contents/samples%2Ffile_center_of_mass%2Foutput.obj?ref=11510a02d8294cac5943b8ebdc416170f5b738b5',
patch: '@@ -1,3 +1,3 @@\n version https://git-lfs.github.com/spec/v1\n-oid sha256:2a07f53add3eee88b80a0bbe0412cf91df3d3bd9d45934ce849e0440eff90ee1\n-size 62122\n+oid sha256:0c0eb961e7e0589d83693335408b90d3b8adae9f4054c3e396c6eedbc5ed16ec\n+size 62545',
},
]
describe('Function getGithubUrlParams', () => {
it('gets params out of a valid github pull request link', () => {
const pullUrl = 'https://github.com/KittyCAD/kittycad.ts/pull/67/files'
const params = getGithubUrlParams(pullUrl)
expect(params).toBeDefined()
const { owner, repo, pull } = params!
expect(owner).toEqual('KittyCAD')
expect(repo).toEqual('kittycad.ts')
expect(pull).toEqual(67)
})
it("doesn't match other URLs", () => {
expect(getGithubUrlParams('http://google.com')).toBeUndefined()
expect(
getGithubUrlParams('https://github.com/KittyCAD/litterbox')
).toBeUndefined()
})
})
it('finds web elements for supported files', () => {
const elements = getWebPullElements(githubPullHtmlDocument)
expect(elements).toHaveLength(1)
})
it('finds the filename of a supported file element', () => {
const elements = getWebPullElements(githubPullHtmlDocument)
const filename = getElementFilename(elements[0])
expect(filename).toEqual('samples/file_center_of_mass/output.obj')
})
it('finds injectable elements from html and api results', () => {
const elements = getWebPullElements(githubPullHtmlDocument)
const injectableElements = getInjectablePullElements(
elements,
githubPullFilesSample
)
expect(injectableElements).toHaveLength(1)
const { element, file } = injectableElements[0]
expect(element).toBeDefined()
expect(file).toBeDefined()
})

66
src/chrome/web.ts Normal file
View File

@ -0,0 +1,66 @@
import { supportedSrcFormats } from './diff'
import { DiffEntry } from './types'
export type GithubUrlParams =
| {
owner: string
repo: string
pull: number
}
| undefined
export function getGithubUrlParams(url: string): GithubUrlParams {
// TODO: support commit diff
const pullRe =
/https:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)\/pull\/(\d+)\/files/
const result = pullRe.exec(url)
if (!result) {
return undefined
}
const [, owner, repo, pull] = result
return { owner, repo, pull: parseInt(pull) }
}
export function getWebPullElements(document: Document): HTMLElement[] {
const fileTypeSelectors = Array.from(supportedSrcFormats).map(
t => `.file[data-file-type=".${t}"]`
)
const selector = fileTypeSelectors.join(', ')
return [...document.querySelectorAll(selector)].map(n => n as HTMLElement)
}
export function getElementFilename(element: HTMLElement) {
const titleElement = element.querySelector(
'.file-info a[title]'
) as HTMLElement
return titleElement.getAttribute('title')
}
export function getInjectablePullElements(
elements: HTMLElement[],
files: DiffEntry[]
) {
if (elements.length !== files.length) {
throw Error(
`elements and files have different length. Got ${elements.length} and ${files.length}`
)
}
const injectableElements = []
for (const [index, element] of elements.entries()) {
const apiFile = files[index]
const filename = getElementFilename(element)
if (filename !== apiFile.filename) {
throw Error(
"Couldn't match API file with a diff element on the page. Aborting."
)
}
const diffElement = element.querySelector(
'.js-file-content'
) as HTMLElement
injectableElements.push({ element: diffElement, file: apiFile })
}
return injectableElements
}

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { CadDiff } from './CadDiff'
it('renders the CAD diff element', () => {
render(<CadDiff />)
// TODO: find a way to add proper tests for ModelView,
// seems non-trivial with the simulated DOM
// Probably will have to go for end-to-end
})

View File

@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react'
import '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { BufferGeometry } from 'three'
import { Canvas } from '@react-three/fiber'
import { Box, ThemeProvider, useTheme } from '@primer/react'
import { FileDiff } from '../chrome/types'
function ModelView({ file }: { file: string }): React.ReactElement {
const { theme } = useTheme()
const [geometry, setGeometry] = useState<BufferGeometry>()
useEffect(() => {
const loader = new STLLoader()
const geometry = loader.parse(atob(file))
console.log(`Model ${geometry.id} loaded`)
setGeometry(geometry)
}, [file])
return (
<Canvas>
<ambientLight intensity={0.7} />
<pointLight position={[10, 10, 10]} />
<mesh geometry={geometry}>
<meshStandardMaterial color={theme?.colors.fg.default} />
</mesh>
<OrbitControls />
</Canvas>
)
}
export type CadDiffProps = FileDiff
export function CadDiff({ before, after }: CadDiffProps): React.ReactElement {
return (
<ThemeProvider colorMode="auto">
<Box display="flex" height={300}>
<Box flexGrow={1} backgroundColor="danger.subtle">
{before && <ModelView file={before} />}
</Box>
<Box
flexGrow={1}
backgroundColor="success.subtle"
borderLeftWidth={1}
borderLeftColor="border.default"
borderLeftStyle="solid"
>
{after && <ModelView file={after} />}
</Box>
</Box>
</ThemeProvider>
)
}

View File

@ -0,0 +1,6 @@
import { render } from '@testing-library/react'
import { Loading } from './Loading'
it('renders welcome message', () => {
render(<Loading />)
})

View File

@ -0,0 +1,11 @@
import { Box, Spinner } from '@primer/react'
export function Loading() {
return (
<Box display="flex" alignItems="center" justifyContent="space-around">
<Box display="block" py={4}>
<Spinner size="large" />
</Box>
</Box>
)
}

View File

@ -0,0 +1,14 @@
import { render, screen, waitFor } from '@testing-library/react'
import { Settings } from './Settings'
it('renders settings popup with both save buttons', async () => {
render(<Settings />)
// Waiting for loading
await waitFor(() => screen.findByText(/github token/i))
// GitHub and KittyCAD buttons
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toBeEnabled()
expect(buttons[1]).toBeEnabled()
})

120
src/components/Settings.tsx Normal file
View File

@ -0,0 +1,120 @@
import { Box, ThemeProvider } from '@primer/react'
import { useEffect, useState } from 'react'
import { KittycadUser, MessageIds, User } from '../chrome/types'
import { Loading } from './Loading'
import { TokenForm } from './TokenForm'
import { UserCard } from './UserCard'
export function Settings() {
const [githubUser, setGithubUser] = useState<User>()
const [kittycadUser, setKittycadUser] = useState<KittycadUser>()
const [firstInitDone, setFirstInitDone] = useState(false)
async function fetchGithubUser() {
try {
const response = await chrome.runtime.sendMessage({
id: MessageIds.GetGithubUser,
})
if (Object.keys(response).length === 0) throw Error('no response')
const user = response as User
setGithubUser(user)
} catch (e) {
console.error(e)
setGithubUser(undefined)
}
}
async function fetchKittycadUser() {
try {
const response = await chrome.runtime.sendMessage({
id: MessageIds.GetKittycadUser,
})
if (Object.keys(response).length === 0) throw Error('no response')
const user = response as KittycadUser
setKittycadUser(user)
} catch (e) {
console.error(e)
setKittycadUser(undefined)
}
}
async function onToken(id: MessageIds, token: string) {
await chrome.runtime.sendMessage({ id, data: { token } })
}
useEffect(() => {
;(async () => {
await fetchGithubUser()
await fetchKittycadUser()
setFirstInitDone(true)
})()
}, [])
return (
<ThemeProvider colorMode="auto">
<Box backgroundColor="canvas.default" width={300} p={4}>
{firstInitDone ? (
<Box>
<Box>
{githubUser ? (
<UserCard
login={'@' + githubUser.login}
avatar={githubUser.avatar_url}
onSignOut={async () => {
await onToken(
MessageIds.SaveGithubToken,
''
)
setGithubUser(undefined)
}}
/>
) : (
<TokenForm
service="GitHub"
onToken={async (token: string) => {
await onToken(
MessageIds.SaveGithubToken,
token
)
await fetchGithubUser()
}}
/>
)}
</Box>
<Box mt={4}>
{kittycadUser ? (
<UserCard
login={kittycadUser.email}
avatar={
kittycadUser.image ||
'https://kittycad.io/logo-green.png'
}
onSignOut={async () => {
await onToken(
MessageIds.SaveKittycadToken,
''
)
setKittycadUser(undefined)
}}
/>
) : (
<TokenForm
service="KittyCAD"
onToken={async (token: string) => {
await onToken(
MessageIds.SaveKittycadToken,
token
)
await fetchKittycadUser()
}}
/>
)}
</Box>
</Box>
) : (
<Loading />
)}
</Box>
</ThemeProvider>
)
}

View File

@ -0,0 +1,21 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { TokenForm } from './TokenForm'
it('renders a token form and checks its callback', () => {
const service = 'service'
const token = 'token'
const callback = jest.fn()
render(<TokenForm service={service} onToken={callback} />)
expect(screen.getByText(`Enter a ${service} token`)).toBeInTheDocument()
const field = screen.getByRole('textbox')
fireEvent.change(field, { target: { value: token } })
const button = screen.getByRole('button')
expect(button).toBeEnabled()
fireEvent.click(button)
expect(callback.mock.calls).toHaveLength(1)
expect(callback.mock.lastCall[0]).toEqual(token)
})

View File

@ -0,0 +1,31 @@
import { Box, BranchName, Button, FormControl, TextInput } from '@primer/react'
import { useState } from 'react'
export type TokenFormProps = {
service: string
onToken: (token: string) => void
}
export function TokenForm({ service, onToken }: TokenFormProps) {
const [token, setToken] = useState('')
return (
<Box>
<FormControl required>
<FormControl.Label>Enter a {service} token</FormControl.Label>
<TextInput
value={token}
onChange={e => setToken(e.target.value)}
/>
{service === 'GitHub' && (
<FormControl.Caption>
With <BranchName>repo</BranchName> permissions
</FormControl.Caption>
)}
</FormControl>
<Button sx={{ mt: 2 }} onClick={() => onToken(token)}>
Save
</Button>
</Box>
)
}

View File

@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { UserCard } from './UserCard'
it('renders a user card and checks its callback button', () => {
const login = 'login'
const avatar = 'avatar'
const callback = jest.fn()
render(<UserCard login={login} avatar={avatar} onSignOut={callback} />)
expect(screen.getByText(login)).toBeInTheDocument()
expect(screen.getByRole('img')).toBeInTheDocument()
const button = screen.getByRole('button')
expect(button).toBeEnabled()
expect(callback.mock.calls).toHaveLength(0)
fireEvent.click(button)
expect(callback.mock.calls).toHaveLength(1)
})

View File

@ -0,0 +1,23 @@
import { Avatar, Box, Button, Text } from '@primer/react'
export type UserCardProps = {
login: string
avatar: string
onSignOut: () => void
}
export function UserCard({ login, avatar, onSignOut }: UserCardProps) {
return (
<Box>
<Box display="flex" alignItems="center" mb={2}>
<Avatar src={avatar} size={32} />
<Box flexGrow={1} pl={2}>
<Text color="fg.default" fontSize={20}>
{login}
</Text>
</Box>
</Box>
<Button onClick={onSignOut}>Sign out</Button>
</Box>
)
}

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

11
src/index.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Settings } from './components/Settings'
import './index.css'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<Settings />
</React.StrictMode>
)

1
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

26
src/setupTests.ts Normal file
View File

@ -0,0 +1,26 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'
// From https://github.com/primer/react/blob/5dd4bb1f7f92647197160298fc1f521b23b4823b/src/utils/test-helpers.tsx#L12
global.CSS = {
escape: jest.fn(),
supports: jest.fn().mockImplementation(() => {
return false
}),
}
// TODO: improve/replace chrome mocks
global.chrome = {
runtime: {
sendMessage: jest.fn(),
},
storage: {
local: {
set: jest.fn(),
get: jest.fn(),
},
},
}