Skip to content

Commit

Permalink
Added availability and influence fields to Stakeholder (#55)
Browse files Browse the repository at this point in the history
  • Loading branch information
mlhaufe authored Dec 19, 2023
1 parent 2bf15da commit e610305
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 93 deletions.
48 changes: 41 additions & 7 deletions src/domain/Stakeholder.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,50 @@ export enum StakeholderCategory {
}

export default class Stakeholder extends Entity {
category: StakeholderCategory;
description: string;
name: string;
segmentation: StakeholderSegmentation;
#influence!: number;
#availability!: number;

constructor({ id, category, description, name, segmentation }: Properties<Stakeholder>) {
super({ id });
this.category = category;
this.description = description;
this.name = name;
this.segmentation = segmentation;
constructor(properties: Omit<Properties<Stakeholder>, 'category'>) {
super({ id: properties.id });
this.description = properties.description;
this.name = properties.name;
this.segmentation = properties.segmentation;
this.influence = properties.influence;
this.availability = properties.availability;
}

get influence() {
return this.#influence;
}

set influence(value) {
if (value < 0 || value > 100)
throw new Error('Invalid value for influence. Must be between 0 and 100.');

this.#influence = value;
}

get availability() {
return this.#availability;
}

set availability(value) {
if (value < 0 || value > 100)
throw new Error('Invalid value for availability. Must be between 0 and 100.');
this.#availability = value;
}

get category(): StakeholderCategory {
if (this.influence >= 75 && this.availability >= 75)
return StakeholderCategory.KeyStakeholder;
if (this.influence >= 75 && this.availability < 75)
return StakeholderCategory.ShadowInfluencer;
if (this.influence < 75 && this.availability >= 75)
return StakeholderCategory.FellowTraveler;

return StakeholderCategory.Observer;
}
}
30 changes: 19 additions & 11 deletions src/mappers/StakeholderToJsonMapper.mts
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
import Stakeholder, { StakeholderCategory, StakeholderSegmentation } from '~/domain/Stakeholder.mjs';
import Stakeholder, { StakeholderSegmentation } from '~/domain/Stakeholder.mjs';
import EntityToJsonMapper, { type EntityJson } from './EntityToJsonMapper.mjs';

export interface StakeholderJson extends EntityJson {
category: string;
description: string;
name: string;
segmentation: string;
influence: number;
availability: number;
}

export default class StakeholderToJsonMapper extends EntityToJsonMapper {
override mapFrom(target: StakeholderJson): Stakeholder {
return new Stakeholder({
category: target.category as StakeholderCategory,
description: target.description,
id: target.id,
name: target.name,
segmentation: target.segmentation as StakeholderSegmentation
});
const version = target.serializationVersion ?? '{undefined}';

if (version.startsWith('0.3.'))
return new Stakeholder({
description: target.description,
id: target.id,
name: target.name,
segmentation: target.segmentation as StakeholderSegmentation,
influence: target.influence ?? 0,
availability: target.availability ?? 0
});

throw new Error(`Unsupported serialization version: ${version}`);
}

override mapTo(source: Stakeholder): StakeholderJson {
return {
...super.mapTo(source),
category: source.category,
description: source.description,
name: source.name,
segmentation: source.segmentation
segmentation: source.segmentation,
influence: source.influence,
availability: source.availability
};
}
}
123 changes: 80 additions & 43 deletions src/presentation/components/DataTable.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@ import { Component } from './Component.mjs';
import buttonTheme from '../theme/buttonTheme.mjs';
import formTheme from '../theme/formTheme.mjs';

export interface DataColumn {
formType?: 'text' | 'hidden' | 'select';
export type DataColumn = {
readonly?: boolean;
headerText: string;
required?: boolean;
options?: string[];
}
} & ({
formType: 'text' | 'hidden';
} | {
formType: 'number' | 'range';
min: number;
max: number;
step: number;
} | {
formType: 'select';
options: string[];
});

export type DataColumns<T extends Entity> =
{ id: DataColumn }
& { [K in keyof Properties<T>]: DataColumn };
& { [K in keyof Partial<Properties<T>>]: DataColumn };

export interface DataTableOptions<T extends Entity> {
columns?: DataColumns<T>;
Expand Down Expand Up @@ -49,11 +57,6 @@ export class DataTable<T extends Entity> extends Component {
constructor({ columns, select, onCreate, onDelete, onUpdate }: DataTableOptions<T>) {
super({});

Object.entries(columns ?? {}).forEach(([key, value]) => {
if (value.formType == 'select' && !value.options)
throw new Error(`When formType is "select", options must be specified for column "${key}".`);
});

this.#columns = Object.freeze(columns ?? {} as DataColumns<T>);
this.#select = select ?? (() => Promise.resolve([]));
this.#onCreate = onCreate;
Expand Down Expand Up @@ -258,20 +261,33 @@ export class DataTable<T extends Entity> extends Component {
...Object.entries(this.#columns).map(([id, col]) =>
td({ hidden: col.formType == 'hidden' }, [
input({
type: 'text',
type: col.formType,
name: id,
required: col.required,
form: this.#frmDataTableCreate,
[renderIf]: col.formType != 'select'
[renderIf]: col.formType == 'text' || col.formType == 'hidden'
}),
select({
hidden: col.formType != 'select',
name: id,
form: this.#frmDataTableCreate,
[renderIf]: col.formType == 'select'
}, [
...(col.options?.map(opt => option({ value: opt }, opt)) ?? [])
])
},
col.formType == 'select' ?
col.options.map(opt => option({ value: opt }, opt))
: []
),
input({
type: col.formType,
name: id,
min: col.formType == 'number' || col.formType == 'range' ?
`${col.min}` : '0',
max: col.formType == 'number' || col.formType == 'range' ?
`${col.max}` : '0',
step: col.formType == 'number' || col.formType == 'range' ?
`${col.step}` : '1',
form: this.#frmDataTableCreate,
[renderIf]: col.formType == 'number' || col.formType == 'range'
})
]))
);
this.#newItemRow.append(td(button({
Expand All @@ -287,34 +303,55 @@ export class DataTable<T extends Entity> extends Component {
span({
'className': 'view-data',
// @ts-expect-error: data-* attributes are valid
'data-name': id
'data-name': id,
[renderIf]: col.formType != 'range'
}, (item as any)[id]),
col.formType != 'select' ?
input({
form: this.#frmDataTableUpdate,
type: 'text',
className: 'edit-data',
name: id,
value: (item as any)[id],
required: col.required,
disabled: true,
hidden: true
})
: '',
col.formType == 'select' ?
select({
form: this.#frmDataTableUpdate,
className: 'edit-data',
name: id,
disabled: true,
hidden: true
}, [
...col.options!.map(opt => option({
value: opt,
selected: opt == (item as any)[id]
}, opt))
])
: ''
input({
form: this.#frmDataTableUpdate,
type: col.formType,
className: 'view-data',
name: id,
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'
}),
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}`,
disabled: true,
hidden: true,
[renderIf]: col.formType == 'number' || col.formType == 'range',
value: (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))
)
])),
td([
button({
Expand Down
4 changes: 2 additions & 2 deletions src/presentation/pages/environments/Glossary.mts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export class Glossary extends SlugPage {
const dataTable = new DataTable<GlossaryTerm>({
columns: {
id: { headerText: 'ID', readonly: true, formType: 'hidden' },
term: { headerText: 'Term', required: true },
definition: { headerText: 'Definition' }
term: { headerText: 'Term', required: true, formType: 'text' },
definition: { headerText: 'Definition', formType: 'text' }
},
select: async () => {
if (!this.#environment)
Expand Down
2 changes: 1 addition & 1 deletion src/presentation/pages/goals/Functionality.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class Functionality extends SlugPage {
const dataTable = new DataTable({
columns: {
id: { headerText: 'ID', readonly: true, formType: 'hidden' },
statement: { headerText: 'Statement', required: true }
statement: { headerText: 'Statement', required: true, formType: 'text' }
},
select: async () => {
if (!this.#goals)
Expand Down
44 changes: 15 additions & 29 deletions src/presentation/pages/goals/Stakeholders.mts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ export class Stakeholders extends SlugPage {
const dataTable = new DataTable<Stakeholder>({
columns: {
id: { headerText: 'ID', readonly: true, formType: 'hidden' },
name: { headerText: 'Name', required: true },
description: { headerText: 'Description', required: true },
name: { headerText: 'Name', required: true, formType: 'text' },
description: { headerText: 'Description', required: true, formType: 'text' },
segmentation: { headerText: 'Segmentation', formType: 'select', options: Object.values(StakeholderSegmentation) },
category: { headerText: 'Category', formType: 'select', options: Object.values(StakeholderCategory) }
influence: { headerText: 'Influence', formType: 'range', min: 0, max: 100, step: 1 },
availability: { headerText: 'Availability', formType: 'range', min: 0, max: 100, step: 1 },
},
select: async () => {
if (!this.#goals)
Expand Down Expand Up @@ -105,38 +106,23 @@ export class Stakeholders extends SlugPage {
await this.#stakeholderRepository.getAll(),
({ segmentation }) => segmentation
),
clientGroups = groupBy(
stakeholders[StakeholderSegmentation.Client] ?? [],
({ category }) => category
),
vendorGroups = groupBy(
stakeholders[StakeholderSegmentation.Vendor] ?? [],
({ category }) => category
),
chartDefinition = (groups: Record<StakeholderCategory, Stakeholder[]>, category: StakeholderSegmentation) => `
clientGroup = stakeholders[StakeholderSegmentation.Client] ?? [],
vendorGroup = stakeholders[StakeholderSegmentation.Vendor] ?? [],
chartDefinition = (stakeholders: Stakeholder[], category: StakeholderSegmentation) => `
quadrantChart
title ${category}
x-axis Low Availability --> High Availability
y-axis Low Infuence --> High Influence
quadrant-1 "Shadow Influencers (Manage)"
quadrant-2 "Key Stakeholders (Satisfy)"
quadrant-3 "Fellow Travelers (Monitor)"
quadrant-4 "Observers (Inform)"
${(groups[StakeholderCategory.ShadowInfluencer] ?? [])
.map(({ name }) => `"${name}": [0.75, 0.75]`)?.join('\n')
}
${(groups[StakeholderCategory.KeyStakeholder] ?? [])
.map(({ name }) => `"${name}": [0.25, 0.75]`)?.join('\n')
}
${(groups[StakeholderCategory.FellowTraveler] ?? [])
.map(({ name }) => `"${name}": [0.25, 0.25]`)?.join('\n')
}
${(groups[StakeholderCategory.Observer] ?? [])
.map(({ name }) => `"${name}": [0.75, 0.25]`)?.join('\n')
quadrant-1 "${StakeholderCategory.KeyStakeholder} (Satisfy)"
quadrant-2 "${StakeholderCategory.ShadowInfluencer} (Manage)"
quadrant-3 "${StakeholderCategory.Observer} (Inform)"
quadrant-4 "${StakeholderCategory.FellowTraveler} (Monitor)"
${stakeholders.map(({ name, availability, influence }) =>
`"${name}": [${availability / 100}, ${influence / 100}]`)?.join('\n')
}
`,
{ svg: svgClient } = await mermaid.render('clientMap', chartDefinition(clientGroups, StakeholderSegmentation.Client)),
{ svg: svgVendor } = await mermaid.render('vendorMap', chartDefinition(vendorGroups, StakeholderSegmentation.Vendor));
{ svg: svgClient } = await mermaid.render('clientMap', chartDefinition(clientGroup, StakeholderSegmentation.Client)),
{ svg: svgVendor } = await mermaid.render('vendorMap', chartDefinition(vendorGroup, StakeholderSegmentation.Vendor));
mermaidContainer.innerHTML = `${svgClient}<br>${svgVendor}`;
}
}

0 comments on commit e610305

Please sign in to comment.