From db4813ebd1d1a38df6a6477698f7975cd55ff3ce Mon Sep 17 00:00:00 2001 From: Boxel Submission Bot Date: Mon, 6 Apr 2026 14:19:40 +0800 Subject: [PATCH] add Sprint Planner Card Definition changes [boxel-content-hash:1c4e4f62b8f0] --- .../83a85da9-c0ba-4a6b-8281-316755c059c9.json | 194 ++++++ CatalogEntry/sprint-planner.json | 40 ++ CatalogEntry/sprint-task.json | 20 + CatalogEntry/tag.json | 20 + CatalogEntry/task.json | 20 + CatalogEntry/team-member.json | 20 + CatalogEntry/team.json | 20 + CatalogEntry/todo.json | 20 + .../3d6c0d68-b20c-4da4-b0bc-850d81632df4.json | 16 + .../44a3947e-0393-434b-9d4a-58c898f08cff.json | 16 + .../6f0d4f5d-9324-492a-a478-bc9428fcfb0e.json | 16 + .../9b43240d-8c9c-42bc-a4d1-f9c626661562.json | 16 + .../0e722749-8c87-4e7b-bec9-0dffc9ddd43e.json | 40 ++ .../120e7227-498c-475e-bb3e-c90dffc9ddd4.json | 40 ++ .../12120e72-2749-4c87-9e7b-3ec90dffc9dd.json | 40 ++ .../144b2f15-6bca-4ffc-8e76-03f080cc3f5a.json | 40 ++ .../27144b2f-156b-4a0f-bc4e-7603f080cc3f.json | 40 ++ .../27498c87-5e7b-4ec9-8dff-c9ddd43e899d.json | 40 ++ .../2e27144b-2f15-4bca-8ffc-4e7603f080cc.json | 40 ++ .../40ed8d64-31a8-48ad-8112-120e7227498c.json | 40 ++ .../4b2f156b-ca0f-4c4e-b603-f080cc3f5a3d.json | 40 ++ .../4e7603f0-80cc-4f5a-bdc2-d833733c12ee.json | 40 ++ .../7227498c-875e-4b3e-890d-ffc9ddd43e89.json | 40 ++ .../8112120e-7227-498c-875e-7b3ec90dffc9.json | 40 ++ .../99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json | 40 ++ .../ed8d6431-a8c8-4d81-9212-0e7227498c87.json | 40 ++ .../ee40ed8d-6431-48c8-ad81-12120e722749.json | 40 ++ .../fc4e7603-f080-4c3f-9a3d-c2d833733c12.json | 40 ++ .../4b108a8b-eac0-4e64-8c42-c3a0a53c1762.json | 23 + .../661fcc09-b5be-4e1f-8d88-c8a743d29b3a.json | 23 + .../6bacffa6-952f-4c49-b0e3-ad0e5bf4f899.json | 31 + .../803476a4-1de3-4c24-9194-1a4650e1077f.json | 23 + components/base-task-planner.gts | 618 +++++++++++++++++ components/filter/filter-display.gts | 64 ++ components/filter/filter-dropdown-item.gts | 96 +++ components/filter/filter-dropdown.gts | 50 ++ components/filter/filter-trigger.gts | 48 ++ kanban-resource.gts | 92 +++ sprint-planner.gts | 238 +++++++ sprint-task.gts | 537 +++++++++++++++ tag.gts | 33 + task.gts | 648 ++++++++++++++++++ todo.gts | 13 + user.gts | 18 + 44 files changed, 3613 insertions(+) create mode 100644 CardListing/83a85da9-c0ba-4a6b-8281-316755c059c9.json create mode 100644 CatalogEntry/sprint-planner.json create mode 100644 CatalogEntry/sprint-task.json create mode 100644 CatalogEntry/tag.json create mode 100644 CatalogEntry/task.json create mode 100644 CatalogEntry/team-member.json create mode 100644 CatalogEntry/team.json create mode 100644 CatalogEntry/todo.json create mode 100644 Project/3d6c0d68-b20c-4da4-b0bc-850d81632df4.json create mode 100644 Project/44a3947e-0393-434b-9d4a-58c898f08cff.json create mode 100644 Project/6f0d4f5d-9324-492a-a478-bc9428fcfb0e.json create mode 100644 Project/9b43240d-8c9c-42bc-a4d1-f9c626661562.json create mode 100644 Spec/0e722749-8c87-4e7b-bec9-0dffc9ddd43e.json create mode 100644 Spec/120e7227-498c-475e-bb3e-c90dffc9ddd4.json create mode 100644 Spec/12120e72-2749-4c87-9e7b-3ec90dffc9dd.json create mode 100644 Spec/144b2f15-6bca-4ffc-8e76-03f080cc3f5a.json create mode 100644 Spec/27144b2f-156b-4a0f-bc4e-7603f080cc3f.json create mode 100644 Spec/27498c87-5e7b-4ec9-8dff-c9ddd43e899d.json create mode 100644 Spec/2e27144b-2f15-4bca-8ffc-4e7603f080cc.json create mode 100644 Spec/40ed8d64-31a8-48ad-8112-120e7227498c.json create mode 100644 Spec/4b2f156b-ca0f-4c4e-b603-f080cc3f5a3d.json create mode 100644 Spec/4e7603f0-80cc-4f5a-bdc2-d833733c12ee.json create mode 100644 Spec/7227498c-875e-4b3e-890d-ffc9ddd43e89.json create mode 100644 Spec/8112120e-7227-498c-875e-7b3ec90dffc9.json create mode 100644 Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json create mode 100644 Spec/ed8d6431-a8c8-4d81-9212-0e7227498c87.json create mode 100644 Spec/ee40ed8d-6431-48c8-ad81-12120e722749.json create mode 100644 Spec/fc4e7603-f080-4c3f-9a3d-c2d833733c12.json create mode 100644 SprintPlanner/4b108a8b-eac0-4e64-8c42-c3a0a53c1762.json create mode 100644 SprintPlanner/661fcc09-b5be-4e1f-8d88-c8a743d29b3a.json create mode 100644 SprintPlanner/6bacffa6-952f-4c49-b0e3-ad0e5bf4f899.json create mode 100644 SprintPlanner/803476a4-1de3-4c24-9194-1a4650e1077f.json create mode 100644 components/base-task-planner.gts create mode 100644 components/filter/filter-display.gts create mode 100644 components/filter/filter-dropdown-item.gts create mode 100644 components/filter/filter-dropdown.gts create mode 100644 components/filter/filter-trigger.gts create mode 100644 kanban-resource.gts create mode 100644 sprint-planner.gts create mode 100644 sprint-task.gts create mode 100644 tag.gts create mode 100644 task.gts create mode 100644 todo.gts create mode 100644 user.gts diff --git a/CardListing/83a85da9-c0ba-4a6b-8281-316755c059c9.json b/CardListing/83a85da9-c0ba-4a6b-8281-316755c059c9.json new file mode 100644 index 0000000..f7be119 --- /dev/null +++ b/CardListing/83a85da9-c0ba-4a6b-8281-316755c059c9.json @@ -0,0 +1,194 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "CardListing", + "module": "https://realms-staging.stack.cards/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Sprint Planner Card Definition", + "images": [], + "summary": "The SprintPlanner is a card component designed to visualize and manage tasks within a sprint using a Kanban-style interface. Its primary purpose is to facilitate project planning and tracking by organizing tasks into columns based on their status. The component supports creating, moving, and editing tasks, with filters for status and assignee, integrated with project and realm data sources. It provides an interactive, drag-and-drop task management experience optimized for sprint planning workflows.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "specs.0": { + "links": { + "self": "../CatalogEntry/sprint-planner" + } + }, + "specs.1": { + "links": { + "self": "../Spec/2e27144b-2f15-4bca-8ffc-4e7603f080cc" + } + }, + "specs.2": { + "links": { + "self": "../Spec/4e7603f0-80cc-4f5a-bdc2-d833733c12ee" + } + }, + "specs.3": { + "links": { + "self": "../Spec/27144b2f-156b-4a0f-bc4e-7603f080cc3f" + } + }, + "specs.4": { + "links": { + "self": "../Spec/144b2f15-6bca-4ffc-8e76-03f080cc3f5a" + } + }, + "specs.5": { + "links": { + "self": "../Spec/4b2f156b-ca0f-4c4e-b603-f080cc3f5a3d" + } + }, + "specs.6": { + "links": { + "self": "../Spec/fc4e7603-f080-4c3f-9a3d-c2d833733c12" + } + }, + "specs.7": { + "links": { + "self": "../CatalogEntry/team" + } + }, + "specs.8": { + "links": { + "self": "../CatalogEntry/team-member" + } + }, + "specs.9": { + "links": { + "self": "../Spec/ed8d6431-a8c8-4d81-9212-0e7227498c87" + } + }, + "specs.10": { + "links": { + "self": "../Spec/ee40ed8d-6431-48c8-ad81-12120e722749" + } + }, + "specs.11": { + "links": { + "self": "../Spec/40ed8d64-31a8-48ad-8112-120e7227498c" + } + }, + "specs.12": { + "links": { + "self": "../CatalogEntry/sprint-task" + } + }, + "specs.13": { + "links": { + "self": "../CatalogEntry/tag" + } + }, + "specs.14": { + "links": { + "self": "../Spec/8112120e-7227-498c-875e-7b3ec90dffc9" + } + }, + "specs.15": { + "links": { + "self": "../Spec/120e7227-498c-475e-bb3e-c90dffc9ddd4" + } + }, + "specs.16": { + "links": { + "self": "../Spec/7227498c-875e-4b3e-890d-ffc9ddd43e89" + } + }, + "specs.17": { + "links": { + "self": "../Spec/0e722749-8c87-4e7b-bec9-0dffc9ddd43e" + } + }, + "specs.18": { + "links": { + "self": "../CatalogEntry/task" + } + }, + "specs.19": { + "links": { + "self": "../Spec/12120e72-2749-4c87-9e7b-3ec90dffc9dd" + } + }, + "specs.20": { + "links": { + "self": "../Spec/27498c87-5e7b-4ec9-8dff-c9ddd43e899d" + } + }, + "specs.21": { + "links": { + "self": "../CatalogEntry/todo" + } + }, + "specs.22": { + "links": { + "self": "../Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78" + } + }, + "skills": { + "links": { + "self": null + } + }, + "tags.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/631d1b5d-fcd0-465c-964e-e535fc6bb893" + } + }, + "tags.1": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/b793225c-32f9-404c-b2a9-b4041b93090c" + } + }, + "license": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "examples.0": { + "links": { + "self": "../SprintPlanner/4b108a8b-eac0-4e64-8c42-c3a0a53c1762" + } + }, + "examples.1": { + "links": { + "self": "../SprintPlanner/803476a4-1de3-4c24-9194-1a4650e1077f" + } + }, + "examples.2": { + "links": { + "self": "../SprintPlanner/6bacffa6-952f-4c49-b0e3-ad0e5bf4f899" + } + }, + "examples.3": { + "links": { + "self": "../SprintPlanner/661fcc09-b5be-4e1f-8d88-c8a743d29b3a" + } + }, + "categories.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Category/project-management" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/CatalogEntry/sprint-planner.json b/CatalogEntry/sprint-planner.json new file mode 100644 index 0000000..9071c42 --- /dev/null +++ b/CatalogEntry/sprint-planner.json @@ -0,0 +1,40 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Spec", + "module": "https://cardstack.com/base/spec" + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "SprintPlanner", + "module": "../sprint-planner" + }, + "readMe": "**Summary**\nThe `SprintPlanner` spec is a card definition that provides a Kanban-style task planner for a given project. It allows users to create, manage, and move tasks within the planner.\n\n**Import**\n```js\nimport { SprintPlanner } from 'https://realms-staging.stack.cards/experiments/sprint-planner';\n```\n\n**Usage as a Field**\nTo use the `SprintPlanner` as a field within a consuming card or field, you can add it as a `linksTo` field:\n\n```gts\n@field sprintPlanner = linksTo(SprintPlanner);\n```\n\nThis will allow you to link a `SprintPlanner` instance to the consuming card.\n\n**Template Usage**\nTo display the `SprintPlanner` within a template, you can use the following:\n\n```hbs\n<@fields.sprintPlanner />\n```\n\nThis will render the `SprintPlanner` card in the isolated format.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "specType": "app", + "cardTitle": null, + "cardDescription": null, + "containedExamples": [] + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/CatalogEntry/sprint-task.json b/CatalogEntry/sprint-task.json new file mode 100644 index 0000000..1166683 --- /dev/null +++ b/CatalogEntry/sprint-task.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Sprint Task", + "description": "Spec for Sprint Task", + "ref": { + "name": "SprintTask", + "module": "../sprint-task" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/tag.json b/CatalogEntry/tag.json new file mode 100644 index 0000000..7f2992d --- /dev/null +++ b/CatalogEntry/tag.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Tag", + "description": "Spec for Tag", + "ref": { + "name": "Tag", + "module": "../tag" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/task.json b/CatalogEntry/task.json new file mode 100644 index 0000000..47793f7 --- /dev/null +++ b/CatalogEntry/task.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Task", + "description": "Spec for Task", + "ref": { + "name": "Task", + "module": "../task" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/team-member.json b/CatalogEntry/team-member.json new file mode 100644 index 0000000..26880c5 --- /dev/null +++ b/CatalogEntry/team-member.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Team Member", + "description": "Spec for Team Member", + "ref": { + "name": "TeamMember", + "module": "../sprint-task" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/team.json b/CatalogEntry/team.json new file mode 100644 index 0000000..8546a95 --- /dev/null +++ b/CatalogEntry/team.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Team", + "description": "Spec for Team", + "ref": { + "name": "Team", + "module": "../sprint-task" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/todo.json b/CatalogEntry/todo.json new file mode 100644 index 0000000..4b705bc --- /dev/null +++ b/CatalogEntry/todo.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Todo", + "description": "Spec for Todo", + "ref": { + "name": "Todo", + "module": "../todo" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/Project/3d6c0d68-b20c-4da4-b0bc-850d81632df4.json b/Project/3d6c0d68-b20c-4da4-b0bc-850d81632df4.json new file mode 100644 index 0000000..cc66c4f --- /dev/null +++ b/Project/3d6c0d68-b20c-4da4-b0bc-850d81632df4.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "20250218 test sprint planner", + "description": "20250218 test sprint planner", + "thumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-task", + "name": "Project" + } + } + } +} \ No newline at end of file diff --git a/Project/44a3947e-0393-434b-9d4a-58c898f08cff.json b/Project/44a3947e-0393-434b-9d4a-58c898f08cff.json new file mode 100644 index 0000000..ec2cfc8 --- /dev/null +++ b/Project/44a3947e-0393-434b-9d4a-58c898f08cff.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "20250221 test project", + "description": "20250221 test project", + "thumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-task", + "name": "Project" + } + } + } +} \ No newline at end of file diff --git a/Project/6f0d4f5d-9324-492a-a478-bc9428fcfb0e.json b/Project/6f0d4f5d-9324-492a-a478-bc9428fcfb0e.json new file mode 100644 index 0000000..6ae385a --- /dev/null +++ b/Project/6f0d4f5d-9324-492a-a478-bc9428fcfb0e.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "20250207 test proj", + "description": null, + "thumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-task", + "name": "Project" + } + } + } +} \ No newline at end of file diff --git a/Project/9b43240d-8c9c-42bc-a4d1-f9c626661562.json b/Project/9b43240d-8c9c-42bc-a4d1-f9c626661562.json new file mode 100644 index 0000000..b644db5 --- /dev/null +++ b/Project/9b43240d-8c9c-42bc-a4d1-f9c626661562.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Miscellaneous Backlog", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-task", + "name": "Project" + } + } + } +} diff --git a/Spec/0e722749-8c87-4e7b-bec9-0dffc9ddd43e.json b/Spec/0e722749-8c87-4e7b-bec9-0dffc9ddd43e.json new file mode 100644 index 0000000..62e565b --- /dev/null +++ b/Spec/0e722749-8c87-4e7b-bec9-0dffc9ddd43e.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../task", + "name": "TaskPriority" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Field", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/120e7227-498c-475e-bb3e-c90dffc9ddd4.json b/Spec/120e7227-498c-475e-bb3e-c90dffc9ddd4.json new file mode 100644 index 0000000..ef25b5b --- /dev/null +++ b/Spec/120e7227-498c-475e-bb3e-c90dffc9ddd4.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../task", + "name": "TaskStatusField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Field", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/12120e72-2749-4c87-9e7b-3ec90dffc9dd.json b/Spec/12120e72-2749-4c87-9e7b-3ec90dffc9dd.json new file mode 100644 index 0000000..4cbcada --- /dev/null +++ b/Spec/12120e72-2749-4c87-9e7b-3ec90dffc9dd.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../task", + "name": "getDueDateStatus" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "getDueDateStatus", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/144b2f15-6bca-4ffc-8e76-03f080cc3f5a.json b/Spec/144b2f15-6bca-4ffc-8e76-03f080cc3f5a.json new file mode 100644 index 0000000..c781b46 --- /dev/null +++ b/Spec/144b2f15-6bca-4ffc-8e76-03f080cc3f5a.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/filter/filter-dropdown-item", + "name": "StatusPill" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "StatusPill", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/27144b2f-156b-4a0f-bc4e-7603f080cc3f.json b/Spec/27144b2f-156b-4a0f-bc4e-7603f080cc3f.json new file mode 100644 index 0000000..726f53a --- /dev/null +++ b/Spec/27144b2f-156b-4a0f-bc4e-7603f080cc3f.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/filter/filter-dropdown", + "name": "FilterDropdown" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "FilterDropdown", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/27498c87-5e7b-4ec9-8dff-c9ddd43e899d.json b/Spec/27498c87-5e7b-4ec9-8dff-c9ddd43e899d.json new file mode 100644 index 0000000..d59a6db --- /dev/null +++ b/Spec/27498c87-5e7b-4ec9-8dff-c9ddd43e899d.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../task", + "name": "TaskCompletionStatus" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "TaskCompletionStatus", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/2e27144b-2f15-4bca-8ffc-4e7603f080cc.json b/Spec/2e27144b-2f15-4bca-8ffc-4e7603f080cc.json new file mode 100644 index 0000000..dad8696 --- /dev/null +++ b/Spec/2e27144b-2f15-4bca-8ffc-4e7603f080cc.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/base-task-planner", + "name": "TaskPlanner" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "TaskPlanner", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/40ed8d64-31a8-48ad-8112-120e7227498c.json b/Spec/40ed8d64-31a8-48ad-8112-120e7227498c.json new file mode 100644 index 0000000..db5122a --- /dev/null +++ b/Spec/40ed8d64-31a8-48ad-8112-120e7227498c.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../sprint-task", + "name": "SprintTaskStatusField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Field", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/4b2f156b-ca0f-4c4e-b603-f080cc3f5a3d.json b/Spec/4b2f156b-ca0f-4c4e-b603-f080cc3f5a3d.json new file mode 100644 index 0000000..d1f1f0f --- /dev/null +++ b/Spec/4b2f156b-ca0f-4c4e-b603-f080cc3f5a3d.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/filter/filter-trigger", + "name": "FilterTrigger" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "FilterTrigger", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/4e7603f0-80cc-4f5a-bdc2-d833733c12ee.json b/Spec/4e7603f0-80cc-4f5a-bdc2-d833733c12ee.json new file mode 100644 index 0000000..3b20b60 --- /dev/null +++ b/Spec/4e7603f0-80cc-4f5a-bdc2-d833733c12ee.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/filter/filter-display", + "name": "FilterDisplay" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "FilterDisplay", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/7227498c-875e-4b3e-890d-ffc9ddd43e89.json b/Spec/7227498c-875e-4b3e-890d-ffc9ddd43e89.json new file mode 100644 index 0000000..e3c9eb2 --- /dev/null +++ b/Spec/7227498c-875e-4b3e-890d-ffc9ddd43e89.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../task", + "name": "FittedTask" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "FittedTask", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/8112120e-7227-498c-875e-7b3ec90dffc9.json b/Spec/8112120e-7227-498c-875e-7b3ec90dffc9.json new file mode 100644 index 0000000..993a381 --- /dev/null +++ b/Spec/8112120e-7227-498c-875e-7b3ec90dffc9.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../task", + "name": "TaskStatusEdit" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "TaskStatusEdit", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json b/Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json new file mode 100644 index 0000000..8a65ef2 --- /dev/null +++ b/Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../user", + "name": "User" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "User", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/ed8d6431-a8c8-4d81-9212-0e7227498c87.json b/Spec/ed8d6431-a8c8-4d81-9212-0e7227498c87.json new file mode 100644 index 0000000..c4cbd48 --- /dev/null +++ b/Spec/ed8d6431-a8c8-4d81-9212-0e7227498c87.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../sprint-task", + "name": "Project" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Project", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/ee40ed8d-6431-48c8-ad81-12120e722749.json b/Spec/ee40ed8d-6431-48c8-ad81-12120e722749.json new file mode 100644 index 0000000..cc1ae69 --- /dev/null +++ b/Spec/ee40ed8d-6431-48c8-ad81-12120e722749.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../sprint-task", + "name": "Issues" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Issues", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/fc4e7603-f080-4c3f-9a3d-c2d833733c12.json b/Spec/fc4e7603-f080-4c3f-9a3d-c2d833733c12.json new file mode 100644 index 0000000..c0dfa54 --- /dev/null +++ b/Spec/fc4e7603-f080-4c3f-9a3d-c2d833733c12.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../kanban-resource", + "name": "default" + }, + "specType": null, + "containedExamples": [], + "cardTitle": "default", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/SprintPlanner/4b108a8b-eac0-4e64-8c42-c3a0a53c1762.json b/SprintPlanner/4b108a8b-eac0-4e64-8c42-c3a0a53c1762.json new file mode 100644 index 0000000..cb57b16 --- /dev/null +++ b/SprintPlanner/4b108a8b-eac0-4e64-8c42-c3a0a53c1762.json @@ -0,0 +1,23 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": null, + "description": null, + "thumbnailURL": null + }, + "relationships": { + "project": { + "links": { + "self": "../Project/3d6c0d68-b20c-4da4-b0bc-850d81632df4" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-planner", + "name": "SprintPlanner" + } + } + } +} \ No newline at end of file diff --git a/SprintPlanner/661fcc09-b5be-4e1f-8d88-c8a743d29b3a.json b/SprintPlanner/661fcc09-b5be-4e1f-8d88-c8a743d29b3a.json new file mode 100644 index 0000000..44fee50 --- /dev/null +++ b/SprintPlanner/661fcc09-b5be-4e1f-8d88-c8a743d29b3a.json @@ -0,0 +1,23 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "20250207 test", + "description": null, + "thumbnailURL": null + }, + "relationships": { + "project": { + "links": { + "self": "../Project/6f0d4f5d-9324-492a-a478-bc9428fcfb0e" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-planner", + "name": "SprintPlanner" + } + } + } +} \ No newline at end of file diff --git a/SprintPlanner/6bacffa6-952f-4c49-b0e3-ad0e5bf4f899.json b/SprintPlanner/6bacffa6-952f-4c49-b0e3-ad0e5bf4f899.json new file mode 100644 index 0000000..70c134e --- /dev/null +++ b/SprintPlanner/6bacffa6-952f-4c49-b0e3-ad0e5bf4f899.json @@ -0,0 +1,31 @@ +{ + "data": { + "type": "card", + "attributes": { + "tabs": [], + "headerIcon": { + "altText": null, + "size": "actual", + "height": null, + "width": null, + "base64": null + }, + "moduleId": null, + "description": null, + "thumbnailURL": null + }, + "relationships": { + "project": { + "links": { + "self": "../Project/9b43240d-8c9c-42bc-a4d1-f9c626661562" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-planner", + "name": "SprintPlanner" + } + } + } +} diff --git a/SprintPlanner/803476a4-1de3-4c24-9194-1a4650e1077f.json b/SprintPlanner/803476a4-1de3-4c24-9194-1a4650e1077f.json new file mode 100644 index 0000000..0d5f249 --- /dev/null +++ b/SprintPlanner/803476a4-1de3-4c24-9194-1a4650e1077f.json @@ -0,0 +1,23 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "20250221 test sprint", + "description": null, + "thumbnailURL": null + }, + "relationships": { + "project": { + "links": { + "self": "../Project/44a3947e-0393-434b-9d4a-58c898f08cff" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../sprint-planner", + "name": "SprintPlanner" + } + } + } +} \ No newline at end of file diff --git a/components/base-task-planner.gts b/components/base-task-planner.gts new file mode 100644 index 0000000..6c08b03 --- /dev/null +++ b/components/base-task-planner.gts @@ -0,0 +1,618 @@ +import { + CardContext, + CardDef, + BaseDef, +} from 'https://cardstack.com/base/card-api'; +import { tracked } from '@glimmer/tracking'; +import { TrackedMap } from 'tracked-built-ins'; +import GlimmerComponent from '@glimmer/component'; +import { IconPlus } from '@cardstack/boxel-ui/icons'; +import { action } from '@ember/object'; +import { type getCards } from '@cardstack/runtime-common'; +import { FilterDropdown } from './filter/filter-dropdown'; +import { StatusPill } from './filter/filter-dropdown-item'; +import { FilterTrigger } from './filter/filter-trigger'; +import { FilterDisplay } from './filter/filter-display'; +import RectangleEllipsis from '@cardstack/boxel-icons/rectangle-ellipsis'; +import User from '@cardstack/boxel-icons/user'; +import { eq, not } from '@cardstack/boxel-ui/helpers'; +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import type Owner from '@ember/owner'; +import { + BoxelButton, + DndItem, + LoadingIndicator, + DndKanbanBoard, + DndColumn, + BoxelSelect, +} from '@cardstack/boxel-ui/components'; +import type { Query } from '@cardstack/runtime-common/query'; +import getKanbanResource from '../kanban-resource'; + +interface ColumnHeaderSignature { + statusLabel: string; + createNewTask: (statusLabel: string) => void; + isCreateNewTaskLoading: (statusLabel: string) => boolean; +} + +class ColumnHeader extends GlimmerComponent { + + + @action createNewTask() { + this.args.createNewTask(this.args.statusLabel); + } +} + +export interface SelectedItem { + name?: string; + label?: string; + index?: number; +} + +export type FilterType = 'status' | 'assignee'; + +// Type definitions for the card structure +export interface TaskCard extends CardDef { + id: string; + status?: { + label: string; + index: number; + }; + assignee?: { + name: string; + }; +} + +// Configuration for the status field +export interface StatusFieldConfig { + values: Array<{ + label: string; + index: number; + }>; +} + +// Configuration for card operations +export interface CardOperations { + onCreateTask: (statusLabel: string) => Promise; + onMoveCard: (params: { + draggedCard: DndItem; + targetCard?: DndItem; + sourceColumn: DndColumn; + targetColumn: DndColumn; + }) => Promise; + hasColumnKey: (card: TaskCard, key: string) => boolean; + orderBy?: (a: T, b: T) => number; +} + +// Configuration for the task source +export interface TaskSourceConfig { + module: string; + name: string; + getQuery: () => Query; +} + +// Main configuration interface +export interface TaskPlannerConfig { + status: StatusFieldConfig; + cardOperations: CardOperations; + taskSource: TaskSourceConfig; + filters: { + status: FilterConfig; + assignee: FilterConfig; + }; +} + +export interface FilterConfig { + searchKey: string; + label: string; + codeRef: { + module: string; + name: string; + }; + options: () => any[]; +} + +export interface TaskColumn { + title: string; + cards: TaskCard[]; +} + +export interface TaskCollection { + columns: TaskColumn[]; +} + +interface TaskPlannerArgs { + Args: { + config: TaskPlannerConfig; + realmURL: URL | undefined; + parentId: string | undefined; + context: CardContext | undefined; + emptyStateMessage?: string; + editCard: () => void; + }; + Element: HTMLElement; +} + +export class TaskPlanner extends GlimmerComponent { + @tracked loadingColumnKey: string | undefined; + @tracked selectedFilter: FilterType | undefined; + selectedItems = new TrackedMap(); + filters: Record< + FilterType, + { + searchKey: string; + label: string; + codeRef: { + module: string; + name: string; + }; + options: () => any[]; + } + >; + cards: ReturnType | undefined; + assigneeQuery: ReturnType | undefined; + + constructor(owner: Owner, args: any) { + super(owner, args); + this.selectedItems = new TrackedMap(); + + // Initialize filters + this.filters = { + status: { + searchKey: 'label', + label: 'Status', + codeRef: { + module: this.args.config.taskSource.module, + name: 'Status', + }, + options: () => this.args.config.status.values, + }, + assignee: { + searchKey: 'name', + label: 'Assignee', + codeRef: { + module: this.args.config.taskSource.module, + name: 'Assignee', + }, + options: () => this.assigneeCards, + }, + }; + + // Initialize cards and assignee query + this.cards = this.args.context?.getCards( + this, + () => this.getTaskQuery, + () => this.realmHrefs, + { + isLive: true, + }, + ); + + this.assigneeQuery = this.args.context?.getCards( + this, + () => { + return { + filter: { + type: this.args.config.filters.assignee.codeRef, + }, + }; + }, + () => this.realmHrefs, + { isLive: true }, + ); + } + + get emptyStateMessage(): string { + return this.args.emptyStateMessage ?? 'Select a parent to continue'; + } + + get getTaskQuery(): Query { + return this.args.config.taskSource.getQuery(); + } + + get filterTypes() { + return Object.keys(this.args.config.filters) as FilterType[]; + } + + get cardInstances() { + if (!this.cards || !this.cards.instances) { + return []; + } + return this.cards.instances; + } + + get assigneeCards() { + return this.assigneeQuery?.instances ?? []; + } + + get realmHref() { + return this.args.realmURL?.href; + } + + get realmHrefs() { + if (!this.args.realmURL) { + return []; + } + return [this.args.realmURL.href]; + } + + @action async onMoveCardMutation( + draggedCard: DndItem, + targetCard: DndItem | undefined, + sourceColumnAfterDrag: DndColumn, + targetColumnAfterDrag: DndColumn, + ) { + await this.args.config.cardOperations.onMoveCard({ + draggedCard, + targetCard, + sourceColumn: sourceColumnAfterDrag, + targetColumn: targetColumnAfterDrag, + }); + } + + @action showTaskCard(card: any): boolean { + return this.filterTypes.every((filterType: FilterType) => { + let selectedItems = this.selectedItems.get(filterType) ?? []; + if (selectedItems.length === 0) return true; + return selectedItems.some((item) => { + if (filterType === 'status') { + return card.status?.label === item.label; + } else if (filterType === 'assignee') { + return card.assignee?.name === item.name; + } else { + return false; + } + }); + }); + } + + @action getFilterIcon(filterType: FilterType) { + switch (filterType) { + case 'status': + return RectangleEllipsis; + case 'assignee': + return User; + default: + return undefined; + } + } + + get selectedFilterConfig() { + if (this.selectedFilter === undefined) { + return undefined; + } + return this.args.config.filters[this.selectedFilter]; + } + + get selectedItemsForFilter() { + if (this.selectedFilter === undefined) { + return []; + } + return this.selectedItems.get(this.selectedFilter) ?? []; + } + + @action onSelectFilter(item: FilterType) { + this.selectedFilter = item; + } + + @action onChange(selected: SelectedItem[]) { + if (this.selectedFilter) { + this.selectedItems.set(this.selectedFilter, selected); + } + } + + @action onClose() { + this.selectedFilter = undefined; + return true; + } + + @action isSelectedItem(item: SelectedItem) { + let selectedItems = this.selectedItemsForFilter; + return selectedItems.includes(item); + } + + @action selectedFilterItems(filterType: FilterType) { + return this.selectedItems.get(filterType) ?? []; + } + + get assigneeIsLoading() { + return ( + this.selectedFilter === 'assignee' && + this.assigneeQuery && + this.assigneeQuery.isLoading + ); + } + + getComponent(cardOrField: BaseDef) { + return cardOrField.constructor.getComponent(cardOrField); + } + + removeFileExtension(cardUrl: string) { + return cardUrl.replace(/\.[^/.]+$/, ''); + } + + @action removeFilter(key: FilterType, item: SelectedItem) { + let items = this.selectedItems.get(key); + if (!items) { + return; + } + let itemIndex: number; + if (key === 'status') { + itemIndex = items.findIndex((o) => item?.index === o?.index); + } else { + itemIndex = items.findIndex((o) => item === o); + } + + if (itemIndex > -1) { + items.splice(itemIndex, 1); + this.selectedItems.set(key, items); + } + } + + taskCollection = getKanbanResource( + this, + () => this.cardInstances, + () => this.args.config.status.values.map((status) => status.label) ?? [], + () => this.args.config.cardOperations.hasColumnKey, + () => this.args.config.cardOperations.orderBy, + ); + + @action async createNewTask(statusLabel: string) { + this.loadingColumnKey = statusLabel; + try { + await this.args.config.cardOperations.onCreateTask(statusLabel); + } finally { + this.loadingColumnKey = undefined; + } + } + + @action isCreateNewTaskLoading(statusLabel: string): boolean { + return this.loadingColumnKey === statusLabel; + } + + +} diff --git a/components/filter/filter-display.gts b/components/filter/filter-display.gts new file mode 100644 index 0000000..1b8ec06 --- /dev/null +++ b/components/filter/filter-display.gts @@ -0,0 +1,64 @@ +import GlimmerComponent from '@glimmer/component'; +import { Pill, IconButton } from '@cardstack/boxel-ui/components'; +import { IconX } from '@cardstack/boxel-ui/icons'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +export interface FilterDisplaySignature { + Args: { + key: string; + items: any[]; + removeItem: (item: any) => void; + icon: any; + }; + Element: HTMLDivElement; + Blocks: { + default: [any]; + }; +} + +export class FilterDisplay extends GlimmerComponent { + +} diff --git a/components/filter/filter-dropdown-item.gts b/components/filter/filter-dropdown-item.gts new file mode 100644 index 0000000..309272c --- /dev/null +++ b/components/filter/filter-dropdown-item.gts @@ -0,0 +1,96 @@ +import GlimmerComponent from '@glimmer/component'; +import { CheckMark } from '@cardstack/boxel-ui/icons'; +import { cn } from '@cardstack/boxel-ui/helpers'; + +interface CheckBoxArgs { + Args: { + isSelected: boolean; + }; + Element: Element; +} + +class CheckboxIndicator extends GlimmerComponent { + +} + +interface StatusPillArgs { + Args: { + isSelected: boolean; + label: string; + }; + Element: Element; +} + +export class StatusPill extends GlimmerComponent { + +} diff --git a/components/filter/filter-dropdown.gts b/components/filter/filter-dropdown.gts new file mode 100644 index 0000000..3da855f --- /dev/null +++ b/components/filter/filter-dropdown.gts @@ -0,0 +1,50 @@ +import GlimmerComponent from '@glimmer/component'; +import { BoxelMultiSelectBasic } from '@cardstack/boxel-ui/components'; +import { FilterTrigger } from './filter-trigger'; + +interface FilterDropdownSignature { + Element: HTMLDivElement; + Args: { + searchField: string; + realmURLs: string[]; + options: any; + selected: any; + onChange: (value: any) => void; + onClose: () => boolean | undefined; + isLoading?: boolean; + }; + Blocks: { + default: [any]; + }; +} + +export class FilterDropdown extends GlimmerComponent { + +} diff --git a/components/filter/filter-trigger.gts b/components/filter/filter-trigger.gts new file mode 100644 index 0000000..4f921e1 --- /dev/null +++ b/components/filter/filter-trigger.gts @@ -0,0 +1,48 @@ +import GlimmerComponent from '@glimmer/component'; +import { IconButton } from '@cardstack/boxel-ui/components'; +import ListFilter from '@cardstack/boxel-icons/list-filter'; + +interface TriggerSignature { + Args: { + isLoading?: boolean; + }; + Element: HTMLDivElement; +} + +export class FilterTrigger extends GlimmerComponent { + +} diff --git a/kanban-resource.gts b/kanban-resource.gts new file mode 100644 index 0000000..2564646 --- /dev/null +++ b/kanban-resource.gts @@ -0,0 +1,92 @@ +import { tracked } from '@glimmer/tracking'; +import { DndColumn } from '@cardstack/boxel-ui/components'; +import { CardDef } from 'https://cardstack.com/base/card-api'; + +import { Resource } from 'ember-modify-based-class-resource'; + +interface Args { + named: { + cards: CardDef[]; + // Custom getter that is describe to access a way to access the data of the card + // that is equal to the column + hasColumnKey: (card: T, key: string) => boolean; + columnKeys: string[]; + orderBy?: (a: T, b: T) => number; + }; +} + +// This is a resource because we have to +// 1. to hold state of cards inside of the kanban board without a flickering/ loading +// 2. to maintain a natural order cards that are newly added to the kanban board +// Note: this resource assumes that you have already loaded the cards and is unsuitable for pre-rendered cards +class KanbanResource extends Resource { + @tracked private data: Map = new Map(); + hasColumnKey?: (card: T, key: string) => boolean = + undefined; + orderBy?: (a: T, b: T) => number = undefined; + + commit(cards: CardDef[], columnKeys: string[]) { + columnKeys.forEach((key: string) => { + let currentColumn = this.data.get(key); + let cardsForStatus = cards.filter((card) => { + return this.hasColumnKey ? this.hasColumnKey(card, key) : false; + }); + + if (currentColumn) { + // Maintain order of existing cards and append new ones + let existingCardIds = new Set( + currentColumn.cards.map((card: CardDef) => card.id), + ); + let existingCards = currentColumn.cards.filter((card: CardDef) => + cardsForStatus.some((c) => c.id === card.id), + ); + let newCards = cardsForStatus.filter( + (card: CardDef) => !existingCardIds.has(card.id), + ); + + let sortedCards = [...newCards, ...existingCards]; + if (this.orderBy) { + sortedCards = sortedCards.sort(this.orderBy); + } + this.data.set(key, new DndColumn(key, sortedCards)); + } else { + // First time loading this column + let sortedCards = cardsForStatus; + if (this.orderBy) { + sortedCards = sortedCards.sort(this.orderBy); + } + this.data.set(key, new DndColumn(key, sortedCards)); + } + }); + } + + get columns() { + return Array.from(this.data.values()); + } + + modify(_positional: never[], named: Args['named']) { + this.hasColumnKey = named.hasColumnKey; + this.orderBy = named.orderBy; + this.commit(named.cards, named.columnKeys); + } +} + +export default function getKanbanResource( + parent: object, + cards: () => T[], + columnKeys: () => string[], + hasColumnKey: () => (card: T, key: string) => boolean, + orderBy: () => ((a: T, b: T) => number) | undefined, +) { + return KanbanResource.from(parent, () => ({ + named: { + cards: cards(), + columnKeys: columnKeys(), + hasColumnKey: hasColumnKey() as ( + card: T, + key: string, + ) => boolean, + orderBy: orderBy?.(), + }, + })); +} diff --git a/sprint-planner.gts b/sprint-planner.gts new file mode 100644 index 0000000..3352842 --- /dev/null +++ b/sprint-planner.gts @@ -0,0 +1,238 @@ +import { + field, + CardDef, + Component, + linksTo, + realmURL, +} from 'https://cardstack.com/base/card-api'; +import { SprintTaskStatusField, Project } from './sprint-task'; +import LayoutKanbanIcon from '@cardstack/boxel-icons/layout-kanban'; +import { TaskPlanner, TaskCard } from './components/base-task-planner'; +import { LooseSingleCardDocument } from '@cardstack/runtime-common'; +import { + AnyFilter, + CardTypeFilter, + Query, + EqFilter, +} from '@cardstack/runtime-common/query'; +import { DndItem } from '@cardstack/boxel-ui/components'; +import { action } from '@ember/object'; + +class SprintPlannerIsolated extends Component { + get parentId() { + return this.args.model?.project?.id; + } + + get emptyStateMessage() { + return 'Link a project to continue'; + } + + get currentRealm() { + return this.args.model[realmURL]; + } + + get getTaskQuery(): Query { + let everyArr: (AnyFilter | CardTypeFilter | EqFilter)[] = []; + if (!this.currentRealm) { + throw new Error('No realm url'); + } + if (!this.parentId) { + console.log('No project'); + everyArr.push({ eq: { 'project.id': null } }); + } else { + everyArr.push({ eq: { 'project.id': this.parentId } }); + } + return everyArr.length > 0 + ? { + filter: { + on: { + module: this.config.taskSource.module, + name: this.config.taskSource.name, + }, + every: everyArr, + }, + } + : { + filter: { + type: { + module: this.config.taskSource.module, + name: this.config.taskSource.name, + }, + }, + }; + } + + get realmHref() { + return this.currentRealm?.href; + } + + get realmHrefs() { + if (!this.currentRealm) { + return []; + } + return [this.currentRealm.href]; + } + + assigneeQuery = this.args.context?.getCards( + this, + () => { + return { + filter: { + type: this.config.filters.assignee.codeRef, + }, + }; + }, + () => this.realmHrefs, + { isLive: true }, + ); + + get assigneeCards() { + return this.assigneeQuery?.instances ?? []; + } + + get config() { + return { + status: { + values: SprintTaskStatusField.values, + }, + cardOperations: { + hasColumnKey: (card: TaskCard, key: string) => { + return card.status?.label === key; + }, + onCreateTask: async (statusLabel: string) => { + if (this.currentRealm === undefined) { + return; + } + + try { + let index = this.config.status.values.find((value) => { + return value.label === statusLabel; + })?.index; + + let doc: LooseSingleCardDocument = { + data: { + type: 'card', + attributes: { + name: null, + details: null, + status: { + index, + label: statusLabel, + }, + priority: { + index: null, + label: null, + }, + description: null, + thumbnailURL: null, + }, + relationships: { + assignee: { + links: { + self: null, + }, + }, + project: { + links: { + self: this.parentId ?? null, + }, + }, + }, + meta: { + adoptsFrom: this.config.taskSource, + }, + }, + }; + + await this.args.createCard?.( + this.config.taskSource, + new URL(this.config.taskSource.module), + { + realmURL: this.currentRealm, + doc, + }, + ); + } catch (error) { + console.error('Error creating card:', error); + } + }, + onMoveCard: async ({ + draggedCard, + targetColumn, + }: { + draggedCard: DndItem; + targetColumn: DndItem; + }) => { + let cardInNewCol = targetColumn.cards.find( + (c: CardDef) => c.id === draggedCard.id, + ); + if ( + cardInNewCol && + cardInNewCol.status.label !== targetColumn.title + ) { + let statusValue = this.config.status.values.find( + (value) => value.label === targetColumn.title, + ); + cardInNewCol.status = new SprintTaskStatusField(statusValue); + await this.args.saveCard?.(cardInNewCol); + } + }, + }, + taskSource: { + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./sprint-task', import.meta.url).href, + name: 'SprintTask', + getQuery: () => this.getTaskQuery, + }, + filters: { + status: { + searchKey: 'label', + label: 'Status', + codeRef: { + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./sprint-task', import.meta.url).href, + name: 'Status', + }, + options: () => SprintTaskStatusField.values, + }, + assignee: { + searchKey: 'name', + label: 'Assignee', + codeRef: { + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./sprint-task', import.meta.url).href, + name: 'TeamMember', + }, + options: () => this.assigneeCards, + }, + }, + }; + } + + @action editCard() { + if (!this.args.model.id) { + throw new Error('No card id'); + } + this.args.editCard?.(this.args.model as CardDef); + } + + +} + +export class SprintPlanner extends CardDef { + static displayName = 'Sprint Planner'; + static icon = LayoutKanbanIcon; + static headerColor = '#ff7f7b'; + static prefersWideFormat = true; + static isolated = SprintPlannerIsolated; + @field project = linksTo(() => Project); +} diff --git a/sprint-task.gts b/sprint-task.gts new file mode 100644 index 0000000..ce76b17 --- /dev/null +++ b/sprint-task.gts @@ -0,0 +1,537 @@ +import { + CardDef, + Component, + StringField, + contains, + field, + linksTo, + linksToMany, +} from 'https://cardstack.com/base/card-api'; +import { + Avatar, + Pill, + ProgressBar, + ProgressRadial, +} from '@cardstack/boxel-ui/components'; +import FolderGitIcon from '@cardstack/boxel-icons/folder-git'; +import CheckboxIcon from '@cardstack/boxel-icons/checkbox'; +import UsersIcon from '@cardstack/boxel-icons/users'; +import UserIcon from '@cardstack/boxel-icons/user'; +import Calendar from '@cardstack/boxel-icons/calendar'; +import { User } from './user'; +import { Task, TaskStatusField, getDueDateStatus } from './task'; + +export class Team extends CardDef { + static displayName = 'Team'; + static icon = UsersIcon; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Team) { + return this.name; + }, + }); + + @field shortName = contains(StringField, { + computeVia: function (this: Team) { + return this.name ? this.name.slice(0, 2).toUpperCase() : undefined; + }, + }); + + static atom = class Atom extends Component { + + }; +} + +export class TeamMember extends User { + static displayName = 'Team Member'; + static icon = UserIcon; + @field team = linksTo(Team); + + static atom = class Atom extends Component { + + }; +} + +export class Project extends CardDef { + static displayName = 'Project'; + static icon = FolderGitIcon; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Project) { + return this.name; + }, + }); + static atom = class Atom extends Component { + + }; +} + +export class Issues extends CardDef { + static displayName = 'Issues'; +} + +function extractId(href: string): string { + const urlObj = new URL(href); + const pathname = urlObj.pathname; + const parts = pathname.split('/'); + const lastPart = parts[parts.length - 1]; + return lastPart.replace('.json', ''); +} + +function shortenId(id: string): string { + const shortUuid = id.slice(0, 8); + const decimal = parseInt(shortUuid, 16); + return decimal.toString(36).padStart(6, '0'); +} + +class TaskIsolated extends Component { + get dueDate() { + return this.args.model.dateRange?.end; + } + + get dueDateStatus() { + return this.dueDate ? getDueDateStatus(this.dueDate.toString()) : undefined; + } + + get hasDueDate() { + return Boolean(this.dueDate); + } + + get hasDueDateStatus() { + return Boolean(this.dueDateStatus); + } + + + + get tagNames() { + return this.args.model.tags?.map((tag) => tag.name) ?? []; + } + get hasDateRange() { + return this.args.model.dateRange; + } + + get progress() { + if (!this.hasChildren) return 0; + const shippedCount = this.args.model.subtasks!.filter( + (child) => child.status.label === 'Shipped', + ).length; + + return Math.round((shippedCount / this.childrenCount) * 100); + } + + get hasProgress() { + return this.progress > 0; + } + + get progressLabel() { + return `${this.progress}%`; + } + + get hasChildren() { + return this.args.model.subtasks && this.args.model.subtasks.length > 0; + } + + get childrenCount() { + return this.args.model.subtasks ? this.args.model.subtasks.length : 0; + } + + get shippedCount() { + return this.args.model.subtasks + ? this.args.model.subtasks.filter( + (child) => child.status.label === 'Shipped', + ).length + : 0; + } +} + +export class SprintTaskStatusField extends TaskStatusField { + static values = [ + { index: 0, label: 'Not Started', color: '#B0BEC5', completed: false }, + { + index: 1, + label: 'Next Sprint', + color: '#64B5F6', + completed: false, + }, + { + index: 2, + label: 'Current Sprint', + color: '#00BCD4', + completed: false, + }, + { + index: 3, + label: 'In Progress', + color: '#FFB74D', + completed: false, + }, + { + index: 4, + label: 'In Review', + color: '#9575CD', + completed: false, + }, + { + index: 5, + label: 'Staged', + color: '#26A69A', + completed: false, + }, + { + index: 6, + label: 'Shipped', + color: '#66BB6A', + completed: true, + }, + ]; +} + +export class SprintTask extends Task { + static displayName = 'Sprint Task'; + static icon = CheckboxIcon; + @field project = linksTo(() => Project); + @field team = linksTo(() => Team, { isUsed: true }); + @field subtasks = linksToMany(() => SprintTask); + @field status = contains(SprintTaskStatusField); + + @field cardTitle = contains(StringField, { + computeVia: function (this: SprintTask) { + return this.name; + }, + }); + + //Removing this causes a missing .title error + @field assignee = linksTo(() => TeamMember); + + @field shortId = contains(StringField, { + computeVia: function (this: SprintTask) { + if (this.id) { + let id = shortenId(extractId(this.id)); + let _shortId: string; + if (this.team && this.team.shortName) { + // computeds are hard to debug -- the logs only appear on the server. We need to always include a check for links + _shortId = this.team.shortName + '-' + id; + } else { + _shortId = id; + } + return _shortId.toUpperCase(); + } + return; + }, + }); + + static isolated = TaskIsolated; +} diff --git a/tag.gts b/tag.gts new file mode 100644 index 0000000..8577cd7 --- /dev/null +++ b/tag.gts @@ -0,0 +1,33 @@ +import { Component } from 'https://cardstack.com/base/card-api'; +import TagCard from 'https://cardstack.com/base/tag'; + +import { BoxelTag } from '@cardstack/boxel-ui/components'; +import { getContrastColor } from '@cardstack/boxel-ui/helpers'; + +class ViewTemplate extends Component { + + private get fontColor() { + if (this.args.model.fontColor) { + return this.args.model.fontColor; + } + if (this.args.model.color) { + return getContrastColor(this.args.model.color, undefined, undefined, { + isSmallText: true, + }); + } + return 'var(--boxel-400)'; + } +} + +export class Tag extends TagCard { + static atom = ViewTemplate; + static embedded = ViewTemplate; +} diff --git a/task.gts b/task.gts new file mode 100644 index 0000000..21d5c48 --- /dev/null +++ b/task.gts @@ -0,0 +1,648 @@ +import { + StringField, + contains, + field, + linksTo, + linksToMany, + Component, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import NumberField from 'https://cardstack.com/base/number'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import DateRangeField from 'https://cardstack.com/base/date-range-field'; +import ColorField from 'https://cardstack.com/base/color'; + +import { Tag } from './tag'; +import { User } from './user'; +import { BoxelSelect } from '@cardstack/boxel-ui/components'; +import { RadioInput } from '@cardstack/boxel-ui/components'; + +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { fn } from '@ember/helper'; +import ChevronsUp from '@cardstack/boxel-icons/chevrons-up'; +import ChevronUp from '@cardstack/boxel-icons/chevron-up'; +import ChevronsDown from '@cardstack/boxel-icons/chevrons-down'; +import ChevronDown from '@cardstack/boxel-icons/chevrons-down'; +import CircleEqual from '@cardstack/boxel-icons/circle-equal'; +import { isToday, isThisWeek, addWeeks } from 'date-fns'; +import GlimmerComponent from '@glimmer/component'; +import Calendar from '@cardstack/boxel-icons/calendar'; +import { Pill } from '@cardstack/boxel-ui/components'; +import { CheckMark } from '@cardstack/boxel-ui/icons'; +import { Todo } from './todo'; + +export class TaskStatusEdit extends Component { + @tracked label: string | undefined = this.args.model.label; + + + get selectedStatus() { + return this.statuses.find((status) => { + return status.label === this.label; + }); + } + + // This ensures you get values from the class the instance is created + get statuses() { + return (this.args.model.constructor as any).values as TaskStatusField[]; + } + + @action onSelectStatus(status: TaskStatusField): void { + this.label = status.label; + this.args.model.label = this.selectedStatus?.label; + this.args.model.index = this.selectedStatus?.index; + this.args.model.color = this.selectedStatus?.color; + this.args.model.completed = this.selectedStatus?.completed; + } + + get placeholder() { + return 'Fill in'; + } +} + +export class TaskStatusField extends FieldDef { + @field index = contains(NumberField); //sorting order + @field label = contains(StringField); + @field color = contains(ColorField); + @field completed = contains(BooleanField); + static values = [ + { index: 0, label: 'Not Started', color: '#B0BEC5', completed: false }, + { + index: 1, + label: 'In Progress', + color: '#64B5F6', + completed: false, + }, + { + index: 2, + label: 'Done', + color: '#00BCD4', + completed: true, + }, + ]; + + static embedded = class Embedded extends Component { + + }; + + //TODO: Not static. Need to improve ability to extend field templates + static edit = TaskStatusEdit; +} + +export class FittedTask extends Component { + get visibleTags() { + return [this.args.fields.tags[0], this.args.fields.tags[1]].filter(Boolean); + } + + get dueDate() { + return this.args.model.dateRange?.end; + } + + get dueDateStatus() { + return this.dueDate ? getDueDateStatus(this.dueDate.toString()) : undefined; + } + + get hasDueDate() { + return Boolean(this.dueDate); + } + + get hasDueDateStatus() { + return Boolean(this.dueDateStatus); + } + + get isCompleted() { + return this.args.model.status?.completed ?? false; + } + + +} + +class EditPriority extends Component { + @tracked label = this.args.model.label; + + get priorities() { + return TaskPriority.values; + } + + get selectedPriority() { + return this.priorities?.find((priority) => { + return priority.label === this.label; + }); + } + + @action handlePriorityChange(priority: TaskPriority): void { + this.label = priority.label; + this.args.model.label = this.selectedPriority?.label; + this.args.model.index = this.selectedPriority?.index; + } + + +} + +export class TaskPriority extends FieldDef { + @field index = contains(NumberField); //sorting order + @field label = contains(StringField); + static values = [ + { index: 0, label: 'Lowest', icon: ChevronsDown }, + { index: 1, label: 'Low', icon: ChevronDown }, + { + index: 2, + label: 'Medium', + icon: CircleEqual, + }, + { + index: 3, + label: 'High', + icon: ChevronUp, + }, + { + index: 4, + label: 'Highest', + icon: ChevronsUp, + }, + ]; + + static edit = EditPriority; + static embedded = class Embedded extends Component { + + }; + + static atom = class Atom extends Component { + get selectedPriority() { + return TaskPriority.values.find((priority) => { + return priority.label === this.args.model.label; + }); + } + + get selectedIcon() { + return this.selectedPriority?.icon; + } + + }; +} + +export class Task extends Todo { + static displayName = 'Task'; + @field tags = linksToMany(() => Tag); + @field dateRange = contains(DateRangeField); + @field status = contains(TaskStatusField); + @field assignee = linksTo(() => User); + @field priority = contains(TaskPriority); + + @field cardTitle = contains(StringField, { + computeVia: function (this: Task) { + return this.name ?? `Untitled ${this.constructor.displayName}`; + }, + }); + + @field shortId = contains(StringField, { + computeVia: function (this: Task) { + if (this.id) { + let id = shortenId(extractId(this.id)); + return id.toUpperCase(); + } + return; + }, + }); + + static fitted = FittedTask; +} + +function extractId(href: string): string { + const urlObj = new URL(href); + const pathname = urlObj.pathname; + const parts = pathname.split('/'); + const lastPart = parts[parts.length - 1]; + return lastPart.replace('.json', ''); +} + +function shortenId(id: string): string { + const shortUuid = id.slice(0, 8); + const decimal = parseInt(shortUuid, 16); + return decimal.toString(36).padStart(6, '0'); +} + +export function getDueDateStatus(dueDateString: string | null) { + if (!dueDateString) return null; + + const dueDate = new Date(dueDateString); + const today = new Date(); + const nextWeek = addWeeks(today, 1); + + if (isToday(dueDate)) { + return { + label: 'Due Today', + color: '#01de67', + }; + } else if (isThisWeek(dueDate)) { + return { + label: 'This Week', + color: '#ffbc00', + }; + } else if (dueDate > today && dueDate < nextWeek) { + return { + label: 'Next Week', + color: '#4fc8fd', + }; + } + + return null; +} + +interface TaskCompletionStatusSignature { + Element: HTMLDivElement; + Args: { + completed: boolean; + }; +} + +export class TaskCompletionStatus extends GlimmerComponent { + +} diff --git a/todo.gts b/todo.gts new file mode 100644 index 0000000..5b57076 --- /dev/null +++ b/todo.gts @@ -0,0 +1,13 @@ +import { + CardDef, + field, + contains, + StringField, +} from 'https://cardstack.com/base/card-api'; +import MarkdownField from 'https://cardstack.com/base/markdown'; + +export class Todo extends CardDef { + static displayName = 'Todo'; + @field name = contains(StringField); + @field details = contains(MarkdownField); +} diff --git a/user.gts b/user.gts new file mode 100644 index 0000000..cb407b4 --- /dev/null +++ b/user.gts @@ -0,0 +1,18 @@ +import { + CardDef, + StringField, + contains, + field, +} from 'https://cardstack.com/base/card-api'; +import EmailField from 'https://cardstack.com/base/email'; + +export class User extends CardDef { + static displayName = 'User'; + @field name = contains(StringField); + @field email = contains(EmailField); + @field cardTitle = contains(StringField, { + computeVia: function (this: User) { + return this.name; + }, + }); +}