diff --git a/client/src/api/configTemplates.ts b/client/src/api/configTemplates.ts new file mode 100644 index 000000000000..5ca51cf7e2ee --- /dev/null +++ b/client/src/api/configTemplates.ts @@ -0,0 +1,25 @@ +import type { components } from "@/api/schema/schema"; + +export type Instance = + | components["schemas"]["UserFileSourceModel"] + | components["schemas"]["UserConcreteObjectStoreModel"]; + +export type TemplateVariable = + | components["schemas"]["TemplateVariableString"] + | components["schemas"]["TemplateVariableInteger"] + | components["schemas"]["TemplateVariablePathComponent"] + | components["schemas"]["TemplateVariableBoolean"]; +export type TemplateSecret = components["schemas"]["TemplateSecret"]; +export type VariableValueType = (string | boolean | number) | undefined; +export type VariableData = { [key: string]: VariableValueType }; +export type SecretData = { [key: string]: string }; + +export interface TemplateSummary { + description: string | null; + hidden?: boolean; + id: string; + name: string | null; + secrets?: TemplateSecret[] | null; + variables?: TemplateVariable[] | null; + version?: number; +} diff --git a/client/src/api/fileSources.ts b/client/src/api/fileSources.ts new file mode 100644 index 000000000000..68935db4573f --- /dev/null +++ b/client/src/api/fileSources.ts @@ -0,0 +1,6 @@ +import { type components } from "@/api/schema"; + +export type FileSourceTemplateSummary = components["schemas"]["FileSourceTemplateSummary"]; +export type FileSourceTemplateSummaries = FileSourceTemplateSummary[]; + +export type UserFileSourceModel = components["schemas"]["UserFileSourceModel"]; diff --git a/client/src/api/objectStores.ts b/client/src/api/objectStores.ts index 6b1ace439818..b6c1f6c59f7d 100644 --- a/client/src/api/objectStores.ts +++ b/client/src/api/objectStores.ts @@ -1,4 +1,9 @@ import { fetcher } from "@/api/schema"; +import type { components } from "@/api/schema/schema"; + +export type UserConcreteObjectStore = components["schemas"]["UserConcreteObjectStoreModel"]; + +export type ObjectStoreTemplateType = "aws_s3" | "azure_blob" | "boto3" | "disk" | "generic_s3"; const getObjectStores = fetcher.path("/api/object_stores").method("get").create(); @@ -8,10 +13,20 @@ export async function getSelectableObjectStores() { } const getObjectStore = fetcher.path("/api/object_stores/{object_store_id}").method("get").create(); +const getUserObjectStoreInstance = fetcher + .path("/api/object_store_instances/{user_object_store_id}") + .method("get") + .create(); export async function getObjectStoreDetails(id: string) { - const { data } = await getObjectStore({ object_store_id: id }); - return data; + if (id.startsWith("user_objects://")) { + const userObjectStoreId = id.substring("user_objects://".length); + const { data } = await getUserObjectStoreInstance({ user_object_store_id: userObjectStoreId }); + return data; + } else { + const { data } = await getObjectStore({ object_store_id: id }); + return data; + } } const updateObjectStoreFetcher = fetcher.path("/api/datasets/{dataset_id}/object_store_id").method("put").create(); diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 09f781afd5f0..489637c03256 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -318,6 +318,24 @@ export interface paths { /** Download */ get: operations["download_api_drs_download__object_id__get"]; }; + "/api/file_source_instances": { + /** Get a list of persisted file source instances defined by the requesting user. */ + get: operations["file_sources__instances_index"]; + /** Create a user-bound file source. */ + post: operations["file_sources__create_instance"]; + }; + "/api/file_source_instances/{user_file_source_id}": { + /** Get a list of persisted file source instances defined by the requesting user. */ + get: operations["file_sources__instances_get"]; + /** Update or upgrade user file source instance. */ + put: operations["file_sources__instances_update"]; + /** Purge user file source instance. */ + delete: operations["file_sources__instances_purge"]; + }; + "/api/file_source_templates": { + /** Get a list of file source templates available to build user defined file sources from */ + get: operations["file_sources__templates_index"]; + }; "/api/folders/{folder_id}/contents": { /** * Returns a list of a folder's contents (files and sub-folders) with additional metadata about the folder. @@ -1245,6 +1263,24 @@ export interface paths { */ delete: operations["delete_user_notification_api_notifications__notification_id__delete"]; }; + "/api/object_store_instances": { + /** Get a list of persisted object store instances defined by the requesting user. */ + get: operations["object_stores__instances_index"]; + /** Create a user-bound object store. */ + post: operations["object_stores__create_instance"]; + }; + "/api/object_store_instances/{user_object_store_id}": { + /** Get a persisted object store instances owned by the requesting user. */ + get: operations["object_stores__instances_get"]; + /** Update or upgrade user object store instance. */ + put: operations["object_stores__instances_update"]; + /** Purge user object store instance. */ + delete: operations["object_stores__instances_purge"]; + }; + "/api/object_store_templates": { + /** Get a list of object store templates available to build user defined object stores from */ + get: operations["object_stores__templates_index"]; + }; "/api/object_stores": { /** Get a list of (currently only concrete) object stores configured with this Galaxy instance. */ get: operations["index_api_object_stores_get"]; @@ -2452,7 +2488,7 @@ export interface components { | "more_stable" | "less_stable" ) - | ("cloud" | "quota" | "no_quota" | "restricted"); + | ("cloud" | "quota" | "no_quota" | "restricted" | "user_defined"); }; /** BasicRoleModel */ BasicRoleModel: { @@ -2636,7 +2672,7 @@ export interface components { * Documentation * @description Documentation or extended description for this plugin. */ - doc: string; + doc?: string | null; /** * ID * @description The `FilesSource` plugin identifier @@ -3161,6 +3197,25 @@ export interface components { /** Store Dict */ store_dict?: Record | null; }; + /** CreateInstancePayload */ + CreateInstancePayload: { + /** Description */ + description?: string | null; + /** Name */ + name: string; + /** Secrets */ + secrets: { + [key: string]: string | undefined; + }; + /** Template Id */ + template_id: string; + /** Template Version */ + template_version: number; + /** Variables */ + variables: { + [key: string]: (string | boolean | number) | undefined; + }; + }; /** CreateInvocationsFromStorePayload */ CreateInvocationsFromStorePayload: { /** @@ -5203,6 +5258,43 @@ export interface components { */ update_time: string; }; + /** FileSourceTemplateSummaries */ + FileSourceTemplateSummaries: components["schemas"]["FileSourceTemplateSummary"][]; + /** FileSourceTemplateSummary */ + FileSourceTemplateSummary: { + /** Description */ + description: string | null; + /** + * Hidden + * @default false + */ + hidden?: boolean; + /** Id */ + id: string; + /** Name */ + name: string | null; + /** Secrets */ + secrets?: components["schemas"]["TemplateSecret"][] | null; + /** + * Type + * @enum {string} + */ + type: "ftp" | "posix" | "s3fs" | "azure"; + /** Variables */ + variables?: + | ( + | components["schemas"]["TemplateVariableString"] + | components["schemas"]["TemplateVariableInteger"] + | components["schemas"]["TemplateVariablePathComponent"] + | components["schemas"]["TemplateVariableBoolean"] + )[] + | null; + /** + * Version + * @default 0 + */ + version?: number; + }; /** FilesSourcePlugin */ FilesSourcePlugin: { /** @@ -5214,7 +5306,7 @@ export interface components { * Documentation * @description Documentation or extended description for this plugin. */ - doc: string; + doc?: string | null; /** * ID * @description The `FilesSource` plugin identifier @@ -9894,6 +9986,45 @@ export interface components { */ up_to_date: boolean; }; + /** ObjectStoreTemplateSummaries */ + ObjectStoreTemplateSummaries: components["schemas"]["ObjectStoreTemplateSummary"][]; + /** ObjectStoreTemplateSummary */ + ObjectStoreTemplateSummary: { + /** Badges */ + badges: components["schemas"]["BadgeDict"][]; + /** Description */ + description: string | null; + /** + * Hidden + * @default false + */ + hidden?: boolean; + /** Id */ + id: string; + /** Name */ + name: string | null; + /** Secrets */ + secrets?: components["schemas"]["TemplateSecret"][] | null; + /** + * Type + * @enum {string} + */ + type: "aws_s3" | "azure_blob" | "boto3" | "disk" | "generic_s3"; + /** Variables */ + variables?: + | ( + | components["schemas"]["TemplateVariableString"] + | components["schemas"]["TemplateVariableInteger"] + | components["schemas"]["TemplateVariablePathComponent"] + | components["schemas"]["TemplateVariableBoolean"] + )[] + | null; + /** + * Version + * @default 0 + */ + version?: number; + }; /** OutputReferenceByLabel */ OutputReferenceByLabel: { /** @@ -11770,6 +11901,92 @@ export interface components { * @enum {string} */ TaskState: "PENDING" | "STARTED" | "RETRY" | "FAILURE" | "SUCCESS"; + /** TemplateSecret */ + TemplateSecret: { + /** Help */ + help: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + }; + /** TemplateVariableBoolean */ + TemplateVariableBoolean: { + /** + * Default + * @default false + */ + default?: boolean; + /** Help */ + help: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Type + * @constant + * @enum {string} + */ + type: "boolean"; + }; + /** TemplateVariableInteger */ + TemplateVariableInteger: { + /** + * Default + * @default 0 + */ + default?: number; + /** Help */ + help: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Type + * @constant + * @enum {string} + */ + type: "integer"; + }; + /** TemplateVariablePathComponent */ + TemplateVariablePathComponent: { + /** Default */ + default?: string | null; + /** Help */ + help: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Type + * @constant + * @enum {string} + */ + type: "path_component"; + }; + /** TemplateVariableString */ + TemplateVariableString: { + /** + * Default + * @default + */ + default?: string; + /** Help */ + help: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Type + * @constant + * @enum {string} + */ + type: "string"; + }; /** ToolDataDetails */ ToolDataDetails: { /** @@ -12112,6 +12329,28 @@ export interface components { visible?: boolean | null; [key: string]: unknown | undefined; }; + /** UpdateInstancePayload */ + UpdateInstancePayload: { + /** Active */ + active?: boolean | null; + /** Description */ + description?: string | null; + /** Hidden */ + hidden?: boolean | null; + /** Name */ + name?: string | null; + /** Variables */ + variables?: { + [key: string]: (string | boolean | number) | undefined; + } | null; + }; + /** UpdateInstanceSecretPayload */ + UpdateInstanceSecretPayload: { + /** Secret Name */ + secret_name: string; + /** Secret Value */ + secret_value: string; + }; /** UpdateLibraryFolderPayload */ UpdateLibraryFolderPayload: { /** @@ -12293,6 +12532,19 @@ export interface components { */ action_type: "upgrade_all_steps"; }; + /** UpgradeInstancePayload */ + UpgradeInstancePayload: { + /** Secrets */ + secrets: { + [key: string]: string | undefined; + }; + /** Template Version */ + template_version: number; + /** Variables */ + variables: { + [key: string]: (string | boolean | number) | undefined; + }; + }; /** UpgradeSubworkflowAction */ UpgradeSubworkflowAction: { /** @@ -12394,6 +12646,47 @@ export interface components { */ enabled: boolean; }; + /** UserConcreteObjectStoreModel */ + UserConcreteObjectStoreModel: { + /** Active */ + active: boolean; + /** Badges */ + badges: components["schemas"]["BadgeDict"][]; + /** Description */ + description?: string | null; + /** Device */ + device?: string | null; + /** Hidden */ + hidden: boolean; + /** Id */ + id: number | string; + /** Name */ + name?: string | null; + /** Object Store Id */ + object_store_id?: string | null; + /** Private */ + private: boolean; + /** Purged */ + purged: boolean; + quota: components["schemas"]["QuotaModel"]; + /** Secrets */ + secrets: string[]; + /** Template Id */ + template_id: string; + /** Template Version */ + template_version: number; + /** + * Type + * @enum {string} + */ + type: "aws_s3" | "azure_blob" | "boto3" | "disk" | "generic_s3"; + /** Uuid */ + uuid: string; + /** Variables */ + variables: { + [key: string]: (string | boolean | number) | undefined; + } | null; + }; /** UserCreationPayload */ UserCreationPayload: { /** @@ -12436,6 +12729,40 @@ export interface components { */ id: string; }; + /** UserFileSourceModel */ + UserFileSourceModel: { + /** Active */ + active: boolean; + /** Description */ + description: string | null; + /** Hidden */ + hidden: boolean; + /** Id */ + id: string | number; + /** Name */ + name: string; + /** Purged */ + purged: boolean; + /** Secrets */ + secrets: string[]; + /** Template Id */ + template_id: string; + /** Template Version */ + template_version: number; + /** + * Type + * @enum {string} + */ + type: "ftp" | "posix" | "s3fs" | "azure"; + /** Uri Root */ + uri_root: string; + /** Uuid */ + uuid: string; + /** Variables */ + variables: { + [key: string]: (string | boolean | number) | undefined; + } | null; + }; /** * UserModel * @description User in a transaction context. @@ -14625,6 +14952,165 @@ export interface operations { }; }; }; + file_sources__instances_index: { + /** Get a list of persisted file source instances defined by the requesting user. */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserFileSourceModel"][]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_sources__create_instance: { + /** Create a user-bound file source. */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateInstancePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserFileSourceModel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_sources__instances_get: { + /** Get a list of persisted file source instances defined by the requesting user. */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + /** @description The index for a persisted UserFileSourceStore object. */ + path: { + user_file_source_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserFileSourceModel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_sources__instances_update: { + /** Update or upgrade user file source instance. */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + /** @description The index for a persisted UserFileSourceStore object. */ + path: { + user_file_source_id: string; + }; + }; + requestBody: { + content: { + "application/json": + | components["schemas"]["UpdateInstanceSecretPayload"] + | components["schemas"]["UpgradeInstancePayload"] + | components["schemas"]["UpdateInstancePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserFileSourceModel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_sources__instances_purge: { + /** Purge user file source instance. */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + /** @description The index for a persisted UserFileSourceStore object. */ + path: { + user_file_source_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 204: never; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_sources__templates_index: { + /** Get a list of file source templates available to build user defined file sources from */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + responses: { + /** @description A list of the configured file source templates. */ + 200: { + content: { + "application/json": components["schemas"]["FileSourceTemplateSummaries"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; index_api_folders__folder_id__contents_get: { /** * Returns a list of a folder's contents (files and sub-folders) with additional metadata about the folder. @@ -20343,6 +20829,165 @@ export interface operations { }; }; }; + object_stores__instances_index: { + /** Get a list of persisted object store instances defined by the requesting user. */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserConcreteObjectStoreModel"][]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + object_stores__create_instance: { + /** Create a user-bound object store. */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateInstancePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserConcreteObjectStoreModel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + object_stores__instances_get: { + /** Get a persisted object store instances owned by the requesting user. */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + /** @description The identifier used to index a persisted UserObjectStore object. */ + path: { + user_object_store_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserConcreteObjectStoreModel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + object_stores__instances_update: { + /** Update or upgrade user object store instance. */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + /** @description The identifier used to index a persisted UserObjectStore object. */ + path: { + user_object_store_id: string; + }; + }; + requestBody: { + content: { + "application/json": + | components["schemas"]["UpdateInstanceSecretPayload"] + | components["schemas"]["UpgradeInstancePayload"] + | components["schemas"]["UpdateInstancePayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserConcreteObjectStoreModel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + object_stores__instances_purge: { + /** Purge user object store instance. */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + /** @description The identifier used to index a persisted UserObjectStore object. */ + path: { + user_object_store_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 204: never; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + object_stores__templates_index: { + /** Get a list of object store templates available to build user defined object stores from */ + parameters?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string | null; + }; + }; + responses: { + /** @description A list of the configured object store templates. */ + 200: { + content: { + "application/json": components["schemas"]["ObjectStoreTemplateSummaries"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; index_api_object_stores_get: { /** Get a list of (currently only concrete) object stores configured with this Galaxy instance. */ parameters?: { @@ -20359,7 +21004,10 @@ export interface operations { /** @description A list of the configured object stores. */ 200: { content: { - "application/json": components["schemas"]["ConcreteObjectStoreModel"][]; + "application/json": ( + | components["schemas"]["ConcreteObjectStoreModel"] + | components["schemas"]["UserConcreteObjectStoreModel"] + )[]; }; }; /** @description Validation Error */ diff --git a/client/src/components/ConfigTemplates/CreateInstance.test.ts b/client/src/components/ConfigTemplates/CreateInstance.test.ts new file mode 100644 index 000000000000..fe4e59ad4a59 --- /dev/null +++ b/client/src/components/ConfigTemplates/CreateInstance.test.ts @@ -0,0 +1,32 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import CreateInstance from "./CreateInstance.vue"; + +const localVue = getLocalVue(true); + +describe("CreateInstance", () => { + it("should render a loading message during loading", async () => { + const wrapper = shallowMount(CreateInstance, { + propsData: { + loading: true, + loadingMessage: "component loading...", + }, + localVue, + }); + const loadingSpan = wrapper.findComponent({ name: "LoadingSpan" }).exists(); + expect(loadingSpan).toBeTruthy(); + }); + + it("should hide a loading message after loading", async () => { + const wrapper = shallowMount(CreateInstance, { + propsData: { + loading: false, + loadingMessage: "component loading...", + }, + localVue, + }); + const loadingSpan = wrapper.findComponent({ name: "LoadingSpan" }).exists(); + expect(loadingSpan).toBeFalsy(); + }); +}); diff --git a/client/src/components/ConfigTemplates/CreateInstance.vue b/client/src/components/ConfigTemplates/CreateInstance.vue new file mode 100644 index 000000000000..334d341bc590 --- /dev/null +++ b/client/src/components/ConfigTemplates/CreateInstance.vue @@ -0,0 +1,21 @@ + + + diff --git a/client/src/components/ConfigTemplates/EditSecretsForm.test.ts b/client/src/components/ConfigTemplates/EditSecretsForm.test.ts new file mode 100644 index 000000000000..3a6252da0e29 --- /dev/null +++ b/client/src/components/ConfigTemplates/EditSecretsForm.test.ts @@ -0,0 +1,36 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import { STANDARD_FILE_SOURCE_TEMPLATE, STANDARD_OBJECT_STORE_TEMPLATE } from "./test_fixtures"; + +import EditSecretsForm from "./EditSecretsForm.vue"; + +const localVue = getLocalVue(true); + +describe("EditSecretsForm", () => { + it("should render a secrets for for file source templates", async () => { + const wrapper = mount(EditSecretsForm, { + propsData: { + template: STANDARD_FILE_SOURCE_TEMPLATE, + title: "Secrets FORM for file source", + }, + localVue, + }); + const titleText = wrapper.find(".portlet-title-text"); + expect(titleText.exists()).toBeTruthy(); + expect(titleText.text()).toEqual("Secrets FORM for file source"); + }); + + it("should render a secrets for for object store templates", async () => { + const wrapper = mount(EditSecretsForm, { + propsData: { + template: STANDARD_OBJECT_STORE_TEMPLATE, + title: "Secrets FORM for object store", + }, + localVue, + }); + const titleText = wrapper.find(".portlet-title-text"); + expect(titleText.exists()).toBeTruthy(); + expect(titleText.text()).toEqual("Secrets FORM for object store"); + }); +}); diff --git a/client/src/components/ConfigTemplates/EditSecretsForm.vue b/client/src/components/ConfigTemplates/EditSecretsForm.vue new file mode 100644 index 000000000000..7d5cd1777d5b --- /dev/null +++ b/client/src/components/ConfigTemplates/EditSecretsForm.vue @@ -0,0 +1,37 @@ + + + diff --git a/client/src/components/ConfigTemplates/InstanceDropdown.test.ts b/client/src/components/ConfigTemplates/InstanceDropdown.test.ts new file mode 100644 index 000000000000..e29d8d883113 --- /dev/null +++ b/client/src/components/ConfigTemplates/InstanceDropdown.test.ts @@ -0,0 +1,40 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import InstanceDropdown from "./InstanceDropdown.vue"; + +const localVue = getLocalVue(true); + +describe("InstanceDropdown", () => { + it("should render a drop down without upgrade if upgrade unavailable as an option", async () => { + const wrapper = shallowMount(InstanceDropdown, { + propsData: { + prefix: "file-source", + name: "my cool instance", + routeEdit: "/object_store_instance/edit", + routeUpgrade: "/object_store_instance/upgrade", + isUpgradable: false, + }, + localVue, + }); + const menu = wrapper.find(".dropdown-menu"); + const links = menu.findAll("button.dropdown-item"); + expect(links.length).toBe(2); + }); + + it("should render a drop down with upgrade if upgrade available as an option", async () => { + const wrapper = shallowMount(InstanceDropdown, { + propsData: { + prefix: "file-source", + name: "my cool instance", + routeEdit: "/object_store_instance/edit", + routeUpgrade: "/object_store_instance/upgrade", + isUpgradable: true, + }, + localVue, + }); + const menu = wrapper.find(".dropdown-menu"); + const links = menu.findAll("button.dropdown-item"); + expect(links.length).toBe(3); + }); +}); diff --git a/client/src/components/ConfigTemplates/InstanceDropdown.vue b/client/src/components/ConfigTemplates/InstanceDropdown.vue new file mode 100644 index 000000000000..3314f58300e8 --- /dev/null +++ b/client/src/components/ConfigTemplates/InstanceDropdown.vue @@ -0,0 +1,62 @@ + + + diff --git a/client/src/components/ConfigTemplates/InstanceForm.test.ts b/client/src/components/ConfigTemplates/InstanceForm.test.ts new file mode 100644 index 000000000000..30189e068d54 --- /dev/null +++ b/client/src/components/ConfigTemplates/InstanceForm.test.ts @@ -0,0 +1,42 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import { FormEntry } from "./formUtil"; + +import InstanceForm from "./InstanceForm.vue"; + +const localVue = getLocalVue(true); + +const inputs: FormEntry[] = []; +const SUBMIT_TITLE = "Submit the form!"; + +describe("InstanceForm", () => { + it("should render a loading message and not submit button if inputs is null", async () => { + const wrapper = shallowMount(InstanceForm, { + propsData: { + title: "MY FORM", + inputs: null, + submitTitle: SUBMIT_TITLE, + }, + localVue, + }); + const loadingSpan = wrapper.findComponent({ name: "LoadingSpan" }).exists(); + expect(loadingSpan).toBeTruthy(); + expect(wrapper.find("#submit").exists()).toBeFalsy(); + }); + + it("should hide a loading message after loading", async () => { + const wrapper = shallowMount(InstanceForm, { + propsData: { + title: "MY FORM", + inputs: inputs, + submitTitle: SUBMIT_TITLE, + }, + localVue, + }); + const loadingSpan = wrapper.findComponent({ name: "LoadingSpan" }).exists(); + expect(loadingSpan).toBeFalsy(); + expect(wrapper.find("#submit").exists()).toBeTruthy(); + expect(wrapper.find("#submit").text()).toEqual(SUBMIT_TITLE); + }); +}); diff --git a/client/src/components/ConfigTemplates/InstanceForm.vue b/client/src/components/ConfigTemplates/InstanceForm.vue new file mode 100644 index 000000000000..129870128228 --- /dev/null +++ b/client/src/components/ConfigTemplates/InstanceForm.vue @@ -0,0 +1,51 @@ + + diff --git a/client/src/components/ConfigTemplates/ManageIndexHeader.vue b/client/src/components/ConfigTemplates/ManageIndexHeader.vue new file mode 100644 index 000000000000..4b7c46276fe9 --- /dev/null +++ b/client/src/components/ConfigTemplates/ManageIndexHeader.vue @@ -0,0 +1,37 @@ + + + diff --git a/client/src/components/ConfigTemplates/SelectTemplate.test.js b/client/src/components/ConfigTemplates/SelectTemplate.test.js new file mode 100644 index 000000000000..ce631ceb54d0 --- /dev/null +++ b/client/src/components/ConfigTemplates/SelectTemplate.test.js @@ -0,0 +1,33 @@ +import { mount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import { STANDARD_FILE_SOURCE_TEMPLATE } from "./test_fixtures"; + +import SelectTemplate from "./SelectTemplate.vue"; + +const localVue = getLocalVue(true); + +const help = "some help text about selection"; + +describe("SelectTemplate", () => { + it("should render a selection row for supplied templates", async () => { + const wrapper = mount(SelectTemplate, { + propsData: { + templates: [STANDARD_FILE_SOURCE_TEMPLATE], + selectText: help, + idPrefix: "file-source", + }, + localVue, + }); + console.log(wrapper.html()); + const helpText = wrapper.find(".file-source-template-select-help"); + expect(helpText.exists()).toBeTruthy(); + expect(helpText.text()).toBeLocalizationOf(help); + const buttons = wrapper.findAll("button"); + expect(buttons.length).toBe(1); + const button = buttons.at(0); + expect(button.attributes().id).toEqual("file-source-template-button-moo"); + expect(button.attributes()["data-template-id"]).toEqual("moo"); + expect(button.text()).toEqual("moo"); + }); +}); diff --git a/client/src/components/ConfigTemplates/SelectTemplate.vue b/client/src/components/ConfigTemplates/SelectTemplate.vue new file mode 100644 index 000000000000..012edb83ebf0 --- /dev/null +++ b/client/src/components/ConfigTemplates/SelectTemplate.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/src/components/ConfigTemplates/TemplateSummaryPopover.test.ts b/client/src/components/ConfigTemplates/TemplateSummaryPopover.test.ts new file mode 100644 index 000000000000..e230874d65e7 --- /dev/null +++ b/client/src/components/ConfigTemplates/TemplateSummaryPopover.test.ts @@ -0,0 +1,22 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import { STANDARD_FILE_SOURCE_TEMPLATE } from "./test_fixtures"; + +import TemplateSummaryPopover from "./TemplateSummaryPopover.vue"; + +const localVue = getLocalVue(true); + +describe("TemplateSummaryPopover", () => { + it("should render a secrets for for file source templates", async () => { + const wrapper = shallowMount(TemplateSummaryPopover, { + propsData: { + template: STANDARD_FILE_SOURCE_TEMPLATE, + target: "popover-target", + }, + localVue, + }); + const popover = wrapper.findComponent({ name: "BPopover" }); + expect(popover.attributes().target).toEqual("popover-target"); + }); +}); diff --git a/client/src/components/ConfigTemplates/TemplateSummaryPopover.vue b/client/src/components/ConfigTemplates/TemplateSummaryPopover.vue new file mode 100644 index 000000000000..77484b6b8c9a --- /dev/null +++ b/client/src/components/ConfigTemplates/TemplateSummaryPopover.vue @@ -0,0 +1,21 @@ + + + diff --git a/client/src/components/ConfigTemplates/VaultSecret.test.ts b/client/src/components/ConfigTemplates/VaultSecret.test.ts new file mode 100644 index 000000000000..6ab7e53f5304 --- /dev/null +++ b/client/src/components/ConfigTemplates/VaultSecret.test.ts @@ -0,0 +1,25 @@ +import { shallowMount } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; + +import VaultSecret from "./VaultSecret.vue"; + +const localVue = getLocalVue(true); + +describe("VaultSecret", () => { + it("should render a form element", async () => { + const wrapper = shallowMount(VaultSecret, { + propsData: { + name: "secret name", + label: "Label Secret", + help: "here is some good *help*", + isSet: true, + }, + localVue, + }); + const titleWrapper = wrapper.find(".ui-form-title-text"); + expect(titleWrapper.text()).toEqual("Label Secret"); + const helpWrapper = wrapper.find(".ui-form-info p"); + // verify markdown converted + expect(helpWrapper.html()).toEqual("

here is some good help

"); + }); +}); diff --git a/client/src/components/ConfigTemplates/VaultSecret.vue b/client/src/components/ConfigTemplates/VaultSecret.vue new file mode 100644 index 000000000000..fe231af18417 --- /dev/null +++ b/client/src/components/ConfigTemplates/VaultSecret.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/client/src/components/ConfigTemplates/fields.ts b/client/src/components/ConfigTemplates/fields.ts new file mode 100644 index 000000000000..33a00f7e69f4 --- /dev/null +++ b/client/src/components/ConfigTemplates/fields.ts @@ -0,0 +1,25 @@ +import _l from "@/utils/localization"; + +export const NAME_FIELD = { + key: "name", + label: _l("Name"), + sortable: true, +}; + +export const DESCRIPTION_FIELD = { + key: "description", + label: _l("Description"), + sortable: true, +}; + +export const TYPE_FIELD = { + key: "type", + label: _l("Type"), + sortable: true, +}; + +export const TEMPLATE_FIELD = { + key: "template", + label: _l("From Template"), + sortable: true, +}; diff --git a/client/src/components/ConfigTemplates/formUtil.test.ts b/client/src/components/ConfigTemplates/formUtil.test.ts new file mode 100644 index 000000000000..e95caf3d95f5 --- /dev/null +++ b/client/src/components/ConfigTemplates/formUtil.test.ts @@ -0,0 +1,100 @@ +import { TemplateVariable } from "@/api/configTemplates"; + +import { createTemplateForm, templateVariableFormEntry, upgradeForm } from "./formUtil"; +import { + GENERIC_FTP_FILE_SOURCE_TEMPLATE, + OBJECT_STORE_INSTANCE, + STANDARD_FILE_SOURCE_TEMPLATE, + STANDARD_OBJECT_STORE_TEMPLATE, +} from "./test_fixtures"; + +const FTP_VARIABLES = GENERIC_FTP_FILE_SOURCE_TEMPLATE.variables as TemplateVariable[]; +const PROJECT_VARIABLE = { + name: "Project", + type: "path_component", +} as TemplateVariable; + +describe("formUtils", () => { + describe("createTemplateForm", () => { + it("should create a form from an object store templates", () => { + const form = createTemplateForm(STANDARD_OBJECT_STORE_TEMPLATE, "storage location"); + expect(form.length).toBe(6); + const formEl0 = form[0]; + expect(formEl0?.name).toEqual("_meta_name"); + expect(formEl0?.help).toEqual("Label this new storage location with a name."); + const formEl1 = form[1]; + expect(formEl1?.name).toEqual("_meta_description"); + }); + + it("should create a form from a file source templates", () => { + const form = createTemplateForm(STANDARD_FILE_SOURCE_TEMPLATE, "file source"); + expect(form.length).toBe(6); + const formEl0 = form[0]; + expect(formEl0?.name).toEqual("_meta_name"); + expect(formEl0?.help).toEqual("Label this new file source with a name."); + const formEl1 = form[1]; + expect(formEl1?.name).toEqual("_meta_description"); + }); + }); + + describe("upgradeForm", () => { + it("should create a form from an object store templates", () => { + const form = upgradeForm(STANDARD_OBJECT_STORE_TEMPLATE, OBJECT_STORE_INSTANCE); + expect(form.length).toBe(3); + const formEl0 = form[0]; + expect(formEl0?.name).toEqual("oldvar"); + const formEl1 = form[1]; + expect(formEl1?.name).toEqual("newvar"); + }); + + it("should only ask for new secrets during upgrade", () => { + const form = upgradeForm(STANDARD_OBJECT_STORE_TEMPLATE, OBJECT_STORE_INSTANCE); + expect(form.length).toBe(3); + const formEl0 = form[2]; + expect(formEl0?.name).toEqual("newsecret"); + expect(formEl0?.type).toEqual("password"); + }); + }); + + describe("templateVariableFormEntry", () => { + it("should render string types as Galaxy text form inputs", () => { + const hostVariable = FTP_VARIABLES[0] as TemplateVariable; + const formEntry = templateVariableFormEntry(hostVariable, undefined); + expect(formEntry.name).toBe("host"); + expect(formEntry.label).toBe("FTP Host"); + expect(formEntry.type).toBe("text"); + expect(formEntry.help).toBe("

Host of FTP Server to connect to.

\n"); + }); + it("should render integer types as Galaxy integer inputs", () => { + const portVariable = FTP_VARIABLES[3] as TemplateVariable; + const formEntry = templateVariableFormEntry(portVariable, undefined); + expect(formEntry.name).toBe("port"); + expect(formEntry.label).toBe("FTP Port"); + expect(formEntry.type).toBe("integer"); + expect(formEntry.value).toBe(21); + }); + it("should render boolean types as Galaxy boolean inputs", () => { + const writableVariable = FTP_VARIABLES[2] as TemplateVariable; + const formEntry = templateVariableFormEntry(writableVariable, undefined); + expect(formEntry.name).toBe("writable"); + expect(formEntry.label).toBe("Writable?"); + expect(formEntry.type).toBe("boolean"); + expect(formEntry.value).toBe(false); + }); + it("should render path_component types as Galaxy text form inputs", () => { + const formEntry = templateVariableFormEntry(PROJECT_VARIABLE, undefined); + expect(formEntry.name).toBe("Project"); + expect(formEntry.label).toBe("Project"); + expect(formEntry.type).toBe("text"); + }); + it("should render path_component updated default values if supplied", () => { + const formEntry = templateVariableFormEntry(PROJECT_VARIABLE, "foobar"); + expect(formEntry.value).toBe("foobar"); + }); + it("should render string types with updated default values if supplied", () => { + const hostVariable = FTP_VARIABLES[0] as TemplateVariable; + const formEntry = templateVariableFormEntry(hostVariable, "mycoolhost.org"); + expect(formEntry.value).toBe("mycoolhost.org"); + }); + }); +}); diff --git a/client/src/components/ConfigTemplates/formUtil.ts b/client/src/components/ConfigTemplates/formUtil.ts new file mode 100644 index 000000000000..c785f48e0c11 --- /dev/null +++ b/client/src/components/ConfigTemplates/formUtil.ts @@ -0,0 +1,245 @@ +import type { + Instance, + SecretData, + TemplateSecret, + TemplateSummary, + TemplateVariable, + VariableData, + VariableValueType, +} from "@/api/configTemplates"; +import { markup } from "@/components/ObjectStore/configurationMarkdown"; + +export interface FormEntry { + name: string; + label?: string; + optional?: boolean; + help?: string | null; + type: string; + value?: any; +} + +export function metadataFormEntryName(what: string): FormEntry { + return { + name: "_meta_name", + label: "Name", + type: "text", + optional: false, + help: `Label this new ${what} with a name.`, + }; +} + +export function metadataFormEntryDescription(what: string): FormEntry { + return { + name: "_meta_description", + label: "Description", + optional: true, + type: "textarea", + help: `Provide some notes to yourself about this ${what} - perhaps to remind you how it is configured, where it stores the data, etc..`, + }; +} + +export function templateVariableFormEntry(variable: TemplateVariable, variableValue: VariableValueType): FormEntry { + const common_fields = { + name: variable.name, + label: variable.label ?? variable.name, + help: markup(variable.help || "", true), + }; + if (variable.type == "string") { + const defaultValue = variable.default ?? ""; + return { + type: "text", + value: variableValue == undefined ? defaultValue : variableValue, + ...common_fields, + }; + } else if (variable.type == "path_component") { + const defaultValue = variable.default ?? ""; + // TODO: do extra validation with form somehow... + return { + type: "text", + value: variableValue == undefined ? defaultValue : variableValue, + ...common_fields, + }; + } else if (variable.type == "integer") { + const defaultValue = variable.default ?? 0; + return { + type: "integer", + value: variableValue == undefined ? defaultValue : variableValue, + ...common_fields, + }; + } else if (variable.type == "boolean") { + const defaultValue = variable.default ?? false; + return { + type: "boolean", + value: variableValue == undefined ? defaultValue : variableValue, + ...common_fields, + }; + } else { + throw Error("Invalid template form input type found."); + } +} + +export function templateSecretFormEntry(secret: TemplateSecret): FormEntry { + return { + name: secret.name, + label: secret.label ?? secret.name, + type: "password", + help: markup(secret.help || "", true), + value: "", + }; +} + +export function editTemplateForm(template: TemplateSummary, what: string, instance: Instance): FormEntry[] { + const form = []; + const nameInput = metadataFormEntryName(what); + form.push({ value: instance.name ?? "", ...nameInput }); + + const descriptionInput = metadataFormEntryDescription(what); + form.push({ value: instance.description ?? "", ...descriptionInput }); + + const variables = template.variables ?? []; + const variableValues: VariableData = instance.variables || {}; + for (const variable of variables) { + form.push(templateVariableFormEntry(variable, variableValues[variable.name])); + } + return form; +} + +export function editFormDataToPayload(template: TemplateSummary, formData: any) { + const variables = template.variables ?? []; + const name = formData["_meta_name"]; + const description = formData["_meta_description"]; + const variableData: VariableData = {}; + for (const variable of variables) { + const variableValue = formDataTypedGet(variable, formData); + if (variableValue !== undefined) { + variableData[variable.name] = variableValue; + } + } + const payload = { + name: name, + description: description, + variables: variableData, + }; + return payload; +} + +export function createTemplateForm(template: TemplateSummary, what: string): FormEntry[] { + const form = []; + const variables = template.variables ?? []; + const secrets = template.secrets ?? []; + form.push(metadataFormEntryName(what)); + form.push(metadataFormEntryDescription(what)); + for (const variable of variables) { + form.push(templateVariableFormEntry(variable, undefined)); + } + for (const secret of secrets) { + form.push(templateSecretFormEntry(secret)); + } + return form; +} + +export function createFormDataToPayload(template: TemplateSummary, formData: any) { + const variables = template.variables ?? []; + const secrets = template.secrets ?? []; + const variableData: VariableData = {}; + const secretData: SecretData = {}; + for (const variable of variables) { + const variableValue = formDataTypedGet(variable, formData); + if (variableValue !== undefined) { + variableData[variable.name] = variableValue; + } + } + for (const secret of secrets) { + secretData[secret.name] = formData[secret.name]; + } + const name: string = formData._meta_name; + const description: string = formData._meta_description; + const payload = { + name: name, + description: description, + secrets: secretData, + variables: variableData, + template_id: template.id, + template_version: template.version ?? 0, + }; + return payload; +} + +export function formDataTypedGet(variableDefinition: TemplateVariable, formData: any): VariableValueType { + // galaxy form library doesn't type values traditionally, so add a typed + // access to the data if coming back as string. Though it does seem to be + // typed properly - this might not be needed anymore? + const variableType = variableDefinition.type; + const variableName = variableDefinition.name; + const rawValue: boolean | string | number | null | undefined = formData[variableName]; + if (variableType == "string") { + if (rawValue == null || rawValue == undefined) { + return undefined; + } else { + return String(rawValue); + } + } else if (variableType == "path_component") { + if (rawValue == null || rawValue == undefined) { + return undefined; + } else { + return String(rawValue); + } + } else if (variableType == "boolean") { + if (rawValue == null || rawValue == undefined || typeof rawValue == "number") { + return undefined; + } else { + return String(rawValue).toLowerCase() == "true"; + } + } else if (variableType == "integer") { + if (rawValue == null || rawValue == undefined || typeof rawValue == "boolean") { + return undefined; + } else { + if (typeof rawValue == "string") { + return parseInt(rawValue); + } else { + return rawValue; + } + } + } else { + throw Error("Unknown variable type encountered, shouldn't be possible."); + } +} + +export function upgradeForm(template: TemplateSummary, instance: Instance): FormEntry[] { + const form = []; + const variables = template.variables ?? []; + const secrets = template.secrets ?? []; + const variableValues: VariableData = instance.variables || {}; + const secretsSet = instance.secrets || []; + for (const variable of variables) { + form.push(templateVariableFormEntry(variable, variableValues[variable.name])); + } + for (const secret of secrets) { + const secretName = secret.name; + if (secretsSet.indexOf(secretName) >= 0) { + console.log("skipping..."); + } else { + form.push(templateSecretFormEntry(secret)); + } + } + return form; +} + +export function upgradeFormDataToPayload(template: TemplateSummary, formData: any) { + const variables = template.variables ?? []; + const variableData: VariableData = {}; + for (const variable of variables) { + variableData[variable.name] = formData[variable.name]; + } + const secrets = {}; + // ideally we would be able to force a template version here, + // maybe rework backend types to force this in the API response + // even if we don't need it in the config files + const templateVersion: number = template.version || 0; + const payload = { + template_version: templateVersion, + variables: variableData, + secrets: secrets, + }; + return payload; +} diff --git a/client/src/components/ConfigTemplates/test_fixtures.ts b/client/src/components/ConfigTemplates/test_fixtures.ts new file mode 100644 index 000000000000..9e90afa3396c --- /dev/null +++ b/client/src/components/ConfigTemplates/test_fixtures.ts @@ -0,0 +1,122 @@ +import type { FileSourceTemplateSummary } from "@/api/fileSources"; +import type { UserConcreteObjectStore } from "@/components/ObjectStore/Instances/types"; +import type { ObjectStoreTemplateSummary } from "@/components/ObjectStore/Templates/types"; + +export const STANDARD_OBJECT_STORE_TEMPLATE: ObjectStoreTemplateSummary = { + type: "aws_s3", + name: "moo", + description: null, + variables: [ + { + name: "oldvar", + type: "string", + help: "old var help", + }, + { + name: "newvar", + type: "string", + help: "new var help", + }, + ], + secrets: [ + { + name: "oldsecret", + help: "old secret help", + }, + { + name: "newsecret", + help: "new secret help", + }, + ], + id: "moo", + version: 2, + badges: [], +}; + +export const STANDARD_FILE_SOURCE_TEMPLATE: FileSourceTemplateSummary = { + type: "s3fs", + name: "moo", + description: null, + variables: [ + { + name: "oldvar", + type: "string", + help: "old var help", + }, + { + name: "newvar", + type: "string", + help: "new var help", + }, + ], + secrets: [ + { + name: "oldsecret", + help: "old secret help", + }, + { + name: "newsecret", + help: "new secret help", + }, + ], + id: "moo", + version: 2, +}; + +export const GENERIC_FTP_FILE_SOURCE_TEMPLATE: FileSourceTemplateSummary = { + id: "ftp", + type: "ftp", + name: "Generic FTP Server", + description: "Generic FTP configuration with all configuration options exposed.", + variables: [ + { name: "host", label: "FTP Host", type: "string", help: "Host of FTP Server to connect to." }, + { + name: "user", + label: "FTP User", + type: "string", + help: "Username to login to target FTP server with.", + }, + { + name: "writable", + label: "Writable?", + type: "boolean", + help: "Is this an FTP server you have permission to write to?", + default: false, + }, + { + name: "port", + label: "FTP Port", + type: "integer", + help: "Port used to connect to the FTP server.", + default: 21, + }, + ], + secrets: [ + { + name: "password", + label: "FTP Password", + help: "Password to connect to FTP server with.", + }, + ], +}; + +export const OBJECT_STORE_INSTANCE: UserConcreteObjectStore = { + type: "aws_s3", + name: "moo", + description: undefined, + template_id: "moo", + template_version: 1, + badges: [], + variables: { + oldvar: "my old value", + droppedvar: "this will be dropped", + }, + secrets: ["oldsecret", "droppedsecret"], + quota: { enabled: false }, + private: false, + id: 4, + uuid: "112f889f-72d7-4619-a8e8-510a8c685aa7", + active: true, + hidden: false, + purged: false, +}; diff --git a/client/src/components/ConfigTemplates/useInstanceFiltering.ts b/client/src/components/ConfigTemplates/useInstanceFiltering.ts new file mode 100644 index 000000000000..6428b284f687 --- /dev/null +++ b/client/src/components/ConfigTemplates/useInstanceFiltering.ts @@ -0,0 +1,13 @@ +import { computed, type Ref } from "vue"; + +import { type Instance } from "@/api/configTemplates"; + +export function useFiltering(allInstances: Ref) { + const activeInstances = computed(() => { + return allInstances.value.filter((item: T) => !item.hidden); + }); + + return { + activeInstances, + }; +} diff --git a/client/src/components/FileSources/FileSourceTypeSpan.vue b/client/src/components/FileSources/FileSourceTypeSpan.vue new file mode 100644 index 000000000000..538b635c4a42 --- /dev/null +++ b/client/src/components/FileSources/FileSourceTypeSpan.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/client/src/components/FileSources/Instances/CreateForm.vue b/client/src/components/FileSources/Instances/CreateForm.vue new file mode 100644 index 000000000000..7d8a8b7ecef3 --- /dev/null +++ b/client/src/components/FileSources/Instances/CreateForm.vue @@ -0,0 +1,53 @@ + + diff --git a/client/src/components/FileSources/Instances/CreateInstance.vue b/client/src/components/FileSources/Instances/CreateInstance.vue new file mode 100644 index 000000000000..9b93e2d7c269 --- /dev/null +++ b/client/src/components/FileSources/Instances/CreateInstance.vue @@ -0,0 +1,34 @@ + + + diff --git a/client/src/components/FileSources/Instances/EditInstance.vue b/client/src/components/FileSources/Instances/EditInstance.vue new file mode 100644 index 000000000000..fbea25de628c --- /dev/null +++ b/client/src/components/FileSources/Instances/EditInstance.vue @@ -0,0 +1,77 @@ + + diff --git a/client/src/components/FileSources/Instances/EditSecrets.vue b/client/src/components/FileSources/Instances/EditSecrets.vue new file mode 100644 index 000000000000..dd14126a1242 --- /dev/null +++ b/client/src/components/FileSources/Instances/EditSecrets.vue @@ -0,0 +1,28 @@ + + diff --git a/client/src/components/FileSources/Instances/InstanceDropdown.vue b/client/src/components/FileSources/Instances/InstanceDropdown.vue new file mode 100644 index 000000000000..75b93ec5329e --- /dev/null +++ b/client/src/components/FileSources/Instances/InstanceDropdown.vue @@ -0,0 +1,42 @@ + + + diff --git a/client/src/components/FileSources/Instances/ManageIndex.vue b/client/src/components/FileSources/Instances/ManageIndex.vue new file mode 100644 index 000000000000..784e0b84a685 --- /dev/null +++ b/client/src/components/FileSources/Instances/ManageIndex.vue @@ -0,0 +1,73 @@ + + + diff --git a/client/src/components/FileSources/Instances/UpgradeForm.vue b/client/src/components/FileSources/Instances/UpgradeForm.vue new file mode 100644 index 000000000000..cf5621e01b24 --- /dev/null +++ b/client/src/components/FileSources/Instances/UpgradeForm.vue @@ -0,0 +1,63 @@ + + diff --git a/client/src/components/FileSources/Instances/UpgradeInstance.vue b/client/src/components/FileSources/Instances/UpgradeInstance.vue new file mode 100644 index 000000000000..a2aa1ed672f4 --- /dev/null +++ b/client/src/components/FileSources/Instances/UpgradeInstance.vue @@ -0,0 +1,27 @@ + + diff --git a/client/src/components/FileSources/Instances/instance.ts b/client/src/components/FileSources/Instances/instance.ts new file mode 100644 index 000000000000..f45fd6bf76dd --- /dev/null +++ b/client/src/components/FileSources/Instances/instance.ts @@ -0,0 +1,23 @@ +import { computed, type Ref } from "vue"; + +import type { FileSourceTemplateSummary, UserFileSourceModel } from "@/api/fileSources"; +import { useFileSourceInstancesStore } from "@/stores/fileSourceInstancesStore"; +import { useFileSourceTemplatesStore } from "@/stores/fileSourceTemplatesStore"; + +export function useInstanceAndTemplate(instanceIdRef: Ref) { + const fileSourceTemplatesStore = useFileSourceTemplatesStore(); + const fileSourceInstancesStore = useFileSourceInstancesStore(); + fileSourceInstancesStore.fetchInstances(); + fileSourceTemplatesStore.fetchTemplates(); + + const instance = computed( + () => fileSourceInstancesStore.getInstance(instanceIdRef.value) || null + ); + const template = computed(() => + instance.value + ? fileSourceTemplatesStore.getTemplate(instance.value?.template_id, instance.value?.template_version) + : null + ); + + return { instance, template }; +} diff --git a/client/src/components/FileSources/Instances/routing.ts b/client/src/components/FileSources/Instances/routing.ts new file mode 100644 index 000000000000..6d2ed12bae6e --- /dev/null +++ b/client/src/components/FileSources/Instances/routing.ts @@ -0,0 +1,16 @@ +import { useRouter } from "vue-router/composables"; + +export function useInstanceRouting() { + const router = useRouter(); + + async function goToIndex(query: Record<"message", string>) { + router.push({ + path: "/file_source_instances/index", + query: query, + }); + } + + return { + goToIndex, + }; +} diff --git a/client/src/components/FileSources/Instances/services.ts b/client/src/components/FileSources/Instances/services.ts new file mode 100644 index 000000000000..7e0b52376a47 --- /dev/null +++ b/client/src/components/FileSources/Instances/services.ts @@ -0,0 +1,12 @@ +import type { UserFileSourceModel } from "@/api/fileSources"; +import { fetcher } from "@/api/schema/fetcher"; + +export const create = fetcher.path("/api/file_source_instances").method("post").create(); +export const update = fetcher.path("/api/file_source_instances/{user_file_source_id}").method("put").create(); + +export async function hide(instance: UserFileSourceModel) { + const payload = { hidden: true }; + const args = { user_file_source_id: String(instance?.id) }; + const { data: fileSource } = await update({ ...args, ...payload }); + return fileSource; +} diff --git a/client/src/components/FileSources/Templates/CreateUserFileSource.vue b/client/src/components/FileSources/Templates/CreateUserFileSource.vue new file mode 100644 index 000000000000..5cc4a9f8e3aa --- /dev/null +++ b/client/src/components/FileSources/Templates/CreateUserFileSource.vue @@ -0,0 +1,30 @@ + + diff --git a/client/src/components/FileSources/Templates/SelectTemplate.vue b/client/src/components/FileSources/Templates/SelectTemplate.vue new file mode 100644 index 000000000000..6e45ab9f8d3d --- /dev/null +++ b/client/src/components/FileSources/Templates/SelectTemplate.vue @@ -0,0 +1,39 @@ + + + diff --git a/client/src/components/FileSources/Templates/TemplateSummary.vue b/client/src/components/FileSources/Templates/TemplateSummary.vue new file mode 100644 index 000000000000..6cef09f37ed1 --- /dev/null +++ b/client/src/components/FileSources/Templates/TemplateSummary.vue @@ -0,0 +1,22 @@ + + + diff --git a/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue b/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue new file mode 100644 index 000000000000..c056e03d9b72 --- /dev/null +++ b/client/src/components/FileSources/Templates/TemplateSummaryPopover.vue @@ -0,0 +1,19 @@ + + + diff --git a/client/src/components/FileSources/Templates/TemplateSummarySpan.vue b/client/src/components/FileSources/Templates/TemplateSummarySpan.vue new file mode 100644 index 000000000000..1c4291dc3aad --- /dev/null +++ b/client/src/components/FileSources/Templates/TemplateSummarySpan.vue @@ -0,0 +1,32 @@ + + + diff --git a/client/src/components/FileSources/style.css b/client/src/components/FileSources/style.css new file mode 100644 index 000000000000..c2962e44de2e --- /dev/null +++ b/client/src/components/FileSources/style.css @@ -0,0 +1,4 @@ +.file-source-help-on-hover { + text-decoration-line: underline; + text-decoration-style: dashed; +} diff --git a/client/src/components/FilesDialog/utilities.ts b/client/src/components/FilesDialog/utilities.ts index 21aff76122a0..68f1dfd76eac 100644 --- a/client/src/components/FilesDialog/utilities.ts +++ b/client/src/components/FilesDialog/utilities.ts @@ -18,7 +18,7 @@ export function fileSourcePluginToItem(plugin: BrowsableFilesSourcePlugin): Sele const result = { id: plugin.id, label: plugin.label, - details: plugin.doc, + details: plugin.doc || "", isLeaf: false, url: plugin.uri_root, }; diff --git a/client/src/components/Form/FormElement.vue b/client/src/components/Form/FormElement.vue index 17314b77cbcc..3ecde5128092 100644 --- a/client/src/components/Form/FormElement.vue +++ b/client/src/components/Form/FormElement.vue @@ -312,62 +312,5 @@ const isOptional = computed(() => !isRequired.value && attrs.value["optional"] ! diff --git a/client/src/components/Form/_form-elements.scss b/client/src/components/Form/_form-elements.scss new file mode 100644 index 000000000000..17aeb65b914e --- /dev/null +++ b/client/src/components/Form/_form-elements.scss @@ -0,0 +1,58 @@ +@import "theme/blue.scss"; +@import "~@fortawesome/fontawesome-free/scss/_variables"; + +.ui-form-element { + margin-top: $margin-v * 0.25; + margin-bottom: $margin-v * 0.5; + overflow: visible; + clear: both; + + .ui-form-title { + word-wrap: break-word; + font-weight: bold; + + .ui-form-title-message { + font-size: $font-size-base * 0.7; + font-weight: 300; + vertical-align: text-top; + color: $text-light; + cursor: default; + } + + .ui-form-title-star { + color: $text-light; + font-weight: 300; + cursor: default; + } + + .warning { + color: $brand-danger; + } + } + + .ui-form-field { + position: relative; + margin-top: $margin-v * 0.25; + } + + &:deep(.ui-form-collapsible-icon), + &:deep(.ui-form-connected-icon) { + border: none; + background: none; + padding: 0; + line-height: 1; + font-size: 1.2em; + + &:hover { + color: $brand-info; + } + + &:focus { + color: $brand-primary; + } + + &:active { + background: none; + } + } +} diff --git a/client/src/components/Grid/GridElements/GridOperations.vue b/client/src/components/Grid/GridElements/GridOperations.vue index ff85e9832054..62248b224cee 100644 --- a/client/src/components/Grid/GridElements/GridOperations.vue +++ b/client/src/components/Grid/GridElements/GridOperations.vue @@ -42,7 +42,7 @@ function hasCondition(conditionHandler: (rowData: RowData, config: GalaxyConfigu {{ title }} -