diff --git a/src/app/domain/github/githubUserAuth/oauthCallback.ts b/src/app/domain/github/githubUserAuth/oauthCallback.ts index 3b3f6f6..21061bf 100644 --- a/src/app/domain/github/githubUserAuth/oauthCallback.ts +++ b/src/app/domain/github/githubUserAuth/oauthCallback.ts @@ -5,7 +5,8 @@ import { redirectResponseWithCookies } from '../../../inboundInterfaces/httpResp import { APIGatewayProxyResult } from 'aws-lambda/trigger/api-gateway-proxy' import { getUserByAuthToken } from '../githubUser' import { cookies } from './cicadaAuthCookies' -import { generatePageViewResultWithoutHtmx } from '../../../web/views/viewResultWrappers' +import { pageViewResultWithoutHtmx } from '../../../web/views/viewResultWrappers' +import { p } from '../../../web/hiccough/hiccoughElements' export async function oauthCallback( appState: AppState, @@ -64,7 +65,7 @@ async function tryOauthCallback( function failedToLoginResult(message: string): APIGatewayProxyResult { return { - ...generatePageViewResultWithoutHtmx(`

${message}

`), + ...pageViewResultWithoutHtmx([p(message)]), statusCode: 400 } } diff --git a/src/app/domain/github/setup/processGithubSetupRedirect.ts b/src/app/domain/github/setup/processGithubSetupRedirect.ts index a194248..84f6398 100644 --- a/src/app/domain/github/setup/processGithubSetupRedirect.ts +++ b/src/app/domain/github/setup/processGithubSetupRedirect.ts @@ -1,7 +1,7 @@ import { Route } from '../../../internalHttpRouter/internalHttpRoute' import { APIGatewayProxyEvent } from 'aws-lambda' import { GithubSetupAppState } from './githubSetupAppState' -import { generatePageViewResultWithoutHtmx } from '../../../web/views/viewResultWrappers' +import { pageViewResultWithoutHtmx } from '../../../web/views/viewResultWrappers' import { Octokit } from '@octokit/rest' import { ParameterType, PutParameterCommand, SSMClient } from '@aws-sdk/client-ssm' import { @@ -11,6 +11,7 @@ import { } from '../../../../multipleContexts/ssmParams' import { logger } from '../../../util/logging' import { fromRawAccountType, ORGANIZATION_ACCOUNT_TYPE } from '../../types/githubCommonTypes' +import { a, p } from '../../../web/hiccough/hiccoughElements' export const setupRedirectRoute: Route = { path: '/github/setup/redirect', @@ -40,12 +41,14 @@ async function processRedirect(appState: GithubSetupAppState, event: APIGatewayP ? `https://github.com/organizations/${appDetails.ownerLogin}/settings/apps/${appDetails.appName}/installations` : `https://github.com/settings/apps/${appDetails.appName}/installations` - return generatePageViewResultWithoutHtmx( - `

-Github app ${appDetails.appName} has been successsfully created. -You now need to install it in GitHub here. -Once you've installed the GitHub app Cicada will start loading your GitHub data - this can take a minute or more. -

`, + return pageViewResultWithoutHtmx( + [ + p( + `Github app ${appDetails.appName} has been successsfully created. You now need to install it in GitHub `, + a(installationsPath, 'here'), + `Once you've installed the GitHub app Cicada will start loading your GitHub data - this can take a minute or more` + ) + ], false ) } @@ -125,17 +128,17 @@ async function writeSSMParameter( ) } -const noCodeResponse = generatePageViewResultWithoutHtmx( - `

Unexpected redirect from GitHub - no code on URL

`, +const noCodeResponse = pageViewResultWithoutHtmx( + [p('Unexpected redirect from GitHub - no code on URL')], false ) -const noStateResponse = generatePageViewResultWithoutHtmx( - `

Unexpected redirect from GitHub - no state on URL

`, +const noStateResponse = pageViewResultWithoutHtmx( + [p('Unexpected redirect from GitHub - no state on URL')], false ) -const invalidStateResponse = generatePageViewResultWithoutHtmx( - `

Unexpected redirect from GitHub - invalid state on URL

`, +const invalidStateResponse = pageViewResultWithoutHtmx( + [p('Unexpected redirect from GitHub - invalid state on URL')], false ) diff --git a/src/app/domain/github/setup/startGithubSetup.ts b/src/app/domain/github/setup/startGithubSetup.ts index 50a819b..98ecd8c 100644 --- a/src/app/domain/github/setup/startGithubSetup.ts +++ b/src/app/domain/github/setup/startGithubSetup.ts @@ -1,5 +1,5 @@ import { GithubSetupAppState } from './githubSetupAppState' -import { generatePageViewResultWithoutHtmx } from '../../../web/views/viewResultWrappers' +import { pageViewResultWithoutHtmx } from '../../../web/views/viewResultWrappers' import { APIGatewayProxyEvent } from 'aws-lambda' import { Route } from '../../../internalHttpRouter/internalHttpRoute' @@ -49,8 +49,8 @@ GitHub Account. If you're not sure then the official GitHub docs ` - return generatePageViewResultWithoutHtmx(bodyContents, false) + return pageViewResultWithoutHtmx([bodyContents], false) } diff --git a/src/app/lambdaFunctions/authenticatedWeb/lambda.ts b/src/app/lambdaFunctions/authenticatedWeb/lambda.ts index 5937b30..b11f2d0 100644 --- a/src/app/lambdaFunctions/authenticatedWeb/lambda.ts +++ b/src/app/lambdaFunctions/authenticatedWeb/lambda.ts @@ -13,9 +13,10 @@ import { logoutResponse } from '../../domain/github/githubUserAuth/githubWebAuth import { authorizeUserRequest } from '../../domain/webAuth/userAuthorizer' import { notAuthorizedHTMLResponse } from '../../inboundInterfaces/standardHttpResponses' import { logger } from '../../util/logging' -import { generateFragmentViewResult } from '../../web/views/viewResultWrappers' +import { fragmentViewResult } from '../../web/views/viewResultWrappers' import { startSetupRoute } from '../../domain/github/setup/startGithubSetup' import { isFailure } from '../../util/structuredResult' +import { a, p } from '../../web/hiccough/hiccoughElements' const router = createRouter([showHelloRoute, showLatestActivityRoute, showRepoRoute, showWorkflowRoute]) @@ -35,9 +36,9 @@ export const baseHandler: CicadaAPIAuthorizedAPIHandler = async (event: APIGatew return await handleWebRequest(appState, event) } -export const setupRequiredResponse = generateFragmentViewResult(`

