Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added availability and influence fields to Stakeholder #55

Merged
merged 1 commit into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}`;
}
}