Skip to content

Commit

Permalink
Implemented Goals -> Use Cases feature
Browse files Browse the repository at this point in the history
  • Loading branch information
mlhaufe committed Dec 21, 2023
1 parent e610305 commit b7f68e3
Show file tree
Hide file tree
Showing 22 changed files with 300 additions and 101 deletions.
8 changes: 0 additions & 8 deletions _old/domain/entities/Goal.mts

This file was deleted.

2 changes: 1 addition & 1 deletion src/data/GoalsRepository.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Goals> {
export default class GoalsRepository extends PEGSRepository<Goals> {
constructor() { super('goals', new GoalsToJsonMapper(pkg.version)); }
}
2 changes: 1 addition & 1 deletion src/data/StakeholderRepository.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Stakeholder> {
export default class StakeholderRepository extends LocalStorageRepository<Stakeholder> {
constructor() { super('stakeholder', new StakeholderToJsonMapper(pkg.version)); }
}
8 changes: 8 additions & 0 deletions src/data/UseCaseRepository.mts
Original file line number Diff line number Diff line change
@@ -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<UseCase> {
constructor() { super('use-case', new UseCaseToJsonMapper(pkg.version)); }
}
3 changes: 3 additions & 0 deletions src/domain/Behavior.mts
Original file line number Diff line number Diff line change
@@ -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<Behavior>) {
super(options);
Expand Down
2 changes: 2 additions & 0 deletions src/domain/Goals.mts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default class Goals extends PEGS {
outcomes: string;
stakeholders: Uuid[];
situation: string;
useCases: Uuid[];

constructor(options: Properties<Goals>) {
super(options);
Expand All @@ -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;
}
}
6 changes: 3 additions & 3 deletions src/domain/Requirement.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export default class Requirement extends Entity {
*/
accessor statement: string;

constructor(options: Properties<Requirement>) {
super(options);
constructor(properties: Properties<Requirement>) {
super(properties);

this.statement = options.statement;
this.statement = properties.statement;
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/domain/UseCase.mts
Original file line number Diff line number Diff line change
@@ -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<UseCase>) {
super(properties);

this.actor = properties.actor;
}
}
8 changes: 8 additions & 0 deletions src/lib/groupBy.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const groupBy = <T, K extends keyof any>(arr: T[], key: (i: T) => K) =>
arr.reduce((groups, item) => {
(groups[key(item)] ||= []).push(item);

return groups;
}, {} as Record<K, T[]>);

export default groupBy;
9 changes: 7 additions & 2 deletions src/mappers/GoalsToJsonMapper.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ export interface GoalsJson extends PEGSJson {
outcomes: string;
situation: string;
stakeholders: Uuid[];
useCases: Uuid[];
}

export default class GoalsToJsonMapper extends PEGSToJsonMapper {
override mapFrom(target: GoalsJson): Goals {
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}`);
}
Expand All @@ -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
};
}
}
24 changes: 24 additions & 0 deletions src/mappers/UseCaseToJsonMapper.mts
Original file line number Diff line number Diff line change
@@ -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
};
}
}
1 change: 1 addition & 0 deletions src/presentation/Application.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
138 changes: 76 additions & 62 deletions src/presentation/components/DataTable.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Entity> =
{ id: DataColumn }
Expand All @@ -35,8 +44,8 @@ export interface DataTableOptions<T extends Entity> {

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<T extends Entity> extends Component {
Expand Down Expand Up @@ -127,7 +136,7 @@ export class DataTable<T extends Entity> extends Component {
const form = e.target as HTMLFormElement,
formData = new FormData(form),
item = Object.fromEntries(formData.entries()) as Properties<T>;
this._cancelEdit();
// this._cancelEdit(e.submitter as HTMLButtonElement);
this.onUpdate?.(item);
}

Expand Down Expand Up @@ -216,15 +225,27 @@ export class DataTable<T extends Entity> 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<HTMLElement>('.view-data'),
editData = root.querySelectorAll<HTMLInputElement>('.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<HTMLInputElement | HTMLSelectElement>('.data-cell > *'),
editButtons = tr.querySelectorAll<HTMLButtonElement>('.button-cell > .edit-data'),
viewButtons = tr.querySelectorAll<HTMLButtonElement>('.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);
}

/**
Expand All @@ -235,12 +256,18 @@ export class DataTable<T extends Entity> extends Component {
*/
protected _editRow(e: Event) {
const tr = (e.target as Element).closest('tr')!,
tbody = tr.closest('tbody')!;
tbody.querySelectorAll<HTMLElement>('.view-data').forEach(show);
tbody.querySelectorAll<HTMLElement>('.edit-data').forEach(hide);
tr.querySelectorAll<HTMLElement>('.view-data').forEach(hide);
tr.querySelectorAll<HTMLElement>('.edit-data').forEach(show);
tr.querySelectorAll<HTMLInputElement>('.edit-data').forEach(enable);
cancelButtons = tr.closest('tbody')!.querySelectorAll<HTMLButtonElement>('.cancel-button'),
fields = tr.querySelectorAll<HTMLInputElement>('.data-cell > *'),
editButtons = tr.querySelectorAll<HTMLButtonElement>('.button-cell > .edit-data'),
viewButtons = tr.querySelectorAll<HTMLButtonElement>('.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() {
Expand Down Expand Up @@ -273,7 +300,7 @@ export class DataTable<T extends Entity> 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({
Expand All @@ -298,62 +325,49 @@ export class DataTable<T extends Entity> 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),
Expand All @@ -375,7 +389,7 @@ export class DataTable<T extends Entity> 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')
Expand Down
Loading

0 comments on commit b7f68e3

Please sign in to comment.