-Cicada GitHub app not ready yet. Go here to start the setup process. -

`) +export const setupRequiredResponse = fragmentViewResult( + p('Cicada GitHub app not ready yet.', a(startSetupRoute.path, 'Go here to start the setup process')) +) // TOEventually - top level error handler export async function handleWebRequest(appState: AppState, event: APIGatewayProxyEvent) { diff --git a/src/app/lambdaFunctions/githubAuth/lambda.ts b/src/app/lambdaFunctions/githubAuth/lambda.ts index 928c1ca..59267b1 100644 --- a/src/app/lambdaFunctions/githubAuth/lambda.ts +++ b/src/app/lambdaFunctions/githubAuth/lambda.ts @@ -6,7 +6,7 @@ import { powertoolsMiddlewares } from '../../middleware/standardMiddleware' import { handleGitHubWebAuthRequest } from '../../domain/github/githubUserAuth/githubWebAuthHandler' import { logger } from '../../util/logging' import { isFailure } from '../../util/structuredResult' -import { generatePageViewResultWithoutHtmx } from '../../web/views/viewResultWrappers' +import { pageViewResultWithoutHtmx } from '../../web/views/viewResultWrappers' import { startSetupRoute } from '../../domain/github/setup/startGithubSetup' let appState: AppState @@ -24,10 +24,13 @@ export const baseHandler: APIGatewayProxyHandler = async (event) => { return await handleGitHubWebAuthRequest(appState, event) } -export const setupRequiredResponse = generatePageViewResultWithoutHtmx( - `

