Skip to content

Commit

Permalink
Merge pull request #22 from funidata/trunk
Browse files Browse the repository at this point in the history
Implement make-shift view switching in home tab
  • Loading branch information
joonashak authored Oct 21, 2024
2 parents 3c2010e + 02f4f0e commit 7ed223f
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 20 deletions.
50 changes: 50 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
},
"dependencies": {
"@golevelup/nestjs-discovery": "^4.0.1",
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/microservices": "^10.4.3",
"@nestjs/platform-express": "^10.4.2",
"@nestjs/swagger": "^7.4.0",
"@nestjs/typeorm": "^10.0.2",
"@slack/bolt": "^4.0.0-rc.1",
"cache-manager": "^5.7.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
Expand Down
2 changes: 2 additions & 0 deletions app/src/bolt/enums/action.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ enum Action {
DAY_LIST_ITEM_OVERFLOW = "day_list_item_overflow",
SET_VISIBLE_OFFICE = "set_visible_office",
OPEN_PRESENCE_VIEW = "open_presence_view",
OPEN_REGISTRATION_VIEW = "open_registration_view",
OPEN_SETTINGS_VIEW = "open_settings_view",
}

export default Action;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsIn, ValidateNested } from "class-validator";
import { IsBoolean, IsIn, ValidateNested } from "class-validator";
import { BoltConfiguration } from "./bolt-configuration.model";
import { DatabaseConfiguration } from "./database-configuration.model";

Expand All @@ -15,4 +15,7 @@ export class KaikuAppConfiguration {

@ValidateNested()
bolt = new BoltConfiguration();

@IsBoolean()
hideDevTools = process.env.HIDE_DEV_TOOLS === "true";
}
4 changes: 3 additions & 1 deletion app/src/database/database-config.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Test } from "@nestjs/testing";
import { omit } from "lodash";
import { createMockProvider } from "../../test/mocks/mock-provider.factory";
import { ConfigService } from "../common/config/config.service";
import { KaikuAppConfiguration } from "../common/config/models/kaiku-app-configuration.model";
import { DatabaseConfigService } from "./database-config.service";

