mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: store resource type display names and drop the hardcoded list
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f99047c698
commit
0c159f31ba
+20
@@ -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"
|
||||
}
|
||||
+10
-3
@@ -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"
|
||||
}
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+9
-4
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE resource_type DROP COLUMN display_name;
|
||||
@@ -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;
|
||||
+48
-11
@@ -409,6 +409,13 @@ struct HubResourceTypeRaw {
|
||||
/// Absent from hubs predating the column, and from caches written before it.
|
||||
#[serde(default)]
|
||||
pub format_extension: Option<String>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +439,14 @@ pub struct HubResourceType {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub format_extension: Option<Option<String>>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
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<sqlx::Postgres>) -> anyh
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
bool,
|
||||
Option<String>,
|
||||
)> = 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<sqlx::Postgres>) -> anyh
|
||||
|
||||
let existing_map: std::collections::HashMap<
|
||||
String,
|
||||
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
|
||||
(
|
||||
Option<serde_json::Value>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
bool,
|
||||
Option<String>,
|
||||
),
|
||||
> = 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<sqlx::Postgres>) -> 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<sqlx::Postgres>) -> 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<sqlx::Postgres>) -> 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))?;
|
||||
|
||||
@@ -418,7 +418,7 @@ impl EmbeddingsDb {
|
||||
let hub_resource_types = response.json::<Vec<HubResourceType>>().await?;
|
||||
|
||||
let resource_types: Vec<ResourceType> =
|
||||
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?;
|
||||
|
||||
|
||||
@@ -443,6 +443,30 @@ async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let body = resp.json::<serde_json::Value>().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::<serde_json::Value>().await?;
|
||||
assert_eq!(body["display_name"], expected);
|
||||
}
|
||||
|
||||
// type/delete
|
||||
let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type")))
|
||||
.send()
|
||||
|
||||
@@ -2059,6 +2059,13 @@ struct CachedResourceType {
|
||||
deserialize_with = "windmill_common::more_serde::double_option"
|
||||
)]
|
||||
format_extension: Option<Option<String>>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -2070,6 +2077,11 @@ struct HubResourceTypeRaw {
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
format_extension: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "windmill_common::more_serde::double_option"
|
||||
)]
|
||||
display_name: Option<Option<String>>,
|
||||
}
|
||||
|
||||
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
|
||||
@@ -2112,6 +2124,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
|
||||
app: rt.app,
|
||||
description: rt.description,
|
||||
format_extension: Some(rt.format_extension),
|
||||
display_name: rt.display_name,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
@@ -2165,12 +2178,14 @@ async fn sync_cached_resource_types(
|
||||
|
||||
for rt in &resource_types {
|
||||
let exists: Option<bool> = 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?;
|
||||
|
||||
@@ -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<ItemComparison> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -118,6 +118,10 @@ pub struct ResourceType {
|
||||
pub edited_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub format_extension: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -127,6 +131,7 @@ pub struct CreateResourceType {
|
||||
pub description: Option<String>,
|
||||
pub format_extension: Option<String>,
|
||||
pub is_fileset: Option<bool>,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -143,6 +148,13 @@ pub struct EditResourceType {
|
||||
deserialize_with = "windmill_common::more_serde::double_option"
|
||||
)]
|
||||
pub format_extension: Option<Option<String>>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
@@ -2560,7 +2572,7 @@ async fn list_resource_types(
|
||||
) -> JsonResult<Vec<ResourceType>> {
|
||||
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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
/// 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::<Vec<HubResourceTypeEntry>>()
|
||||
@@ -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::<HashMap<String, HubResourceType>>(),
|
||||
)
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Option<String>> {
|
||||
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<DB>,
|
||||
@@ -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?;
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
|
||||
const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createCache } from '$lib/utils'
|
||||
import {
|
||||
isCustomResourceTypeName,
|
||||
setHubIntegrationDisplayNames,
|
||||
setHubResourceTypeDisplayNames
|
||||
setResourceTypeDisplayNames
|
||||
} from './resourceTypeDisplay'
|
||||
|
||||
/**
|
||||
@@ -21,14 +21,12 @@ export type PopularityCounts = Record<string, number>
|
||||
*/
|
||||
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<HubResourceTypeInfo[]> => {
|
||||
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<Popularit
|
||||
return Object.fromEntries(info.map((rt) => [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<void> {
|
||||
if (get(disableHubStore)) return
|
||||
await hubInfoCached({ workspace })
|
||||
export function loadResourceTypeDisplayName(workspace: string, name: string): Promise<void> {
|
||||
return resourceTypeRowCached({ workspace, name })
|
||||
}
|
||||
|
||||
const hubIntegrationNamesCached = createCache(
|
||||
|
||||
@@ -182,23 +182,22 @@ const RESOURCE_TYPE_WORDS: Record<string, string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>()
|
||||
const resourceTypeNames = new SvelteMap<string, string>()
|
||||
const hubIntegrationNames = new SvelteMap<string, string>()
|
||||
|
||||
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<string, string>, entries: HubNamed[]): void {
|
||||
function recordNames(names: SvelteMap<string, string>, 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<string, string>, 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<string, string> = {
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user