+export const setupRequiredResponse = pageViewResultWithoutHtmx( + // TODO - use hiccough elements + [ + `

Cicada GitHub app not ready yet. Go here to start the setup process. -

`, +

` + ], false ) diff --git a/src/app/lambdaFunctions/githubSetup/lambda.ts b/src/app/lambdaFunctions/githubSetup/lambda.ts index 6cb91dc..cf85c71 100644 --- a/src/app/lambdaFunctions/githubSetup/lambda.ts +++ b/src/app/lambdaFunctions/githubSetup/lambda.ts @@ -4,7 +4,8 @@ import { powertoolsMiddlewares } from '../../middleware/standardMiddleware' import { handleGithubSetupRequest } from '../../domain/github/setup/appSetupHandler' import { GithubSetupAppState, githubSetupStartup } from '../../domain/github/setup/githubSetupAppState' import { githubAppIsReady } from '../../domain/github/setup/githubAppReadyCheck' -import { generatePageViewResultWithoutHtmx } from '../../web/views/viewResultWrappers' +import { pageViewResultWithoutHtmx } from '../../web/views/viewResultWrappers' +import { p } from '../../web/hiccough/hiccoughElements' let appState: GithubSetupAppState @@ -18,9 +19,7 @@ export const baseHandler: APIGatewayProxyHandler = async (event) => { return await handleGithubSetupRequest(appState, event) } -const setupAlreadyCompleteResponse = generatePageViewResultWithoutHtmx(`

-Cicada is already configured. -

`) +const setupAlreadyCompleteResponse = pageViewResultWithoutHtmx([p('Cicada is already configured.')], false) // Entry point - usage is defined by CDK // noinspection JSUnusedGlobalSymbols diff --git a/src/app/util/types.ts b/src/app/util/types.ts new file mode 100644 index 0000000..3561729 --- /dev/null +++ b/src/app/util/types.ts @@ -0,0 +1,2 @@ +export type Optional = Pick, K> & Omit +export type Mandatory = Pick, K> & Omit diff --git a/src/app/web/hiccough/hiccoughCore.ts b/src/app/web/hiccough/hiccoughCore.ts new file mode 100644 index 0000000..b6b8886 --- /dev/null +++ b/src/app/web/hiccough/hiccoughCore.ts @@ -0,0 +1,70 @@ +import { HiccoughContent, HiccoughElement, HiccoughOptions } from './hiccoughElement' +import { Mandatory } from '../../util/types' + +export function html(input: HiccoughContent | HiccoughContent[], options: HiccoughOptions = {}): string { + const fullOptions: InternalHiccoughOptions = { + newLines: false, + eachIndent: ' ', + indentCount: -1, + ...options + } + + return renderContentArray(Array.isArray(input) ? input : [input], fullOptions).rendered +} + +type InternalHiccoughOptions = Mandatory + +function renderContentArray(content: HiccoughContent[], options: InternalHiccoughOptions) { + const renderedElements = content + .map((x) => renderContent(x, options)) + .filter((x) => x !== undefined) as RenderedContent[] + + return { + rendered: renderedElements.map(({ rendered }) => rendered).join(`${options.newLines ? '\n' : ''}`), + containsStructure: renderedElements.length > 1 || renderedElements.some(({ hasChildren }) => hasChildren) + } +} + +type RenderedContent = { rendered: string; hasChildren: boolean } + +function renderContent( + content: HiccoughContent, + options: InternalHiccoughOptions +): RenderedContent | undefined { + if (typeof content === 'undefined') return undefined + if (typeof content === 'string') return { rendered: content, hasChildren: false } + return { + rendered: `${renderElement(content, { + ...options, + indentCount: options.indentCount + 1 + })}`, + hasChildren: true + } +} + +function renderElement(input: HiccoughElement, parentOptions: InternalHiccoughOptions) { + const options = { ...parentOptions, ...input.options } + const childOptions = { ...options, ...{ indentFromParent: parentOptions.indentFromParent } } + + const { name, attributes, content } = input, + { rendered, containsStructure } = renderContentArray(content ?? [], childOptions), + preOpenIndentString = + options.newLines || options.indentFromParent + ? Array.from({ length: options.indentCount }, () => options.eachIndent).join('') + : '', + renderedAttributes = attributes + ? ' ' + + Object.entries(attributes) + .map(([k, v]) => `${k}="${v}"`) + .join(' ') + : '' + + if (rendered.length === 0) { + return `${preOpenIndentString}<${name}${renderedAttributes} />` + } + + const newLineString = options.newLines && containsStructure ? '\n' : '', + preCloseIndentString = options.newLines && containsStructure ? preOpenIndentString : '' + + return `${preOpenIndentString}<${name}${renderedAttributes}>${newLineString}${rendered}${newLineString}${preCloseIndentString}` +} diff --git a/src/app/web/hiccough/hiccoughElement.ts b/src/app/web/hiccough/hiccoughElement.ts new file mode 100644 index 0000000..d16bcce --- /dev/null +++ b/src/app/web/hiccough/hiccoughElement.ts @@ -0,0 +1,67 @@ +export type HiccoughAttributes = Record + +export type HiccoughContent = string | HiccoughElement | undefined + +export type HiccoughOptions = { + newLines?: boolean + indentFromParent?: boolean + eachIndent?: string + indentCount?: number +} + +export type HiccoughElement = { + isElement: true + name: string + attributes?: HiccoughAttributes + content?: HiccoughContent[] + options?: HiccoughOptions +} + +function isHiccoughElement(x: unknown): x is HiccoughElement { + return typeof x === 'object' && (x as HiccoughElement).isElement +} + +function isHiccoughContent(x: unknown): x is HiccoughContent { + const xtype = typeof x + return xtype === 'undefined' || xtype === 'string' || isHiccoughElement(x) +} + +export type HiccoughElementDefinition = + | HiccoughContent[] + | [HiccoughAttributes | HiccoughContent, ...HiccoughContent[]] + +// First element of def can be either attributes or content +export function element(name: string, ...def: HiccoughElementDefinition): HiccoughElement { + const baseElement: HiccoughElement = { isElement: true, name } + const [first, ...rest] = def + + return isHiccoughContent(first) + ? { ...baseElement, content: def as HiccoughContent[] } + : { + ...baseElement, + attributes: first, + content: rest as HiccoughContent[] + } +} + +export function withAttributes(attributes: HiccoughAttributes, element: HiccoughElement): HiccoughElement { + return { + ...element, + attributes: { + ...element.attributes, + ...attributes + } + } +} + +export function withOptions(options: HiccoughOptions, element: HiccoughElement): HiccoughElement { + return { + ...element, + options + } +} + +export const inlineChildren: HiccoughOptions = { + indentFromParent: true, + newLines: false +} diff --git a/src/app/web/hiccough/hiccoughElements.ts b/src/app/web/hiccough/hiccoughElements.ts new file mode 100644 index 0000000..e2eb5a1 --- /dev/null +++ b/src/app/web/hiccough/hiccoughElements.ts @@ -0,0 +1,77 @@ +import { element, HiccoughAttributes, HiccoughElementDefinition, withAttributes } from './hiccoughElement' + +export function htmlPage(...def: HiccoughElementDefinition) { + return element('html', ...def) +} + +export function head(...def: HiccoughElementDefinition) { + return element('head', ...def) +} + +export function meta(...def: HiccoughElementDefinition) { + return element('meta', ...def) +} + +export function title(content: string) { + return element('title', content) +} + +export function link(rel: string, href: string, attributes?: HiccoughAttributes) { + return element('link', { rel, href, ...attributes }) +} + +export function body(...def: HiccoughElementDefinition) { + return element('body', ...def) +} + +export function h1(...def: HiccoughElementDefinition) { + return element('h1', ...def) +} + +export function h2(...def: HiccoughElementDefinition) { + return element('h2', ...def) +} + +export function h3(...def: HiccoughElementDefinition) { + return element('h3', ...def) +} + +export function h4(...def: HiccoughElementDefinition) { + return element('h4', ...def) +} + +export function table(...def: HiccoughElementDefinition) { + return element('table', ...def) +} + +export function thead(...def: HiccoughElementDefinition) { + return element('thead', ...def) +} + +export function tbody(...def: HiccoughElementDefinition) { + return element('tbody', ...def) +} + +export function tr(...def: HiccoughElementDefinition) { + return element('tr', ...def) +} + +export function th(...def: HiccoughElementDefinition) { + return element('th', ...def) +} + +export function td(...def: HiccoughElementDefinition) { + return element('td', ...def) +} + +export function p(...def: HiccoughElementDefinition) { + return element('p', ...def) +} + +export function a(href: string, ...def: HiccoughElementDefinition) { + return withAttributes({ href }, element('a', ...def)) +} + +export function div(...def: HiccoughElementDefinition) { + return element('div', ...def) +} diff --git a/src/app/web/hiccough/hiccoughPage.ts b/src/app/web/hiccough/hiccoughPage.ts new file mode 100644 index 0000000..d33e039 --- /dev/null +++ b/src/app/web/hiccough/hiccoughPage.ts @@ -0,0 +1 @@ +export const DOCTYPE_HTML5 = '' diff --git a/src/app/web/showHello.ts b/src/app/web/showHello.ts index 94dd114..0a31c6c 100644 --- a/src/app/web/showHello.ts +++ b/src/app/web/showHello.ts @@ -1,7 +1,8 @@ import { AppState } from '../environment/AppState' import { Route } from '../internalHttpRouter/internalHttpRoute' import { CicadaAuthorizedAPIEvent } from '../inboundInterfaces/lambdaTypes' -import { generatePageViewResultWithoutHtmx } from './views/viewResultWrappers' +import { pageViewResultWithoutHtmx } from './views/viewResultWrappers' +import { p } from './hiccough/hiccoughElements' // Used for testing / diagnostics export const showHelloRoute: Route = { @@ -10,5 +11,5 @@ export const showHelloRoute: Route = { } export async function showHello(_: AppState, event: CicadaAuthorizedAPIEvent) { - return generatePageViewResultWithoutHtmx(`

Hello ${event.username} / ${event.userId}

