From b7f68e3cfa60f45b580bbe098642a50fc2c7943f Mon Sep 17 00:00:00 2001 From: Michael Haufe Date: Wed, 20 Dec 2023 23:08:05 -0600 Subject: [PATCH] Implemented Goals -> Use Cases feature --- _old/domain/entities/Goal.mts | 8 - src/data/GoalsRepository.mts | 2 +- src/data/StakeholderRepository.mts | 2 +- src/data/UseCaseRepository.mts | 8 + src/domain/Behavior.mts | 3 + src/domain/Goals.mts | 2 + src/domain/Requirement.mts | 6 +- src/domain/UseCase.mts | 19 +++ src/lib/groupBy.mts | 8 + src/mappers/GoalsToJsonMapper.mts | 9 +- src/mappers/UseCaseToJsonMapper.mts | 24 +++ src/presentation/Application.mts | 1 + src/presentation/components/DataTable.mts | 138 ++++++++++-------- .../pages/goals/Functionality.mts | 2 +- src/presentation/pages/goals/Goal.mts | 5 + src/presentation/pages/goals/Goals.mts | 2 +- src/presentation/pages/goals/NewGoals.mts | 5 +- src/presentation/pages/goals/Rationale.mts | 2 +- src/presentation/pages/goals/Stakeholders.mts | 28 ++-- src/presentation/pages/goals/UseCases.mts | 119 +++++++++++++++ src/presentation/theme/formTheme.mts | 7 +- webpack.config.mjs | 1 + 22 files changed, 300 insertions(+), 101 deletions(-) delete mode 100644 _old/domain/entities/Goal.mts create mode 100644 src/data/UseCaseRepository.mts create mode 100644 src/domain/UseCase.mts create mode 100644 src/lib/groupBy.mts create mode 100644 src/mappers/UseCaseToJsonMapper.mts create mode 100644 src/presentation/pages/goals/UseCases.mts diff --git a/_old/domain/entities/Goal.mts b/_old/domain/entities/Goal.mts deleted file mode 100644 index a29c5b5d..00000000 --- a/_old/domain/entities/Goal.mts +++ /dev/null @@ -1,8 +0,0 @@ -import Requirement from './Requirement.mjs'; - -/** - * A result desired by an organization. - * an objective of the project or system, in terms - * of their desired effect on the environment - */ -export default class Goal extends Requirement { } \ No newline at end of file diff --git a/src/data/GoalsRepository.mts b/src/data/GoalsRepository.mts index 54b9a299..49a268a5 100644 --- a/src/data/GoalsRepository.mts +++ b/src/data/GoalsRepository.mts @@ -3,6 +3,6 @@ import { PEGSRepository } from './PEGSRepository.mjs'; import GoalsToJsonMapper from '~/mappers/GoalsToJsonMapper.mjs'; import pkg from '~/../package.json' with { type: 'json' }; -export class GoalsRepository extends PEGSRepository { +export default class GoalsRepository extends PEGSRepository { constructor() { super('goals', new GoalsToJsonMapper(pkg.version)); } } \ No newline at end of file diff --git a/src/data/StakeholderRepository.mts b/src/data/StakeholderRepository.mts index abdb9127..f9026dc7 100644 --- a/src/data/StakeholderRepository.mts +++ b/src/data/StakeholderRepository.mts @@ -3,6 +3,6 @@ import { LocalStorageRepository } from './LocalStorageRepository.mjs'; import StakeholderToJsonMapper from '~/mappers/StakeholderToJsonMapper.mjs'; import pkg from '~/../package.json' with { type: 'json' }; -export class StakeholderRepository extends LocalStorageRepository { +export default class StakeholderRepository extends LocalStorageRepository { constructor() { super('stakeholder', new StakeholderToJsonMapper(pkg.version)); } } \ No newline at end of file diff --git a/src/data/UseCaseRepository.mts b/src/data/UseCaseRepository.mts new file mode 100644 index 00000000..044ad323 --- /dev/null +++ b/src/data/UseCaseRepository.mts @@ -0,0 +1,8 @@ +import UseCase from '~/domain/UseCase.mjs'; +import { LocalStorageRepository } from './LocalStorageRepository.mjs'; +import UseCaseToJsonMapper from '~/mappers/UseCaseToJsonMapper.mjs'; +import pkg from '~/../package.json' with { type: 'json' }; + +export default class UseCaseRepository extends LocalStorageRepository { + constructor() { super('use-case', new UseCaseToJsonMapper(pkg.version)); } +} \ No newline at end of file diff --git a/src/domain/Behavior.mts b/src/domain/Behavior.mts index e84fd656..cca801d2 100644 --- a/src/domain/Behavior.mts +++ b/src/domain/Behavior.mts @@ -1,6 +1,9 @@ import type { Properties } from '~/types/Properties.mjs'; import Requirement from './Requirement.mjs'; +/** + * A Behavior is a specification of how a system produces an outcome or effect. + */ export default class Behavior extends Requirement { constructor(options: Properties) { super(options); diff --git a/src/domain/Goals.mts b/src/domain/Goals.mts index 23f3f642..1c345848 100644 --- a/src/domain/Goals.mts +++ b/src/domain/Goals.mts @@ -16,6 +16,7 @@ export default class Goals extends PEGS { outcomes: string; stakeholders: Uuid[]; situation: string; + useCases: Uuid[]; constructor(options: Properties) { super(options); @@ -24,5 +25,6 @@ export default class Goals extends PEGS { this.outcomes = options.outcomes; this.stakeholders = options.stakeholders; this.situation = options.situation; + this.useCases = options.useCases; } } \ No newline at end of file diff --git a/src/domain/Requirement.mts b/src/domain/Requirement.mts index 49a3cdda..a8668aad 100644 --- a/src/domain/Requirement.mts +++ b/src/domain/Requirement.mts @@ -10,10 +10,10 @@ export default class Requirement extends Entity { */ accessor statement: string; - constructor(options: Properties) { - super(options); + constructor(properties: Properties) { + super(properties); - this.statement = options.statement; + this.statement = properties.statement; } /** diff --git a/src/domain/UseCase.mts b/src/domain/UseCase.mts new file mode 100644 index 00000000..50772526 --- /dev/null +++ b/src/domain/UseCase.mts @@ -0,0 +1,19 @@ +import type { Properties } from '~/types/Properties.mjs'; +import type { Uuid } from '~/types/Uuid.mjs'; +import Behavior from './Behavior.mjs'; + +/** + * A Use Case specifies a scenario of a complete interaction of an actor with a system. + */ +export default class UseCase extends Behavior { + /** + * The actor that is involved in the use case. + */ + actor: Uuid; + + constructor(properties: Properties) { + super(properties); + + this.actor = properties.actor; + } +} \ No newline at end of file diff --git a/src/lib/groupBy.mts b/src/lib/groupBy.mts new file mode 100644 index 00000000..1882dea4 --- /dev/null +++ b/src/lib/groupBy.mts @@ -0,0 +1,8 @@ +const groupBy = (arr: T[], key: (i: T) => K) => + arr.reduce((groups, item) => { + (groups[key(item)] ||= []).push(item); + + return groups; + }, {} as Record); + +export default groupBy; \ No newline at end of file diff --git a/src/mappers/GoalsToJsonMapper.mts b/src/mappers/GoalsToJsonMapper.mts index f71e4683..c13c0da9 100644 --- a/src/mappers/GoalsToJsonMapper.mts +++ b/src/mappers/GoalsToJsonMapper.mts @@ -8,6 +8,7 @@ export interface GoalsJson extends PEGSJson { outcomes: string; situation: string; stakeholders: Uuid[]; + useCases: Uuid[]; } export default class GoalsToJsonMapper extends PEGSToJsonMapper { @@ -15,7 +16,10 @@ export default class GoalsToJsonMapper extends PEGSToJsonMapper { const version = target.serializationVersion ?? '{undefined}'; if (version.startsWith('0.3.')) - return new Goals(target); + return new Goals({ + ...target, + useCases: target.useCases ?? [] + }); throw new Error(`Unsupported serialization version: ${version}`); } @@ -26,7 +30,8 @@ export default class GoalsToJsonMapper extends PEGSToJsonMapper { objective: source.objective, outcomes: source.outcomes, situation: source.situation, - stakeholders: source.stakeholders + stakeholders: source.stakeholders, + useCases: source.useCases }; } } \ No newline at end of file diff --git a/src/mappers/UseCaseToJsonMapper.mts b/src/mappers/UseCaseToJsonMapper.mts new file mode 100644 index 00000000..280f1c4d --- /dev/null +++ b/src/mappers/UseCaseToJsonMapper.mts @@ -0,0 +1,24 @@ +import UseCase from '~/domain/UseCase.mjs'; +import BehaviorToJsonMapper, { type BehaviorJson } from './BehaviorToJsonMapper.mjs'; +import type { Uuid } from '~/types/Uuid.mjs'; + +export interface UseCaseJson extends BehaviorJson { + actor: Uuid; +} + +export default class UseCaseToJsonMapper extends BehaviorToJsonMapper { + override mapFrom(target: UseCaseJson): UseCase { + const version = target.serializationVersion ?? '{undefined}'; + + if (version.startsWith('0.3.')) + return new UseCase(target); + + throw new Error(`Unsupported serialization version: ${version}`); + } + override mapTo(source: UseCase): UseCaseJson { + return { + ...super.mapTo(source), + actor: source.actor + }; + } +} \ No newline at end of file diff --git a/src/presentation/Application.mts b/src/presentation/Application.mts index 70173b60..7a49157c 100644 --- a/src/presentation/Application.mts +++ b/src/presentation/Application.mts @@ -77,6 +77,7 @@ export default class Application extends Container { ['/goals/:slug/rationale', (await import('./pages/goals/Rationale.mjs')).Rationale], ['/goals/:slug/functionality', (await import('./pages/goals/Functionality.mjs')).Functionality], ['/goals/:slug/stakeholders', (await import('./pages/goals/Stakeholders.mjs')).Stakeholders], + ['/goals/:slug/use-cases', (await import('./pages/goals/UseCases.mjs')).UseCases] ]); this.#router.addEventListener('route', this); } diff --git a/src/presentation/components/DataTable.mts b/src/presentation/components/DataTable.mts index 7898afff..829e3613 100644 --- a/src/presentation/components/DataTable.mts +++ b/src/presentation/components/DataTable.mts @@ -5,21 +5,30 @@ import { Component } from './Component.mjs'; import buttonTheme from '../theme/buttonTheme.mjs'; import formTheme from '../theme/formTheme.mjs'; -export type DataColumn = { +interface BaseDataColumn { readonly?: boolean; headerText: string; required?: boolean; -} & ({ +} + +interface TextHiddenDataColumn { formType: 'text' | 'hidden'; -} | { +} + +interface NumberRangeDataColumn { formType: 'number' | 'range'; min: number; max: number; step: number; -} | { +} + +interface OptDataColumn { formType: 'select'; - options: string[]; -}); + options: { value: string; text: string }[]; +} + +export type DataColumn = BaseDataColumn & + (TextHiddenDataColumn | NumberRangeDataColumn | OptDataColumn); export type DataColumns = { id: DataColumn } @@ -35,8 +44,8 @@ export interface DataTableOptions { const show = ((item: HTMLElement) => item.hidden = false), hide = ((item: HTMLElement) => item.hidden = true), - disable = ((item: HTMLInputElement) => item.disabled = true), - enable = ((item: HTMLInputElement) => item.disabled = false), + disable = ((item: HTMLInputElement | HTMLSelectElement) => item.disabled = true), + enable = ((item: HTMLInputElement | HTMLSelectElement) => item.disabled = false), { button, caption, form, input, option, select, span, table, tbody, td, template, th, thead, tr } = html; export class DataTable extends Component { @@ -127,7 +136,7 @@ export class DataTable extends Component { const form = e.target as HTMLFormElement, formData = new FormData(form), item = Object.fromEntries(formData.entries()) as Properties; - this._cancelEdit(); + // this._cancelEdit(e.submitter as HTMLButtonElement); this.onUpdate?.(item); } @@ -216,15 +225,27 @@ export class DataTable extends Component { /** * Hide all edit items in the table and then swap the row * from edit mode to view mode. + * @param btn - The button that triggered the cancel. * @returns void */ - protected _cancelEdit() { - const root = this.shadowRoot, - viewData = root.querySelectorAll('.view-data'), - editData = root.querySelectorAll('.edit-data'); - viewData.forEach(show); - editData.forEach(hide); - editData.forEach(disable); + protected _cancelEdit(btn: HTMLButtonElement) { + if (btn.hidden) + return; + + const tr = btn.closest('tr')!, + fields = tr.querySelectorAll('.data-cell > *'), + editButtons = tr.querySelectorAll('.button-cell > .edit-data'), + viewButtons = tr.querySelectorAll('.button-cell > .view-data'); + + fields.forEach(disable); + fields.forEach(field => { + if (field instanceof HTMLSelectElement) + field.selectedIndex = [...field.options].findIndex(opt => opt.defaultSelected); + else + field.value = field.defaultValue; + }); + viewButtons.forEach(show); + editButtons.forEach(hide); } /** @@ -235,12 +256,18 @@ export class DataTable extends Component { */ protected _editRow(e: Event) { const tr = (e.target as Element).closest('tr')!, - tbody = tr.closest('tbody')!; - tbody.querySelectorAll('.view-data').forEach(show); - tbody.querySelectorAll('.edit-data').forEach(hide); - tr.querySelectorAll('.view-data').forEach(hide); - tr.querySelectorAll('.edit-data').forEach(show); - tr.querySelectorAll('.edit-data').forEach(enable); + cancelButtons = tr.closest('tbody')!.querySelectorAll('.cancel-button'), + fields = tr.querySelectorAll('.data-cell > *'), + editButtons = tr.querySelectorAll('.button-cell > .edit-data'), + viewButtons = tr.querySelectorAll('.button-cell > .view-data'); + + // before editing, cancel any other edits + cancelButtons.forEach(btn => this._cancelEdit(btn)); + + // toggle the edit buttons and fields + fields.forEach(enable); + viewButtons.forEach(hide); + editButtons.forEach(show); } async renderData() { @@ -273,7 +300,7 @@ export class DataTable extends Component { [renderIf]: col.formType == 'select' }, col.formType == 'select' ? - col.options.map(opt => option({ value: opt }, opt)) + col.options.map(opt => option({ value: opt.value }, opt.text)) : [] ), input({ @@ -298,62 +325,49 @@ export class DataTable extends Component { const dataItems = await this.select(), tRows = dataItems.map(item => tr([ - ...Object.entries(this.#columns).map(([id, col]) => - td({ hidden: col.formType == 'hidden' }, [ - span({ - 'className': 'view-data', - // @ts-expect-error: data-* attributes are valid - 'data-name': id, - [renderIf]: col.formType != 'range' - }, (item as any)[id]), + ...Object.entries(this.#columns).map(([id, col]) => { + const colOptions = ((col as OptDataColumn).options ?? []).map(opt => option({ + value: opt.value, + defaultSelected: opt.value == (item as any)[id] + }, opt.text)); + + return td({ + className: 'data-cell', + hidden: col.formType == 'hidden' + }, [ input({ + [renderIf]: col.formType == 'text' || col.formType == 'hidden', form: this.#frmDataTableUpdate, - type: col.formType, - className: 'view-data', - name: id, + type: 'text', disabled: true, - [renderIf]: col.formType == 'range', - value: (item as any)[id] - }), - input({ - form: this.#frmDataTableUpdate, - type: col.formType, - className: 'edit-data', - name: id, - value: (item as any)[id], required: col.required, - disabled: true, - hidden: true, - [renderIf]: col.formType == 'text' || col.formType == 'hidden' - }), + name: id, + defaultValue: (item as any)[id] + }, []), input({ form: this.#frmDataTableUpdate, type: col.formType, - className: 'edit-data', name: id, - min: `${(col as any).min ?? 0}`, - max: `${(col as any).max ?? 0}`, - step: `${(col as any).step ?? 1}`, + min: `${(col as NumberRangeDataColumn).min ?? 0}`, + max: `${(col as NumberRangeDataColumn).max ?? 0}`, + step: `${(col as NumberRangeDataColumn).step ?? 1}`, disabled: true, - hidden: true, [renderIf]: col.formType == 'number' || col.formType == 'range', - value: (item as any)[id] + defaultValue: (item as any)[id] }), select({ form: this.#frmDataTableUpdate, - className: 'edit-data', name: id, disabled: true, - hidden: true, [renderIf]: col.formType == 'select' }, - ((col as any).options ?? []).map((opt: string) => option({ - value: opt, - selected: opt == (item as any)[id] - }, opt)) + colOptions ) - ])), - td([ + ]); + }), + td({ + className: 'button-cell', + }, [ button({ className: 'view-data edit-button', onclick: e => this._editRow(e), @@ -375,7 +389,7 @@ export class DataTable extends Component { }, 'Save'), button({ className: 'edit-data cancel-button', - onclick: () => this._cancelEdit(), + onclick: e => this._cancelEdit(e.target as HTMLButtonElement), hidden: true, [renderIf]: Boolean(this.onUpdate) }, 'Cancel') diff --git a/src/presentation/pages/goals/Functionality.mts b/src/presentation/pages/goals/Functionality.mts index 1069604c..30791802 100644 --- a/src/presentation/pages/goals/Functionality.mts +++ b/src/presentation/pages/goals/Functionality.mts @@ -1,6 +1,6 @@ import Behavior from '~/domain/Behavior.mjs'; import type Goals from '~/domain/Goals.mjs'; -import { GoalsRepository } from '~/data/GoalsRepository.mjs'; +import GoalsRepository from '~/data/GoalsRepository.mjs'; import { BehaviorRepository } from '~/data/BehaviorRepository.mjs'; import html from '~/presentation/lib/html.mjs'; import { DataTable } from '~/presentation/components/DataTable.mjs'; diff --git a/src/presentation/pages/goals/Goal.mts b/src/presentation/pages/goals/Goal.mts index e10035c3..425eba3a 100644 --- a/src/presentation/pages/goals/Goal.mts +++ b/src/presentation/pages/goals/Goal.mts @@ -24,6 +24,11 @@ export class Goal extends Page { icon: 'users', href: `${location.pathname}/stakeholders` }), + new MiniCard({ + title: 'Use Cases', + icon: 'briefcase', + href: `${location.pathname}/use-cases` + }) ]) ]); } diff --git a/src/presentation/pages/goals/Goals.mts b/src/presentation/pages/goals/Goals.mts index a03c7efb..2168b530 100644 --- a/src/presentation/pages/goals/Goals.mts +++ b/src/presentation/pages/goals/Goals.mts @@ -1,7 +1,7 @@ import { PegsCards } from '~components/index.mjs'; import html from '../../lib/html.mjs'; import Page from '../Page.mjs'; -import { GoalsRepository } from '~/data/GoalsRepository.mjs'; +import GoalsRepository from '~/data/GoalsRepository.mjs'; const { p } = html; diff --git a/src/presentation/pages/goals/NewGoals.mts b/src/presentation/pages/goals/NewGoals.mts index abe09e25..33b41f94 100644 --- a/src/presentation/pages/goals/NewGoals.mts +++ b/src/presentation/pages/goals/NewGoals.mts @@ -1,6 +1,6 @@ import Goals from '~/domain/Goals.mjs'; import PEGS from '~/domain/PEGS.mjs'; -import { GoalsRepository } from '~/data/GoalsRepository.mjs'; +import GoalsRepository from '~/data/GoalsRepository.mjs'; import html from '~/presentation/lib/html.mjs'; import requiredTheme from '~/presentation/theme/requiredTheme.mjs'; import formTheme from '~/presentation/theme/formTheme.mjs'; @@ -92,7 +92,8 @@ export class NewGoals extends Page { outcomes: '', situation: '', stakeholders: [], - functionalBehaviors: [] + functionalBehaviors: [], + useCases: [] }); await this.#repository.add(goals); diff --git a/src/presentation/pages/goals/Rationale.mts b/src/presentation/pages/goals/Rationale.mts index deb0b508..a8bf7ea7 100644 --- a/src/presentation/pages/goals/Rationale.mts +++ b/src/presentation/pages/goals/Rationale.mts @@ -1,5 +1,5 @@ import Goals from '~/domain/Goals.mjs'; -import { GoalsRepository } from '~/data/GoalsRepository.mjs'; +import GoalsRepository from '~/data/GoalsRepository.mjs'; import html from '~/presentation/lib/html.mjs'; import { SlugPage } from '../SlugPage.mjs'; diff --git a/src/presentation/pages/goals/Stakeholders.mts b/src/presentation/pages/goals/Stakeholders.mts index c471e7e1..a1133f0a 100644 --- a/src/presentation/pages/goals/Stakeholders.mts +++ b/src/presentation/pages/goals/Stakeholders.mts @@ -1,30 +1,23 @@ import type Goals from '~/domain/Goals.mjs'; import Stakeholder, { StakeholderCategory, StakeholderSegmentation } from '~/domain/Stakeholder.mjs'; -import { GoalsRepository } from '~/data/GoalsRepository.mjs'; -import { StakeholderRepository } from '~/data/StakeholderRepository.mjs'; +import GoalsRepository from '~/data/GoalsRepository.mjs'; +import StakeholderRepository from '~/data/StakeholderRepository.mjs'; import html from '~/presentation/lib/html.mjs'; import { DataTable } from '~/presentation/components/DataTable.mjs'; import { SlugPage } from '../SlugPage.mjs'; import { Tabs } from '~components/Tabs.mjs'; import mermaid from 'mermaid'; +import groupBy from '~/lib/groupBy.mjs'; -const { h2, p, div } = html, - groupBy = (arr: T[], key: (i: T) => K) => - arr.reduce((groups, item) => { - (groups[key(item)] ||= []).push(item); - - return groups; - }, {} as Record); - - -mermaid.initialize({ - startOnLoad: true, - theme: 'dark' -}); +const { h2, p, div } = html; export class Stakeholders extends SlugPage { static { customElements.define('x-stakeholders-page', this); + mermaid.initialize({ + startOnLoad: true, + theme: 'dark' + }); } #goalsRepository = new GoalsRepository(); @@ -39,7 +32,10 @@ export class Stakeholders extends SlugPage { id: { headerText: 'ID', readonly: true, formType: 'hidden' }, name: { headerText: 'Name', required: true, formType: 'text' }, description: { headerText: 'Description', required: true, formType: 'text' }, - segmentation: { headerText: 'Segmentation', formType: 'select', options: Object.values(StakeholderSegmentation) }, + segmentation: { + headerText: 'Segmentation', formType: 'select', + options: Object.values(StakeholderSegmentation).map(x => ({ value: x, text: x })) + }, influence: { headerText: 'Influence', formType: 'range', min: 0, max: 100, step: 1 }, availability: { headerText: 'Availability', formType: 'range', min: 0, max: 100, step: 1 }, }, diff --git a/src/presentation/pages/goals/UseCases.mts b/src/presentation/pages/goals/UseCases.mts new file mode 100644 index 00000000..30104c49 --- /dev/null +++ b/src/presentation/pages/goals/UseCases.mts @@ -0,0 +1,119 @@ +import html from '~/presentation/lib/html.mjs'; +import { DataTable } from '~/presentation/components/DataTable.mjs'; +import { SlugPage } from '../SlugPage.mjs'; +import { Tabs } from '~components/Tabs.mjs'; +import mermaid from 'mermaid'; +import UseCase from '~/domain/UseCase.mjs'; +import GoalsRepository from '~/data/GoalsRepository.mjs'; +import StakeholderRepository from '~/data/StakeholderRepository.mjs'; +import type Goals from '~/domain/Goals.mjs'; +import Stakeholder from '~/domain/Stakeholder.mjs'; +import UseCaseRepository from '~/data/UseCaseRepository.mjs'; + +const { h2, p, div, br } = html; + +export class UseCases extends SlugPage { + static { + customElements.define('x-use-cases-page', this); + mermaid.initialize({ + startOnLoad: true, + theme: 'dark' + }); + } + + #goals?: Goals; + #goalsRepository = new GoalsRepository(); + #stakeholderRepository = new StakeholderRepository(); + #stakeholders: Stakeholder[] = []; + #useCaseRepository = new UseCaseRepository(); + + constructor() { + super({ title: 'Use Cases' }, []); + } + + async connectedCallback() { + this.#stakeholders = await this.#stakeholderRepository.getAll(); + + const dataTable = new DataTable({ + columns: { + id: { headerText: 'ID', readonly: true, formType: 'hidden' }, + actor: { + headerText: 'Actor', required: true, formType: 'select', + options: this.#stakeholders.map(x => ({ value: x.id, text: x.name })) + }, + statement: { headerText: 'Use Case', required: true, formType: 'text' } + }, + select: async () => { + if (!this.#goals) + return []; + + return this.#useCaseRepository.getAll(u => this.#goals!.useCases.includes(u.id)); + }, + onCreate: async item => { + const useCase = new UseCase({ ...item, id: self.crypto.randomUUID() }); + await this.#useCaseRepository.add(useCase); + this.#goals!.useCases.push(useCase.id); + await this.#goalsRepository.update(this.#goals!); + }, + onUpdate: async item => { + await this.#useCaseRepository.update(new UseCase({ + ...item + })); + }, + onDelete: async id => { + await this.#useCaseRepository.delete(id); + this.#goals!.useCases = this.#goals!.useCases.filter(x => x !== id); + await this.#goalsRepository.update(this.#goals!); + } + }); + + dataTable.slot = 'content'; + + const update = async () => { + dataTable.renderData(); + this.#renderUseCaseDiagram(); + }; + + this.#goalsRepository.addEventListener('update', update); + this.#stakeholderRepository.addEventListener('update', update); + this.#goalsRepository.getBySlug(this.slug) + .then(goals => { this.#goals = goals; }) + .then(update); + this.#useCaseRepository.addEventListener('update', update); + + this.replaceChildren( + p([` + A use case is a list of related steps that actors perform to achieve a goal + or to complete a scenario. On this page, you can define the use cases that + are associated with the goals of your system. The system itself is not + mentioned here, only the actors and their associated use case. Example:`, + br(), + 'Pilot -> Check schedule', + br(), + 'Clerk -> Confirm booking' + ]), + new Tabs({ selectedIndex: 0 }, [ + h2({ slot: 'tab' }, 'Use Cases'), + dataTable, + h2({ slot: 'tab' }, 'Diagram'), + div({ id: 'mermaid-container', slot: 'content' }, []), + ]) + ); + } + + async #renderUseCaseDiagram() { + const mermaidContainer = this.querySelector('#mermaid-container')!, + useCases = await this.#useCaseRepository.getAll(u => this.#goals!.useCases.includes(u.id)), + chartDefinition = ` + flowchart LR + ${useCases.map(u => { + const actor = this.#stakeholders.find(s => s.id === u.actor)!; + + return `${actor.id}("#128100;
${actor.name}") --> ${u.id}["${u.statement}"]`; + }).join('\n')} + `, + { svg } = await mermaid.render('diagram', chartDefinition); + + mermaidContainer.innerHTML = svg; + } +} \ No newline at end of file diff --git a/src/presentation/theme/formTheme.mts b/src/presentation/theme/formTheme.mts index 5a583e08..8092b35c 100644 --- a/src/presentation/theme/formTheme.mts +++ b/src/presentation/theme/formTheme.mts @@ -11,9 +11,10 @@ const formTheme: Theme = { padding: '0.5em', width: '100%' }, - 'input:read-only, textarea:read-only': { - backgroundColor: 'var(--content-bg)', - color: 'var(--font-color)', + 'input:read-only, input:disabled, textarea:read-only, textarea:disabled, select:disabled': { + backgroundColor: 'transparent', + border: 'none', + color: 'var(--font-color)' } }; diff --git a/webpack.config.mjs b/webpack.config.mjs index 05155954..b4117914 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -11,6 +11,7 @@ const __filename = url.fileURLToPath(import.meta.url), export default { mode: process.env.NODE_ENV, + devtool: process.env.NODE_ENV === 'development' ? 'eval-source-map' : 'source-map', entry: { main: './src/main.mts' },