Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules/
packages/*/node_modules/
apps/*/node_modules/
apps/labs/.cache/
apps/labs/runtime/.generated/
ee/apps/*/node_modules/
ee/packages/*/node_modules/
.next/
Expand Down
50 changes: 50 additions & 0 deletions apps/app/src/app/bundles/skill-org-publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createDenClient, readDenSettings, writeDenSettings } from "../lib/den";

export async function saveInstalledSkillToOpenWorkOrg(input: {
skillText: string;
skillHubId?: string | null;
}): Promise<{ skillId: string; orgId: string; orgName: string }> {
const settings = readDenSettings();
const token = settings.authToken?.trim() ?? "";
if (!token) {
throw new Error("Sign in to OpenWork Cloud in Settings to share with your team.");
}

const cloudClient = createDenClient({ baseUrl: settings.baseUrl, token });
let orgId = settings.activeOrgId?.trim() ?? "";
let orgSlug = settings.activeOrgSlug?.trim() ?? "";
let orgName = settings.activeOrgName?.trim() ?? "";

if (!orgSlug || !orgName || !orgId) {
const response = await cloudClient.listOrgs();
const match = orgId
? response.orgs.find((org) => org.id === orgId)
: response.orgs.find((org) => org.slug === orgSlug) ?? response.orgs[0];
if (!match) {
throw new Error("Choose an organization in Settings -> Cloud before sharing with your team.");
}
orgId = match.id;
orgSlug = match.slug;
orgName = match.name;
writeDenSettings({
...settings,
baseUrl: settings.baseUrl,
authToken: token,
activeOrgId: orgId,
activeOrgSlug: orgSlug,
activeOrgName: orgName,
});
}

const created = await cloudClient.createOrgSkill(orgId, {
skillText: input.skillText,
shared: "org",
});

const hubId = input.skillHubId?.trim() ?? "";
if (hubId) {
await cloudClient.addOrgSkillToHub(orgId, hubId, created.id);
}

return { skillId: created.id, orgId, orgName };
}
116 changes: 116 additions & 0 deletions apps/app/src/app/components/select-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { Check, ChevronDown } from "lucide-solid";

export type SelectMenuOption = {
value: string;
label: string;
};

type SelectMenuProps = {
options: SelectMenuOption[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
id?: string;
/** For pairing with a visible `<label>` element */
ariaLabelledBy?: string;
/** When there is no visible label */
ariaLabel?: string;
};

const triggerClass =
"flex w-full items-center justify-between gap-2 rounded-xl border border-dls-border bg-dls-surface px-3.5 py-2.5 text-left text-[14px] text-dls-text shadow-none transition-[border-color,box-shadow] hover:border-dls-border focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.14)] disabled:cursor-not-allowed disabled:opacity-60";

const panelClass =
"absolute left-0 right-0 top-[calc(100%+6px)] z-[100] max-h-56 overflow-auto rounded-xl border border-dls-border bg-dls-surface py-1 shadow-[var(--dls-shell-shadow)]";

const optionRowClass =
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] text-dls-text transition-colors hover:bg-dls-hover";

export default function SelectMenu(props: SelectMenuProps) {
const [open, setOpen] = createSignal(false);
let rootEl: HTMLDivElement | undefined;

const displayLabel = createMemo(() => {
const match = props.options.find((o) => o.value === props.value);
if (match) return match.label;
return props.placeholder?.trim() || "";
});

const close = () => setOpen(false);

createEffect(() => {
if (!open()) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (rootEl && target && !rootEl.contains(target)) {
close();
}
};
window.addEventListener("pointerdown", onPointerDown, true);
onCleanup(() => window.removeEventListener("pointerdown", onPointerDown, true));
});

createEffect(() => {
if (!open()) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
}
};
window.addEventListener("keydown", onKeyDown);
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
});