`) + return pageViewResultWithoutHtmx([p('Hello ', event.username, ' / ', `${event.userId}`)]) } diff --git a/src/app/web/views/pageElements.ts b/src/app/web/views/pageElements.ts index a38afe7..fb17f10 100644 --- a/src/app/web/views/pageElements.ts +++ b/src/app/web/views/pageElements.ts @@ -3,6 +3,8 @@ import { GithubPush } from '../../domain/types/GithubPush' import { Clock, displayDateTime } from '../../util/dateAndTime' import { GithubRepositoryElement } from '../../domain/types/GithubRepositoryElement' import { latestCommitInPush } from '../../domain/github/githubPush' +import { a, td, tr } from '../hiccough/hiccoughElements' +import { inlineChildren, withOptions } from '../hiccough/hiccoughElement' export function workflowRow( clock: Clock, @@ -15,20 +17,20 @@ export function workflowRow( showWorkflowCell: boolean } = { showRepoCell: true, showWorkflowCell: true } ) { - return ` -${showRepoCell ? ` ${repoCell(event)}` : ''} -${showWorkflowCell ? ` ${workflowCell(event)}` : ''} - ${workflowResultCell(event)} - ${workflowRunCell(clock, event)} - ${userCell(event.actor)} - ${commitCellForWorkflowRunEvent(event)} - ` + return tr( + { class: workflowRunClass(event) }, + showRepoCell ? repoCell(event) : undefined, + showWorkflowCell ? workflowCell(event) : undefined, + workflowResultCell(event), + workflowRunCell(clock, event), + userCell(event.actor), + commitCellForWorkflowRunEvent(event) + ) } export function workflowRunClass(event: GithubWorkflowRunEvent) { // TOEventually - handle in progress - const isSuccessfulWorkflowRun = event.conclusion === 'success' - return `class='${isSuccessfulWorkflowRun ? 'success' : 'danger'}'` + return event.conclusion === 'success' ? 'success' : 'danger' } export function repoCellForPush(push: GithubPush) { @@ -43,25 +45,37 @@ export function repoCell({ }: GithubRepositoryElement & { repoHtmlUrl: string }) { - const cicadaRepoAnchor = anchor(`/app/account/${ownerId}/repo/${repoId}`, repoName) - return cell(`${cicadaRepoAnchor}  ${githubAnchor(repoHtmlUrl)}`) + return withOptions( + inlineChildren, + td(a(`/app/account/${ownerId}/repo/${repoId}`, repoName), '  ', githubAnchor(repoHtmlUrl)) + ) } export function workflowCell( event: GithubRepositoryElement & Pick ) { - const cicadaPath = `/app/account/${event.ownerId}/repo/${event.repoId}/workflow/${event.workflowId}` - - const workflowPath = `${event.path.substring(event.path.indexOf('/') + 1)}`, - workflowName = event.workflowName ?? workflowPath - const githubWorkflowUrl = event.workflowHtmlUrl ?? `${githubRepoUrl(event)}/actions/${workflowPath}` - - return cell(`${anchor(cicadaPath, workflowName)}  ${githubAnchor(githubWorkflowUrl)}`) + const workflowPath = `${event.path.substring(event.path.indexOf('/') + 1)}` + return withOptions( + inlineChildren, + td( + a( + `/app/account/${event.ownerId}/repo/${event.repoId}/workflow/${event.workflowId}`, + event.workflowName ?? workflowPath + ), + '  ', + githubAnchor(event.workflowHtmlUrl ?? `${githubRepoUrl(event)}/actions/${workflowPath}`) + ) + ) } export function userCell(actor?: { login: string }) { - return cell(actor ? `${actor.login}  ${githubAnchor(`https://github.com/${actor.login}`)}` : '') + return actor === undefined + ? td() + : withOptions( + inlineChildren, + td(actor.login, `  `, githubAnchor(`https://github.com/${actor.login}`)) + ) } export function commitCellForPush(push: GithubPush) { @@ -90,40 +104,45 @@ export function commitCell( } ) { const { repoHtmlUrl, message, sha } = event - const commitUrl = `${repoHtmlUrl ?? githubRepoUrl(event)}/commit/${sha}` - const commitMessage = message.length < 40 ? message : `${message.substring(0, 40)}...` - return cell(`${commitMessage}  ${githubAnchor(commitUrl)}`) + return withOptions( + inlineChildren, + td( + message.length < 40 ? message : `${message.substring(0, 40)}...`, + '  ', + githubAnchor(`${repoHtmlUrl ?? githubRepoUrl(event)}/commit/${sha}`) + ) + ) } export function branchCell(push: GithubRepositoryElement & Pick) { - const branchName = push.ref.split('/')[2] - const githubUrlBranchForPush = `${githubRepoUrl(push)}/tree/${branchName}` - return cell(`${branchName}  ${githubAnchor(githubUrlBranchForPush)}`) + return withOptions( + inlineChildren, + td( + push.ref.split('/')[2], + `  `, + githubAnchor(`${githubRepoUrl(push)}/tree/${push.ref.split('/')[2]}`) + ) + ) } export function plainDateTimeCell(clock: Clock, { dateTime }: { dateTime: string }) { - return cell(displayDateTime(clock, dateTime)) + return td(displayDateTime(clock, dateTime)) } export function workflowRunCell(clock: Clock, event: GithubWorkflowRunEvent) { - return cell(`${displayDateTime(clock, event.updatedAt)}  ${githubAnchor(event.htmlUrl)}`) + return withOptions( + inlineChildren, + td(displayDateTime(clock, event.updatedAt), '  ', githubAnchor(event.htmlUrl)) + ) } export function workflowResultCell(event: GithubWorkflowRunEvent) { // TOEventually - handle in progress - return cell(event.conclusion === 'success' ? 'Success' : 'Failed') + return td(event.conclusion === 'success' ? 'Success' : 'Failed') } export function githubAnchor(target: string) { - return anchor(target, githubIcon) -} - -export function cell(content: string) { - return `${content}` -} - -export function anchor(href: string, content: string) { - return `${content}` + return a(target, githubIcon) } export const githubIcon = `` diff --git a/src/app/web/views/showLatestActivityView.ts b/src/app/web/views/showLatestActivityView.ts index fbef15e..94340a0 100644 --- a/src/app/web/views/showLatestActivityView.ts +++ b/src/app/web/views/showLatestActivityView.ts @@ -1,4 +1,4 @@ -import { generateFragmentViewResult } from './viewResultWrappers' +import { fragmentViewResult } from './viewResultWrappers' import { GithubWorkflowRunEvent } from '../../domain/types/GithubWorkflowRunEvent' import { GithubPush } from '../../domain/types/GithubPush' import { Clock } from '../../util/dateAndTime' @@ -10,40 +10,39 @@ import { userCell, workflowRow } from './pageElements' +import { div, h3, table, tbody, th, thead, tr } from '../hiccough/hiccoughElements' export function createShowLatestActivityResponse( clock: Clock, workflowStatus: GithubWorkflowRunEvent[], recentPushes: GithubPush[] ) { - return generateFragmentViewResult(`
-

