Add issue creation

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-03-01 15:12:03 +01:00
parent d71f47a98a
commit d1168dc6ff
33 changed files with 848 additions and 327 deletions
+14
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@radicle/gray-matter": "4.1.0",
"@wooorm/starry-night": "^1.5.0",
"bs58": "^5.0.0",
"buffer": "^6.0.3",
"dompurify": "^3.0.0",
"hast-util-to-dom": "^3.1.1",
@@ -1100,6 +1101,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base-x": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz",
"integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw=="
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1150,6 +1156,14 @@
"node": ">=8"
}
},
"node_modules/bs58": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz",
"integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==",
"dependencies": {
"base-x": "^4.0.0"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+1
View File
@@ -43,6 +43,7 @@
"dependencies": {
"@radicle/gray-matter": "4.1.0",
"@wooorm/starry-night": "^1.5.0",
"bs58": "^5.0.0",
"buffer": "^6.0.3",
"dompurify": "^3.0.0",
"hast-util-to-dom": "^3.1.1",
+1
View File
@@ -5,6 +5,7 @@
:root {
--border-radius: 0.75rem;
--border-radius-tiny: 0.25rem;
--border-radius-small: 0.5rem;
--border-radius-round: 10rem;
+14 -4
View File
@@ -2,8 +2,8 @@
import type { Author } from "@app/lib/cobs";
import {
formatNodeId,
formatRadicleId,
formatSeedId,
formatTimestamp,
} from "@app/lib/utils";
@@ -19,6 +19,11 @@
color: var(--color-foreground);
padding: 0.125rem 0;
}
.id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.caption {
color: var(--color-foreground-5);
}
@@ -32,11 +37,16 @@
</style>
<span class="authorship txt-tiny">
<span class="highlight">
{window.HEARTWOOD ? formatSeedId(author.id) : formatRadicleId(author.id)}
<span class="id highlight layout-desktop">
{window.HEARTWOOD ? formatNodeId(author.id) : formatRadicleId(author.id)}
</span>
<span class="id highlight layout-mobile">
{window.HEARTWOOD
? formatNodeId(author.id).replace("did:key:", "")
: formatRadicleId(author.id)}
</span>
<span class="caption">&nbsp;{caption}&nbsp;</span>
<span class="txt-tiny date">
<span class="date">
{formatTimestamp(timestamp)}
</span>
</span>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let removeable: boolean = false;
export let key: number;
</script>
<style>
.chip {
display: inline-flex;
justify-content: center;
align-items: center;
color: var(--color-secondary);
}
.section {
display: flex;
align-items: center;
padding: 0.2rem 0.5rem;
}
.text {
background-color: var(--color-secondary-3);
border-radius: var(--border-radius);
}
.close {
color: var(--color-secondary);
border: none;
border-bottom-right-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
background-color: var(--color-secondary-2);
line-height: 1.5;
cursor: pointer;
}
.close:hover {
background-color: var(--color-secondary-5);
color: var(--color-foreground);
}
.removeable {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
</style>
<div class="chip">
<span class="section text" class:removeable><slot name="text" /></span>
{#if removeable}
<button class="section close" on:click={() => dispatch("remove", key)}>
✕
</button>
{/if}
</div>
+6 -36
View File
@@ -1,36 +1,17 @@
<script lang="ts">
import type { Blob } from "@app/lib/project";
import type { MaybeBlob } from "@app/lib/project";
import type { Comment, Thread } from "@app/lib/issue";
import Authorship from "@app/components/Authorship.svelte";
import Avatar from "@app/components/Comment/Avatar.svelte";
import Markdown from "@app/components/Markdown.svelte";
import ReactionSelector from "./Comment/ReactionSelector.svelte";
import Reactions from "@app/components/Comment/Reactions.svelte";
export let comment: Comment | Thread;
export let caption = "left a comment";
export let getImage: (path: string) => Promise<Blob>;
const templateComment = `<!--
Please enter a comment message for your patch update. Leaving this
blank is also okay.
-->`;
export let caption = "commented";
export let getImage: (path: string) => Promise<MaybeBlob>;
$: source = comment.author.id;
$: title = comment.author.profile
? comment.author.profile.name
: comment.author.id;
const selectReaction = (event: { detail: string }) => {
// TODO: Once we allow adding reactions through the http-api, we should call it here.
console.debug(event.detail);
};
const incrementReaction = (event: { detail: string }) => {
// TODO: Once we allow increment reactions through the http-api, we should call it here.
console.debug(event.detail);
};
$: title = comment.author.id;
</script>
<style>
@@ -57,10 +38,7 @@ blank is also okay.
.card-body {
font-size: var(--font-size-small);
padding: 0rem 1rem 1rem 1rem;
}
.reactions {
display: flex;
margin-top: 1rem;
word-break: break-all;
}
</style>
@@ -74,21 +52,13 @@ blank is also okay.
{caption}
author={comment.author}
timestamp={comment.timestamp} />
<ReactionSelector on:select={selectReaction} />
</div>
<div class="card-body">
{#if comment.body.trim() === "" || comment.body.trim() === templateComment}
{#if comment.body.trim() === ""}
<span class="txt-missing">No description.</span>
{:else}
<Markdown content={comment.body} {getImage} />
{/if}
{#if comment.reactions.length > 0}
<div class="reactions">
<Reactions
reactions={comment.reactions}
on:click={incrementReaction} />
</div>
{/if}
</div>
</div>
</div>
+1
View File
@@ -13,6 +13,7 @@
}
function createContainer(source: string) {
source = source.replace("did:key:", "");
const seed = source.toLowerCase();
const avatar = createIcon({
seed,
@@ -1,63 +0,0 @@
<!-- TODO: Once we are able to add reactions, we should allow people to interact with the reaction handler -->
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import Icon from "@app/components/Icon.svelte";
import { config } from "@app/lib/config";
const showReactions = false;
const dispatch = createEventDispatcher<{ select: string }>();
</script>
<style>
.selector {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
position: relative;
color: var(--color-foreground-5);
border-radius: var(--border-radius);
height: 1rem;
width: 1rem;
cursor: not-allowed;
}
.selector > div {
display: flex;
}
.modal {
position: absolute;
left: 1.5rem;
background-color: var(--color-foreground-1);
border-radius: var(--border-radius);
}
.modal > div {
padding: 0.5rem;
}
.modal > div:last-child {
border-top-right-radius: var(--border-radius-small);
border-bottom-right-radius: var(--border-radius-small);
}
.modal > div:first-child {
border-top-left-radius: var(--border-radius-small);
border-bottom-left-radius: var(--border-radius-small);
}
.modal > div:hover {
background-color: var(--color-foreground-2);
}
</style>
<div class="selector">
<Icon name="ellipsis" />
{#if showReactions}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal">
{#each config.reactions as reaction}
<div on:click={() => dispatch("select", reaction)}>
{reaction}
</div>
{/each}
</div>
{/if}
</div>
-30
View File
@@ -1,30 +0,0 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import Button from "@app/components/Button.svelte";
export let reactions: Record<string, number> | null = null;
const dispatch = createEventDispatcher<{ click: string }>();
</script>
<style>
.reactions {
display: flex;
gap: 0.5rem;
}
</style>
{#if reactions}
<div class="reactions">
{#each Object.entries(reactions) as [reaction, count]}
<!-- TODO: Remove the disabled attribute once we are able to increment reactions -->
<Button
variant="foreground"
size="tiny"
on:click={() => dispatch("click", reaction)}>
{reaction}
{count}
</Button>
{/each}
</div>
{/if}
+16
View File
@@ -12,6 +12,7 @@
| "ellipsis"
| "fork"
| "gear"
| "chat"
| "magnifying-glass"
| "moon"
| "sun"
@@ -70,6 +71,21 @@
d="M14 7L17 10V16H10V7H14ZM13
8H11V15H16V11H13V8ZM14 8.41421V10H15.5858L14 8.41421ZM8
10H9V17H14V18H8V10Z" />
{:else if name === "chat"}
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5 6.5C4.72386 6.5 4.5
6.72386 4.5 7V16C4.5 16.2761 4.72386 16.5 5 16.5H14.5147C16.2386 16.5 17.8919
17.1848 19.1109 18.4038L19.5 18.7929V7C19.5 6.72386 19.2761 6.5 19 6.5H5ZM3.5
7C3.5 6.17157 4.17157 5.5 5 5.5H19C19.8284 5.5 20.5 6.17157 20.5 7V20C20.5
20.2022 20.3782 20.3845 20.1913 20.4619C20.0045 20.5393 19.7894 20.4966
19.6464 20.3536L18.4038 19.1109C17.3724 18.0795 15.9734 17.5 14.5147
17.5H5C4.17157 17.5 3.5 16.8284 3.5 16V7ZM7.5 10C7.5 9.72386 7.72386 9.5 8
9.5H16C16.2761 9.5 16.5 9.72386 16.5 10C16.5 10.2761 16.2761 10.5 16
10.5H8C7.72386 10.5 7.5 10.2761 7.5 10ZM7.5 13C7.5 12.7239 7.72386 12.5 8
12.5H13C13.2761 12.5 13.5 12.7239 13.5 13C13.5 13.2761 13.2761 13.5 13
13.5H8C7.72386 13.5 7.5 13.2761 7.5 13Z" />
{:else if name === "ellipsis"}
<path
d="M7 12a2 2 0 1 1-4.001-.001A2 2 0 0 1 7 12zm12-2a2 2 0 1 0 .001
+12 -9
View File
@@ -8,7 +8,7 @@
export let placeholder: string | undefined = undefined;
export let value: string | undefined = undefined;
export let variant: "regular" | "dashed" = "regular";
export let variant: "regular" | "form" = "regular";
export let autofocus: boolean = false;
export let disabled: boolean = false;
@@ -76,14 +76,17 @@
font-size: var(--font-size-regular);
padding: 1rem 1.5rem;
}
.dashed {
border: 1px dashed var(--color-secondary);
font-size: var(--font-size-small);
padding: 0.5rem 1.25rem;
.form {
background: var(--color-foreground-1);
border-radius: var(--border-radius-small);
border: 1px solid var(--color-foreground-1);
}
.dashed:hover,
.dashed:focus {
background: var(--color-background-1);
.form::placeholder {
color: var(--color-foreground-5);
}
.form:focus,
.form:hover {
border: 1px solid var(--color-foreground-4);
}
.left-container {
color: var(--color-secondary);
@@ -141,7 +144,7 @@
<input
class:regular={variant === "regular"}
class:dashed={variant === "dashed"}
class:form={variant === "form"}
style:padding-left={leftContainerWidth
? `${leftContainerWidth}px`
: "auto"}
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
export let resizable: boolean = false;
export let value: string | number | undefined = undefined;
export let placeholder: string | undefined = undefined;
let textareaElement: HTMLTextAreaElement | undefined = undefined;
// We either auto-grow the text area, or allow the user to resize it. These
// options are mutually exclusive because a user resized textarea would
// automatically shrink upon text input otherwise.
$: if (textareaElement && !resizable) {
// React to changes to the textarea content.
value;
// Reset height to 0px on every value change so that the textarea
// immediately shrinks when all text is deleted.
textareaElement.style.height = `0px`;
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
}
const dispatch = createEventDispatcher<{
submit: never;
}>();
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
dispatch("submit");
}
if (event.key === "Escape") {
textareaElement?.blur();
}
}
</script>
<style>
textarea {
background-color: var(--color-foreground-1);
border: 1px solid var(--color-foreground-1);
color: var(--color-foreground);
border-radius: 0.5rem;
font-family: inherit;
height: 5rem;
padding: 1rem;
width: 100%;
min-height: 2.5rem;
resize: none;
overflow: hidden;
}
.resizable {
resize: vertical;
overflow: scroll;
}
textarea::-webkit-scrollbar {
display: none;
}
textarea::-webkit-scrollbar-corner {
background-color: transparent;
}
textarea::-webkit-resizer {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAMAAAAolt3jAAAAAXNSR0IB2cksfwAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAD9QTFRFAAAAZWZmZmZmZmVmZWVmwsLBwsLCZ2ZmwsPCZmdlZWZnwcLBZmZkYGJjw8LDwsPBZmZnZWZkZ2ZkwMDBWFtcNbXb2AAAABV0Uk5TAP///////////////////////1H/YDRrSAAAAFBJREFUeJxVjUESgCAMA2mqAoqK6P/f6kzjIXIos5NumpI8g5LbpJnNQvDl52mWUYTquqnXwstshpHaTi+o+hHXccoKmHVW9yvIxv218ntivmOYAWpLfqaRAAAAAElFTkSuQmCC);
background-size: 7px;
background-repeat: no-repeat;
background-position: bottom 1px right 1px;
}
textarea::placeholder {
color: var(--color-foreground-5);
}
textarea:focus,
textarea:hover {
border: 1px solid var(--color-foreground-4);
}
</style>
<textarea
bind:this={textareaElement}
bind:value
class:resizable
{placeholder}
on:change
on:click
on:input
on:keydown|stopPropagation={handleKeydown}
on:keypress />
+3 -2
View File
@@ -1,4 +1,5 @@
import { defaultSeedPort } from "@app/lib/seed";
import { isLocal } from "@app/lib/utils";
export interface Host {
host: string;
@@ -19,7 +20,7 @@ export class Request {
this.base = api.host;
}
this.path = path.startsWith("/") ? path.slice(1) : path;
this.protocol = api.host === "0.0.0.0" ? "http://" : "https://";
this.protocol = isLocal(api.host) ? "http://" : "https://";
}
async get(
@@ -87,7 +88,7 @@ export class Request {
const query: Record<string, string> = {};
for (const [key, val] of Object.entries(params)) {
if (val !== undefined && val !== null) {
query[key] = val.toString();
query[key] = val;
}
}
+11 -14
View File
@@ -1,26 +1,23 @@
import type { PeerId } from "@app/lib/project";
export type Thread = Comment<Comment[]>;
export interface Comment<R = null> {
author: Author;
body: string;
reactions: Record<string, number>;
timestamp: number;
replies: R; // TODO: Remove for Heartwood migration
replyTo: R;
}
export interface Author {
peer: PeerId;
id: string;
profile: {
name: string;
ens: {
name: string;
} | null;
} | null;
}
export interface PeerIdentity {
id: string;
name: string;
ens: {
name: string;
} | null;
}
export interface PeerInfo {
id: PeerId;
id: string;
person?: PeerIdentity;
delegate: boolean;
}
+28 -5
View File
@@ -1,5 +1,7 @@
import { type Host, Request } from "@app/lib/api";
import type { Author } from "@app/lib/cobs";
import type { Host } from "@app/lib/api";
import { Request } from "@app/lib/api";
export interface TimelineItem {
person: Author;
@@ -14,7 +16,8 @@ export interface IIssue {
state: State;
comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
discussion: Thread[];
tags: Tag[];
tags: string[];
assignees: string[];
timestamp: number;
}
@@ -38,8 +41,6 @@ export interface Comment<R = null> {
export type Thread = Comment<Comment[]>;
export type Tag = string;
export function groupIssues(issues: Issue[]): {
open: Issue[];
closed: Issue[];
@@ -60,7 +61,8 @@ export class Issue {
state: State;
comment: Comment; // TODO: Remove this after we have migrated to Heartwood.
discussion: Thread[];
tags: Tag[];
tags: string[];
assignees: string[];
timestamp: number;
constructor(issue: IIssue) {
@@ -71,6 +73,7 @@ export class Issue {
this.comment = issue.comment; // TODO: Remove this after we have migrated to Heartwood.
this.discussion = issue.discussion;
this.tags = issue.tags;
this.assignees = issue.assignees;
if (window.HEARTWOOD) {
this.timestamp = issue.discussion[0].timestamp;
} else {
@@ -92,6 +95,26 @@ export class Issue {
}
}
static async createIssue(
project: string,
title: string,
description: string,
assignees: string[],
tags: string[],
host: Host,
authToken: string,
): Promise<void> {
await new Request(`projects/${project}/issues`, host).post(
{
title,
description,
assignees,
tags,
},
{ Authorization: `Bearer ${authToken}` },
);
}
static async getIssues(id: string, host: Host): Promise<Issue[]> {
const response: IIssue[] = await new Request(
`projects/${id}/issues`,
+24 -12
View File
@@ -41,10 +41,17 @@ export interface ProjectInfo {
name: string;
description: string;
defaultBranch: string;
delegates: Delegate[]; // TODO: Remove this after we have migrated to Heartwood.
delegates: string[];
remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
patches?: number;
issues?: number;
patches: {
proposed: number;
draft: number;
archived: number;
};
issues: {
open: number;
closed: number;
};
}
export interface Tree {
@@ -153,14 +160,20 @@ export class Project implements ProjectInfo {
name: string;
description: string;
defaultBranch: string;
delegates: Delegate[]; // TODO: Remove this after we have migrated to Heartwood.
delegates: string[];
remotes: PeerId[]; // TODO: Remove this after we have migrated to Heartwood.
seed: Seed;
peers: Peer[];
branches: Branches;
// At the moment we still have seed nodes which won't return neither patches or issues
patches?: number;
issues?: number;
patches: {
proposed: number;
draft: number;
archived: number;
};
issues: {
open: number;
closed: number;
};
constructor(
id: string,
@@ -174,7 +187,7 @@ export class Project implements ProjectInfo {
this.name = info.name;
this.description = info.description;
this.defaultBranch = info.defaultBranch;
this.delegates = info.delegates; // TODO: Remove this after we have migrated to Heartwood.
this.delegates = info.delegates;
this.remotes = info.remotes; // TODO: Remove this after we have migrated to Heartwood.
this.seed = seed;
this.peers = peers;
@@ -407,11 +420,10 @@ export class Project implements ProjectInfo {
? seedHost.split(":")
: [seedHost, defaultSeedPort];
const seed = await Seed.lookup(host, Number(port));
if (!seed) {
const seed = await Seed.lookup(host, Number(port)).catch(() => {
throw new Error("Couldn't load project");
}
});
if (!seed?.valid) {
throw new Error("Couldn't load project: invalid seed");
}
+18 -3
View File
@@ -256,6 +256,11 @@ export function routeToPath(route: Route) {
return `${hostPrefix}/${route.params.id}${peer}/patches${suffix}`;
} else if (route.params.view.resource === "patch") {
return `${hostPrefix}/${route.params.id}${peer}/patches/${route.params.view.params.patch}`;
} else if (
route.params.view.resource === "issues" &&
route.params.view.params?.view.resource === "new"
) {
return `${hostPrefix}/${route.params.id}${peer}/issues/new${suffix}`;
} else if (route.params.view.resource === "issues") {
return `${hostPrefix}/${route.params.id}${peer}/issues${suffix}`;
} else if (route.params.view.resource === "issue") {
@@ -344,10 +349,20 @@ function resolveProjectRoute(
};
}
} else if (content === "issues") {
const issue = segments.shift();
if (issue) {
const issueOrAction = segments.shift();
if (issueOrAction === "new") {
return {
view: { resource: "issue", params: { issue } },
view: { resource: "issues", params: { view: { resource: "new" } } },
id,
seed,
peer,
search: sanitizeQueryString(url.search),
path: undefined,
revision: undefined,
};
} else if (issueOrAction) {
return {
view: { resource: "issue", params: { issue: issueOrAction } },
id,
seed,
peer,
+6 -1
View File
@@ -15,7 +15,12 @@ export interface ProjectsParams {
| { resource: "commits" }
| { resource: "history" }
| { resource: "issue"; params: { issue: string } }
| { resource: "issues" }
| {
resource: "issues";
params?: {
view: { resource: "new" };
};
}
| { resource: "patch"; params: { patch: string } }
| { resource: "patches" };
seed: string;
+1 -1
View File
@@ -1,6 +1,6 @@
import { derived, get, writable } from "svelte/store";
interface Session {
export interface Session {
id: string;
publicKey: string;
}
+38 -2
View File
@@ -1,4 +1,5 @@
import md5 from "md5";
import bs58 from "bs58";
import twemojiModule from "twemoji";
import { assert } from "@app/lib/error";
@@ -56,6 +57,41 @@ export function formatRadicleId(id: string): string {
}
}
// Parses a NID into an object of prefix and pubkey,
// since prefix can be undefined
export function parseNid(
nid: string,
): { prefix: string; pubkey: string } | undefined {
const match = /^(did:key:)?(z[a-zA-Z0-9]+)$/.exec(nid);
if (match) {
const hex = bs58.decode(match[2].substring(1));
// This checks also that the first 2 bytes are equal
// to the ed25519 public key type used.
if (!(hex.byteLength === 34 && hex[0] === 0xed && hex[1] === 1)) {
return undefined;
}
return { prefix: match[1] || "did:key:", pubkey: match[2] };
}
return undefined;
}
// Format a Node Identifier (NID), also represents users or peers
export function formatNodeId(nid: string): string {
const parsedNid = parseNid(nid);
if (parsedNid) {
const { prefix, pubkey } = parsedNid;
const formattedPubKey =
pubkey.substring(0, 6) +
"…" +
pubkey.substring(pubkey.length - 6, pubkey.length);
return `${prefix}${formattedPubKey}`;
}
return nid;
}
export function formatCommit(oid: string): string {
return oid.substring(0, 7);
}
@@ -158,7 +194,7 @@ export const formatTimestamp = (
// Check whether the input is a Radicle ID.
export function isRadicleId(input: string): boolean {
if (window.HEARTWOOD) {
return /^rad:[a-zA-Z0-9]+$/.test(input);
return /^(did:key:)?[a-zA-Z0-9]+$/.test(input);
} else {
return /^rad:[a-z]+:[a-zA-Z0-9]+$/.test(input);
}
@@ -226,7 +262,7 @@ export function isMarkdownPath(path: string): boolean {
export function isDomain(input: string): boolean {
return (
(/^[a-z][a-z0-9.-]+$/.test(input) && /\.[a-z]+$/.test(input)) ||
(!import.meta.env.PROD && /^0.0.0.0$/.test(input))
(!import.meta.env.PROD && isLocal(input))
);
}
+16 -22
View File
@@ -123,28 +123,22 @@
<span class="txt-bold">{tree.stats.commits}</span>
commit(s)
</HeaderToggleLabel>
{#if project.issues}
<HeaderToggleLabel
ariaLabel="Issue count"
active={activeRoute.params.view.resource === "issues"}
disabled={project.issues === 0}
clickable={project.issues > 0}
on:click={() => toggleContent("issues", false)}>
<span class="txt-bold">{project.issues}</span>
issue(s)
</HeaderToggleLabel>
{/if}
{#if project.patches}
<HeaderToggleLabel
ariaLabel="Patch count"
clickable={project.patches > 0}
active={activeRoute.params.view.resource === "patches"}
disabled={project.patches === 0}
on:click={() => toggleContent("patches", false)}>
<span class="txt-bold">{project.patches}</span>
patch(es)
</HeaderToggleLabel>
{/if}
<HeaderToggleLabel
ariaLabel="Issue count"
active={activeRoute.params.view.resource === "issues"}
clickable
on:click={() => toggleContent("issues", false)}>
<span class="txt-bold">{project.issues.open ?? 0}</span>
issue(s)
</HeaderToggleLabel>
<HeaderToggleLabel
ariaLabel="Patch count"
clickable
active={activeRoute.params.view.resource === "patches"}
on:click={() => toggleContent("patches", false)}>
<span class="txt-bold">{project.patches.proposed ?? 0}</span>
patch(es)
</HeaderToggleLabel>
<HeaderToggleLabel ariaLabel="Contributor count">
<span class="txt-bold">{tree.stats.contributors}</span>
contributor(s)
+51 -28
View File
@@ -3,8 +3,10 @@
import type { Issue } from "@app/lib/issue";
import Authorship from "@app/components/Authorship.svelte";
import Avatar from "@app/components/Comment/Avatar.svelte";
import Chip from "@app/components/Chip.svelte";
import Comment from "@app/components/Comment.svelte";
import { canonicalize, capitalize } from "@app/lib/utils";
import { formatNodeId, canonicalize, capitalize } from "@app/lib/utils";
import { formatObjectId } from "@app/lib/cobs";
export let issue: Issue;
@@ -51,41 +53,35 @@
color: var(--color-foreground-5);
}
.metadata-section-body {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.metadata-section-empty {
color: var(--color-foreground-6);
}
.label {
border-radius: var(--border-radius);
color: var(--color-tertiary);
background-color: var(--color-tertiary-2);
padding: 0.25rem 0.75rem;
margin-right: 0.5rem;
font-size: var(--font-size-small);
line-height: 1.6;
}
.summary {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.summary-left {
display: flex;
align-items: center;
}
.summary-title {
display: flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.id {
flex: 1 0 auto;
font-size: var(--font-size-tiny);
margin-left: 0.75rem;
color: var(--color-foreground-5);
}
.summary-state {
margin-left: 2rem;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
@@ -100,25 +96,37 @@
.replies {
margin-left: 2rem;
}
.assignee {
display: flex;
flex-direction: row;
align-items: center;
}
.tag {
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 960px) {
.issue {
padding-left: 2rem;
}
.summary-state {
margin-left: 0.5rem;
}
}
</style>
<div class="issue">
<header>
<div class="summary">
<div class="summary-left">
<span class="summary-title txt-medium">
{issue.title}
</span>
<span class="txt-monospace id layout-desktop">{issue.id}</span>
<span class="txt-monospace id layout-mobile">
{formatObjectId(issue.id)}
</span>
<div class="summary-title txt-medium">
{issue.title}
</div>
<div class="txt-monospace id layout-desktop">{issue.id}</div>
<div class="txt-monospace id layout-mobile">
{formatObjectId(issue.id)}
</div>
<div
class="summary-state"
@@ -130,7 +138,7 @@
<Authorship
author={issue.author}
timestamp={issue.timestamp}
caption="opened on" />
caption="opened" />
</header>
<main>
<div class="comments">
@@ -153,14 +161,29 @@
</div>
<div class="metadata layout-desktop">
<div class="metadata-section">
<div class="metadata-section-header">Assignees</div>
<div class="metadata-section-body">
{#if issue.assignees?.length}
{#each issue.assignees as assignee, key}
<Chip {key}>
<div slot="text" class="assignee">
<Avatar inline source={assignee} title={assignee} />
<span>{formatNodeId(assignee)}</span>
</div>
</Chip>
{/each}
{:else}
<div class="metadata-section-empty">No assignees</div>
{/if}
</div>
<div class="metadata-section-header">Tags</div>
<div class="metadata-section-body">
{#if issue.tags?.length}
{#each issue.tags as tag}
<span class="label">{tag}</span>
{#each issue.tags as tag, key}
<Chip {key}><span class="tag" slot="text">{tag}</span></Chip>
{/each}
{:else}
<div class="metadata-section-empty">No tags.</div>
<div class="metadata-section-empty">No tags</div>
{/if}
</div>
</div>
+25 -43
View File
@@ -2,9 +2,8 @@
import type { Issue } from "@app/lib/issue";
import { formatObjectId } from "@app/lib/cobs";
import { twemoji } from "@app/lib/utils";
import Authorship from "@app/components/Authorship.svelte";
import Icon from "@app/components/Icon.svelte";
export let issue: Issue;
@@ -13,11 +12,10 @@
<style>
.issue-teaser {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-foreground-1);
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) 4rem;
padding: 0.75rem 0;
background-color: var(--color-foreground-1);
}
.issue-teaser:hover {
background-color: var(--color-foreground-2);
@@ -29,27 +27,33 @@
font-family: var(--font-family-monospace);
margin-left: 0.5rem;
}
.column-left {
flex: min-content;
}
.column-right {
.summary {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-right: 1rem;
flex-basis: 5rem;
padding-right: 2rem;
}
.issue-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-count {
color: var(--color-foreground-4);
font-weight: var(--font-weight-bold);
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
color: var(--color-foreground-5);
}
.comment-count .emoji {
margin-right: 0.25rem;
.column-right {
align-self: center;
justify-self: center;
}
.state {
padding: 0 1rem;
justify-self: center;
align-self: center;
}
.state-icon {
width: 0.5rem;
@@ -62,27 +66,6 @@
.closed {
background-color: var(--color-negative);
}
.summary {
display: flex;
flex-direction: row;
align-items: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
@media (max-width: 720px) {
.column-left {
overflow: hidden;
}
.summary {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 1rem;
}
}
</style>
<div class="issue-teaser">
@@ -94,8 +77,7 @@
</div>
<div class="column-left">
<div class="summary">
<!-- TODO: Truncation not working on overflow -->
{issue.title}
<span class="issue-title">{issue.title}</span>
<span class="issue-id">{formatObjectId(issue.id)}</span>
</div>
<Authorship
@@ -106,7 +88,7 @@
{#if commentCount > 0}
<div class="column-right">
<div class="comment-count">
<span class="txt-tiny emoji" use:twemoji>💬</span>
<Icon name="chat" />
<span>{commentCount}</span>
</div>
</div>
+279
View File
@@ -0,0 +1,279 @@
<script lang="ts" strictEvents>
import type { MaybeBlob, Project } from "@app/lib/project";
import type { Session } from "@app/lib/session";
import * as modal from "@app/lib/modal";
import AuthenticationErrorModal from "@app/views/session/AuthenticationErrorModal.svelte";
import Avatar from "@app/components/Comment/Avatar.svelte";
import Button from "@app/components/Button.svelte";
import Chip from "@app/components/Chip.svelte";
import Comment from "@app/components/Comment.svelte";
import TextInput from "@app/components/TextInput.svelte";
import Textarea from "@app/components/Textarea.svelte";
import { Issue } from "@app/lib/issue";
import { canonicalize, formatNodeId, parseNid } from "@app/lib/utils";
import { createEventDispatcher } from "svelte";
export let session: Session;
export let project: Project;
const dispatch = createEventDispatcher<{ create: never }>();
// Get an image blob based on a relative path.
const getImage = async (imagePath: string): Promise<MaybeBlob> => {
const finalPath = canonicalize(imagePath, "/"); // We only use the root path in issues.
const commit = project.branches[project.defaultBranch]; // We suppose that all issues are only looked at on HEAD of the default branch.
return project.getBlob(commit, finalPath);
};
let preview: boolean = false;
function handleAddAssignee() {
const nid = parseNid(assignee);
if (nid) {
if (assignees.includes(nid.pubkey)) {
assigneeCaption = "This user is already assigned";
return;
}
assignees.push(nid.pubkey);
assignees = assignees;
assignee = "";
} else {
assigneeCaption = "This user is not valid";
}
}
function handleAddTag() {
if (tags.includes(tag)) {
tagCaption = "This tag already exists";
return;
}
tags.push(tag);
tags = tags;
tag = "";
}
let issueTitle = "";
let issueText = "";
let assignees: string[] = [];
let assignee: string = "";
let assigneeCaption: string | undefined = undefined;
let tags: string[] = [];
let tag: string = "";
let tagCaption: string | undefined = undefined;
async function createIssue() {
try {
await Issue.createIssue(
project.id,
issueTitle,
issueText,
assignees,
tags,
project.seed.addr,
session.id,
);
dispatch("create");
} catch {
modal.show({
component: AuthenticationErrorModal,
props: {
title: "Authentication failed",
subtitle: [
"Could not create the issue. Make sure you're still logged in.",
],
},
});
}
}
</script>
<style>
main {
padding: 0 2rem 0 8rem;
}
.form {
display: grid;
grid-template-columns: minmax(0, 2fr) 1fr;
margin-bottom: 1rem;
}
.side-bar {
border-left: 1px solid var(--color-foreground-4);
padding-left: 1rem;
display: flex;
flex-direction: column;
flex: 1;
}
.actions {
text-align: right;
display: flex;
justify-content: flex-end;
flex-direction: row;
gap: 1rem;
margin-top: 1rem;
}
.editor {
flex: 2;
padding-right: 1rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 3rem;
}
.section:not(:first-child) {
border-top: 1px solid var(--color-foreground-4);
padding-top: 1rem;
}
.summary {
padding: 1rem;
background: var(--color-foreground-1);
border-radius: var(--border-radius);
margin-bottom: 2rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chips {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
}
.tags {
flex-direction: row;
}
.tag {
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assignee {
display: flex;
flex-direction: row;
align-items: center;
}
@media (max-width: 960px) {
main {
padding-left: 2rem;
}
}
@media (max-width: 720px) {
.form {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<main>
<div class="form">
<div class="editor">
{#if preview}
<div class="summary txt-medium" class:txt-missing={preview}>
{#if issueTitle.trim() === ""}
No title
{:else}
{issueTitle}
{/if}
</div>
<div class="comments">
<Comment
comment={{
author: { id: session.publicKey },
body: issueText,
reactions: {},
replies: null,
replyTo: null,
timestamp: Date.now(),
}}
{getImage} />
</div>
{:else}
<Textarea bind:value={issueTitle} placeholder="Title" />
<Textarea
bind:value={issueText}
resizable
placeholder="Leave a comment" />
{/if}
<div class="actions">
<Button
size="small"
variant="text"
on:click={() => (preview = !preview)}>
{#if preview}
Resume editing
{:else}
Preview
{/if}
</Button>
<Button
disabled={!issueTitle}
size="small"
variant="secondary"
on:click={createIssue}>
Submit
</Button>
</div>
</div>
<div class="side-bar layout-desktop">
<div class="section txt-small">
<span class="txt-bold">Assignees</span>
<div class="chips">
{#each assignees as assignee, key}
<Chip
{key}
removeable
on:remove={e => {
assignees.splice(e.detail, 1);
assignees = assignees;
}}>
<div slot="text" class="assignee">
<Avatar inline source={assignee} title={assignee} />
<span>{formatNodeId(assignee)}</span>
</div>
</Chip>
{/each}
</div>
<div>
<TextInput
bind:value={assignee}
variant="form"
on:submit={handleAddAssignee}
on:input={() => (assigneeCaption = undefined)}
placeholder="Assign this issue"
validationMessage={assigneeCaption}
valid={Boolean(parseNid(assignee))} />
</div>
</div>
<div class="section txt-small">
<span class="txt-bold">Tags</span>
<div class="chips tags">
{#each tags as tag, key}
<Chip
{key}
removeable
on:remove={e => {
tags.splice(e.detail, 1);
tags = tags;
}}>
<span class="tag" slot="text">{tag}</span>
</Chip>
{/each}
</div>
<div>
<TextInput
bind:value={tag}
variant="form"
on:input={() => (tagCaption = undefined)}
on:submit={handleAddTag}
placeholder="Tag this issue"
validationMessage={tagCaption}
valid={tag !== ""} />
</div>
</div>
</div>
</div>
</main>
+36 -11
View File
@@ -3,19 +3,23 @@
</script>
<script lang="ts">
import type { Project } from "@app/lib/project";
import type { Issue } from "@app/lib/issue";
import type { Tab } from "@app/components/TabBar.svelte";
import * as router from "@app/lib/router";
import { capitalize } from "@app/lib/utils";
import { groupIssues } from "@app/lib/issue";
import * as router from "@app/lib/router";
import { sessionStore } from "@app/lib/session";
import HeaderToggleLabel from "@app/views/projects/HeaderToggleLabel.svelte";
import IssueTeaser from "@app/views/projects/Issue/IssueTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import TabBar from "@app/components/TabBar.svelte";
export let issues: Issue[];
export let state: State;
export let project: Project;
let options: Tab<State>[];
const { open, closed } = groupIssues(issues);
@@ -28,11 +32,11 @@
$: options = [
{
value: "open",
count: open.length,
count: project.issues.open,
},
{
value: "closed",
count: closed.length,
count: project.issues.closed,
},
];
</script>
@@ -49,6 +53,12 @@
.teaser:not(:last-child) {
border-bottom: 1px dashed var(--color-background);
}
.section-header {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
}
@media (max-width: 960px) {
.issues {
@@ -58,14 +68,29 @@
</style>
<div class="issues">
<div style="margin-bottom: 1rem;">
<TabBar
{options}
on:select={e =>
<div class="section-header">
<div style="margin-bottom: 1rem;">
<TabBar
{options}
on:select={e =>
router.updateProjectRoute({
search: `state=${e.detail}`,
})}
active={state} />
</div>
<HeaderToggleLabel
disabled={!$sessionStore}
on:click={() => {
router.updateProjectRoute({
search: `state=${e.detail}`,
})}
active={state} />
view: {
resource: "issues",
params: { view: { resource: "new" } },
},
});
}}
clickable>
New issue
</HeaderToggleLabel>
</div>
{#if filteredIssues.length}
@@ -87,7 +112,7 @@
{/each}
</div>
{:else}
<Placeholder emoji="🍣">
<Placeholder emoji="🍂">
<div slot="title">{capitalize(state)} issues</div>
<div slot="body">No issues matched the current filter</div>
</Placeholder>
@@ -41,15 +41,11 @@
{#if element.type === TimelineType.Merge && element.inner.peer.person}
<div class="element">
<Authorship
author={{
peer: element.inner.peer.id,
id: element.inner.peer.person.id,
profile: element.inner.peer.person,
}}
author={{ id: element.inner.peer.person.id }}
caption={`merged to ${formatSeedId(element.inner.peer.id)}`}
timestamp={element.timestamp} />
</div>
{:else if element.type === TimelineType.Review && element.inner.author.profile?.ens?.name}
{:else if element.type === TimelineType.Review && element.inner.author.id}
<div class="margin-left">
<Review review={element.inner} {getImage} />
</div>
+6 -4
View File
@@ -5,6 +5,7 @@
<script lang="ts">
import type { Patch } from "@app/lib/patch";
import type { Tab } from "@app/components/TabBar.svelte";
import type { Project } from "@app/lib/project";
import PatchTeaser from "./Patch/PatchTeaser.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
@@ -16,6 +17,7 @@
export let state: State;
export let patches: Patch[];
export let project: Project;
let options: Tab<State>[];
const sortedPatches = groupPatches(patches);
@@ -24,15 +26,15 @@
$: options = [
{
value: "proposed",
count: sortedPatches.proposed.length,
count: project.patches.proposed,
},
{
value: "draft",
count: sortedPatches.draft.length,
count: project.patches.draft,
},
{
value: "archived",
count: sortedPatches.archived.length,
count: project.patches.archived,
},
];
</script>
@@ -84,7 +86,7 @@
{/each}
</div>
{:else}
<Placeholder emoji="🍖">
<Placeholder emoji="🍂">
<div slot="title">{capitalize(state)} patches</div>
<div slot="body">No patches matched the current filter</div>
</Placeholder>
+8 -7
View File
@@ -1,11 +1,12 @@
<script lang="ts" strictEvents>
import { createEventDispatcher, onMount } from "svelte";
import Icon from "@app/components/Icon.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import { formatSeedId } from "@app/lib/utils";
import type { Peer } from "@app/lib/project";
import Floating from "@app/components/Floating.svelte";
import Badge from "@app/components/Badge.svelte";
import Dropdown from "@app/components/Dropdown.svelte";
import Floating from "@app/components/Floating.svelte";
import Icon from "@app/components/Icon.svelte";
import { createEventDispatcher, onMount } from "svelte";
import { formatNodeId } from "@app/lib/utils";
export let peer: string | null = null;
export let peers: Peer[];
@@ -90,7 +91,7 @@
<Icon name="fork" />
{#if meta}
<span class="peer-id">
{meta.person?.name ?? formatSeedId(meta.id)}
{meta.person?.name ?? formatNodeId(meta.id)}
</span>
{#if meta.delegate}
<Badge variant="primary">delegate</Badge>
@@ -98,7 +99,7 @@
<!-- If the delegate metadata is not found -->
{:else if peer}
<span class="peer-id">
{formatSeedId(peer)}
{formatNodeId(peer)}
</span>
{/if}
</div>
+45 -11
View File
@@ -9,19 +9,21 @@
import * as router from "@app/lib/router";
import Loading from "@app/components/Loading.svelte";
import NotFound from "@app/components/NotFound.svelte";
import { formatSeedId, unreachable } from "@app/lib/utils";
import { formatNodeId, unreachable } from "@app/lib/utils";
import { sessionStore } from "@app/lib/session";
import Header from "./Header.svelte";
import Browser from "./Browser.svelte";
import History from "./History.svelte";
import Commit from "./Commit.svelte";
import Issues from "./Issues.svelte";
import Header from "./Header.svelte";
import History from "./History.svelte";
import Issue from "./Issue.svelte";
import Patches from "./Patches.svelte";
import Patch from "./Patch.svelte";
import ProjectMeta from "./ProjectMeta.svelte";
import Issues from "./Issues.svelte";
import Message from "@app/components/Message.svelte";
import NewIssue from "./Issue/New.svelte";
import Patch from "./Patch.svelte";
import Patches from "./Patches.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import ProjectMeta from "./ProjectMeta.svelte";
export let activeRoute: ProjectRoute;
@@ -60,6 +62,24 @@
return project;
};
const handleIssueCreation = () => {
router.push({
resource: "projects",
params: {
id,
seed,
view: {
resource: "issues",
},
},
});
// This assignment allows us to have an up-to-date issue count
projectPromise = getProject(id, seed, peer);
};
// React to peer changes
$: projectPromise = getProject(id, seed, peer);
// Content can be altered in child components.
$: revision = activeRoute.params.revision || null;
</script>
@@ -92,7 +112,7 @@
}
</style>
{#await getProject(id, seed, peer)}
{#await projectPromise}
<main>
<header>
<Loading center />
@@ -128,11 +148,25 @@
<Message error>{e.message}</Message>
</div>
{/await}
{:else if activeRoute.params.view.resource === "issues" && activeRoute.params.view.params?.view.resource === "new"}
{#if $sessionStore}
<NewIssue
on:create={handleIssueCreation}
session={$sessionStore}
{project} />
{:else}
<div class="message">
<Message error>
Could not access the issue creation. Make sure you're still logged
in.
</Message>
</div>
{/if}
{:else if activeRoute.params.view.resource === "issues"}
{#await issue.Issue.getIssues(project.id, project.seed.addr)}
<Loading center />
{:then issues}
<Issues state={issueFilter} {issues} />
<Issues {project} state={issueFilter} {issues} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
@@ -152,7 +186,7 @@
{#await patch.Patch.getPatches(project.id, project.seed.addr)}
<Loading center />
{:then patches}
<Patches state={patchFilter} {patches} />
<Patches {project} state={patchFilter} {patches} />
{:catch e}
<div class="message">
<Message error>{e.message}</Message>
@@ -176,7 +210,7 @@
{#if peer}
<Placeholder emoji="🍂">
<span slot="title">
<span class="txt-monospace">{formatSeedId(peer)}</span>
<span class="txt-monospace">{formatNodeId(peer)}</span>
</span>
<span slot="body">
<span style="display: block">
+2 -2
View File
@@ -5,7 +5,7 @@
import Link from "@app/components/Link.svelte";
import {
formatSeedAddress,
formatSeedId,
formatNodeId,
formatSeedHost,
twemoji,
} from "@app/lib/utils";
@@ -51,7 +51,7 @@
resource: "seeds",
params: { host: formatSeedHost(seedHost) },
}}>
<span class="txt-link">{formatSeedId(seed.id)}@{seed.host}</span>
<span class="txt-link">{formatNodeId(seed.id)}@{seed.host}</span>
</Link>
</span>
<span class="txt-faded">:{port}</span>
+2 -2
View File
@@ -260,7 +260,7 @@ test("peer and branch switching", async ({ page }) => {
if (process.env.HEARTWOOD) {
await page.locator(`text=${aliceRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
`did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
);
} else {
await page.locator("text=alice").click();
@@ -329,7 +329,7 @@ test("peer and branch switching", async ({ page }) => {
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
} else {
await page.locator("text=bob").click();
+4 -4
View File
@@ -16,7 +16,7 @@ test("peer and branch switching", async ({ page }) => {
if (process.env.HEARTWOOD) {
await page.locator(`text=${aliceRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
`did:key:${aliceRemote.substring(0, 6)}…${aliceRemote.slice(-6)}`,
);
} else {
await page.locator("text=alice hybg18").click();
@@ -69,7 +69,7 @@ test("peer and branch switching", async ({ page }) => {
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
} else {
await page.locator("text=bob hyyzz9").click();
@@ -112,7 +112,7 @@ test("verified badge", async ({ page }) => {
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
} else {
await page.locator("text=bob hyyzz9").click();
@@ -146,7 +146,7 @@ test("relative timestamps", async ({ page }) => {
if (process.env.HEARTWOOD) {
await page.locator(`text=${bobRemote}`).click();
await expect(page.getByTitle("Change peer")).toHaveText(
`${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
`did:key:${bobRemote.substring(0, 6)}…${bobRemote.slice(-6)}`,
);
const latestCommit = page.locator(".commit-teaser").first();
await expect(latestCommit).toContainText("Bob Belcher committed now");
+35 -5
View File
@@ -11,11 +11,15 @@ describe("Format functions", () => {
test.each([
{
id: "hydkkkf5ksbe5fuszdhpqhytu3q36gwagj874wxwpo5a8ti8coygh1",
expected: "hydkkk…coygh1",
id: "did:key:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
expected: "did:key:z6Mkmz…md6fz6",
},
])("formatSeedId $id => $expected", ({ id, expected }) => {
expect(utils.formatSeedId(id)).toEqual(expected);
{
id: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
expected: "did:key:z6Mkmz…md6fz6",
},
])("formatNodeId $id => $expected", ({ id, expected }) => {
expect(utils.formatNodeId(id)).toEqual(expected);
});
test("formatRadicleId", () => {
@@ -97,13 +101,39 @@ describe("String Assertions", () => {
});
});
describe("Parse Strings", () => {
describe("Parse Functions", () => {
test.each([
{ input: "https://twitter.com/cloudhead", expected: "cloudhead" },
{ input: "sebastinez", expected: "sebastinez" },
])("parseUsername", ({ input, expected }) => {
expect(utils.parseUsername(input)).toEqual(expected);
});
test.each([
{
input: "rad:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
expected: undefined,
},
{
input: "did:key:z6Mkmz…md6fz6",
expected: undefined,
},
{
input: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
expected: {
prefix: "did:key:",
pubkey: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
},
},
{
input: "did:key:z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
expected: {
prefix: "did:key:",
pubkey: "z6MkmzRwg47UWQxczLLLFfkEwpBGitjzJ1vKPE8U9ymd6fz6",
},
},
])("parseNid", ({ input, expected }) => {
expect(utils.parseNid(input)).toEqual(expected);
});
});
describe("Path Manipulation", () => {