Skip to main content
Glama
AttributePanel.vue21.6 kB
<template> <div v-if="attributeTree && root" data-testid="attribute-panel" class="p-xs flex flex-col gap-xs" > <div class="grid grid-cols-2 items-center gap-2xs text-sm h-lg"> <nameForm.Field :validators="{ onChange: required, onBlur: required, }" name="name" > <template #default="{ field }"> <div>Name</div> <div v-if="component.toDelete" ref="nameInputRef" v-tooltip="{ content: 'Unable to edit this value.', placement: 'left', }" :class=" clsx( 'h-lg p-xs text-sm border font-mono', 'focus:outline-none focus:ring-0 focus:z-10', 'cursor-not-allowed focus:outline-none focus:z-10', themeClasses( 'bg-neutral-100 text-neutral-600 border-neutral-400 focus:border-action-500', 'bg-neutral-900 text-neutral-400 border-neutral-600 focus:border-action-300', ), ) " tabindex="0" @keydown.tab.stop.prevent="onNameInputTab" > {{ field.state.value }} </div> <input v-else ref="nameInputRef" data-testid="name-input" :value="field.state.value" :placeholder="namePlaceholder" :class=" clsx( 'h-lg p-xs text-sm border font-mono cursor-text', 'focus:outline-none focus:ring-0 focus:z-10', themeClasses( 'text-shade-100 bg-white border-neutral-400 focus:border-action-500', 'text-shade-0 bg-black border-neutral-600 focus:border-action-300', ), ) " tabindex="0" type="text" @blur="blurNameInput" @input=" (e: Event) => { handleNameInput(e, field); } " @keydown.enter.stop.prevent="blurNameInput" @keydown.esc.stop.prevent="resetNameInput" @keydown.tab.stop.prevent="onNameInputTab" /> </template> </nameForm.Field> </div> <div> <!-- TODO(Wendy) - this doesn't work on the secrets tree yet --> <SiSearch ref="searchRef" v-model="q" placeholder="Find an attribute" :tabIndex="0" :borderBottom="false" variant="new" @keydown.tab="onSearchInputTab" /> </div> <AttributeChildLayout v-if="'children' in filtered.tree && filtered.tree.children.length > 0" defaultOpen > <template #header><span class="text-sm">domain</span></template> <ComponentAttribute v-for="(child, index) in filtered.tree.children" :key="child.id" :component="component" :attributeTree="child" :stickyDepth="0" :isFirstChild="index === 0" @save="save" @delete="remove" @remove-subscription="removeSubscription" @set-default-subscription-source=" (path, setTo) => setDefaultSubscriptionSourceStatus(path, setTo) " @add="add" @set-key="setKey" @focused="(path) => emit('focused', path)" /> </AttributeChildLayout> <AttributeChildLayout v-if="secrets && secrets.children.length > 0"> <template #header><span class="text-sm">secrets</span></template> <ComponentSecretAttribute v-for="secret in secrets.children" :key="secret.id" :component="component" :attributeTree="secret" data-testid="secret" @set-default-subscription-source=" (path, setTo) => setDefaultSubscriptionSourceStatus(path, setTo) " /> </AttributeChildLayout> </div> <EmptyState v-else text="No attributes to display" icon="code-circle" /> </template> <script lang="ts" setup> import { computed, onBeforeUnmount, onMounted, provide, reactive, Ref, ref, watch, } from "vue"; import { Fzf } from "fzf"; import { useRoute } from "vue-router"; import { SiSearch, themeClasses } from "@si/vue-lib/design-system"; import clsx from "clsx"; import * as _ from "lodash-es"; import { useMutation, useQueryClient } from "@tanstack/vue-query"; import { AttributeTree, AttributeValue, BifrostComponent, EntityKind, JsonValue, MgmtFunction, } from "@/workers/types/entity_kind_types"; import { PropKind } from "@/api/sdf/dal/prop"; import { useMakeKey } from "@/store/realtime/heimdall"; import { AttributePath, ComponentId } from "@/api/sdf/dal/component"; import { componentTypes, routes, UseApi, useApi } from "./api_composables"; import ComponentAttribute, { NewChildValue, } from "./layout_components/ComponentAttribute.vue"; import { keyEmitter } from "./logic_composables/emitters"; import ComponentSecretAttribute from "./layout_components/ComponentSecretAttribute.vue"; import { useWatchedForm } from "./logic_composables/watched_form"; import { NameFormData } from "./ComponentDetails.vue"; import EmptyState from "./EmptyState.vue"; import { findAttributeValueInTree, escapeJsonPointerSegment } from "./util"; import { arrayAttrTreeIntoTree, AttrTree, makeAvTree, makeSavePayload, } from "./logic_composables/attribute_tree"; import AttributeChildLayout from "./layout_components/AttributeChildLayout.vue"; import { AttributeInputContext } from "./types"; import { FuncRun } from "./api_composables/func_run"; provide<AttributeInputContext>("ATTRIBUTEINPUT", { blankInput: false }); const q = ref(""); const props = defineProps<{ component: BifrostComponent; attributeTree?: AttributeTree; importFunc?: MgmtFunction; importFuncRun?: FuncRun; }>(); const root = computed<AttrTree>(() => { const empty = { componentId: "", id: "", children: [] as AttrTree[], attributeValue: {} as AttributeValue, isBuildable: false, }; const raw = props.attributeTree; if (!raw) return empty; // find the root node in the tree, the only one with parent null const rootId = Object.keys(raw.treeInfo).find((avId) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const av = raw.treeInfo[avId]!; if (!av.parent) return true; return false; }); if (!rootId) return empty; const tree = makeAvTree(raw, rootId, false); return tree; }); const domain = computed(() => root.value?.children.find((c) => c.prop?.name === "domain"), ); const secrets = computed(() => root.value?.children.find((c) => c.prop?.name === "secrets"), ); const filtered = reactive<{ tree: AttrTree | object }>({ tree: {}, }); watch( () => [q.value, domain.value], () => { if (!q.value) { filtered.tree = domain.value ?? {}; return; } if (!domain.value) { filtered.tree = {}; return; } // we need to access attrs by id const map: Record<string, AttrTree> = {}; map[domain.value.id] = domain.value; const walking = [...domain.value.children]; // walk all the children and find if they match while (walking.length > 0) { const attr = walking.shift(); if (!attr) break; map[attr.id] = attr; walking.push(...attr.children); } const fzf = new Fzf(Object.values(map), { casing: "case-insensitive", selector: (p) => `${p.id} ${p.prop?.name} ${p.prop?.path} ${p.attributeValue.key} ${p.attributeValue.value}`, }); const results = fzf.find(q.value); // Maybe we want to get rid of low scoring options (via std dev)? const matches: AttrTree[] = results.map((fz) => fz.item); const matchesAsTree = arrayAttrTreeIntoTree(matches, map, domain.value?.id); // all roads lead back to domain const newDomain = matchesAsTree[domain.value.id]; filtered.tree = newDomain ?? {}; }, { immediate: true }, ); const route = useRoute(); const saveApi = useApi(); const saveErrors = ref<Record<string, string>>({}); export interface AttributeErrors { saveErrors: Ref<Record<string, string>>; } const errorContext = computed<AttributeErrors>(() => { return { saveErrors, }; }); provide("ATTRIBUTE_ERRORS", errorContext); const save = async ( path: AttributePath, value: JsonValue, propKind: PropKind, connectingComponentId?: ComponentId, ) => { const call = saveApi.endpoint<{ success: boolean }>( routes.UpdateComponentAttributes, { id: props.component.id }, ); value = value?.toString() ?? ""; const payload = makeSavePayload(path, value, propKind, connectingComponentId); const { req, newChangeSetId, errorMessage } = await call.put<componentTypes.UpdateComponentAttributesArgs>(payload); const key = `${props.component.id}-${path}`; if (!saveApi.ok(req)) { if ( req.status === 400 && errorMessage?.includes("Type mismatch on subscription") ) { // Bad request error due to type mismatch saveErrors.value[key] = "Subscription failed due to type mismatch."; } else { // All other errors go here saveErrors.value[key] = `\`${value}\` failed to save`; } removeSubscriptionMutation.mutate(path); } else { delete saveErrors.value[key]; } if (saveApi.ok(req) && newChangeSetId) { saveApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } }; const keyApi = useApi(); const setKey = async ( attributeTree: AttrTree, key: string, value: NewChildValue, ) => { const call = keyApi.endpoint<{ success: boolean }>( routes.UpdateComponentAttributes, { id: props.component.id }, ); // Track the current children count before making the API call const initialChildrenCount = attributeTree.children.length; keyApi.setWatchFn( // Watch for the children count to actually increase () => attributeTree.children.length > initialChildrenCount, ); // Escape the key according to RFC 6901 (JSON Pointer spec) // This ensures special characters like '/' and '~' are properly escaped const escapedKey = escapeJsonPointerSegment(key); const childPath = `${attributeTree.attributeValue.path}/${escapedKey}` as AttributePath; const payload = { [childPath]: value, }; const { req, newChangeSetId } = await call.put<componentTypes.UpdateComponentAttributesArgs>(payload); if (newChangeSetId && keyApi.ok(req)) { keyApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } }; const add = async ( addApi: UseApi, attributeTree: AttrTree, value: NewChildValue, ) => { if (props.component.toDelete) return; if (!props.attributeTree) return; const call = addApi.endpoint<{ success: boolean }>( routes.UpdateComponentAttributes, { id: props.component.id }, ); // Track the current children count before making the API call const initialChildrenCount = attributeTree.children.length; addApi.setWatchFn( // Watch for the children count to actually increase () => attributeTree.children.length > initialChildrenCount, ); // Do I send `{}` for array of map/object or "" for array of string? // Answer by looking at my prop child const appendPath = `${attributeTree.attributeValue.path}/-` as AttributePath; const payload = { [appendPath]: value, }; const { req, newChangeSetId } = await call.put<componentTypes.UpdateComponentAttributesArgs>(payload); if (addApi.ok(req) && newChangeSetId) { addApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } }; const removeApi = useApi(); const remove = async (path: AttributePath) => { const call = removeApi.endpoint<{ success: boolean }>( routes.UpdateComponentAttributes, { id: props.component.id }, ); const payload: componentTypes.UpdateComponentAttributesArgs = {}; payload[path] = { $source: null }; const { req, newChangeSetId } = await call.put<componentTypes.UpdateComponentAttributesArgs>(payload); if (removeApi.ok(req) && newChangeSetId) { removeApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } }; const removeSubscriptionApi = useApi(); const queryClient = useQueryClient(); const makeKey = useMakeKey(); const removeSubscriptionMutation = useMutation({ mutationFn: async (path: AttributePath) => { const call = removeSubscriptionApi.endpoint<{ success: boolean }>( routes.UpdateComponentAttributes, { id: props.component.id }, ); const payload: componentTypes.UpdateComponentAttributesArgs = {}; payload[path] = { $source: null, }; const { req, newChangeSetId } = await call.put<componentTypes.UpdateComponentAttributesArgs>(payload); if (removeSubscriptionApi.ok(req) && newChangeSetId) { removeSubscriptionApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } return { req, newChangeSetId }; }, onMutate: async (path: AttributePath) => { const queryKey = makeKey(EntityKind.AttributeTree, props.component.id); const previousData = queryClient.getQueryData<AttributeTree>( queryKey.value, ); queryClient.setQueryData( queryKey.value, (cachedData: AttributeTree | undefined) => { if (!cachedData) { return cachedData; } const found = findAttributeValueInTree(cachedData, path); if (!found) { return cachedData; } const updatedData = { ...cachedData }; const updatedFound = findAttributeValueInTree(updatedData, path); if (updatedFound) { updatedFound.attributeValue.externalSources = undefined; updatedFound.attributeValue.value = null; } return updatedData; }, ); return { previousData }; }, onError: (error, path, context) => { if (context?.previousData) { const queryKey = makeKey(EntityKind.AttributeTree, props.component.id); queryClient.setQueryData(queryKey.value, context.previousData); } }, }); const setDefaultSubscriptionSourceApi = useApi(); const deleteDefaultSubscriptionSourceApi = useApi(); const setDefaultSubscriptionSourceStatus = async ( path: AttributePath, isDefaultSource: boolean, ) => { const defaultSourceApi = isDefaultSource ? setDefaultSubscriptionSourceApi : deleteDefaultSubscriptionSourceApi; const call = defaultSourceApi.endpoint(routes.SetDefaultSubscriptionSource, { id: props.component.id, }); const { req, newChangeSetId } = isDefaultSource ? await call.put(path) : await call.delete(path); if (defaultSourceApi.ok(req) && newChangeSetId) { defaultSourceApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } }; const removeSubscription = async (path: AttributePath) => { removeSubscriptionMutation.mutate(path); }; const nameInputRef = ref<HTMLInputElement | HTMLDivElement>(); const searchRef = ref<InstanceType<typeof SiSearch>>(); // Import const resourceIdAttr = computed(() => { const siTree = root.value.children.find((p) => p.prop?.name === "si"); return siTree?.children.find((p) => p.prop?.name === "resourceId"); }); const resourceIdValue = computed( () => resourceIdAttr.value?.attributeValue.value ?? null, ); const resourceIdFormValue = ref<string>(); const bifrostingResourceId = ref(false); const saveResourceId = async (resourceId: string) => { // Normalize values for comparison (handle null, undefined, empty string) const formValue = resourceId || null; const currentValue = resourceIdValue.value || null; // Only save if the value has actually changed if (formValue === currentValue) { return; } bifrostingResourceId.value = true; await save("/si/resourceId", resourceId, PropKind.String); }; watch([resourceIdValue], () => { if (resourceIdFormValue.value === resourceIdValue.value) { bifrostingResourceId.value = false; } }); const runMgmtFuncApi = useApi(); const doImport = async (resourceId: string) => { if (bifrostingResourceId.value) { return; } resourceIdFormValue.value = resourceId; await saveResourceId(resourceId); const func = props.importFunc; if (!func) return; spawningFunc.value = true; const call = runMgmtFuncApi.endpoint<{ success: boolean }>( routes.MgmtFuncRun, { prototypeId: func.id, componentId: props.component.id, viewId: "DEFAULT", }, ); const { req, newChangeSetId } = await call.post<componentTypes.UpdateComponentAttributesArgs>({}); setTimeout(() => { spawningFunc.value = false; }, 2000); if (runMgmtFuncApi.ok(req) && newChangeSetId) { runMgmtFuncApi.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } }; const spawningFunc = ref(false); const importing = computed( () => spawningFunc.value || bifrostingResourceId.value || ["Created", "Dispatched", "Running", "Postprocessing"].includes( props.importFuncRun?.state ?? "", ), ); onMounted(() => { keyEmitter.on("Tab", (e) => { e.preventDefault(); focusNameInput(); }); if (nameIsDefault.value) focusNameInput(); else focusSearch(); }); const focusSearch = () => { searchRef.value?.focusSearch(); }; const focusNameInput = () => { nameInputRef.value?.focus(); }; onBeforeUnmount(() => { keyEmitter.off("Tab"); }); const onNameInputTab = (e: KeyboardEvent) => { if (e.shiftKey) { e.preventDefault(); const focusable = Array.from( document.querySelectorAll('[tabindex="0"]'), ) as HTMLElement[]; if (focusable) { focusable[focusable.length - 1]?.focus(); } } else { nameInputRef.value?.blur(); focusSearch(); } }; const onSearchInputTab = (e: KeyboardEvent) => { if (e.shiftKey) { e.preventDefault(); focusNameInput(); } }; const api = useApi(); const required = ({ value }: { value: string | undefined }) => { const len = value?.trim().length ?? 0; return len > 0 ? undefined : "Name required"; }; const resetNameInput = () => { if (nameForm.state.isSubmitted || nameForm.state.isDirty) { wForm.reset(nameForm, nameFormData.value); } nameInputRef.value?.blur(); }; const blurNameInput = () => { if ( nameForm.fieldInfo.name.instance?.state.meta.isDirty && !props.component.toDelete ) { // don't double submit if you were `select()'d'` if (!nameForm.baseStore.state.isSubmitted) nameForm.handleSubmit(); } else { resetNameInput(); } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleNameInput = (e: Event, field: any) => { if (props.component.toDelete) return; field.handleChange((e.target as HTMLInputElement).value); }; const nameIsDefault = computed(() => props.component.name.startsWith("si-")); const namePlaceholder = computed(() => nameIsDefault.value ? props.component.name : "You must give the component a name!", ); const wForm = useWatchedForm<NameFormData>( `component.name.${props.component.id}`, ); const nameFormData = computed<NameFormData>(() => { return { name: nameIsDefault.value ? "" : props.component.name }; }); const nameForm = wForm.newForm({ data: nameFormData, onSubmit: async ({ value }) => { const name = value.name; // i wish the validator narrowed this type to always be a string if (name) { const id = props.component.id; const call = api.endpoint(routes.UpdateComponentName, { id }); const { req, newChangeSetId } = await call.put<componentTypes.UpdateComponentNameArgs>({ name, }); if (newChangeSetId && api.ok(req)) { api.navigateToNewChangeSet( { name: "new-hotness-component", params: { workspacePk: route.params.workspacePk, changeSetId: newChangeSetId, componentId: props.component.id, }, }, newChangeSetId, ); } } }, watchFn: () => props.component.name, }); const emit = defineEmits<{ (e: "focused", path: string): void; }>(); defineExpose({ focusSearch, doImport, importing, resourceIdValue, }); </script>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server