GitHub Actions Status

- - - - - - ${workflowStatus.map((event) => workflowRow(clock, event)).join('')} - -
RepoWorkflowStatusWhenByCommit
-

Recent Branch Activity

- - - - - - ${recentPushes.map((event) => pushRow(clock, event)).join('')} - -
RepoBranchWhenByCommit
-
`) + const contents = div( + { id: 'latestActivity', class: 'container-fluid' }, + h3('GitHub Actions Status'), + table( + { class: 'table' }, + thead(tr(...['Repo', 'Workflow', 'Status', 'When', 'By', 'Commit'].map((x) => th(x)))), + tbody(...workflowStatus.map((e) => workflowRow(clock, e))) + ), + h3('Recent Branch Activity'), + table( + { class: 'table' }, + thead(tr(...['Repo', 'Branch', 'When', 'By', 'Commit'].map((x) => th(x)))), + tbody(...recentPushes.map((x) => pushRow(clock, x))) + ) + ) + + return fragmentViewResult(contents) } function pushRow(clock: Clock, push: GithubPush) { - return ` - ${repoCellForPush(push)} - ${branchCell(push)} - ${plainDateTimeCell(clock, push)} - ${userCell(push.actor)} - ${commitCellForPush(push)} - ` + return tr( + { class: 'info' }, + repoCellForPush(push), + branchCell(push), + plainDateTimeCell(clock, push), + userCell(push.actor), + commitCellForPush(push) + ) } diff --git a/src/app/web/views/showRepoView.ts b/src/app/web/views/showRepoView.ts index 28bce9d..90daeb7 100644 --- a/src/app/web/views/showRepoView.ts +++ b/src/app/web/views/showRepoView.ts @@ -1,10 +1,8 @@ import { Clock } from '../../util/dateAndTime' -import { generatePageViewResultWithoutHtmx } from './viewResultWrappers' import { activityIsWorkflowRunActivity, GithubActivity } from '../../domain/github/githubActivity' import { GithubWorkflowRunEvent } from '../../domain/types/GithubWorkflowRunEvent' import { branchCell, - cell, commitCellForPush, commitCellForWorkflowRunEvent, githubAnchor, @@ -16,6 +14,9 @@ import { } from './pageElements' import { runWasSuccessful } from '../../domain/github/githubWorkflowRunEvent' import { GithubRepository } from '../../domain/types/GithubRepository' +import { h3, h4, table, tbody, td, th, thead, tr } from '../hiccough/hiccoughElements' +import { inlineChildren, withOptions } from '../hiccough/hiccoughElement' +import { pageViewResultWithoutHtmx } from './viewResultWrappers' export function createShowRepoResponse( clock: Clock, @@ -23,60 +24,65 @@ export function createShowRepoResponse( workflowStatus: GithubWorkflowRunEvent[], activity: GithubActivity[] ) { - return generatePageViewResultWithoutHtmx(` -

Repository: ${repo.ownerName}/${repo.name}  ${githubAnchor( - githubRepoUrl({ - ...repo, - repoName: repo.name - }) - )}

-

GitHub Actions Status