const mockConfig = {
const mockConfig: KaikuAppConfiguration = {
nodeEnv: "production",
hideDevTools: true,
bolt: {
token: "",
appToken: "",
Expand Down
32 changes: 25 additions & 7 deletions app/src/gui/home-tab/home-tab-controls.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import { Injectable } from "@nestjs/common";
import { Actions, Button, ViewBlockBuilder } from "slack-block-builder";
import { Actions, Button, Divider, ViewBlockBuilder } from "slack-block-builder";
import { Appendable } from "slack-block-builder/dist/internal";
import Action from "../../bolt/enums/action.enum";
import { ConfigService } from "../../common/config/config.service";
import { DevUiBuilder } from "../dev/dev-ui.builder";
import { ViewCache } from "./view.cache";

@Injectable()
export class HomeTabControls {
constructor(private devToolsBuilder: DevUiBuilder) {}
constructor(
private devToolsBuilder: DevUiBuilder,
private configService: ConfigService,
private viewCache: ViewCache,
) {}

async build(userId: string): Promise<Appendable<ViewBlockBuilder>> {
const { selectedView } = await this.viewCache.get(userId);

const devTools = this.configService.getConfig().hideDevTools
? []
: this.devToolsBuilder.buildBlocks();

build(): Appendable<ViewBlockBuilder> {
const devTools = this.devToolsBuilder.buildBlocks();
return [
...devTools,
Actions().elements([
Button({ text: "Ilmoittautuminen" }),
Button({ text: "Läsnäolijat", actionId: Action.OPEN_PRESENCE_VIEW }),
Button({ text: "Asetukset" }),
Button({ text: "Ilmoittautuminen", actionId: Action.OPEN_REGISTRATION_VIEW }).primary(
selectedView === "registration",
),
Button({ text: "Läsnäolijat", actionId: Action.OPEN_PRESENCE_VIEW }).primary(
selectedView === "presence",
),
Button({ text: "Asetukset", actionId: Action.OPEN_SETTINGS_VIEW }).primary(
selectedView === "settings",
),
]),
Divider(),
];
}
}
64 changes: 59 additions & 5 deletions app/src/gui/home-tab/home-tab.controller.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,86 @@
import { Controller } from "@nestjs/common";
import { Appendable, ViewBlockBuilder } from "slack-block-builder/dist/internal";
import BoltAction from "../../bolt/decorators/bolt-action.decorator";
import BoltEvent from "../../bolt/decorators/bolt-event.decorator";
import Action from "../../bolt/enums/action.enum";
import Event from "../../bolt/enums/event.enum";
import { AppHomeOpenedArgs } from "../../bolt/types/app-home-opened.type";
import { BoltActionArgs } from "../../bolt/types/bolt-action-args.type";
import { HomeTabService } from "./home-tab.service";
import { ViewCache } from "./view.cache";
import { PresenceView } from "./views/presence.view";
import { RegistrationView } from "./views/registration/registration.view";
import { SettingsView } from "./views/settings.view";

type ViewProps = {
actionArgs: BoltActionArgs;
name: "presence" | "registration" | "settings";
contentFactory: () => Promise<Appendable<ViewBlockBuilder>>;
};

@Controller()
export class HomeTabController {
constructor(
private homeTabBuilder: HomeTabService,
private presenceView: PresenceView,
private registrationView: RegistrationView,
private settingsView: SettingsView,
private viewCache: ViewCache,
) {}

@BoltEvent(Event.APP_HOME_OPENED)
async getView(args: AppHomeOpenedArgs) {
const content = await this.registrationView.build(args.event.user);
const { selectedView } = await this.viewCache.get(args.context.userId);
let content: Appendable<ViewBlockBuilder> = [];

if (selectedView === "presence") {
content = await this.presenceView.build();
} else if (selectedView === "settings") {
content = await this.settingsView.build();
} else {
content = await this.registrationView.build(args.event.user);
}

await this.homeTabBuilder.publish(args, content);
}

@BoltAction(Action.OPEN_PRESENCE_VIEW)
async openPresenceView(args: BoltActionArgs) {
await args.ack();
const content = await this.presenceView.build();
this.homeTabBuilder.update(args, content);
async openPresenceView(actionArgs: BoltActionArgs) {
await this.openView({
actionArgs,
contentFactory: async () => this.presenceView.build(),
name: "presence",
});
}

@BoltAction(Action.OPEN_REGISTRATION_VIEW)
async openRegistrationView(actionArgs: BoltActionArgs) {
await this.openView({
actionArgs,
contentFactory: async () => this.registrationView.build(actionArgs.context.userId),
name: "registration",
});
}

@BoltAction(Action.OPEN_SETTINGS_VIEW)
async openSettingsView(actionArgs: BoltActionArgs) {
await this.openView({
actionArgs,
contentFactory: async () => this.settingsView.build(),
name: "settings",
});
}

/**
* Abstract helper to show home tab views with less boilerplate.
*
* According to Bolt docs, `ack()` should be called ASAP. To comply with this,
* we take a content factory instead of prebuild content as an argument.
*/
private async openView({ actionArgs, contentFactory, name }: ViewProps) {
await actionArgs.ack();
await this.viewCache.set(actionArgs.context.userId, { selectedView: name });
const content = await contentFactory();
this.homeTabBuilder.update(actionArgs, content);
}
}
18 changes: 16 additions & 2 deletions app/src/gui/home-tab/home-tab.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";
import { ConfigModule } from "../../common/config/config.module";
import { OfficeModule } from "../../entities/office/office.module";
import { PresenceModule } from "../../entities/presence/presence.module";
import { DevUiModule } from "../dev/dev-ui.module";
import { HomeTabControls } from "./home-tab-controls";
import { HomeTabController } from "./home-tab.controller";
import { HomeTabService } from "./home-tab.service";
import { ViewCache } from "./view.cache";
import { RegistrationViewModule } from "./views/registration/registration-view.module";
import { SettingsView } from "./views/settings.view";

@Module({
imports: [DevUiModule, OfficeModule, PresenceModule, RegistrationViewModule],
providers: [HomeTabService, HomeTabControls],
imports: [
DevUiModule,
OfficeModule,
PresenceModule,
RegistrationViewModule,
ConfigModule,
CacheModule.register({
max: 1000,
ttl: 0,
}),
],
providers: [HomeTabService, HomeTabControls, ViewCache, SettingsView],
controllers: [HomeTabController],
exports: [HomeTabService, RegistrationViewModule],
})
Expand Down
8 changes: 4 additions & 4 deletions app/src/gui/home-tab/home-tab.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class HomeTabService {
): Promise<ViewsPublishResponse> {
return client.views.publish({
user_id: event.user,
view: await this.build(content),
view: await this.build(content, event.user),
});
}

Expand All @@ -36,15 +36,15 @@ export class HomeTabService {
): Promise<ViewsPublishResponse> {
return client.views.update({
view_id: body.view.id,
view: await this.build(content),
view: await this.build(content, body.user.id),
});
}

/**
* Build home tab layout with given content in it.
*/
private async build(content: Appendable<ViewBlockBuilder>) {
const controls = this.homeTabControls.build();
private async build(content: Appendable<ViewBlockBuilder>, userId: string) {
const controls = await this.homeTabControls.build(userId);

return HomeTab()
.blocks(...controls, ...content)
Expand Down
53 changes: 53 additions & 0 deletions app/src/gui/home-tab/view.cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { Cache } from "cache-manager";

type ViewOptions = {
selectedView: "registration" | "presence" | "settings";
};

const defaultOptions: ViewOptions = {
selectedView: "registration",
};

/**
* View caching service implements lightweight non-persistent storage for view
* selections. This data includes things like selected view, selected drop-down
* values, etc. Basically anything that is nice to store for a better UX but
* isn't something that needs to be persisted in the database.
*
* Note that while partial updates to the cached view objects are fine, nested
* structures will be overwritten by the current implementation.
*/
@Injectable()
export class ViewCache {
private logger = new Logger(ViewCache.name);

constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

async get(userId: string): Promise<ViewOptions> {
const cached = await this.getCachedValue(userId);

if (cached) {
this.logger.debug(`View cache hit for user ${userId}`, cached);
}

return { ...defaultOptions, ...cached };
}

async set(userId: string, value: Partial<ViewOptions>): Promise<void> {
const cached = await this.getCachedValue(userId);
// Note that this will break for nested objects.
const merged = { ...cached, ...value };
await this.cacheManager.set(this.cacheKey(userId), merged);
return;
}

private cacheKey(userId: string): string {
return `view-cache-${userId}`;
}

private getCachedValue(userId: string): Promise<Partial<ViewOptions>> {
return this.cacheManager.get<Partial<ViewOptions>>(this.cacheKey(userId));
}
}
Loading

0 comments on commit 7ed223f

Please sign in to comment.