return (
<div ref={(el) => (rootEl = el)} class="relative w-full">
<button
type="button"
id={props.id}
class={triggerClass}
disabled={props.disabled}
aria-expanded={open()}
aria-haspopup="listbox"
aria-labelledby={props.ariaLabelledBy}
aria-label={props.ariaLabel}
onClick={() => {
if (props.disabled) return;
setOpen((o) => !o);
}}
>
<span class="min-w-0 flex-1 truncate">{displayLabel()}</span>
<ChevronDown
size={18}
class={`shrink-0 text-dls-secondary transition-transform duration-200 ${open() ? "rotate-180" : ""}`}
aria-hidden
/>
</button>

<Show when={open() && !props.disabled}>
<div class={panelClass} role="listbox">
<For each={props.options}>
{(opt) => (
<button
type="button"
role="option"
aria-selected={opt.value === props.value}
class={`${optionRowClass} ${opt.value === props.value ? "bg-dls-hover/80" : ""}`}
onClick={() => {
props.onChange(opt.value);
close();
}}
>
<span class="min-w-0 flex-1 truncate">{opt.label}</span>
<Show when={opt.value === props.value}>
<Check size={16} class="shrink-0 text-[var(--dls-accent)]" aria-hidden />
</Show>
</button>
)}
</For>
</div>
</Show>
</div>
);
}
67 changes: 67 additions & 0 deletions apps/app/src/app/lib/den.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,33 @@ function getBillingInvoice(value: unknown): DenBillingInvoice | null {
};
}

export type DenOrgSkillHubSummary = {
id: string;
name: string;
canManage: boolean;
};

function getOrgSkillHubSummaries(payload: unknown): DenOrgSkillHubSummary[] {
if (!isRecord(payload) || !Array.isArray(payload.skillHubs)) {
return [];
}

return payload.skillHubs
.map((entry) => {
if (!isRecord(entry)) return null;
if (typeof entry.id !== "string" || typeof entry.name !== "string" || typeof entry.canManage !== "boolean") {
return null;
}
return { id: entry.id, name: entry.name, canManage: entry.canManage };
})
.filter((entry): entry is DenOrgSkillHubSummary => Boolean(entry));
}

function getCreatedOrgSkillId(payload: unknown): string | null {
if (!isRecord(payload) || !isRecord(payload.skill)) return null;
return typeof payload.skill.id === "string" ? payload.skill.id : null;
}

function getBillingSummary(payload: unknown): DenBillingSummary | null {
if (!isRecord(payload) || !isRecord(payload.billing)) {
return null;
Expand Down Expand Up @@ -812,6 +839,46 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul
}
},

async listOrgSkillHubSummaries(orgId: string): Promise<DenOrgSkillHubSummary[]> {
const payload = await requestJson<unknown>(baseUrls, `/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs`, {
method: "GET",
token,
});
return getOrgSkillHubSummaries(payload);
},

async createOrgSkill(
orgId: string,
input: { skillText: string; shared?: "org" | "public" | null },
): Promise<{ id: string }> {
const body = {
skillText: input.skillText,
shared: input.shared === undefined ? ("org" as const) : input.shared,
};
const payload = await requestJson<unknown>(baseUrls, `/v1/orgs/${encodeURIComponent(orgId)}/skills`, {
method: "POST",
token,
body,
});
const id = getCreatedOrgSkillId(payload);
if (!id) {
throw new DenApiError(500, "invalid_skill_payload", "Skill response was missing id.");
}
return { id };
},

async addOrgSkillToHub(orgId: string, skillHubId: string, skillId: string): Promise<void> {
await requestJson<unknown>(
baseUrls,
`/v1/orgs/${encodeURIComponent(orgId)}/skill-hubs/${encodeURIComponent(skillHubId)}/skills`,
{
method: "POST",
token,
body: { skillId },
},
);
},

async getBillingStatus(options: { includeCheckout?: boolean; includePortal?: boolean; includeInvoices?: boolean } = {}): Promise<DenBillingSummary> {
const params = new URLSearchParams();
if (options.includeCheckout) {
Expand Down
Loading
Loading