diff --git a/backend/.sqlx/query-212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead.json b/backend/.sqlx/query-212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead.json new file mode 100644 index 0000000000..04b23832d9 --- /dev/null +++ b/backend/.sqlx/query-212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at)\n VALUES ('admins', $1, $2, $3, $4, $6, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END,\n edited_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Jsonb", + "Text", + "Varchar", + "Bool", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "212bf5b32de102a9c537907aaff04befc3a6596c258086805dd42f513ddb3ead" +} diff --git a/backend/.sqlx/query-d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6.json b/backend/.sqlx/query-36ddecbdad3cce7a2593171ff10a6b07cdb70994be5724f08f864d29517f4907.json similarity index 75% rename from backend/.sqlx/query-d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6.json rename to backend/.sqlx/query-36ddecbdad3cce7a2593171ff10a6b07cdb70994be5724f08f864d29517f4907.json index ffd1670c04..76e897d3a4 100644 --- a/backend/.sqlx/query-d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6.json +++ b/backend/.sqlx/query-36ddecbdad3cce7a2593171ff10a6b07cdb70994be5724f08f864d29517f4907.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name", + "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')", "describe": { "columns": [ { @@ -42,10 +42,16 @@ "ordinal": 7, "name": "is_fileset", "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "display_name", + "type_info": "Varchar" } ], "parameters": { "Left": [ + "Text", "Text" ] }, @@ -57,8 +63,9 @@ true, true, true, - false + false, + true ] }, - "hash": "d0a95698b9a2c5e2543e94276d854d7e509c7db2c2ac7d395b7b53ad5dbc25e6" + "hash": "36ddecbdad3cce7a2593171ff10a6b07cdb70994be5724f08f864d29517f4907" } diff --git a/backend/.sqlx/query-8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a.json b/backend/.sqlx/query-386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34.json similarity index 70% rename from backend/.sqlx/query-8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a.json rename to backend/.sqlx/query-386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34.json index 3b9a2e2f34..2f794e6be0 100644 --- a/backend/.sqlx/query-8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a.json +++ b/backend/.sqlx/query-386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))", + "query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))", "describe": { "columns": [ { @@ -15,6 +15,8 @@ "Jsonb", "Text", "Text", + "Bool", + "Text", "Bool" ] }, @@ -22,5 +24,5 @@ null ] }, - "hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a" + "hash": "386e14cf7572027f2c4ef313cd7cc5dd6c7b0da4f76131313d3e6d97f36c5c34" } diff --git a/backend/.sqlx/query-45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545.json b/backend/.sqlx/query-38b6c6cb91d3ba38838a7a015c59ecdad014ce319066eabec0f0d7f53338698a.json similarity index 75% rename from backend/.sqlx/query-45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545.json rename to backend/.sqlx/query-38b6c6cb91d3ba38838a7a015c59ecdad014ce319066eabec0f0d7f53338698a.json index e4db87ec7d..87d740a247 100644 --- a/backend/.sqlx/query-45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545.json +++ b/backend/.sqlx/query-38b6c6cb91d3ba38838a7a015c59ecdad014ce319066eabec0f0d7f53338698a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1", + "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER BY name", "describe": { "columns": [ { @@ -42,6 +42,11 @@ "ordinal": 7, "name": "is_fileset", "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "display_name", + "type_info": "Varchar" } ], "parameters": { @@ -57,8 +62,9 @@ true, true, true, - false + false, + true ] }, - "hash": "45d5e9ead8193a04fd00c44a488590fdd2f7c4de45117a18360651655d153545" + "hash": "38b6c6cb91d3ba38838a7a015c59ecdad014ce319066eabec0f0d7f53338698a" } diff --git a/backend/.sqlx/query-e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3.json b/backend/.sqlx/query-6f993567336a2f5ff642ed54e3aaf4d070f803b6192739e9ebaad7427ce59251.json similarity index 78% rename from backend/.sqlx/query-e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3.json rename to backend/.sqlx/query-6f993567336a2f5ff642ed54e3aaf4d070f803b6192739e9ebaad7427ce59251.json index c44d3d711d..e444c0d549 100644 --- a/backend/.sqlx/query-e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3.json +++ b/backend/.sqlx/query-6f993567336a2f5ff642ed54e3aaf4d070f803b6192739e9ebaad7427ce59251.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name", + "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name", "describe": { "columns": [ { @@ -42,6 +42,11 @@ "ordinal": 7, "name": "is_fileset", "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "display_name", + "type_info": "Varchar" } ], "parameters": { @@ -55,8 +60,9 @@ true, true, true, - false + false, + true ] }, - "hash": "e253b9e7e6450652589d6ee7ffa86d600e449cd399ac781af8b40c1c444972c3" + "hash": "6f993567336a2f5ff642ed54e3aaf4d070f803b6192739e9ebaad7427ce59251" } diff --git a/backend/.sqlx/query-623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4.json b/backend/.sqlx/query-82350027cf9722a993f27808e570e795ff9fb6b863dfe9c0bcad382c3b73a25b.json similarity index 78% rename from backend/.sqlx/query-623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4.json rename to backend/.sqlx/query-82350027cf9722a993f27808e570e795ff9fb6b863dfe9c0bcad382c3b73a25b.json index d2084d76d8..9f7be9d7cb 100644 --- a/backend/.sqlx/query-623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4.json +++ b/backend/.sqlx/query-82350027cf9722a993f27808e570e795ff9fb6b863dfe9c0bcad382c3b73a25b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')", + "query": "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1", "describe": { "columns": [ { @@ -42,11 +42,15 @@ "ordinal": 7, "name": "is_fileset", "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "display_name", + "type_info": "Varchar" } ], "parameters": { "Left": [ - "Text", "Text" ] }, @@ -58,8 +62,9 @@ true, true, true, - false + false, + true ] }, - "hash": "623b061ccaa6bb883e95771fde8c911a165c9c430b7db389370361ca74d737f4" + "hash": "82350027cf9722a993f27808e570e795ff9fb6b863dfe9c0bcad382c3b73a25b" } diff --git a/backend/.sqlx/query-1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986.json b/backend/.sqlx/query-86ad1e7ebe659f97877cc142c09676488dc6c80428e13e5db3c016e37072cfe4.json similarity index 52% rename from backend/.sqlx/query-1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986.json rename to backend/.sqlx/query-86ad1e7ebe659f97877cc142c09676488dc6c80428e13e5db3c016e37072cfe4.json index fc354fb9a2..f417ab94f9 100644 --- a/backend/.sqlx/query-1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986.json +++ b/backend/.sqlx/query-86ad1e7ebe659f97877cc142c09676488dc6c80428e13e5db3c016e37072cfe4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1", + "query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name)\n SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name\n FROM resource_type\n WHERE workspace_id = $1", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "1c2157ce14e90f0751d7f0a9f2dbb3c5a5789a32423e75260098a5300a4af986" + "hash": "86ad1e7ebe659f97877cc142c09676488dc6c80428e13e5db3c016e37072cfe4" } diff --git a/backend/.sqlx/query-972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9.json b/backend/.sqlx/query-972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9.json deleted file mode 100644 index 3624a32893..0000000000 --- a/backend/.sqlx/query-972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Jsonb", - "Text", - "Varchar", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9" -} diff --git a/backend/.sqlx/query-2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f.json b/backend/.sqlx/query-9a93affcf9eedcfebeadcd9c332c1f2f20becaf4fdda42479fb4ef2fd61f0df5.json similarity index 69% rename from backend/.sqlx/query-2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f.json rename to backend/.sqlx/query-9a93affcf9eedcfebeadcd9c332c1f2f20becaf4fdda42479fb4ef2fd61f0df5.json index e69c06f887..31cc68e11a 100644 --- a/backend/.sqlx/query-2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f.json +++ b/backend/.sqlx/query-9a93affcf9eedcfebeadcd9c332c1f2f20becaf4fdda42479fb4ef2fd61f0df5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT schema, description, format_extension, is_fileset\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2", + "query": "SELECT schema, description, format_extension, is_fileset, display_name\n FROM resource_type\n WHERE workspace_id = $1 AND name = $2", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "ordinal": 3, "name": "is_fileset", "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Varchar" } ], "parameters": { @@ -34,8 +39,9 @@ true, true, true, - false + false, + true ] }, - "hash": "2768622b76ad92c05f4f44d997aff285707e1a43ce85e5bb8e87849d78a0637f" + "hash": "9a93affcf9eedcfebeadcd9c332c1f2f20becaf4fdda42479fb4ef2fd61f0df5" } diff --git a/backend/.sqlx/query-5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00.json b/backend/.sqlx/query-dd6f4b505f4c1e2c734c5d04528c95bd6b3fb6ebf3ba115160573487cee4a606.json similarity index 64% rename from backend/.sqlx/query-5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00.json rename to backend/.sqlx/query-dd6f4b505f4c1e2c734c5d04528c95bd6b3fb6ebf3ba115160573487cee4a606.json index 400c8d9ee6..340b09ca27 100644 --- a/backend/.sqlx/query-5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00.json +++ b/backend/.sqlx/query-dd6f4b505f4c1e2c734c5d04528c95bd6b3fb6ebf3ba115160573487cee4a606.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now())", + "query": "INSERT INTO resource_type\n (workspace_id, name, schema, description, created_by, format_extension, is_fileset, display_name, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())", "describe": { "columns": [], "parameters": { @@ -11,10 +11,11 @@ "Text", "Varchar", "Varchar", - "Bool" + "Bool", + "Varchar" ] }, "nullable": [] }, - "hash": "5899c7614f195fdd23e38389e52b004f957aafa2201b80638b5f87a625373f00" + "hash": "dd6f4b505f4c1e2c734c5d04528c95bd6b3fb6ebf3ba115160573487cee4a606" } diff --git a/backend/migrations/20260914131148_resource_type_display_name.down.sql b/backend/migrations/20260914131148_resource_type_display_name.down.sql new file mode 100644 index 0000000000..57c9ffabc8 --- /dev/null +++ b/backend/migrations/20260914131148_resource_type_display_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE resource_type DROP COLUMN display_name; diff --git a/backend/migrations/20260914131148_resource_type_display_name.up.sql b/backend/migrations/20260914131148_resource_type_display_name.up.sql new file mode 100644 index 0000000000..68af132255 --- /dev/null +++ b/backend/migrations/20260914131148_resource_type_display_name.up.sql @@ -0,0 +1,22 @@ +-- The name a product goes by, beside the identifier a resource references: `gsheets` is +-- "Google Sheets". Null where nobody named the type; readers derive a label from the name. +ALTER TABLE resource_type ADD COLUMN display_name VARCHAR(100); + +-- The names the hub carries today, so existing instances show them before any sync. Not +-- limited to admins: a workspace that copied a hub type keeps the type's name. +UPDATE resource_type SET display_name = v.display_name +FROM (VALUES + ('bamboo_hr', 'BambooHR'), + ('cacertificate', 'CA certificate'), + ('deep_infra', 'DeepInfra'), + ('gcal', 'Google Calendar'), + ('gdocs', 'Google Docs'), + ('gdrive', 'Google Drive'), + ('gforms', 'Google Forms'), + ('gsheets', 'Google Sheets'), + ('gworkspace', 'Google Workspace'), + ('sensortower', 'Sensor Tower'), + ('snowflake_oauth', 'Snowflake (OAuth)'), + ('their_stack', 'TheirStack') +) AS v(name, display_name) +WHERE resource_type.name = v.name AND resource_type.display_name IS NULL; diff --git a/backend/src/main.rs b/backend/src/main.rs index 118795bd17..ce9f7efbdd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -409,6 +409,13 @@ struct HubResourceTypeRaw { /// Absent from hubs predating the column, and from caches written before it. #[serde(default)] pub format_extension: Option, + /// Doubly optional, so a hub predating the field (no key) is told apart from a type the + /// hub leaves unnamed (null). + #[serde( + default, + deserialize_with = "windmill_common::more_serde::double_option" + )] + pub display_name: Option>, } @@ -432,6 +439,14 @@ pub struct HubResourceType { skip_serializing_if = "Option::is_none" )] pub format_extension: Option>, + /// Doubly optional like `format_extension`: a cache written before the field leaves the + /// stored name alone, while a null from the hub clears it. + #[serde( + default, + deserialize_with = "windmill_common::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub display_name: Option>, } const HUB_RT_CACHE_FILE: &str = "resource_types.json"; @@ -479,6 +494,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> { app: rt.app, description: rt.description, format_extension: Some(rt.format_extension), + display_name: rt.display_name, }) }) .collect(); @@ -529,8 +545,9 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyh Option, Option, bool, + Option, )> = sqlx::query_as( - "SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'", + "SELECT name, schema, description, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = 'admins'", ) .fetch_all(db) .await @@ -538,12 +555,23 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyh let existing_map: std::collections::HashMap< String, - (Option, Option, Option, bool), + ( + Option, + Option, + Option, + bool, + Option, + ), > = existing_types .into_iter() - .map(|(name, schema, desc, format_extension, is_fileset)| { - (name, (schema, desc, format_extension, is_fileset)) - }) + .map( + |(name, schema, desc, format_extension, is_fileset, display_name)| { + ( + name, + (schema, desc, format_extension, is_fileset, display_name), + ) + }, + ) .collect(); let mut synced_count = 0; @@ -551,8 +579,9 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyh for rt in cached_types { let existing = existing_map.get(&rt.name); - let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false); - let stored_extension = existing.and_then(|(_, _, e, _)| e.clone()); + let is_fileset = existing.map(|(_, _, _, f, _)| *f).unwrap_or(false); + let stored_extension = existing.and_then(|(_, _, e, _, _)| e.clone()); + let stored_display_name = existing.and_then(|(_, _, _, _, n)| n.clone()); // A fileset is a set of files, so it cannot also be one file. Create, update // and the manual sync all reject the pair; this writer would otherwise // persist it onto a same-named local fileset. @@ -568,11 +597,17 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyh None => stored_extension.clone(), } }; + // No key in the cache leaves the stored name alone, as for the extension. + let display_name = match &rt.display_name { + Some(from_cache) => from_cache.clone(), + None => stored_display_name.clone(), + }; - if let Some((existing_schema, existing_desc, _, _)) = existing { + if let Some((existing_schema, existing_desc, _, _, _)) = existing { if existing_schema == &rt.schema && existing_desc == &rt.description && stored_extension == format_extension + && stored_display_name == display_name { skipped_count += 1; continue; @@ -584,16 +619,18 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool) -> anyh // `format_extension` is resolved above rather than coalesced here: a // COALESCE could never clear one, so a hub that dropped an extension // would leave the stale value behind forever. - "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at) - VALUES ('admins', $1, $2, $3, $4, now()) + "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at) + VALUES ('admins', $1, $2, $3, $4, $5, now()) ON CONFLICT (workspace_id, name) DO UPDATE SET schema = EXCLUDED.schema, description = EXCLUDED.description, - format_extension = EXCLUDED.format_extension, edited_at = now()", + format_extension = EXCLUDED.format_extension, + display_name = EXCLUDED.display_name, edited_at = now()", ) .bind(&rt.name) .bind(&rt.schema) .bind(&rt.description) .bind(&format_extension) + .bind(&display_name) .execute(db) .await .with_context(|| format!("Failed to upsert resource type {}", rt.name))?; diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 35816dfaa8..1de99fb44f 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -418,7 +418,7 @@ impl EmbeddingsDb { let hub_resource_types = response.json::>().await?; let resource_types: Vec = - sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type ORDER BY name",) + sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) .fetch_all(pg_db) .await?; diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 3f0b21b184..53648ec759 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -443,6 +443,30 @@ async fn test_resource_endpoints(db: Pool) -> anyhow::Result<()> { let body = resp.json::().await?; assert_eq!(body["description"], "Updated type desc"); + // display_name: an update that omits it, as a push from a CLI predating the field does, + // keeps it; an explicit null clears it. + for (update, expected) in [ + ( + json!({"display_name": "New Test Type"}), + json!("New Test Type"), + ), + ( + json!({"description": "Updated type desc"}), + json!("New Test Type"), + ), + (json!({"display_name": null}), serde_json::Value::Null), + ] { + let resp = authed(client().post(resource_url(port, "type/update", "new_test_type"))) + .json(&update) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let resp = authed_get(port, "type/get", "new_test_type").await; + let body = resp.json::().await?; + assert_eq!(body["display_name"], expected); + } + // type/delete let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type"))) .send() diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e7be0d5ede..c3a60f220a 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -2059,6 +2059,13 @@ struct CachedResourceType { deserialize_with = "windmill_common::more_serde::double_option" )] format_extension: Option>, + /// Doubly optional like `format_extension`: no key leaves the stored name alone, an explicit + /// null (the hub naming nothing) clears it. + #[serde( + default, + deserialize_with = "windmill_common::more_serde::double_option" + )] + display_name: Option>, } #[derive(serde::Deserialize)] @@ -2070,6 +2077,11 @@ struct HubResourceTypeRaw { description: Option, #[serde(default)] format_extension: Option, + #[serde( + default, + deserialize_with = "windmill_common::more_serde::double_option" + )] + display_name: Option>, } async fn fetch_resource_types_from_hub() -> error::Result> { @@ -2112,6 +2124,7 @@ async fn fetch_resource_types_from_hub() -> error::Result = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))", + "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4) AND ($7 IS NOT TRUE OR display_name IS NOT DISTINCT FROM $6))", &rt.name, rt.schema.as_ref(), rt.description.as_deref(), rt.format_extension.clone().flatten(), rt.format_extension.is_some(), + rt.display_name.clone().flatten(), + rt.display_name.is_some(), ) .fetch_one(&db) .await?; @@ -2183,8 +2198,8 @@ async fn sync_cached_resource_types( // Whether the payload carried the key at all is what decides: present // (even as null) is authoritative and may clear, absent means a cache // written before the column and must leave the stored value alone. - "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at) - VALUES ('admins', $1, $2, $3, $4, now()) + "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, display_name, edited_at) + VALUES ('admins', $1, $2, $3, $4, $6, now()) ON CONFLICT (workspace_id, name) DO UPDATE SET schema = EXCLUDED.schema, description = EXCLUDED.description, -- A fileset is a set of files, so it cannot also be one file. @@ -2195,12 +2210,15 @@ async fn sync_cached_resource_types( WHEN resource_type.is_fileset THEN NULL WHEN $5 THEN EXCLUDED.format_extension ELSE resource_type.format_extension END, + display_name = CASE WHEN $7 THEN EXCLUDED.display_name ELSE resource_type.display_name END, edited_at = now()", &rt.name, rt.schema.as_ref(), rt.description.as_deref(), rt.format_extension.clone().flatten(), rt.format_extension.is_some(), + rt.display_name.clone().flatten(), + rt.display_name.is_some(), ) .execute(&db) .await?; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 879e739dc0..4679f390f9 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -6466,8 +6466,8 @@ async fn clone_resource_types( target_workspace_id: &str, ) -> Result<()> { sqlx::query!( - "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset) - SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset + "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name) + SELECT $2, name, schema, description, edited_at, created_by, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1", source_workspace_id, @@ -11948,7 +11948,7 @@ async fn compare_two_resource_types( ) -> Result { // Get resource type from each workspace let source_resource_type = sqlx::query!( - "SELECT schema, description, format_extension, is_fileset + "SELECT schema, description, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1 AND name = $2", source_workspace_id, @@ -11958,7 +11958,7 @@ async fn compare_two_resource_types( .await?; let target_resource_type = sqlx::query!( - "SELECT schema, description, format_extension, is_fileset + "SELECT schema, description, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1 AND name = $2", fork_workspace_id, @@ -11975,6 +11975,7 @@ async fn compare_two_resource_types( || source.description != target.description || source.format_extension != target.format_extension || source.is_fileset != target.is_fileset + || source.display_name != target.display_name { has_changes = true; } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1fef8f844e..f817a4aa10 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8706,9 +8706,6 @@ paths: type: string picks: type: integer - display_name: - description: the label the hub curates for the resource type, absent where it names none - type: string required: - name - app @@ -30286,6 +30283,11 @@ components: type: string is_fileset: type: boolean + display_name: + type: string + description: >- + The name the product goes by, e.g. "Google Sheets" for gsheets. + Absent where nobody named the type. required: - name @@ -30303,6 +30305,12 @@ components: description: >- File extension for a type whose value is one file rather than a set of fields. Omit to leave it unchanged; send null to clear it. + display_name: + type: string + nullable: true + description: >- + The name the product goes by. Omit to leave it unchanged; send null + to clear it. TriggerHistoryEntry: type: object diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e0128e9e7a..baa899f22d 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -926,7 +926,7 @@ pub(crate) async fn tarball_workspace( if !skip_resource_types.unwrap_or(false) { let resource_types = sqlx::query_as!( ResourceType, - "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset FROM resource_type WHERE workspace_id = $1", + "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name FROM resource_type WHERE workspace_id = $1", &w_id ) .fetch_all(&mut *tx) diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index c4c63a3365..be5e6f6e95 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -118,6 +118,10 @@ pub struct ResourceType { pub edited_at: Option>, pub format_extension: Option, pub is_fileset: bool, + /// The name the product goes by (`gsheets` is "Google Sheets"), null where nobody named it. + /// Skipped when absent, so the type files of a synced repo gain nothing until one is set. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, } #[derive(Deserialize)] @@ -127,6 +131,7 @@ pub struct CreateResourceType { pub description: Option, pub format_extension: Option, pub is_fileset: Option, + pub display_name: Option, } #[derive(Deserialize)] @@ -143,6 +148,13 @@ pub struct EditResourceType { deserialize_with = "windmill_common::more_serde::double_option" )] pub format_extension: Option>, + /// Doubly optional for the same reason. A push from a CLI that predates the field omits it, + /// and must not clear a name the hub set. + #[serde( + default, + deserialize_with = "windmill_common::more_serde::double_option" + )] + pub display_name: Option>, } #[derive(FromRow, Serialize, Deserialize)] @@ -2560,7 +2572,7 @@ async fn list_resource_types( ) -> JsonResult> { let rows = sqlx::query_as!( ResourceType, - "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \ + "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'admins') ORDER \ BY name", &w_id ) @@ -2672,10 +2684,6 @@ struct HubResourceTypeEntry { /// fail the whole parse and take pick reporting — which needs just the id — with it. #[serde(default)] app: Option, - /// Raw: `None` where nobody named the type. The frontend derives those labels with its own - /// word casing, which a titleised guess from the hub would override. - #[serde(default)] - display_name: Option, } #[derive(Clone)] @@ -2686,7 +2694,6 @@ struct HubResourceType { /// hub knows that. Without it a workspace holding a `discord_webhook` resource looks /// like one that has never touched Discord. app: String, - display_name: Option, } /// Reads the index cache, choosing the TTL by what is stored: a failure expires far sooner @@ -2727,9 +2734,9 @@ async fn hub_resource_types( if !response.status().is_success() { return None; } - // Only the id, the app and the display name are kept. That listing carries every - // type's schema — around a megabyte — and neither reporting a pick, grouping types by - // integration nor labelling them needs it. + // Only the id and the app are kept. That listing carries every type's schema — + // around a megabyte — and neither reporting a pick nor grouping types by + // integration needs it. Some( response .json::>() @@ -2738,11 +2745,7 @@ async fn hub_resource_types( .into_iter() .map(|rt| { let app = rt.app.unwrap_or_else(|| rt.name.clone()); - let display_name = rt - .display_name - .map(|n| n.trim().to_string()) - .filter(|n| !n.is_empty()); - (rt.name, HubResourceType { id: rt.id, app, display_name }) + (rt.name, HubResourceType { id: rt.id, app }) }) .collect::>(), ) @@ -2822,7 +2825,7 @@ mod hub_picks_tests { let index = || { Some(HashMap::from([( "slack".to_string(), - HubResourceType { id: 1, app: "slack".to_string(), display_name: None }, + HubResourceType { id: 1, app: "slack".to_string() }, )])) }; @@ -2869,13 +2872,10 @@ struct HubResourceTypeInfo { /// integration rather than per type. app: String, picks: i64, - /// The label the hub curates for the type, absent where it names none. - #[serde(skip_serializing_if = "Option::is_none")] - display_name: Option, } -/// What the hub knows about its resource types: which integration each belongs to, what it -/// names each, and how often each has been picked. +/// What the hub knows about its resource types: which integration each belongs to, and how +/// often each has been picked. /// /// Empty rather than an error when the hub answers neither read, so the pickers treat an /// older or private hub as "no hub signal" and fall back to what the workspace itself uses. @@ -2929,7 +2929,6 @@ async fn list_hub_resource_type_info( picks: picks_by_name.remove(&name).unwrap_or(0), name, app: rt.app, - display_name: rt.display_name, }) .collect(); // What the index did not account for is a type the picks read knows and the listing does @@ -2938,12 +2937,7 @@ async fn list_hub_resource_type_info( info.extend( picks_by_name .into_iter() - .map(|(name, picks)| HubResourceTypeInfo { - app: name.clone(), - name, - picks, - display_name: None, - }), + .map(|(name, picks)| HubResourceTypeInfo { app: name.clone(), name, picks }), ); Ok(Json(info)) @@ -2958,7 +2952,7 @@ async fn get_resource_type( let resource_type_o = sqlx::query_as!( ResourceType, - "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')", + "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = 'admins')", &name, &w_id ) @@ -2986,6 +2980,20 @@ async fn exists_resource_type( Ok(Json(exists)) } +/// Trimmed, blank as none, and held to the column's 100 characters, so an over-long name is +/// refused with a message rather than a database error. +fn normalize_display_name(name: Option<&str>) -> Result> { + let Some(name) = name.map(str::trim).filter(|n| !n.is_empty()) else { + return Ok(None); + }; + if name.chars().count() > 100 { + return Err(Error::BadRequest( + "display_name must be at most 100 characters".to_string(), + )); + } + Ok(Some(name.to_string())) +} + async fn create_resource_type( authed: ApiAuthed, Extension(db): Extension, @@ -3019,11 +3027,12 @@ async fn create_resource_type( "A fileset resource type cannot have a format_extension".to_string(), )); } + let display_name = normalize_display_name(resource_type.display_name.as_deref())?; sqlx::query!( "INSERT INTO resource_type - (workspace_id, name, schema, description, created_by, format_extension, is_fileset, edited_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, now())", + (workspace_id, name, schema, description, created_by, format_extension, is_fileset, display_name, edited_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())", w_id, resource_type.name, resource_type.schema, @@ -3031,6 +3040,7 @@ async fn create_resource_type( authed.username, resource_type.format_extension, is_fileset, + display_name, ) .execute(&mut *tx) .await?; @@ -3198,6 +3208,12 @@ async fn update_resource_type( None => sqlb.set("format_extension", "NULL"), }; } + if let Some(display_name) = &ns.display_name { + match normalize_display_name(display_name.as_deref())? { + Some(name) => sqlb.set_str("display_name", name), + None => sqlb.set("display_name", "NULL"), + }; + } sqlb.set_str("edited_at", "now()"); let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; diff --git a/cli/src/commands/hub/hub.ts b/cli/src/commands/hub/hub.ts index f752147811..1a9c89680e 100644 --- a/cli/src/commands/hub/hub.ts +++ b/cli/src/commands/hub/hub.ts @@ -20,6 +20,9 @@ interface HubResourceType { // Absent from hubs predating the column, so a missing value is "ordinary type", // not "unset it". format_extension?: string | null; + // Null where nobody named the type, and absent from hubs predating the field, which + // leaves a stored name alone rather than clearing it. + display_name?: string | null; } export async function pull(opts: GlobalOptions) { @@ -120,7 +123,9 @@ export async function pull(opts: GlobalOptions) { deepEqual(y.schema, x.schema) && y.description === x.description && (y.is_fileset ?? false) === (x.is_fileset ?? false) && - (y.format_extension ?? null) === (x.format_extension ?? null) + (y.format_extension ?? null) === (x.format_extension ?? null) && + (x.display_name === undefined || + (y.display_name ?? null) === x.display_name) ) ) { log.info("skipping " + x.name + " (same as current)"); diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index fd4b72108e..505200f07a 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -28,6 +28,7 @@ export interface ResourceTypeFile { // Extension for a type whose value is one file rather than a set of fields; it // is what makes the resource editor a file editor for that language. format_extension?: string | null; + display_name?: string | null; } export async function pushResourceType( diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 150cc1d59f..34505932c3 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -9,6 +9,7 @@ resourceTypeDisplayName, resourceTypeMatchRank, resourceTypeSearchText, + setResourceTypeDisplayNames, sortResourceTypesByMatch } from './resourceTypeDisplay' import { @@ -45,7 +46,6 @@ alphabetical, byPopularity, hubResourceTypePicks, - loadHubResourceTypeDisplayNames, localResourceTypeCounts, recordHubResourceTypePick } from './pickerPopularity' @@ -341,9 +341,6 @@ if (!rt) { loadResourceTypes() loadPopularity() - } else if (effectiveWorkspace) { - // Opened straight on a type, so its title reads hub names nothing here has loaded yet. - void loadHubResourceTypeDisplayNames(effectiveWorkspace) } step = 1 //express && !manual ? 3 : 1 // The list is keyboard-driven from the search field, so it takes focus on open. @@ -478,6 +475,7 @@ // $derived, so search re-ranks when they land. ResourceService.listResourceType({ workspace: effectiveWorkspace }) .then((types) => { + setResourceTypeDisplayNames(types) resourceTypeDescriptions = Object.fromEntries( types.filter((t) => t.description).map((t) => [t.name, t.description!]) ) @@ -633,6 +631,7 @@ workspace: effectiveWorkspace, path: resourceType }) + setResourceTypeDisplayNames([resourceTypeInfo]) const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? [] diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte index 8da43728a6..e84d81b19a 100644 --- a/frontend/src/lib/components/ImportSetupStep.svelte +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -19,8 +19,11 @@ import { applyRetarget, seesWholeWorkspace } from '$lib/importWizard/retargetDeployed' import { OauthService } from '$lib/gen' import { registryCcCapableFor } from '$lib/components/oauthRegistry' - import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay' - import { loadHubResourceTypeDisplayNames } from '$lib/components/pickerPopularity' + import { + resourceTypeDisplayName, + setResourceTypeDisplayNames + } from '$lib/components/resourceTypeDisplay' + import { loadResourceTypeDisplayName } from '$lib/components/pickerPopularity' import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall' import { probeMigrationsApplied } from '$lib/importWizard/probe' import { @@ -199,9 +202,11 @@ */ const canConnectType = (rt: string) => instanceConnects.has(rt) || registryCcCapableFor(rt) - // Row labels read the hub's curated resource type names, which arrive after first render. + // A row blocked by a resource of another type names that type, whose row nothing here reads. $effect(() => { - loadHubResourceTypeDisplayNames(workspace) + for (const b of blanks) { + if (b.occupiedBy) void loadResourceTypeDisplayName(workspace, b.occupiedBy) + } }) let appConnect: AppConnectDrawer | undefined = $state(undefined) @@ -403,8 +408,9 @@ // row is kept instead; it just cannot name which fields are short. let requirementsUnknown = false try { - const schema = (await ResourceService.getResourceType({ workspace, path: r.resource_type })) - ?.schema as { required?: string[] } | undefined + const rt = await ResourceService.getResourceType({ workspace, path: r.resource_type }) + setResourceTypeDisplayNames([rt]) + const schema = rt?.schema as { required?: string[] } | undefined required = schema?.required ?? [] } catch { requirementsUnknown = true diff --git a/frontend/src/lib/components/LightweightResourcePicker.svelte b/frontend/src/lib/components/LightweightResourcePicker.svelte index 869f80b2ca..457e9aebba 100644 --- a/frontend/src/lib/components/LightweightResourcePicker.svelte +++ b/frontend/src/lib/components/LightweightResourcePicker.svelte @@ -10,7 +10,7 @@ import Select from './select/Select.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' - import { loadHubResourceTypeDisplayNames } from './pickerPopularity' + import { loadResourceTypeDisplayName } from './pickerPopularity' interface Props { value: string | undefined @@ -219,7 +219,7 @@ on:click={() => { refreshCount += 1 open = true - if (ws) void loadHubResourceTypeDisplayNames(ws) + if (ws && resourceType) void loadResourceTypeDisplayName(ws, resourceType) drawer?.openDrawer?.() }} startIcon={{ icon: Plus }} diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 1363daee4b..b61d582e26 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -18,7 +18,7 @@ import ResourceVersionHistory from './ResourceVersionHistory.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' - import { loadHubResourceTypeDisplayNames } from './pickerPopularity' + import { loadResourceTypeDisplayName } from './pickerPopularity' let { workspace = undefined, @@ -107,8 +107,8 @@ // rather than left where the last one put it: a new resource is a typed form, whoever was // looking at JSON before. viewJsonSchema = false - // The title names the type, and nothing else on the page may have loaded the hub's names. - void loadHubResourceTypeDisplayNames(effectiveWorkspace) + // The title names the type, whose row nothing else on the page may have read. + void loadResourceTypeDisplayName(effectiveWorkspace, resourceType) drawer?.openDrawer?.() } diff --git a/frontend/src/lib/components/ResourceTypePicker.svelte b/frontend/src/lib/components/ResourceTypePicker.svelte index 53dc0ed7a2..35d6593635 100644 --- a/frontend/src/lib/components/ResourceTypePicker.svelte +++ b/frontend/src/lib/components/ResourceTypePicker.svelte @@ -9,7 +9,11 @@ import Tooltip from './Tooltip.svelte' import Badge from './common/badge/Badge.svelte' import { untrack } from 'svelte' - import { resourceTypeSearchText, sortResourceTypesByMatch } from './resourceTypeDisplay' + import { + resourceTypeSearchText, + setResourceTypeDisplayNames, + sortResourceTypesByMatch + } from './resourceTypeDisplay' interface Props { value: string | undefined notPickable?: boolean @@ -22,6 +26,7 @@ async function loadResources() { const types = await ResourceService.listResourceType({ workspace: $workspaceStore! }) + setResourceTypeDisplayNames(types) resources = types.map((t) => ({ name: t.name, description: t.description, diff --git a/frontend/src/lib/components/pickerPopularity.ts b/frontend/src/lib/components/pickerPopularity.ts index b92668fdf7..dc48741813 100644 --- a/frontend/src/lib/components/pickerPopularity.ts +++ b/frontend/src/lib/components/pickerPopularity.ts @@ -5,7 +5,7 @@ import { createCache } from '$lib/utils' import { isCustomResourceTypeName, setHubIntegrationDisplayNames, - setHubResourceTypeDisplayNames + setResourceTypeDisplayNames } from './resourceTypeDisplay' /** @@ -21,14 +21,12 @@ export type PopularityCounts = Record */ const CACHE_MS = 60_000 -type HubResourceTypeInfo = { name: string; app: string; picks: number; display_name?: string } +type HubResourceTypeInfo = { name: string; app: string; picks: number } const hubInfoCached = createCache( async ({ workspace }: { workspace: string }): Promise => { try { - const info = await ResourceService.listHubResourceTypeInfo({ workspace }) - setHubResourceTypeDisplayNames(info) - return info + return await ResourceService.listHubResourceTypeInfo({ workspace }) } catch { return [] } @@ -59,13 +57,21 @@ export async function hubResourceTypePicks(workspace: string): Promise [rt.name, rt.picks])) } +const resourceTypeRowCached = createCache( + ({ workspace, name }: { workspace: string; name: string }) => + ResourceService.getResourceType({ workspace, path: name }).then( + (rt) => setResourceTypeDisplayNames([rt]), + () => {} + ), + { invalidateMs: CACHE_MS, maxSize: 50 } +) + /** - * Fill `resourceTypeDisplayName` with the hub's curated names, for a surface that shows type - * labels without ordering by picks. The pickers get them from the read they already make. + * Fill `resourceTypeDisplayName` for one type, for a surface titled with a type it holds no row + * for. The name is stored with the type, so this reads the row rather than the hub. */ -export async function loadHubResourceTypeDisplayNames(workspace: string): Promise { - if (get(disableHubStore)) return - await hubInfoCached({ workspace }) +export function loadResourceTypeDisplayName(workspace: string, name: string): Promise { + return resourceTypeRowCached({ workspace, name }) } const hubIntegrationNamesCached = createCache( diff --git a/frontend/src/lib/components/resourceTypeDisplay.ts b/frontend/src/lib/components/resourceTypeDisplay.ts index c6786058f2..a76f3c2479 100644 --- a/frontend/src/lib/components/resourceTypeDisplay.ts +++ b/frontend/src/lib/components/resourceTypeDisplay.ts @@ -182,23 +182,22 @@ const RESOURCE_TYPE_WORDS: Record = { } /** - * Names the hub curates, for the resource types and integrations no rule names (`gsheets` -> - * Google Sheets). The hub leaves the rest unnamed on purpose, so the word table above keeps - * deciding those. Reactive because they fill in after first render: a label already on screen - * updates when they land. + * Names for the items no rule names (`gsheets` -> Google Sheets): stored with resource types, + * and curated by the hub for integrations. Unnamed items are left to the word table above on + * purpose. Reactive because they fill in after first render: a label already on screen updates + * when they land. */ -const hubResourceTypeNames = new SvelteMap() +const resourceTypeNames = new SvelteMap() const hubIntegrationNames = new SvelteMap() -type HubNamed = { name: string; display_name?: string | null } +type Named = { name: string; display_name?: string | null } /** - * Updates only the entries the listing carries: one without a name drops any name kept for it, - * which is how switching to a hub predating display names reverts to inferred labels. Entries - * absent from the listing are left alone on purpose, since callers pass `kind`-filtered - * listings, and clearing those would wipe names another filter loaded. + * Updates only the entries it is given: one without a name drops any name kept for it, which is + * how a hub predating display names reverts to inferred labels. Entries it is not given are left + * alone on purpose, since callers pass single rows and `kind`-filtered listings. */ -function recordHubNames(names: SvelteMap, entries: HubNamed[]): void { +function recordNames(names: SvelteMap, entries: Named[]): void { for (const entry of entries) { const label = entry.display_name?.trim() if (label) names.set(entry.name, label) @@ -206,33 +205,12 @@ function recordHubNames(names: SvelteMap, entries: HubNamed[]): } } -export function setHubResourceTypeDisplayNames(types: HubNamed[]): void { - recordHubNames(hubResourceTypeNames, types) +export function setResourceTypeDisplayNames(types: Named[]): void { + recordNames(resourceTypeNames, types) } -export function setHubIntegrationDisplayNames(integrations: HubNamed[]): void { - recordHubNames(hubIntegrationNames, integrations) -} - -/** - * What the hub names these types, and the integrations that share their slugs, for an instance - * that cannot ask it: one with the hub switched off, or a hub predating display names. - */ -const RESOURCE_TYPE_NAMES: Record = { - adobe_acrobat_sign: 'Adobe Acrobat Sign', - bamboo_hr: 'BambooHR', - cacertificate: 'CA certificate', - deep_infra: 'DeepInfra', - gcal: 'Google Calendar', - gdocs: 'Google Docs', - gdrive: 'Google Drive', - gforms: 'Google Forms', - gsheets: 'Google Sheets', - gworkspace: 'Google Workspace', - ms_sql_server: 'Microsoft SQL Server', - sage_intacct: 'Sage Intacct', - sensortower: 'Sensor Tower', - their_stack: 'TheirStack' +export function setHubIntegrationDisplayNames(integrations: Named[]): void { + recordNames(hubIntegrationNames, integrations) } /** The prefix the resources page puts on a type created in a workspace. */ @@ -243,14 +221,15 @@ export function isCustomResourceTypeName(name: string): boolean { } /** - * Display name for a resource type: `adobe_acrobat_sign` -> `Adobe Acrobat Sign`, `mysql` -> - * `MySQL`, `c_acme_api` -> `Acme API`. A name the hub curates wins; everything else is - * inferred from the type name, the tables above covering what the inference gets wrong. + * Display name for a resource type: `gsheets` -> `Google Sheets` where the type stores that name, + * otherwise inferred from the type name (`mysql` -> `MySQL`, `c_acme_api` -> `Acme API`), the + * word table above covering what capitalizing gets wrong. */ export function resourceTypeDisplayName(name: string): string { - const exact = hubResourceTypeNames.get(name) ?? wholeName(name) - if (exact) return exact - return titleize(isCustomResourceTypeName(name) ? name.slice(CUSTOM_TYPE_PREFIX.length) : name) + return ( + resourceTypeNames.get(name) ?? + titleize(isCustomResourceTypeName(name) ? name.slice(CUSTOM_TYPE_PREFIX.length) : name) + ) } /** @@ -259,18 +238,13 @@ export function resourceTypeDisplayName(name: string): string { * that does not. */ export function integrationDisplayName(app: string): string { - return hubIntegrationNames.get(app) ?? wholeName(app) ?? titleize(app) -} - -// `Object.hasOwn` throughout: a name like `constructor` would otherwise resolve up the -// prototype chain of these object literals and render as a native function. -function wholeName(name: string): string | undefined { - return Object.hasOwn(RESOURCE_TYPE_NAMES, name) ? RESOURCE_TYPE_NAMES[name] : undefined + return hubIntegrationNames.get(app) ?? titleize(app) } /** * Lowercases each word before the lookup and splits on `-` too: a private hub can predate the - * slug rule, and still serve `aws-ses` or `RSS`. + * slug rule, and still serve `aws-ses` or `RSS`. `Object.hasOwn`, because a word like + * `constructor` would otherwise resolve up the object literal's prototype chain. */ function titleize(name: string): string { return name diff --git a/frontend/src/lib/components/resourceTypeMatchRank.test.ts b/frontend/src/lib/components/resourceTypeMatchRank.test.ts index ae795d4bcb..7b2ff03bd5 100644 --- a/frontend/src/lib/components/resourceTypeMatchRank.test.ts +++ b/frontend/src/lib/components/resourceTypeMatchRank.test.ts @@ -4,7 +4,7 @@ import { integrationDisplayName, resourceTypeDisplayName, setHubIntegrationDisplayNames, - setHubResourceTypeDisplayNames, + setResourceTypeDisplayNames, sortResourceTypesByMatch } from './resourceTypeDisplay' @@ -57,7 +57,7 @@ describe('sortResourceTypesByMatch', () => { }) describe('resourceTypeDisplayName', () => { - it('takes the whole-name override when there is one', () => { + it('cases each word from the word table', () => { expect(resourceTypeDisplayName('ms_sql_server')).toBe('Microsoft SQL Server') }) @@ -69,12 +69,13 @@ describe('resourceTypeDisplayName', () => { expect(resourceTypeDisplayName('stripe')).toBe('Stripe') }) - it("prefers the hub's curated name, and infers one where the hub names none", () => { - setHubResourceTypeDisplayNames([ - { name: 'snowflake_oauth', display_name: 'Snowflake (OAuth)' }, + it('prefers the name stored with the type, and infers one where it has none', () => { + expect(resourceTypeDisplayName('gsheets')).toBe('Gsheets') + setResourceTypeDisplayNames([ + { name: 'gsheets', display_name: 'Google Sheets' }, { name: 'smtp', display_name: null } ]) - expect(resourceTypeDisplayName('snowflake_oauth')).toBe('Snowflake (OAuth)') + expect(resourceTypeDisplayName('gsheets')).toBe('Google Sheets') expect(resourceTypeDisplayName('smtp')).toBe('SMTP') }) }) diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index d1ca52e60d..d5007a515f 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -25,6 +25,7 @@ import { buildResourceTypesFilterSchema } from '$lib/components/resources/resourceTypesFilter' import { resourceTypeSearchText, + setResourceTypeDisplayNames, sortResourceTypesByMatch } from '$lib/components/resourceTypeDisplay' import SharedBadge from '$lib/components/SharedBadge.svelte' @@ -328,14 +329,14 @@ } async function loadResourceTypes(): Promise { - resourceTypes = (await ResourceService.listResourceType({ workspace: $workspaceStore! })).map( - (x) => { - return { - canWrite: $workspaceStore! == x.workspace_id, - ...x - } + const rows = await ResourceService.listResourceType({ workspace: $workspaceStore! }) + setResourceTypeDisplayNames(rows) + resourceTypes = rows.map((x) => { + return { + canWrite: $workspaceStore! == x.workspace_id, + ...x } - ) + }) loading.types = false }