- - - - - - ${workflowStatus - .map((event) => + const contents = [ + withOptions( + inlineChildren, + h3( + `Repository: ${repo.ownerName}/${repo.name}`, + `  `, + githubAnchor( + githubRepoUrl({ + ...repo, + repoName: repo.name + }) + ) + ) + ), + h4('GitHub Actions Status'), + table( + { class: 'table' }, + thead(tr(...['Workflow', 'Status', 'When', 'By', 'Commit'].map((x) => th(x)))), + tbody( + ...workflowStatus.map((event) => workflowRow(clock, event, { showRepoCell: false, showWorkflowCell: true }) ) - .join('')} - -
WorkflowStatusWhenByCommit
-

Recent Activity

- - - - - - ${activity.map((event) => activityRow(clock, event)).join('')} - -
TypeActivityWhenByCommit
-`) + ) + ), + h4('Recent Activity'), + table( + { class: 'table' }, + thead(tr(...['Type', 'Activity', 'When', 'By', 'Commit'].map((x) => th(x)))), + tbody(...activity.map((event) => activityRow(clock, event))) + ) + ] + + return pageViewResultWithoutHtmx(contents) } function activityRow(clock: Clock, event: GithubActivity) { if (activityIsWorkflowRunActivity(event)) { const workflowRun = event.event const wasSuccessful = runWasSuccessful(workflowRun) - return ` - ${cell(wasSuccessful ? 'Successful Run' : 'Failed Run')} - ${workflowCell(workflowRun)} - ${plainDateTimeCell(clock, { dateTime: workflowRun.updatedAt })} - ${userCell(workflowRun.actor)} - ${commitCellForWorkflowRunEvent(workflowRun)} - ` + return tr( + { class: wasSuccessful ? 'success' : 'danger' }, + td(wasSuccessful ? 'Successful Run' : 'Failed Run'), + workflowCell(workflowRun), + plainDateTimeCell(clock, { dateTime: workflowRun.updatedAt }), + userCell(workflowRun.actor), + commitCellForWorkflowRunEvent(workflowRun) + ) } else { const push = event.event - return ` - ${cell('Push')} - ${branchCell(push)} - ${plainDateTimeCell(clock, push)} - ${userCell(push.actor)} - ${commitCellForPush(push)} - ` + return tr( + { class: 'info' }, + td('Push'), + branchCell(push), + plainDateTimeCell(clock, push), + userCell(push.actor), + commitCellForPush(push) + ) } } diff --git a/src/app/web/views/showWorkflowView.ts b/src/app/web/views/showWorkflowView.ts index a984c58..2a3528f 100644 --- a/src/app/web/views/showWorkflowView.ts +++ b/src/app/web/views/showWorkflowView.ts @@ -1,27 +1,24 @@ import { Clock } from '../../util/dateAndTime' -import { generatePageViewResultWithoutHtmx } from './viewResultWrappers' +import { pageViewResultWithoutHtmx } from './viewResultWrappers' import { GithubWorkflowRunEvent } from '../../domain/types/GithubWorkflowRunEvent' import { workflowRow } from './pageElements' +import { h3, p, table, tbody, th, thead, tr } from '../hiccough/hiccoughElements' export function createShowWorkflowResponse(clock: Clock, runs: GithubWorkflowRunEvent[]) { - if (runs.length === 0) { - return generatePageViewResultWithoutHtmx(` -

No activity found. If this is a new workflow make sure it runs first before trying to view here -`) - } + const structure = + runs.length === 0 + ? [p('No activity found. If this is a new workflow make sure it runs first before trying to view here')] + : [ + h3(`Runs for workflow ${runs[0].workflowName} in ${runs[0].ownerName}/${runs[0].repoName}`), + // TOEventually - when we have in-progress runs only show them if no corresponding completed event + table( + { class: 'table' }, + thead(tr(...['Result', 'When', 'By', 'Commit'].map((x) => th(x)))), + tbody( + ...runs.map((run) => workflowRow(clock, run, { showRepoCell: false, showWorkflowCell: false })) + ) + ) + ] - // TOEventually - when we have in-progress runs only show them if no corresponding completed event - return generatePageViewResultWithoutHtmx(` -

Runs for workflow ${runs[0].workflowName} in ${runs[0].ownerName}/${runs[0].repoName}

- - - - - - ${runs - .map((event) => workflowRow(clock, event, { showRepoCell: false, showWorkflowCell: false })) - .join('')} - -
ResultWhenByCommit
-`) + return pageViewResultWithoutHtmx(structure) } diff --git a/src/app/web/views/viewResultWrappers.ts b/src/app/web/views/viewResultWrappers.ts index 2aab338..9aa7260 100644 --- a/src/app/web/views/viewResultWrappers.ts +++ b/src/app/web/views/viewResultWrappers.ts @@ -1,33 +1,48 @@ import { htmlOkResult } from '../../inboundInterfaces/httpResponses' +import { a, body, div, h2, head, htmlPage, link, meta, p, title } from '../hiccough/hiccoughElements' +import { html } from '../hiccough/hiccoughCore' +import { element, HiccoughContent } from '../hiccough/hiccoughElement' +import { DOCTYPE_HTML5 } from '../hiccough/hiccoughPage' -export function generateFragmentViewResult(bodyContents: string) { - return htmlOkResult(` - - - - - - Cicada - - - - -${bodyContents} - -`) +export function fragmentViewResult(...bodyContent: HiccoughContent[]) { + return htmlOkResponseFor(...bodyContent) } -export function generatePageViewResultWithoutHtmx(bodyContents: string, loggedIn = true) { - const footer = loggedIn - ? `

Manage Web Push Notifications

-

Back to home

-

Logout

` - : `

Back to home

` +export function pageViewResultWithoutHtmx(bodyContents: HiccoughContent[], loggedIn = true) { + return htmlOkResponseFor( + div( + { class: 'container', id: 'toplevel' }, + h2('Cicada'), + ...bodyContents, + element('hr'), + ...(loggedIn + ? [ + p(a('web-push.html', 'Manage Web Push Notifications')), + p(a('/', 'Back to home')), + p(a('/github/auth/logout', 'Logout')) + ] + : [p(a('/', 'Back to home'))]) + ) + ) +} - return generateFragmentViewResult(`
-

Cicada

-${bodyContents} -
-${footer} -
`) +function htmlOkResponseFor(...bodyContent: HiccoughContent[]) { + return htmlOkResult( + html([DOCTYPE_HTML5, htmlPage({ lang: 'en' }, standardHead, body(...bodyContent))], { + newLines: true, + eachIndent: ' ' + }) + ) } + +const standardHead = head( + meta({ charset: 'utf-8' }), + meta({ 'http-equiv': 'X-UA-Compatible', content: 'IE=edge' }), + meta({ name: 'viewport', content: 'width=device-width, initial-scale=1' }), + title('Cicada'), + link('stylesheet', 'https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css', { + integrity: 'sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu', + crossorigin: 'anonymous' + }), + link('stylesheet', 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css') +) diff --git a/src/web/js/github-app-setup.js b/src/web/js/github-app-setup.js index e3e5ab1..711b037 100644 --- a/src/web/js/github-app-setup.js +++ b/src/web/js/github-app-setup.js @@ -1,4 +1,4 @@ -export function modifyControls(document, appName, webHostname, webhookCode) { +export function modifyControls(document, appName, webHostname, webhookCode, callbackState) { const manifestConfig = JSON.stringify({ name: appName, url: 'https://github.com/symphoniacloud/cicada', @@ -30,7 +30,7 @@ export function modifyControls(document, appName, webHostname, webhookCode) { orgButton.className = 'btn btn-primary' orgButton.textContent = 'Start GitHub App Creation Process for ' + orgName orgForm.action = - 'https://github.com/organizations/' + orgName + '/settings/apps/new?state=${callbackState}' + 'https://github.com/organizations/' + orgName + '/settings/apps/new?state=' + callbackState } else { orgButton.disabled = 'disabled' orgButton.className = 'btn btn-default' diff --git a/test/local/functional/domain/github/githubUserAuth/githubWebAuthHandler.test.ts b/test/local/functional/domain/github/githubUserAuth/githubWebAuthHandler.test.ts index 10c396d..175d6ea 100644 --- a/test/local/functional/domain/github/githubUserAuth/githubWebAuthHandler.test.ts +++ b/test/local/functional/domain/github/githubUserAuth/githubWebAuthHandler.test.ts @@ -99,25 +99,31 @@ test('failedOauthCallback', async () => { expect(response.statusCode).toEqual(400) expect(response.multiValueHeaders).toBeUndefined() expect(response.body).toEqual(` - - - - - - Cicada - - - - -
-

Cicada

-

Unable to login because there was no code on request

-
-

Manage Web Push Notifications

-

Back to home

-

Logout

-
- + + + + + + Cicada + + + + +
+

Cicada

+

Unable to login because there was no code on request

+
+

+ Manage Web Push Notifications +

+

+ Back to home +

+

+ Logout +

+
+ `) }) diff --git a/test/local/functional/web/latestActivity.test.ts b/test/local/functional/web/latestActivity.test.ts index 6c29a8e..0033b00 100644 --- a/test/local/functional/web/latestActivity.test.ts +++ b/test/local/functional/web/latestActivity.test.ts @@ -90,50 +90,63 @@ test('latest-activity', async () => { }) expect(latestActivity.body).toEqual( ` - - - - - - Cicada - - - - -
-

GitHub Actions Status

- - - - - - - - - - - - - - -
RepoWorkflowStatusWhenByCommit
org-test-repo-one  Test Repo One Workflow  Success2024-03-06T19:25:42Z  mikebroberts  Test Repo One Workflow  
-

Recent Branch Activity

- - - - - - - - - - - - - -
RepoBranchWhenByCommit
org-test-repo-one  main  2024-03-06T17:00:40Zmikebroberts  test workflow  
-
- + + + + + + Cicada + + + + +
+

GitHub Actions Status

+ + + + + + + + + + + + + + + + + + + + + +
RepoWorkflowStatusWhenByCommit
org-test-repo-one  Test Repo One Workflow  Success2024-03-06T19:25:42Z  mikebroberts  Test Repo One Workflow  
+

Recent Branch Activity

+ + + + + + + + + + + + + + + + + + + +
RepoBranchWhenByCommit
org-test-repo-one  main  2024-03-06T17:00:40Zmikebroberts  test workflow  
+
+ ` ) }) diff --git a/test/local/functional/web/viewRepo.test.ts b/test/local/functional/web/viewRepo.test.ts index f37057a..195646c 100644 --- a/test/local/functional/web/viewRepo.test.ts +++ b/test/local/functional/web/viewRepo.test.ts @@ -80,50 +80,64 @@ test('view-repo', async () => { 'Content-Type': 'text/html' }) expect(viewRepoResponse.body).toEqual(` - - - - - - Cicada - - - - -
-

Cicada

- -

Repository: cicada-test-org/org-test-repo-one  

-

GitHub Actions Status

- - - - - - - -
WorkflowStatusWhenByCommit
-

Recent Activity

- - - - - - - - - - - - - -
TypeActivityWhenByCommit
Successful RunTest Repo One Workflow  2024-03-06T19:25:42Zmikebroberts  Test Repo One Workflow  
- -
-

Manage Web Push Notifications

-

Back to home

-

Logout

-
- + + + + + + Cicada + + + + +
+

Cicada

+

Repository: cicada-test-org/org-test-repo-one  

+

GitHub Actions Status

+ + + + + + + + + + + +
WorkflowStatusWhenByCommit
+

Recent Activity

+ + + + + + + + + + + + + + + + + + + +
TypeActivityWhenByCommit
Successful RunTest Repo One Workflow  2024-03-06T19:25:42Zmikebroberts  Test Repo One Workflow  
+
+

+ Manage Web Push Notifications +

+

+ Back to home +

+

+ Logout +

+
+ `) }) diff --git a/test/local/unit/web/hiccough/hiccoughCore.test.ts b/test/local/unit/web/hiccough/hiccoughCore.test.ts new file mode 100644 index 0000000..1fe2d41 --- /dev/null +++ b/test/local/unit/web/hiccough/hiccoughCore.test.ts @@ -0,0 +1,168 @@ +import { expect, test } from 'vitest' +import { html } from '../../../../../src/app/web/hiccough/hiccoughCore' +import { + a, + body, + div, + h1, + head, + htmlPage, + p, + table, + td, + title, + tr +} from '../../../../../src/app/web/hiccough/hiccoughElements' +import { element, withOptions } from '../../../../../src/app/web/hiccough/hiccoughElement' +import { DOCTYPE_HTML5 } from '../../../../../src/app/web/hiccough/hiccoughPage' + +test('hiccough smoke test', () => { + expect( + html( + [ + DOCTYPE_HTML5, + htmlPage( + { lang: 'en' }, + head(title('Hiccough Test')), + body(div({ id: 'top' }, h1('Hello'), table(tr(...['a', 'b', 'c'].map((x) => td(x)))))) + ) + ], + { + newLines: true, + eachIndent: ' ' + } + ) + ).toEqual(` + + + Hiccough Test + + +
+

Hello

+ + + + + + +
abc
+
+ +`) +}) + +test('hiccough', () => { + expect(html(element('span'))).toEqual(``) + expect(html(element('span', 'bar'))).toEqual(`bar`) + expect(html(element('span', 'bar', ' baz'))).toEqual(`bar baz`) + + expect(html(element('span', { class: 'foo' }))).toEqual(``) + expect(html(element('span', { class: 'foo' }, 'bar'))).toEqual(`bar`) + expect(html(element('span', { class: 'foo' }, '\nbar', '\nbaz'))).toEqual(` +bar +baz`) + + expect(html(tr(td('a')))).toEqual(`a`) + expect(html(tr(td('a'), td('b')))).toEqual(`ab`) + expect(html(table(tr(td('a'), td('b'))))).toEqual(`
ab
`) + expect(html(table({ class: 'table' }, tr(...['a', 'b', 'c'].map((x) => td(x)))))).toEqual( + `
abc
` + ) + + expect(html(div(p('Hello'), p('World')))).toEqual(`

Hello

World

`) + expect(html(div(...[p('Hello'), p('World')]))).toEqual(`

Hello

World

`) + expect(html(p(undefined, 'Hello', undefined, 'World'))).toEqual(`

HelloWorld

`) + + expect(html([p('Hello'), p('World')])).toEqual(`

Hello

World

`) +}) + +test('hiccough with options', () => { + const indentAndNewLine = { + eachIndent: ' ', + newLines: true + } + + expect(html(element('span', { class: 'foo' }, 'bar'), indentAndNewLine)).toEqual( + `bar` + ) + + expect(html(div({ class: 'foo' }, p('Hello'), p('World')), indentAndNewLine)).toEqual(`
+

Hello

+

World

+
`) + + expect(html(p('Hello', 'World', 'Again'), indentAndNewLine)).toEqual(`

+Hello +World +Again +

`) + + expect(html(table(tr(td('a'), td('b'))), indentAndNewLine)).toEqual(` + + + + +
ab
`) + + expect(html(table(tr(undefined, td('a'), undefined, td('b'), undefined)), indentAndNewLine)) + .toEqual(` + + + + +
ab
`) + + expect( + html( + table( + withOptions( + { + newLines: false, + indentFromParent: true + }, + tr(td('a'), td('b')) + ) + ), + indentAndNewLine + ) + ).toEqual(` + +
ab
`) + + expect( + html( + div(p('Hello', 'World', a('https://example.com', 'example')), p('Hello'), p('World')), + indentAndNewLine + ) + ).toEqual(`
+

+Hello +World + example +

+

Hello

+

World

+
`) + + expect( + html( + div( + withOptions( + { + newLines: false, + indentFromParent: true + }, + p('Hello', 'World', a('https://example.com', 'example')) + ), + p('Hello'), + p('World') + ), + indentAndNewLine + ) + ).toEqual(`
+

HelloWorldexample

+

Hello

+

World

+
`) +}) diff --git a/test/local/unit/web/hiccough/hiccoughElement.test.ts b/test/local/unit/web/hiccough/hiccoughElement.test.ts new file mode 100644 index 0000000..7918180 --- /dev/null +++ b/test/local/unit/web/hiccough/hiccoughElement.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from 'vitest' +import { element, withAttributes, withOptions } from '../../../../../src/app/web/hiccough/hiccoughElement' +import { p } from '../../../../../src/app/web/hiccough/hiccoughElements' + +test('element', () => { + expect(element('p')).toEqual({ + isElement: true, + name: 'p', + content: [] + }) + expect(element('p', 'hello')).toEqual({ + isElement: true, + name: 'p', + content: ['hello'] + }) + expect(element('p', 'hello', 'world')).toEqual({ + isElement: true, + name: 'p', + content: ['hello', 'world'] + }) +}) + +test('element with nesting', () => { + expect(element('div', element('p', 'hello'))).toEqual({ + isElement: true, + name: 'div', + content: [ + { + content: ['hello'], + isElement: true, + name: 'p' + } + ] + }) + expect(element('div', element('p', 'hello'), element('p', 'world'))).toEqual({ + isElement: true, + name: 'div', + content: [ + { + content: ['hello'], + isElement: true, + name: 'p' + }, + { + content: ['world'], + isElement: true, + name: 'p' + } + ] + }) + expect(element('div', element('p', 'hello'), 'new', element('p', 'world'))).toEqual({ + isElement: true, + name: 'div', + content: [ + { + content: ['hello'], + isElement: true, + name: 'p' + }, + 'new', + { + content: ['world'], + isElement: true, + name: 'p' + } + ] + }) +}) + +test('elementWithAttributes', () => { + expect(element('p', { id: 'myTag' })).toEqual({ + isElement: true, + name: 'p', + attributes: { id: 'myTag' }, + content: [] + }) + expect(element('p', { id: 'myTag' }, 'hello')).toEqual({ + isElement: true, + name: 'p', + attributes: { id: 'myTag' }, + content: ['hello'] + }) + expect(element('p', { id: 'myTag' }, 'hello', 'world')).toEqual({ + isElement: true, + name: 'p', + attributes: { id: 'myTag' }, + content: ['hello', 'world'] + }) + expect(element('p', { id: 'myTag' }, 'hello', element('p', 'world'))).toEqual({ + isElement: true, + name: 'p', + attributes: { id: 'myTag' }, + content: [ + 'hello', + { + content: ['world'], + isElement: true, + name: 'p' + } + ] + }) +}) + +test('with attributes', () => { + expect(withAttributes({ class: 'myClass', id: 'pOne' }, p('Hello World'))).toEqual({ + isElement: true, + name: 'p', + attributes: { + id: 'pOne', + class: 'myClass' + }, + content: ['Hello World'] + }) +}) + +test('with options', () => { + expect(withOptions({ newLines: true }, element('p'))).toEqual({ + isElement: true, + name: 'p', + content: [], + options: { + newLines: true + } + }) +}) diff --git a/test/local/unit/web/hiccough/hiccoughElements.test.ts b/test/local/unit/web/hiccough/hiccoughElements.test.ts new file mode 100644 index 0000000..ab9b766 --- /dev/null +++ b/test/local/unit/web/hiccough/hiccoughElements.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'vitest' +import { table, td, tr } from '../../../../../src/app/web/hiccough/hiccoughElements' + +test('tables', () => { + expect(table(tr(td('a'), td('b')))).toEqual({ + isElement: true, + name: 'table', + content: [ + { + isElement: true, + name: 'tr', + content: [ + { + isElement: true, + name: 'td', + content: ['a'] + }, + { + isElement: true, + name: 'td', + content: ['b'] + } + ] + } + ] + }) +}) diff --git a/test/remote/authorizedContent.test.ts b/test/remote/authorizedContent.test.ts index ef384b1..24e8bbf 100644 --- a/test/remote/authorizedContent.test.ts +++ b/test/remote/authorizedContent.test.ts @@ -33,25 +33,36 @@ test('authenticated app tests', async () => { }) expect(htmlTestPageResponse.status).toEqual(200) expect(await htmlTestPageResponse.text()).toEqual(` - - - - - - Cicada - - - - -
-

Cicada

-

Hello testuser / 1234

-
-

Manage Web Push Notifications

-

Back to home

-

Logout

-
- + + + + + + Cicada + + + + +
+

Cicada

+

+Hello +testuser + / +1234 +

+
+

+ Manage Web Push Notifications +

+

+ Back to home +

+

+ Logout +

+
+ `) const noTokenApiTestPageResponse = await fetch(`https://${webHostName}/apia/hello`)