Skip to content

Commit 174ea7c

Browse files
feat(app): OpenWork Cloud team skills catalog on Skills page (#1301)
* feat(app): OpenWork Cloud team skills on Skills page - Den client: listOrgSkills, listOrgSkillHubs, fetchDenOrgSkillsCatalog (hub name enrichment) - Extensions: cloud catalog refresh/install via upsertSkill; stale on workspace + Den session - Skills UI: Team filter, org section, sign-in and org gates, refresh and install flow - i18n: English strings for cloud catalog - Storybook: Skills tab notes full app needs ExtensionsProvider Made-with: Cursor * feat(app): share skill to org with minimal Cloud + public chooser modal - Den client: listOrgSkillHubSummaries, createOrgSkill, addOrgSkillToHub - Orchestrate org resolve + POST skill + optional hub attach (skill-org-publish) - Skills share modal: WorkspaceOptionCard chooser, public link drill-in, team path with hub picker and sign-in CTA - i18n: EN, ja, zh, vi, pt-BR Made-with: Cursor * fix(app): softer share-skill notices and reusable SelectMenu - Add select-menu custom dropdown (listbox, outside click, Escape) - modalNotice* classes: light dls-border + body text (no heavy emerald/red outlines) - Replace native hub select in share-to-team flow Made-with: Cursor
1 parent 685c08b commit 174ea7c

File tree

13 files changed

+1278
-90
lines changed

13 files changed

+1278
-90
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createDenClient, readDenSettings, writeDenSettings } from "../lib/den";
2+
3+
export async function saveInstalledSkillToOpenWorkOrg(input: {
4+
skillText: string;
5+
skillHubId?: string | null;
6+
}): Promise<{ skillId: string; orgId: string; orgName: string }> {
7+
const settings = readDenSettings();
8+
const token = settings.authToken?.trim() ?? "";
9+
if (!token) {
10+
throw new Error("Sign in to OpenWork Cloud in Settings to share with your team.");
11+
}
12+
13+
const cloudClient = createDenClient({ baseUrl: settings.baseUrl, token });
14+
let orgId = settings.activeOrgId?.trim() ?? "";
15+
let orgSlug = settings.activeOrgSlug?.trim() ?? "";
16+
let orgName = settings.activeOrgName?.trim() ?? "";
17+
18+
if (!orgSlug || !orgName || !orgId) {
19+
const response = await cloudClient.listOrgs();
20+
const match = orgId
21+
? response.orgs.find((org) => org.id === orgId)
22+
: response.orgs.find((org) => org.slug === orgSlug) ?? response.orgs[0];
23+
if (!match) {
24+
throw new Error("Choose an organization in Settings -> Cloud before sharing with your team.");
25+
}
26+
orgId = match.id;
27+
orgSlug = match.slug;
28+
orgName = match.name;
29+
writeDenSettings({
30+
...settings,
31+
baseUrl: settings.baseUrl,
32+
authToken: token,
33+
activeOrgId: orgId,
34+
activeOrgSlug: orgSlug,
35+
activeOrgName: orgName,
36+
});
37+
}
38+
39+
const created = await cloudClient.createOrgSkill(orgId, {
40+
skillText: input.skillText,
41+
shared: "org",
42+
});
43+
44+
const hubId = input.skillHubId?.trim() ?? "";
45+
if (hubId) {
46+
await cloudClient.addOrgSkillToHub(orgId, hubId, created.id);
47+
}
48+
49+
return { skillId: created.id, orgId, orgName };
50+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
2+
import { Check, ChevronDown } from "lucide-solid";
3+
4+
export type SelectMenuOption = {
5+
value: string;
6+
label: string;
7+
};
8+
9+
type SelectMenuProps = {
10+
options: SelectMenuOption[];
11+
value: string;
12+
onChange: (value: string) => void;
13+
disabled?: boolean;
14+
placeholder?: string;
15+
id?: string;
16+
/** For pairing with a visible `<label>` element */
17+
ariaLabelledBy?: string;
18+
/** When there is no visible label */
19+
ariaLabel?: string;
20+
};
21+
22+
const triggerClass =
23+
"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";
24+
25+
const panelClass =
26+
"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)]";
27+
28+
const optionRowClass =
29+
"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";
30+
31+
export default function SelectMenu(props: SelectMenuProps) {
32+
const [open, setOpen] = createSignal(false);
33+
let rootEl: HTMLDivElement | undefined;
34+
35+
const displayLabel = createMemo(() => {
36+
const match = props.options.find((o) => o.value === props.value);
37+
if (match) return match.label;
38+
return props.placeholder?.trim() || "";
39+
});
40+
41+
const close = () => setOpen(false);
42+
43+
createEffect(() => {
44+
if (!open()) return;
45+
const onPointerDown = (event: PointerEvent) => {
46+
const target = event.target as Node | null;
47+
if (rootEl && target && !rootEl.contains(target)) {
48+
close();
49+
}
50+
};
51+
window.addEventListener("pointerdown", onPointerDown, true);
52+
onCleanup(() => window.removeEventListener("pointerdown", onPointerDown, true));
53+
});
54+
55+
createEffect(() => {
56+
if (!open()) return;
57+
const onKeyDown = (event: KeyboardEvent) => {
58+
if (event.key === "Escape") {
59+
event.preventDefault();
60+
close();
61+
}
62+
};
63+
window.addEventListener("keydown", onKeyDown);
64+
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
65+
});
66+
67+
return (
68+
<div ref={(el) => (rootEl = el)} class="relative w-full">
69+
<button
70+
type="button"
71+
id={props.id}
72+
class={triggerClass}
73+
disabled={props.disabled}
74+
aria-expanded={open()}
75+
aria-haspopup="listbox"
76+
aria-labelledby={props.ariaLabelledBy}
77+
aria-label={props.ariaLabel}
78+
onClick={() => {
79+
if (props.disabled) return;
80+
setOpen((o) => !o);
81+
}}
82+
>
83+
<span class="min-w-0 flex-1 truncate">{displayLabel()}</span>
84+
<ChevronDown
85+
size={18}
86+
class={`shrink-0 text-dls-secondary transition-transform duration-200 ${open() ? "rotate-180" : ""}`}
87+
aria-hidden
88+
/>
89+
</button>
90+
91+
<Show when={open() && !props.disabled}>
92+
<div class={panelClass} role="listbox">
93+
<For each={props.options}>
94+
{(opt) => (
95+
<button
96+
type="button"
97+
role="option"
98+
aria-selected={opt.value === props.value}
99+
class={`${optionRowClass} ${opt.value === props.value ? "bg-dls-hover/80" : ""}`}
100+
onClick={() => {
101+
props.onChange(opt.value);
102+
close();
103+
}}
104+
>
105+
<span class="min-w-0 flex-1 truncate">{opt.label}</span>
106+
<Show when={opt.value === props.value}>
107+
<Check size={16} class="shrink-0 text-[var(--dls-accent)]" aria-hidden />
108+
</Show>
109+
</button>
110+
)}
111+
</For>
112+
</div>
113+
</Show>
114+
</div>
115+
);
116+
}

0 commit comments

Comments
 (0)