From 2daac23c4964b42756d0216283c2ea08778b8c8d Mon Sep 17 00:00:00 2001 From: Nastasia Date: Thu, 7 Aug 2025 22:53:43 +0300 Subject: [PATCH 01/19] fix: change color if not dev --- webapp/src/main.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index d92951851bd..99d50040611 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -1,6 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' +import React, {useEffect} from 'react' import ReactDOM from 'react-dom' import {Provider as ReduxProvider} from 'react-redux' import {store as emojiMartStore} from 'emoji-mart' @@ -34,11 +34,16 @@ const MainApp = () => { const me = useAppSelector(getMe) const clientConfig = useAppSelector(getClientConfig) - if (clientConfig.featureFlags['FOCALBOARD_ENVIRONMENT'] != 'prod') { - // TODO also set this when changing a theme - // for some reason useAppSelector doesn't work in theme.ts - document.documentElement.style.setProperty('--sidebar-bg-rgb', '92, 50, 30') - } + useEffect(() => { + if (clientConfig.featureFlags['FOCALBOARD_ENVIRONMENT'] != 'prod') { + // TODO also set this when changing a theme + // for some reason useAppSelector doesn't work in theme.ts + document.documentElement.style.setProperty('--sidebar-bg-rgb', '92, 50, 30') + } else { + document.documentElement.style.setProperty('--sidebar-bg-rgb', '30, 50, 92') + } + }, [clientConfig.featureFlags['FOCALBOARD_ENVIRONMENT']]) + return ( From 3431713d65a0ae9e67c1a1e837b749b6e622837b Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 12 Aug 2025 00:40:15 +0300 Subject: [PATCH 02/19] feat: add tam and olya to admin user names --- webapp/.eslintrc.json | 299 +++++++++++++++++++------------------- webapp/.stylelintrc.json | 26 ++-- webapp/src/store/users.ts | 267 ++++++++++++++++++++-------------- 3 files changed, 317 insertions(+), 275 deletions(-) diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index e7b0749d999..ebc9026b38b 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -1,158 +1,155 @@ { - "extends": [ - "plugin:mattermost/react", - "plugin:cypress/recommended", - "plugin:jquery/deprecated" - ], - "plugins": [ - "react", - "babel", - "mattermost", - "import", - "cypress", - "jquery", - "no-only-tests" - ], - "parser": "@typescript-eslint/parser", - "env": { - "jest": true, - "cypress/globals": true - }, - "settings": { - "import/resolver": "webpack", - "react": { - "pragma": "React", - "version": "detect" - } - }, - "rules": { - "max-lines": "off", - "no-unused-expressions": 0, - "babel/no-unused-expressions": [2, {"allowShortCircuit": true}], - "eol-last": ["error", "always"], - "import/no-unresolved": 2, - "import/order": [ - 2, - { - "newlines-between": "always-and-inside-groups", - "groups": [ - "builtin", - "external", - [ - "internal", - "parent" - ], - "sibling", - "index" - ] - } + "extends": [ + "plugin:mattermost/react", + "plugin:cypress/recommended", + "plugin:jquery/deprecated" ], - "no-undefined": 0, - "react/jsx-filename-extension": 0, - "react/prop-types": [ - 2, - { - "ignore": [ - "location", - "history", - "component" - ] - } + "plugins": [ + "react", + "babel", + "mattermost", + "import", + "cypress", + "jquery", + "no-only-tests" ], - "react/no-string-refs": 2, - "no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}], - "max-nested-callbacks": ["error", {"max": 5}], - "no-shadow": "off", - "@typescript-eslint/no-shadow": "error" - }, - "overrides": [ - { - "files": ["**/*.tsx", "**/*.ts"], - "extends": [ - "plugin:@typescript-eslint/recommended" - ], - "rules": { - "import/no-unresolved": 0, // ts handles this better - "camelcase": 0, - "semi": "off", - "@typescript-eslint/naming-convention": [ - 2, - { - "selector": "function", - "format": ["camelCase", "PascalCase"] - }, - { - "selector": "variable", - "format": ["camelCase", "PascalCase", "UPPER_CASE"] - }, - { - "selector": "parameter", - "format": ["camelCase", "PascalCase"], - "leadingUnderscore": "allow" - }, - { - "selector": "typeLike", - "format": ["PascalCase"] - } - ], - "@typescript-eslint/no-non-null-assertion": 0, - "@typescript-eslint/no-unused-vars": [ - 2, - { - "vars": "all", - "args": "after-used" - } - ], - "@typescript-eslint/member-delimiter-style": [2, {"multiline": {"delimiter": "none"}, "singleline": {"delimiter": "comma"}}], - "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/no-empty-function": 0, - "@typescript-eslint/prefer-interface": 0, - "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/semi": [2, "never"], - "@typescript-eslint/indent": [ - 2, - 4, - { - "SwitchCase": 0 - } + "parser": "@typescript-eslint/parser", + "env": { + "jest": true, + "cypress/globals": true + }, + "settings": { + "import/resolver": "webpack", + "react": { + "pragma": "React", + "version": "detect" + } + }, + "rules": { + "max-lines": "off", + "no-unused-expressions": 0, + "babel/no-unused-expressions": [2, { "allowShortCircuit": true }], + "eol-last": ["error", "always"], + "import/no-unresolved": 2, + "import/order": [ + 2, + { + "newlines-between": "always-and-inside-groups", + "groups": [ + "builtin", + "external", + ["internal", "parent"], + "sibling", + "index" + ] + } ], - "no-use-before-define": "off", - "@typescript-eslint/no-use-before-define": [ - 2, - { - "classes": false, - "functions": false, - "variables": false - } + "no-undefined": 0, + "react/jsx-filename-extension": 0, + "react/prop-types": [ + 2, + { + "ignore": ["location", "history", "component"] + } ], - "no-useless-constructor": 0, - "@typescript-eslint/no-useless-constructor": 2, - "react/jsx-filename-extension": 0 - } - }, - { - "files": ["tests/**", "**/*.test.*"], - "env": { - "jest": true - }, - "rules": { - "func-names": 0, - "global-require": 0, - "new-cap": 0, - "prefer-arrow-callback": 0, - "no-import-assign": 0 - } + "react/no-string-refs": 2, + "no-only-tests/no-only-tests": ["error", { "focus": ["only", "skip"] }], + "max-nested-callbacks": ["error", { "max": 5 }], + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error" }, - { - "files": ["cypress/**"], - "rules": { - "cypress/no-unnecessary-waiting": 0, - "func-names": 0, - "import/no-unresolved": 0, - "max-nested-callbacks": 0, - "no-process-env": 0, - "no-unused-expressions": 0 - } - } - ] + "overrides": [ + { + "files": ["**/*.tsx", "**/*.ts"], + "extends": ["plugin:@typescript-eslint/recommended"], + "rules": { + "import/no-unresolved": 0, // ts handles this better + "camelcase": 0, + "semi": "off", + "@typescript-eslint/naming-convention": [ + 2, + { + "selector": "function", + "format": ["camelCase", "PascalCase"] + }, + { + "selector": "variable", + "format": ["camelCase", "PascalCase", "UPPER_CASE"] + }, + { + "selector": "parameter", + "format": ["camelCase", "PascalCase"], + "leadingUnderscore": "allow" + }, + { + "selector": "typeLike", + "format": ["PascalCase"] + } + ], + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/no-unused-vars": [ + 2, + { + "vars": "all", + "args": "after-used" + } + ], + "@typescript-eslint/member-delimiter-style": [ + 2, + { + "multiline": { "delimiter": "none" }, + "singleline": { "delimiter": "comma" } + } + ], + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/prefer-interface": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/semi": [2, "never"], + "@typescript-eslint/indent": [ + 2, + 4, + { + "SwitchCase": 0 + } + ], + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": [ + 2, + { + "classes": false, + "functions": false, + "variables": false + } + ], + "no-useless-constructor": 0, + "@typescript-eslint/no-useless-constructor": 2, + "react/jsx-filename-extension": 0 + } + }, + { + "files": ["tests/**", "**/*.test.*"], + "env": { + "jest": true + }, + "rules": { + "func-names": 0, + "global-require": 0, + "new-cap": 0, + "prefer-arrow-callback": 0, + "no-import-assign": 0 + } + }, + { + "files": ["cypress/**"], + "rules": { + "cypress/no-unnecessary-waiting": 0, + "func-names": 0, + "import/no-unresolved": 0, + "max-nested-callbacks": 0, + "no-process-env": 0, + "no-unused-expressions": 0 + } + } + ] } diff --git a/webapp/.stylelintrc.json b/webapp/.stylelintrc.json index 762b8934a9f..ee5d111a855 100644 --- a/webapp/.stylelintrc.json +++ b/webapp/.stylelintrc.json @@ -1,14 +1,16 @@ { - "extends": "stylelint-config-sass-guidelines", - "rules": { - "indentation": 4, - "selector-class-pattern": "[a-zA-Z_-]+", - "max-nesting-depth": 4, - "selector-max-compound-selectors": 6, - "selector-max-id": 1, - "selector-no-qualifying-type": null, - "order/properties-alphabetical-order": null, - "declaration-block-no-duplicate-properties": true, - "property-disallowed-list": ["z-index"] - } + "extends": "stylelint-config-sass-guidelines", + "rules": { + "indentation": 4, + "selector-class-pattern": "[a-zA-Z_-]+", + "max-nesting-depth": 4, + "selector-max-compound-selectors": 6, + "selector-max-id": 1, + "selector-no-qualifying-type": null, + "order/properties-alphabetical-order": null, + "declaration-block-no-duplicate-properties": true, + "property-disallowed-list": ["z-index"], + "singleQuote": true, + "semi": false + } } diff --git a/webapp/src/store/users.ts b/webapp/src/store/users.ts index 8342bce146a..7c8520fc9e1 100644 --- a/webapp/src/store/users.ts +++ b/webapp/src/store/users.ts @@ -1,44 +1,49 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {createSlice, createAsyncThunk, PayloadAction, createSelector} from '@reduxjs/toolkit' +import { + createSlice, + createAsyncThunk, + PayloadAction, + createSelector, +} from "@reduxjs/toolkit"; -import {default as client} from '../octoClient' -import {IUser, parseUserProps, UserPreference} from '../user' +import { default as client } from "../octoClient"; +import { IUser, parseUserProps, UserPreference } from "../user"; -import {Utils} from '../utils' +import { Utils } from "../utils"; -import {Subscription} from '../wsclient' +import { Subscription } from "../wsclient"; -import {initialLoad} from './initialLoad' +import { initialLoad } from "./initialLoad"; -import {RootState} from './index' +import { RootState } from "./index"; -export const fetchMe = createAsyncThunk( - 'users/fetchMe', - async () => { - const [me, myConfig] = await Promise.all([ - client.getMe(), - client.getMyConfig(), - ]) - return {me, myConfig} - }, -) +export const fetchMe = createAsyncThunk("users/fetchMe", async () => { + const [me, myConfig] = await Promise.all([ + client.getMe(), + client.getMyConfig(), + ]); + return { me, myConfig }; +}); -export const versionProperty = 'version72MessageCanceled' +export const versionProperty = "version72MessageCanceled"; type UsersStatus = { - me: IUser|null - boardUsers: {[key: string]: IUser} - loggedIn: boolean|null - blockSubscriptions: Subscription[] - myConfig: Record -} + me: IUser | null; + boardUsers: { [key: string]: IUser }; + loggedIn: boolean | null; + blockSubscriptions: Subscription[]; + myConfig: Record; +}; export const fetchUserBlockSubscriptions = createAsyncThunk( - 'user/blockSubscriptions', - async (userId: string) => (Utils.isFocalboardPlugin() ? client.getUserBlockSubscriptions(userId) : []), -) + "user/blockSubscriptions", + async (userId: string) => + Utils.isFocalboardPlugin() + ? client.getUserBlockSubscriptions(userId) + : [] +); const initialState = { me: null, @@ -47,56 +52,62 @@ const initialState = { userWorkspaces: [], blockSubscriptions: [], myConfig: {}, -} as UsersStatus +} as UsersStatus; const usersSlice = createSlice({ - name: 'users', + name: "users", initialState, reducers: { - setMe: (state, action: PayloadAction) => { - state.me = action.payload - state.loggedIn = Boolean(state.me) + setMe: (state, action: PayloadAction) => { + state.me = action.payload; + state.loggedIn = Boolean(state.me); }, setBoardUsers: (state, action: PayloadAction) => { - state.boardUsers = action.payload.reduce((acc: {[key: string]: IUser}, user: IUser) => { - acc[user.id] = user - return acc - }, {}) + state.boardUsers = action.payload.reduce( + (acc: { [key: string]: IUser }, user: IUser) => { + acc[user.id] = user; + return acc; + }, + {} + ); }, addBoardUsers: (state, action: PayloadAction) => { action.payload.forEach((user: IUser) => { - state.boardUsers[user.id] = user - }) + state.boardUsers[user.id] = user; + }); }, removeBoardUsersById: (state, action: PayloadAction) => { action.payload.forEach((userId: string) => { - delete state.boardUsers[userId] - }) + delete state.boardUsers[userId]; + }); }, followBlock: (state, action: PayloadAction) => { - state.blockSubscriptions.push(action.payload) + state.blockSubscriptions.push(action.payload); }, unfollowBlock: (state, action: PayloadAction) => { - const oldSubscriptions = state.blockSubscriptions - state.blockSubscriptions = oldSubscriptions.filter((subscription) => subscription.blockId !== action.payload.blockId) + const oldSubscriptions = state.blockSubscriptions; + state.blockSubscriptions = oldSubscriptions.filter( + (subscription) => + subscription.blockId !== action.payload.blockId + ); }, patchProps: (state, action: PayloadAction) => { - state.myConfig = parseUserProps(action.payload) + state.myConfig = parseUserProps(action.payload); }, }, extraReducers: (builder) => { builder.addCase(fetchMe.fulfilled, (state, action) => { - state.me = action.payload.me || null - state.loggedIn = Boolean(state.me) + state.me = action.payload.me || null; + state.loggedIn = Boolean(state.me); if (action.payload.myConfig) { - state.myConfig = parseUserProps(action.payload.myConfig) + state.myConfig = parseUserProps(action.payload.myConfig); } - }) + }); builder.addCase(fetchMe.rejected, (state) => { - state.me = null - state.loggedIn = false - state.myConfig = {} - }) + state.me = null; + state.loggedIn = false; + state.myConfig = {}; + }); // TODO: change this when the initial load is complete // builder.addCase(initialLoad.fulfilled, (state, action) => { @@ -106,131 +117,163 @@ const usersSlice = createSlice({ // }, {}) // }) - builder.addCase(fetchUserBlockSubscriptions.fulfilled, (state, action) => { - state.blockSubscriptions = action.payload - }) + builder.addCase( + fetchUserBlockSubscriptions.fulfilled, + (state, action) => { + state.blockSubscriptions = action.payload; + } + ); builder.addCase(initialLoad.fulfilled, (state, action) => { if (action.payload.myConfig) { - state.myConfig = parseUserProps(action.payload.myConfig) + state.myConfig = parseUserProps(action.payload.myConfig); } - }) + }); }, -}) - -export const {setMe, setBoardUsers, removeBoardUsersById, addBoardUsers, followBlock, unfollowBlock, patchProps} = usersSlice.actions -export const {reducer} = usersSlice - -export const getMe = (state: RootState): IUser|null => state.users.me -export const getLoggedIn = (state: RootState): boolean|null => state.users.loggedIn -export const getBoardUsers = (state: RootState): {[key: string]: IUser} => state.users.boardUsers -export const getMyConfig = (state: RootState): Record => state.users.myConfig || {} as Record - -export const getBoardUsersList = createSelector( - getBoardUsers, - (boardUsers) => Object.values(boardUsers).sort((a, b) => a.username.localeCompare(b.username)), -) +}); + +export const { + setMe, + setBoardUsers, + removeBoardUsersById, + addBoardUsers, + followBlock, + unfollowBlock, + patchProps, +} = usersSlice.actions; +export const { reducer } = usersSlice; + +export const getMe = (state: RootState): IUser | null => state.users.me; +export const getLoggedIn = (state: RootState): boolean | null => + state.users.loggedIn; +export const getBoardUsers = (state: RootState): { [key: string]: IUser } => + state.users.boardUsers; +export const getMyConfig = (state: RootState): Record => + state.users.myConfig || ({} as Record); + +export const getBoardUsersList = createSelector(getBoardUsers, (boardUsers) => + Object.values(boardUsers).sort((a, b) => + a.username.localeCompare(b.username) + ) +); export const getBoardUsersListWithSticky = createSelector( - [getBoardUsers, (state: RootState, stickedUsernames: string[]) => stickedUsernames], + [ + getBoardUsers, + (state: RootState, stickedUsernames: string[]) => stickedUsernames, + ], (boardUsers, stickedUsernames) => { const allUsers = Object.values(boardUsers); // Get sticked users in their fixed order const stickedUsers = stickedUsernames - .map(username => allUsers.find(user => user.username === username)) + .map((username) => + allUsers.find((user) => user.username === username) + ) .filter(Boolean) as IUser[]; // Get remaining users and sort alphabetically const remainingUsers = allUsers - .filter(user => !stickedUsernames.includes(user.username)) + .filter((user) => !stickedUsernames.includes(user.username)) .sort((a, b) => a.username.localeCompare(b.username)); return [...stickedUsers, ...remainingUsers]; } ); -export const getUser = (userId: string): (state: RootState) => IUser|undefined => { - return (state: RootState): IUser|undefined => { - const users = getBoardUsers(state) - return users[userId] - } -} +export const getUser = ( + userId: string +): ((state: RootState) => IUser | undefined) => { + return (state: RootState): IUser | undefined => { + const users = getBoardUsers(state); + return users[userId]; + }; +}; export const getOnboardingTourStarted = createSelector( getMyConfig, (myConfig): boolean => { if (!myConfig) { - return false + return false; } - return Boolean(myConfig.onboardingTourStarted?.value) - }, -) + return Boolean(myConfig.onboardingTourStarted?.value); + } +); export const getOnboardingTourStep = createSelector( getMyConfig, (myConfig): string => { if (!myConfig) { - return '' + return ""; } - return myConfig.onboardingTourStep?.value - }, -) + return myConfig.onboardingTourStep?.value; + } +); export const getOnboardingTourCategory = createSelector( getMyConfig, - (myConfig): string => (myConfig.tourCategory ? myConfig.tourCategory.value : ''), -) + (myConfig): string => + myConfig.tourCategory ? myConfig.tourCategory.value : "" +); export const getVersionMessageCanceled = createSelector( getMe, getMyConfig, (me, myConfig): boolean => { if (versionProperty && me) { - if (me.id === 'single-user') { - return true + if (me.id === "single-user") { + return true; } - return Boolean(myConfig[versionProperty]?.value) + return Boolean(myConfig[versionProperty]?.value); } - return true - }, -) + return true; + } +); export const getCardLimitSnoozeUntil = createSelector( getMyConfig, (myConfig): number => { if (!myConfig) { - return 0 + return 0; } try { - return parseInt(myConfig.cardLimitSnoozeUntil?.value || '0', 10) + return parseInt(myConfig.cardLimitSnoozeUntil?.value || "0", 10); } catch (_) { - return 0 + return 0; } - }, -) + } +); export const getCardHiddenWarningSnoozeUntil = createSelector( getMyConfig, (myConfig): number => { if (!myConfig) { - return 0 + return 0; } try { - return parseInt(myConfig.cardHiddenWarningSnoozeUntil?.value || 0, 10) + return parseInt( + myConfig.cardHiddenWarningSnoozeUntil?.value || 0, + 10 + ); } catch (_) { - return 0 + return 0; } - }, -) + } +); -export const isAdmin = createSelector( - getMe, - (user): boolean => { - if (!user) return false; - const adminUsernames = ['admin', 'bulgak0v', 'nastasia75']; +export const isAdmin = createSelector(getMe, (user): boolean => { + if (!user) { + return false; + } + const adminUsernames = [ + "admin", + "bulgak0v", + "nastasia75", + "tam", + "olya_dushkina", + ]; return adminUsernames.includes(user.username); -}) +}); From 19ce93eb42c9df3cb78bfda91b4bf3610136fdd9 Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 12 Aug 2025 00:45:47 +0300 Subject: [PATCH 03/19] feat: add tam and olya to hadrcodedadmins --- server/api/auth.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/api/auth.go b/server/api/auth.go index 35bbae1594e..4acd0a84944 100644 --- a/server/api/auth.go +++ b/server/api/auth.go @@ -765,7 +765,8 @@ func (a *API) isHardcodedAdmin(userID string) (bool, error) { return false, err } - hardcodedAdmins := []string{"admin", "bulgak0v", "nastasia75"} + hardcodedAdmins := []string{"admin", "bulgak0v", "nastasia75", "tam", + "olya_dushkina",} for _, adminUsername := range hardcodedAdmins { if user.Username == adminUsername { return true, nil From 77338284325b69e36479abef1fa15996388cd42b Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 12 Aug 2025 20:46:24 +0300 Subject: [PATCH 04/19] refactor: change quotations --- webapp/src/store/users.ts | 232 +++++++++++++++++++------------------- 1 file changed, 115 insertions(+), 117 deletions(-) diff --git a/webapp/src/store/users.ts b/webapp/src/store/users.ts index 7c8520fc9e1..db54510f494 100644 --- a/webapp/src/store/users.ts +++ b/webapp/src/store/users.ts @@ -6,44 +6,42 @@ import { createAsyncThunk, PayloadAction, createSelector, -} from "@reduxjs/toolkit"; +} from '@reduxjs/toolkit' -import { default as client } from "../octoClient"; -import { IUser, parseUserProps, UserPreference } from "../user"; +import {default as client} from '../octoClient' +import {IUser, parseUserProps, UserPreference} from '../user' -import { Utils } from "../utils"; +import {Utils} from '../utils' -import { Subscription } from "../wsclient"; +import {Subscription} from '../wsclient' -import { initialLoad } from "./initialLoad"; +import {initialLoad} from './initialLoad' -import { RootState } from "./index"; +import {RootState} from './index' -export const fetchMe = createAsyncThunk("users/fetchMe", async () => { +export const fetchMe = createAsyncThunk('users/fetchMe', async () => { const [me, myConfig] = await Promise.all([ client.getMe(), client.getMyConfig(), - ]); - return { me, myConfig }; -}); + ]) + return {me, myConfig} +}) -export const versionProperty = "version72MessageCanceled"; +export const versionProperty = 'version72MessageCanceled' type UsersStatus = { - me: IUser | null; - boardUsers: { [key: string]: IUser }; - loggedIn: boolean | null; - blockSubscriptions: Subscription[]; - myConfig: Record; -}; + me: IUser | null + boardUsers: { [key: string]: IUser } + loggedIn: boolean | null + blockSubscriptions: Subscription[] + myConfig: Record +} export const fetchUserBlockSubscriptions = createAsyncThunk( - "user/blockSubscriptions", + 'user/blockSubscriptions', async (userId: string) => - Utils.isFocalboardPlugin() - ? client.getUserBlockSubscriptions(userId) - : [] -); + (Utils.isFocalboardPlugin() ? client.getUserBlockSubscriptions(userId) : []), +) const initialState = { me: null, @@ -52,62 +50,62 @@ const initialState = { userWorkspaces: [], blockSubscriptions: [], myConfig: {}, -} as UsersStatus; +} as UsersStatus const usersSlice = createSlice({ - name: "users", + name: 'users', initialState, reducers: { setMe: (state, action: PayloadAction) => { - state.me = action.payload; - state.loggedIn = Boolean(state.me); + state.me = action.payload + state.loggedIn = Boolean(state.me) }, setBoardUsers: (state, action: PayloadAction) => { state.boardUsers = action.payload.reduce( (acc: { [key: string]: IUser }, user: IUser) => { - acc[user.id] = user; - return acc; + acc[user.id] = user + return acc }, - {} - ); + {}, + ) }, addBoardUsers: (state, action: PayloadAction) => { action.payload.forEach((user: IUser) => { - state.boardUsers[user.id] = user; - }); + state.boardUsers[user.id] = user + }) }, removeBoardUsersById: (state, action: PayloadAction) => { action.payload.forEach((userId: string) => { - delete state.boardUsers[userId]; - }); + delete state.boardUsers[userId] + }) }, followBlock: (state, action: PayloadAction) => { - state.blockSubscriptions.push(action.payload); + state.blockSubscriptions.push(action.payload) }, unfollowBlock: (state, action: PayloadAction) => { - const oldSubscriptions = state.blockSubscriptions; + const oldSubscriptions = state.blockSubscriptions state.blockSubscriptions = oldSubscriptions.filter( (subscription) => - subscription.blockId !== action.payload.blockId - ); + subscription.blockId !== action.payload.blockId, + ) }, patchProps: (state, action: PayloadAction) => { - state.myConfig = parseUserProps(action.payload); + state.myConfig = parseUserProps(action.payload) }, }, extraReducers: (builder) => { builder.addCase(fetchMe.fulfilled, (state, action) => { - state.me = action.payload.me || null; - state.loggedIn = Boolean(state.me); + state.me = action.payload.me || null + state.loggedIn = Boolean(state.me) if (action.payload.myConfig) { - state.myConfig = parseUserProps(action.payload.myConfig); + state.myConfig = parseUserProps(action.payload.myConfig) } - }); + }) builder.addCase(fetchMe.rejected, (state) => { - state.me = null; - state.loggedIn = false; - state.myConfig = {}; - }); + state.me = null + state.loggedIn = false + state.myConfig = {} + }) // TODO: change this when the initial load is complete // builder.addCase(initialLoad.fulfilled, (state, action) => { @@ -120,17 +118,17 @@ const usersSlice = createSlice({ builder.addCase( fetchUserBlockSubscriptions.fulfilled, (state, action) => { - state.blockSubscriptions = action.payload; - } - ); + state.blockSubscriptions = action.payload + }, + ) builder.addCase(initialLoad.fulfilled, (state, action) => { if (action.payload.myConfig) { - state.myConfig = parseUserProps(action.payload.myConfig); + state.myConfig = parseUserProps(action.payload.myConfig) } - }); + }) }, -}); +}) export const { setMe, @@ -140,22 +138,22 @@ export const { followBlock, unfollowBlock, patchProps, -} = usersSlice.actions; -export const { reducer } = usersSlice; +} = usersSlice.actions +export const {reducer} = usersSlice -export const getMe = (state: RootState): IUser | null => state.users.me; +export const getMe = (state: RootState): IUser | null => state.users.me export const getLoggedIn = (state: RootState): boolean | null => - state.users.loggedIn; + state.users.loggedIn export const getBoardUsers = (state: RootState): { [key: string]: IUser } => - state.users.boardUsers; + state.users.boardUsers export const getMyConfig = (state: RootState): Record => - state.users.myConfig || ({} as Record); + state.users.myConfig || ({} as Record) export const getBoardUsersList = createSelector(getBoardUsers, (boardUsers) => Object.values(boardUsers).sort((a, b) => - a.username.localeCompare(b.username) - ) -); + a.username.localeCompare(b.username), + ), +) export const getBoardUsersListWithSticky = createSelector( [ @@ -163,117 +161,117 @@ export const getBoardUsersListWithSticky = createSelector( (state: RootState, stickedUsernames: string[]) => stickedUsernames, ], (boardUsers, stickedUsernames) => { - const allUsers = Object.values(boardUsers); + const allUsers = Object.values(boardUsers) // Get sticked users in their fixed order - const stickedUsers = stickedUsernames - .map((username) => - allUsers.find((user) => user.username === username) - ) - .filter(Boolean) as IUser[]; + const stickedUsers = stickedUsernames. + map((username) => + allUsers.find((user) => user.username === username), + ). + filter(Boolean) as IUser[] // Get remaining users and sort alphabetically - const remainingUsers = allUsers - .filter((user) => !stickedUsernames.includes(user.username)) - .sort((a, b) => a.username.localeCompare(b.username)); + const remainingUsers = allUsers. + filter((user) => !stickedUsernames.includes(user.username)). + sort((a, b) => a.username.localeCompare(b.username)) - return [...stickedUsers, ...remainingUsers]; - } -); + return [...stickedUsers, ...remainingUsers] + }, +) export const getUser = ( - userId: string + userId: string, ): ((state: RootState) => IUser | undefined) => { return (state: RootState): IUser | undefined => { - const users = getBoardUsers(state); - return users[userId]; - }; -}; + const users = getBoardUsers(state) + return users[userId] + } +} export const getOnboardingTourStarted = createSelector( getMyConfig, (myConfig): boolean => { if (!myConfig) { - return false; + return false } - return Boolean(myConfig.onboardingTourStarted?.value); - } -); + return Boolean(myConfig.onboardingTourStarted?.value) + }, +) export const getOnboardingTourStep = createSelector( getMyConfig, (myConfig): string => { if (!myConfig) { - return ""; + return '' } - return myConfig.onboardingTourStep?.value; - } -); + return myConfig.onboardingTourStep?.value + }, +) export const getOnboardingTourCategory = createSelector( getMyConfig, (myConfig): string => - myConfig.tourCategory ? myConfig.tourCategory.value : "" -); + (myConfig.tourCategory ? myConfig.tourCategory.value : ''), +) export const getVersionMessageCanceled = createSelector( getMe, getMyConfig, (me, myConfig): boolean => { if (versionProperty && me) { - if (me.id === "single-user") { - return true; + if (me.id === 'single-user') { + return true } - return Boolean(myConfig[versionProperty]?.value); + return Boolean(myConfig[versionProperty]?.value) } - return true; - } -); + return true + }, +) export const getCardLimitSnoozeUntil = createSelector( getMyConfig, (myConfig): number => { if (!myConfig) { - return 0; + return 0 } try { - return parseInt(myConfig.cardLimitSnoozeUntil?.value || "0", 10); + return parseInt(myConfig.cardLimitSnoozeUntil?.value || '0', 10) } catch (_) { - return 0; + return 0 } - } -); + }, +) export const getCardHiddenWarningSnoozeUntil = createSelector( getMyConfig, (myConfig): number => { if (!myConfig) { - return 0; + return 0 } try { return parseInt( myConfig.cardHiddenWarningSnoozeUntil?.value || 0, - 10 - ); + 10, + ) } catch (_) { - return 0; + return 0 } - } -); + }, +) export const isAdmin = createSelector(getMe, (user): boolean => { if (!user) { - return false; + return false } const adminUsernames = [ - "admin", - "bulgak0v", - "nastasia75", - "tam", - "olya_dushkina", - ]; - - return adminUsernames.includes(user.username); -}); + 'admin', + 'bulgak0v', + 'nastasia75', + 'tam', + 'olya_dushkina', + ] + + return adminUsernames.includes(user.username) +}) From 4c839ef3fcaea829bccb11918a7d25819d4502fe Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 12 Aug 2025 21:26:10 +0300 Subject: [PATCH 05/19] feat: add sorting to admin page --- webapp/src/pages/adminPage.tsx | 110 ++++++++++++++++----------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/webapp/src/pages/adminPage.tsx b/webapp/src/pages/adminPage.tsx index 64910b1ea31..db3f3b59e84 100644 --- a/webapp/src/pages/adminPage.tsx +++ b/webapp/src/pages/adminPage.tsx @@ -4,61 +4,58 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react' import './adminPage.scss' import octoClient from '../octoClient' -import {IUser} from '../user'; -import Sidebar from '../components/sidebar/sidebar'; -import BoardTemplateSelector from '../components/boardTemplateSelector/boardTemplateSelector'; -import ChangePasswordDialog from '../components/changePasswordDialog/changePasswordDialog'; -import User from '../components/user/user'; -import UserSearchForm from '../components/userSearchForm/userSearchForm'; - -import {useAppSelector} from '../store/hooks'; -import {getMe} from '../store/users'; -import {getClientConfig} from '../store/clientConfig'; -import {ClientConfig} from '../config/clientConfig'; +import {IUser} from '../user' +import Sidebar from '../components/sidebar/sidebar' +import BoardTemplateSelector from '../components/boardTemplateSelector/boardTemplateSelector' +import ChangePasswordDialog from '../components/changePasswordDialog/changePasswordDialog' +import User from '../components/user/user' +import UserSearchForm from '../components/userSearchForm/userSearchForm' + +import {useAppSelector} from '../store/hooks' +import {getMe} from '../store/users' +import {getClientConfig} from '../store/clientConfig' +import {ClientConfig} from '../config/clientConfig' const AdminPage = () => { const clientConfig = useAppSelector(getClientConfig) const me = useAppSelector(getMe) - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]) const [searchTerm, setSearchTerm] = useState('') const [boardTemplateSelectorOpen, setBoardTemplateSelectorOpen] = useState(false) - const [changePasswordUser, setChangePasswordUser] = useState(null); + const [changePasswordUser, setChangePasswordUser] = useState(null) const [errorMessage, setErrorMessage] = useState<{messageId: string, defaultMessage: string} | null>(null) const [successMessage, setSuccessMessage] = useState<{messageId: string, defaultMessage: string} | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) const fetchUsers = async () => { - const result = await octoClient.getTeamUsers(); + const result = await octoClient.getTeamUsers() setUsers(result) - }; + } useEffect(() => { - fetchUsers(); - }, []); + fetchUsers() + }, []) const filteredUsers = useMemo(() => { - if (!searchTerm.trim()) { - return users - } - - const lowercaseSearch = searchTerm.toLowerCase().trim() - - return users.filter(user => { - // Search in email (if available) - const emailMatch = user.email?.toLowerCase().includes(lowercaseSearch) - - // Search in nickname - const nicknameMatch = user.nickname?.toLowerCase().includes(lowercaseSearch) - - // Search in username as fallback - const usernameMatch = user.username?.toLowerCase().includes(lowercaseSearch) - - // Search in first/last name - const firstNameMatch = user.firstname?.toLowerCase().includes(lowercaseSearch) - const lastNameMatch = user.lastname?.toLowerCase().includes(lowercaseSearch) - - return emailMatch || nicknameMatch || usernameMatch || firstNameMatch || lastNameMatch - }) + const arr = searchTerm.trim() ? users.filter((user) => { + const lower = searchTerm.toLowerCase().trim() + return ( + user.email?.toLowerCase().includes(lower) || + user.nickname?.toLowerCase().includes(lower) || + user.username?.toLowerCase().includes(lower) || + user.firstname?.toLowerCase().includes(lower) || + user.lastname?.toLowerCase().includes(lower) + ) + }) : [...users] + + return arr.sort((a, b) => + a.username. + trim(). + toLowerCase(). + localeCompare(b.username.trim().toLowerCase(), 'en', { + sensitivity: 'base', + }), + ) }, [users, searchTerm]) const handleSearchChange = useCallback((value: string) => { @@ -82,39 +79,37 @@ const AdminPage = () => { setErrorMessage(null) setSuccessMessage(null) setIsSubmitting(false) - setChangePasswordUser(null); + setChangePasswordUser(null) }, []) const handleChangePassword = useCallback(async (userId: string, newPassword: string) => { - try { - const success = await octoClient.changeUserPassword(userId, newPassword) + const success = await octoClient.changeUserPassword(userId, newPassword) if (success) { console.log('success') setSuccessMessage({ messageId: 'change-password.success', - defaultMessage: 'Password changed successfully' + defaultMessage: 'Password changed successfully', }) setErrorMessage(null) - } else { setErrorMessage({ messageId: 'change-password.failed', - defaultMessage: 'Failed to change password. Please try again.' + defaultMessage: 'Failed to change password. Please try again.', }) setSuccessMessage(null) } } catch (error) { setErrorMessage({ messageId: 'change-password.error', - defaultMessage: 'An error occurred while changing password' + defaultMessage: 'An error occurred while changing password', }) setSuccessMessage(null) } finally { setIsSubmitting(false) } - }, [closeChangePasswordDialog]); + }, [closeChangePasswordDialog]) return (
@@ -128,22 +123,23 @@ const AdminPage = () => { }

Team Users

{users.length > 0 && (

- {searchTerm - ? `Showing ${filteredUsers.length} of ${users.length} users` - : `Total users: ${users.length}` + {searchTerm ? `Showing ${filteredUsers.length} of ${users.length} users` : `Total users: ${users.length}` }

)}
    {filteredUsers.map((user) => ( -
  • +
  • { )}
- ); -}; + ) +} export default React.memo(AdminPage) From 93de3cd01913d8cda45b682094594344de15dcf2 Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 19 Aug 2025 21:57:34 +0300 Subject: [PATCH 06/19] fix: restore .eslinrc --- webapp/.eslintrc.json | 299 +++++++++++++++++++++--------------------- 1 file changed, 151 insertions(+), 148 deletions(-) diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index ebc9026b38b..e7b0749d999 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -1,155 +1,158 @@ { - "extends": [ - "plugin:mattermost/react", - "plugin:cypress/recommended", - "plugin:jquery/deprecated" + "extends": [ + "plugin:mattermost/react", + "plugin:cypress/recommended", + "plugin:jquery/deprecated" + ], + "plugins": [ + "react", + "babel", + "mattermost", + "import", + "cypress", + "jquery", + "no-only-tests" + ], + "parser": "@typescript-eslint/parser", + "env": { + "jest": true, + "cypress/globals": true + }, + "settings": { + "import/resolver": "webpack", + "react": { + "pragma": "React", + "version": "detect" + } + }, + "rules": { + "max-lines": "off", + "no-unused-expressions": 0, + "babel/no-unused-expressions": [2, {"allowShortCircuit": true}], + "eol-last": ["error", "always"], + "import/no-unresolved": 2, + "import/order": [ + 2, + { + "newlines-between": "always-and-inside-groups", + "groups": [ + "builtin", + "external", + [ + "internal", + "parent" + ], + "sibling", + "index" + ] + } ], - "plugins": [ - "react", - "babel", - "mattermost", - "import", - "cypress", - "jquery", - "no-only-tests" + "no-undefined": 0, + "react/jsx-filename-extension": 0, + "react/prop-types": [ + 2, + { + "ignore": [ + "location", + "history", + "component" + ] + } ], - "parser": "@typescript-eslint/parser", - "env": { - "jest": true, - "cypress/globals": true - }, - "settings": { - "import/resolver": "webpack", - "react": { - "pragma": "React", - "version": "detect" - } - }, - "rules": { - "max-lines": "off", - "no-unused-expressions": 0, - "babel/no-unused-expressions": [2, { "allowShortCircuit": true }], - "eol-last": ["error", "always"], - "import/no-unresolved": 2, - "import/order": [ - 2, - { - "newlines-between": "always-and-inside-groups", - "groups": [ - "builtin", - "external", - ["internal", "parent"], - "sibling", - "index" - ] - } + "react/no-string-refs": 2, + "no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}], + "max-nested-callbacks": ["error", {"max": 5}], + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error" + }, + "overrides": [ + { + "files": ["**/*.tsx", "**/*.ts"], + "extends": [ + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "import/no-unresolved": 0, // ts handles this better + "camelcase": 0, + "semi": "off", + "@typescript-eslint/naming-convention": [ + 2, + { + "selector": "function", + "format": ["camelCase", "PascalCase"] + }, + { + "selector": "variable", + "format": ["camelCase", "PascalCase", "UPPER_CASE"] + }, + { + "selector": "parameter", + "format": ["camelCase", "PascalCase"], + "leadingUnderscore": "allow" + }, + { + "selector": "typeLike", + "format": ["PascalCase"] + } + ], + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/no-unused-vars": [ + 2, + { + "vars": "all", + "args": "after-used" + } ], - "no-undefined": 0, - "react/jsx-filename-extension": 0, - "react/prop-types": [ - 2, - { - "ignore": ["location", "history", "component"] - } + "@typescript-eslint/member-delimiter-style": [2, {"multiline": {"delimiter": "none"}, "singleline": {"delimiter": "comma"}}], + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/prefer-interface": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/semi": [2, "never"], + "@typescript-eslint/indent": [ + 2, + 4, + { + "SwitchCase": 0 + } ], - "react/no-string-refs": 2, - "no-only-tests/no-only-tests": ["error", { "focus": ["only", "skip"] }], - "max-nested-callbacks": ["error", { "max": 5 }], - "no-shadow": "off", - "@typescript-eslint/no-shadow": "error" + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": [ + 2, + { + "classes": false, + "functions": false, + "variables": false + } + ], + "no-useless-constructor": 0, + "@typescript-eslint/no-useless-constructor": 2, + "react/jsx-filename-extension": 0 + } + }, + { + "files": ["tests/**", "**/*.test.*"], + "env": { + "jest": true + }, + "rules": { + "func-names": 0, + "global-require": 0, + "new-cap": 0, + "prefer-arrow-callback": 0, + "no-import-assign": 0 + } }, - "overrides": [ - { - "files": ["**/*.tsx", "**/*.ts"], - "extends": ["plugin:@typescript-eslint/recommended"], - "rules": { - "import/no-unresolved": 0, // ts handles this better - "camelcase": 0, - "semi": "off", - "@typescript-eslint/naming-convention": [ - 2, - { - "selector": "function", - "format": ["camelCase", "PascalCase"] - }, - { - "selector": "variable", - "format": ["camelCase", "PascalCase", "UPPER_CASE"] - }, - { - "selector": "parameter", - "format": ["camelCase", "PascalCase"], - "leadingUnderscore": "allow" - }, - { - "selector": "typeLike", - "format": ["PascalCase"] - } - ], - "@typescript-eslint/no-non-null-assertion": 0, - "@typescript-eslint/no-unused-vars": [ - 2, - { - "vars": "all", - "args": "after-used" - } - ], - "@typescript-eslint/member-delimiter-style": [ - 2, - { - "multiline": { "delimiter": "none" }, - "singleline": { "delimiter": "comma" } - } - ], - "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/no-empty-function": 0, - "@typescript-eslint/prefer-interface": 0, - "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/semi": [2, "never"], - "@typescript-eslint/indent": [ - 2, - 4, - { - "SwitchCase": 0 - } - ], - "no-use-before-define": "off", - "@typescript-eslint/no-use-before-define": [ - 2, - { - "classes": false, - "functions": false, - "variables": false - } - ], - "no-useless-constructor": 0, - "@typescript-eslint/no-useless-constructor": 2, - "react/jsx-filename-extension": 0 - } - }, - { - "files": ["tests/**", "**/*.test.*"], - "env": { - "jest": true - }, - "rules": { - "func-names": 0, - "global-require": 0, - "new-cap": 0, - "prefer-arrow-callback": 0, - "no-import-assign": 0 - } - }, - { - "files": ["cypress/**"], - "rules": { - "cypress/no-unnecessary-waiting": 0, - "func-names": 0, - "import/no-unresolved": 0, - "max-nested-callbacks": 0, - "no-process-env": 0, - "no-unused-expressions": 0 - } - } - ] + { + "files": ["cypress/**"], + "rules": { + "cypress/no-unnecessary-waiting": 0, + "func-names": 0, + "import/no-unresolved": 0, + "max-nested-callbacks": 0, + "no-process-env": 0, + "no-unused-expressions": 0 + } + } + ] } From 2deea1458b27e3540b11aa960afbff4fc0c9abba Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 19 Aug 2025 21:59:23 +0300 Subject: [PATCH 07/19] restore stylelint --- webapp/.stylelintrc.json | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/webapp/.stylelintrc.json b/webapp/.stylelintrc.json index ee5d111a855..762b8934a9f 100644 --- a/webapp/.stylelintrc.json +++ b/webapp/.stylelintrc.json @@ -1,16 +1,14 @@ { - "extends": "stylelint-config-sass-guidelines", - "rules": { - "indentation": 4, - "selector-class-pattern": "[a-zA-Z_-]+", - "max-nesting-depth": 4, - "selector-max-compound-selectors": 6, - "selector-max-id": 1, - "selector-no-qualifying-type": null, - "order/properties-alphabetical-order": null, - "declaration-block-no-duplicate-properties": true, - "property-disallowed-list": ["z-index"], - "singleQuote": true, - "semi": false - } + "extends": "stylelint-config-sass-guidelines", + "rules": { + "indentation": 4, + "selector-class-pattern": "[a-zA-Z_-]+", + "max-nesting-depth": 4, + "selector-max-compound-selectors": 6, + "selector-max-id": 1, + "selector-no-qualifying-type": null, + "order/properties-alphabetical-order": null, + "declaration-block-no-duplicate-properties": true, + "property-disallowed-list": ["z-index"] + } } From c7c5a99c2aabb62a92f2e3b96f332276ad1b6ec1 Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 19 Aug 2025 22:06:30 +0300 Subject: [PATCH 08/19] restore AdminPage --- webapp/src/pages/adminPage.tsx | 123 ++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 47 deletions(-) diff --git a/webapp/src/pages/adminPage.tsx b/webapp/src/pages/adminPage.tsx index db3f3b59e84..67c8c17b815 100644 --- a/webapp/src/pages/adminPage.tsx +++ b/webapp/src/pages/adminPage.tsx @@ -4,58 +4,86 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react' import './adminPage.scss' import octoClient from '../octoClient' -import {IUser} from '../user' -import Sidebar from '../components/sidebar/sidebar' -import BoardTemplateSelector from '../components/boardTemplateSelector/boardTemplateSelector' -import ChangePasswordDialog from '../components/changePasswordDialog/changePasswordDialog' -import User from '../components/user/user' -import UserSearchForm from '../components/userSearchForm/userSearchForm' - -import {useAppSelector} from '../store/hooks' -import {getMe} from '../store/users' -import {getClientConfig} from '../store/clientConfig' -import {ClientConfig} from '../config/clientConfig' +import {IUser} from '../user'; +import Sidebar from '../components/sidebar/sidebar'; +import BoardTemplateSelector from '../components/boardTemplateSelector/boardTemplateSelector'; +import ChangePasswordDialog from '../components/changePasswordDialog/changePasswordDialog'; +import User from '../components/user/user'; +import UserSearchForm from '../components/userSearchForm/userSearchForm'; + +import {useAppSelector} from '../store/hooks'; +import {getMe} from '../store/users'; +import {getClientConfig} from '../store/clientConfig'; +import {ClientConfig} from '../config/clientConfig'; const AdminPage = () => { const clientConfig = useAppSelector(getClientConfig) const me = useAppSelector(getMe) - const [users, setUsers] = useState([]) + const [users, setUsers] = useState([]); const [searchTerm, setSearchTerm] = useState('') const [boardTemplateSelectorOpen, setBoardTemplateSelectorOpen] = useState(false) - const [changePasswordUser, setChangePasswordUser] = useState(null) + const [changePasswordUser, setChangePasswordUser] = useState(null); const [errorMessage, setErrorMessage] = useState<{messageId: string, defaultMessage: string} | null>(null) const [successMessage, setSuccessMessage] = useState<{messageId: string, defaultMessage: string} | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) const fetchUsers = async () => { - const result = await octoClient.getTeamUsers() + const result = await octoClient.getTeamUsers(); setUsers(result) - } + }; useEffect(() => { - fetchUsers() - }, []) + fetchUsers(); + }, []); const filteredUsers = useMemo(() => { - const arr = searchTerm.trim() ? users.filter((user) => { - const lower = searchTerm.toLowerCase().trim() - return ( + if (!searchTerm.trim()) { const arr = searchTerm.trim() ? users.filter((user) => { + return users const lower = searchTerm.toLowerCase().trim() + } return ( + user.email?.toLowerCase().includes(lower) || - user.nickname?.toLowerCase().includes(lower) || + const lowercaseSearch = searchTerm.toLowerCase().trim() user.nickname?.toLowerCase().includes(lower) || + user.username?.toLowerCase().includes(lower) || - user.firstname?.toLowerCase().includes(lower) || - user.lastname?.toLowerCase().includes(lower) - ) + return users.filter(user => { user.firstname?.toLowerCase().includes(lower) || + // Search in email (if available) user.lastname?.toLowerCase().includes(lower) + const emailMatch = user.email?.toLowerCase().includes(lowercaseSearch) ) + }) : [...users] + // Search in nickname + const nicknameMatch = user.nickname?.toLowerCase().includes(lowercaseSearch) return arr.sort((a, b) => - return arr.sort((a, b) => a.username. - trim(). - toLowerCase(). + // Search in username as fallback trim(). + const usernameMatch = user.username?.toLowerCase().includes(lowercaseSearch) toLowerCase(). + localeCompare(b.username.trim().toLowerCase(), 'en', { - sensitivity: 'base', - }), - ) + // Search in first/last name sensitivity: 'base', + const firstNameMatch = user.firstname?.toLowerCase().includes(lowercaseSearch) }), + const lastNameMatch = user.lastname?.toLowerCase().includes(lowercaseSearch) ) + + return emailMatch || nicknameMatch || usernameMatch || firstNameMatch || lastNameMatch + }) + }, [users, searchTerm]) }, [users, searchTerm]) + + const lowercaseSearch = searchTerm.toLowerCase().trim() + + return users.filter(user => { + // Search in email (if available) + const emailMatch = user.email?.toLowerCase().includes(lowercaseSearch) + + // Search in nickname + const nicknameMatch = user.nickname?.toLowerCase().includes(lowercaseSearch) + + // Search in username as fallback + const usernameMatch = user.username?.toLowerCase().includes(lowercaseSearch) + + // Search in first/last name + const firstNameMatch = user.firstname?.toLowerCase().includes(lowercaseSearch) + const lastNameMatch = user.lastname?.toLowerCase().includes(lowercaseSearch) + + return emailMatch || nicknameMatch || usernameMatch || firstNameMatch || lastNameMatch + }) }, [users, searchTerm]) const handleSearchChange = useCallback((value: string) => { @@ -79,37 +107,39 @@ const AdminPage = () => { setErrorMessage(null) setSuccessMessage(null) setIsSubmitting(false) - setChangePasswordUser(null) + setChangePasswordUser(null); }, []) const handleChangePassword = useCallback(async (userId: string, newPassword: string) => { + try { - const success = await octoClient.changeUserPassword(userId, newPassword) + const success = await octoClient.changeUserPassword(userId, newPassword) if (success) { console.log('success') setSuccessMessage({ messageId: 'change-password.success', - defaultMessage: 'Password changed successfully', + defaultMessage: 'Password changed successfully' }) setErrorMessage(null) + } else { setErrorMessage({ messageId: 'change-password.failed', - defaultMessage: 'Failed to change password. Please try again.', + defaultMessage: 'Failed to change password. Please try again.' }) setSuccessMessage(null) } } catch (error) { setErrorMessage({ messageId: 'change-password.error', - defaultMessage: 'An error occurred while changing password', + defaultMessage: 'An error occurred while changing password' }) setSuccessMessage(null) } finally { setIsSubmitting(false) } - }, [closeChangePasswordDialog]) + }, [closeChangePasswordDialog]); return (
@@ -123,23 +153,22 @@ const AdminPage = () => { }

Team Users

{users.length > 0 && (

- {searchTerm ? `Showing ${filteredUsers.length} of ${users.length} users` : `Total users: ${users.length}` + {searchTerm + ? `Showing ${filteredUsers.length} of ${users.length} users` + : `Total users: ${users.length}` }

)}
    {filteredUsers.map((user) => ( -
  • +
  • { )}
- ) -} + ); +}; export default React.memo(AdminPage) From d27d58cc380a57882b1ae19d0190e257a9b7609c Mon Sep 17 00:00:00 2001 From: tabarzin Date: Tue, 19 Aug 2025 22:09:04 +0300 Subject: [PATCH 09/19] restore users.ts --- webapp/src/store/users.ts | 143 +++++++++++++++----------------------- 1 file changed, 55 insertions(+), 88 deletions(-) diff --git a/webapp/src/store/users.ts b/webapp/src/store/users.ts index db54510f494..9b1eed69499 100644 --- a/webapp/src/store/users.ts +++ b/webapp/src/store/users.ts @@ -1,12 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import { - createSlice, - createAsyncThunk, - PayloadAction, - createSelector, -} from '@reduxjs/toolkit' +import {createSlice, createAsyncThunk, PayloadAction, createSelector} from '@reduxjs/toolkit' import {default as client} from '../octoClient' import {IUser, parseUserProps, UserPreference} from '../user' @@ -19,28 +14,30 @@ import {initialLoad} from './initialLoad' import {RootState} from './index' -export const fetchMe = createAsyncThunk('users/fetchMe', async () => { - const [me, myConfig] = await Promise.all([ - client.getMe(), - client.getMyConfig(), - ]) - return {me, myConfig} -}) +export const fetchMe = createAsyncThunk( + 'users/fetchMe', + async () => { + const [me, myConfig] = await Promise.all([ + client.getMe(), + client.getMyConfig(), + ]) + return {me, myConfig} + }, +) export const versionProperty = 'version72MessageCanceled' type UsersStatus = { - me: IUser | null - boardUsers: { [key: string]: IUser } - loggedIn: boolean | null + me: IUser|null + boardUsers: {[key: string]: IUser} + loggedIn: boolean|null blockSubscriptions: Subscription[] myConfig: Record } export const fetchUserBlockSubscriptions = createAsyncThunk( 'user/blockSubscriptions', - async (userId: string) => - (Utils.isFocalboardPlugin() ? client.getUserBlockSubscriptions(userId) : []), + async (userId: string) => (Utils.isFocalboardPlugin() ? client.getUserBlockSubscriptions(userId) : []), ) const initialState = { @@ -56,18 +53,15 @@ const usersSlice = createSlice({ name: 'users', initialState, reducers: { - setMe: (state, action: PayloadAction) => { + setMe: (state, action: PayloadAction) => { state.me = action.payload state.loggedIn = Boolean(state.me) }, setBoardUsers: (state, action: PayloadAction) => { - state.boardUsers = action.payload.reduce( - (acc: { [key: string]: IUser }, user: IUser) => { - acc[user.id] = user - return acc - }, - {}, - ) + state.boardUsers = action.payload.reduce((acc: {[key: string]: IUser}, user: IUser) => { + acc[user.id] = user + return acc + }, {}) }, addBoardUsers: (state, action: PayloadAction) => { action.payload.forEach((user: IUser) => { @@ -84,10 +78,7 @@ const usersSlice = createSlice({ }, unfollowBlock: (state, action: PayloadAction) => { const oldSubscriptions = state.blockSubscriptions - state.blockSubscriptions = oldSubscriptions.filter( - (subscription) => - subscription.blockId !== action.payload.blockId, - ) + state.blockSubscriptions = oldSubscriptions.filter((subscription) => subscription.blockId !== action.payload.blockId) }, patchProps: (state, action: PayloadAction) => { state.myConfig = parseUserProps(action.payload) @@ -115,12 +106,9 @@ const usersSlice = createSlice({ // }, {}) // }) - builder.addCase( - fetchUserBlockSubscriptions.fulfilled, - (state, action) => { - state.blockSubscriptions = action.payload - }, - ) + builder.addCase(fetchUserBlockSubscriptions.fulfilled, (state, action) => { + state.blockSubscriptions = action.payload + }) builder.addCase(initialLoad.fulfilled, (state, action) => { if (action.payload.myConfig) { @@ -130,44 +118,27 @@ const usersSlice = createSlice({ }, }) -export const { - setMe, - setBoardUsers, - removeBoardUsersById, - addBoardUsers, - followBlock, - unfollowBlock, - patchProps, -} = usersSlice.actions +export const {setMe, setBoardUsers, removeBoardUsersById, addBoardUsers, followBlock, unfollowBlock, patchProps} = usersSlice.actions export const {reducer} = usersSlice -export const getMe = (state: RootState): IUser | null => state.users.me -export const getLoggedIn = (state: RootState): boolean | null => - state.users.loggedIn -export const getBoardUsers = (state: RootState): { [key: string]: IUser } => - state.users.boardUsers -export const getMyConfig = (state: RootState): Record => - state.users.myConfig || ({} as Record) - -export const getBoardUsersList = createSelector(getBoardUsers, (boardUsers) => - Object.values(boardUsers).sort((a, b) => - a.username.localeCompare(b.username), - ), +export const getMe = (state: RootState): IUser|null => state.users.me +export const getLoggedIn = (state: RootState): boolean|null => state.users.loggedIn +export const getBoardUsers = (state: RootState): {[key: string]: IUser} => state.users.boardUsers +export const getMyConfig = (state: RootState): Record => state.users.myConfig || {} as Record + +export const getBoardUsersList = createSelector( + getBoardUsers, + (boardUsers) => Object.values(boardUsers).sort((a, b) => a.username.localeCompare(b.username)), ) export const getBoardUsersListWithSticky = createSelector( - [ - getBoardUsers, - (state: RootState, stickedUsernames: string[]) => stickedUsernames, - ], + [getBoardUsers, (state: RootState, stickedUsernames: string[]) => stickedUsernames], (boardUsers, stickedUsernames) => { const allUsers = Object.values(boardUsers) // Get sticked users in their fixed order const stickedUsers = stickedUsernames. - map((username) => - allUsers.find((user) => user.username === username), - ). + map((username) => allUsers.find((user) => user.username === username)). filter(Boolean) as IUser[] // Get remaining users and sort alphabetically @@ -179,10 +150,8 @@ export const getBoardUsersListWithSticky = createSelector( }, ) -export const getUser = ( - userId: string, -): ((state: RootState) => IUser | undefined) => { - return (state: RootState): IUser | undefined => { +export const getUser = (userId: string): (state: RootState) => IUser|undefined => { + return (state: RootState): IUser|undefined => { const users = getBoardUsers(state) return users[userId] } @@ -212,8 +181,7 @@ export const getOnboardingTourStep = createSelector( export const getOnboardingTourCategory = createSelector( getMyConfig, - (myConfig): string => - (myConfig.tourCategory ? myConfig.tourCategory.value : ''), + (myConfig): string => (myConfig.tourCategory ? myConfig.tourCategory.value : ''), ) export const getVersionMessageCanceled = createSelector( @@ -251,27 +219,26 @@ export const getCardHiddenWarningSnoozeUntil = createSelector( return 0 } try { - return parseInt( - myConfig.cardHiddenWarningSnoozeUntil?.value || 0, - 10, - ) + return parseInt(myConfig.cardHiddenWarningSnoozeUntil?.value || 0, 10) } catch (_) { return 0 } }, ) -export const isAdmin = createSelector(getMe, (user): boolean => { - if (!user) { - return false - } - const adminUsernames = [ - 'admin', - 'bulgak0v', - 'nastasia75', - 'tam', - 'olya_dushkina', - ] - - return adminUsernames.includes(user.username) -}) +export const isAdmin = createSelector( + getMe, + (user): boolean => { + if (!user) { + return false + } + const adminUsernames = [ + 'admin', + 'bulgak0v', + 'nastasia75', + 'tam', + 'olya_dushkina', + ] + + return adminUsernames.includes(user.username) + }) From 7a5e7ea1e50f03432121b6c4caaff328d173dbd6 Mon Sep 17 00:00:00 2001 From: tabarzin Date: Wed, 20 Aug 2025 18:28:53 +0300 Subject: [PATCH 10/19] refactor: change formatting --- webapp/src/pages/adminPage.tsx | 224 +++++++++++++++++---------------- 1 file changed, 115 insertions(+), 109 deletions(-) diff --git a/webapp/src/pages/adminPage.tsx b/webapp/src/pages/adminPage.tsx index 67c8c17b815..1cf638afb75 100644 --- a/webapp/src/pages/adminPage.tsx +++ b/webapp/src/pages/adminPage.tsx @@ -4,86 +4,79 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react' import './adminPage.scss' import octoClient from '../octoClient' -import {IUser} from '../user'; -import Sidebar from '../components/sidebar/sidebar'; -import BoardTemplateSelector from '../components/boardTemplateSelector/boardTemplateSelector'; -import ChangePasswordDialog from '../components/changePasswordDialog/changePasswordDialog'; -import User from '../components/user/user'; -import UserSearchForm from '../components/userSearchForm/userSearchForm'; - -import {useAppSelector} from '../store/hooks'; -import {getMe} from '../store/users'; -import {getClientConfig} from '../store/clientConfig'; -import {ClientConfig} from '../config/clientConfig'; +import {IUser} from '../user' +import Sidebar from '../components/sidebar/sidebar' +import BoardTemplateSelector from '../components/boardTemplateSelector/boardTemplateSelector' +import ChangePasswordDialog from '../components/changePasswordDialog/changePasswordDialog' +import User from '../components/user/user' +import UserSearchForm from '../components/userSearchForm/userSearchForm' + +import {useAppSelector} from '../store/hooks' +import {getMe} from '../store/users' +import {getClientConfig} from '../store/clientConfig' +import {ClientConfig} from '../config/clientConfig' const AdminPage = () => { const clientConfig = useAppSelector(getClientConfig) - const me = useAppSelector(getMe) - const [users, setUsers] = useState([]); + const me = useAppSelector(getMe) + const [users, setUsers] = useState([]) const [searchTerm, setSearchTerm] = useState('') - const [boardTemplateSelectorOpen, setBoardTemplateSelectorOpen] = useState(false) - const [changePasswordUser, setChangePasswordUser] = useState(null); - const [errorMessage, setErrorMessage] = useState<{messageId: string, defaultMessage: string} | null>(null) - const [successMessage, setSuccessMessage] = useState<{messageId: string, defaultMessage: string} | null>(null) + const [boardTemplateSelectorOpen, setBoardTemplateSelectorOpen] = + useState(false) + const [changePasswordUser, setChangePasswordUser] = useState( + null, + ) + const [errorMessage, setErrorMessage] = useState<{ + messageId: string + defaultMessage: string + } | null>(null) + const [successMessage, setSuccessMessage] = useState<{ + messageId: string + defaultMessage: string + } | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) const fetchUsers = async () => { - const result = await octoClient.getTeamUsers(); + const result = await octoClient.getTeamUsers() setUsers(result) - }; + } useEffect(() => { - fetchUsers(); - }, []); + fetchUsers() + }, []) const filteredUsers = useMemo(() => { - if (!searchTerm.trim()) { const arr = searchTerm.trim() ? users.filter((user) => { - return users const lower = searchTerm.toLowerCase().trim() - } return ( - - user.email?.toLowerCase().includes(lower) || - const lowercaseSearch = searchTerm.toLowerCase().trim() user.nickname?.toLowerCase().includes(lower) || + if (!searchTerm.trim()) { + return [...users].sort((a, b) => + a.username. + trim(). + toLowerCase(). + localeCompare(b.username.trim().toLowerCase(), 'en', { + sensitivity: 'base', + }), + ) + } - user.username?.toLowerCase().includes(lower) || - return users.filter(user => { user.firstname?.toLowerCase().includes(lower) || - // Search in email (if available) user.lastname?.toLowerCase().includes(lower) - const emailMatch = user.email?.toLowerCase().includes(lowercaseSearch) ) + const lowercaseSearch = searchTerm.toLowerCase().trim() - }) : [...users] - // Search in nickname - const nicknameMatch = user.nickname?.toLowerCase().includes(lowercaseSearch) return arr.sort((a, b) => + const arr = users.filter((user) => { + return ( + user.email?.toLowerCase().includes(lowercaseSearch) || + user.nickname?.toLowerCase().includes(lowercaseSearch) || + user.username?.toLowerCase().includes(lowercaseSearch) || + user.firstname?.toLowerCase().includes(lowercaseSearch) || + user.lastname?.toLowerCase().includes(lowercaseSearch) + ) + }) + return arr.sort((a, b) => a.username. - // Search in username as fallback trim(). - const usernameMatch = user.username?.toLowerCase().includes(lowercaseSearch) toLowerCase(). - + trim(). + toLowerCase(). localeCompare(b.username.trim().toLowerCase(), 'en', { - // Search in first/last name sensitivity: 'base', - const firstNameMatch = user.firstname?.toLowerCase().includes(lowercaseSearch) }), - const lastNameMatch = user.lastname?.toLowerCase().includes(lowercaseSearch) ) - - return emailMatch || nicknameMatch || usernameMatch || firstNameMatch || lastNameMatch - }) - }, [users, searchTerm]) }, [users, searchTerm]) - - const lowercaseSearch = searchTerm.toLowerCase().trim() - - return users.filter(user => { - // Search in email (if available) - const emailMatch = user.email?.toLowerCase().includes(lowercaseSearch) - - // Search in nickname - const nicknameMatch = user.nickname?.toLowerCase().includes(lowercaseSearch) - - // Search in username as fallback - const usernameMatch = user.username?.toLowerCase().includes(lowercaseSearch) - - // Search in first/last name - const firstNameMatch = user.firstname?.toLowerCase().includes(lowercaseSearch) - const lastNameMatch = user.lastname?.toLowerCase().includes(lowercaseSearch) - - return emailMatch || nicknameMatch || usernameMatch || firstNameMatch || lastNameMatch - }) + sensitivity: 'base', + }), + ) }, [users, searchTerm]) const handleSearchChange = useCallback((value: string) => { @@ -107,39 +100,44 @@ const AdminPage = () => { setErrorMessage(null) setSuccessMessage(null) setIsSubmitting(false) - setChangePasswordUser(null); + setChangePasswordUser(null) }, []) - const handleChangePassword = useCallback(async (userId: string, newPassword: string) => { - - try { - const success = await octoClient.changeUserPassword(userId, newPassword) - - if (success) { - console.log('success') - setSuccessMessage({ - messageId: 'change-password.success', - defaultMessage: 'Password changed successfully' - }) - setErrorMessage(null) - - } else { + const handleChangePassword = useCallback( + async (userId: string, newPassword: string) => { + try { + const success = await octoClient.changeUserPassword( + userId, + newPassword, + ) + + if (success) { + console.log('success') + setSuccessMessage({ + messageId: 'change-password.success', + defaultMessage: 'Password changed successfully', + }) + setErrorMessage(null) + } else { + setErrorMessage({ + messageId: 'change-password.failed', + defaultMessage: + 'Failed to change password. Please try again.', + }) + setSuccessMessage(null) + } + } catch (error) { setErrorMessage({ - messageId: 'change-password.failed', - defaultMessage: 'Failed to change password. Please try again.' + messageId: 'change-password.error', + defaultMessage: 'An error occurred while changing password', }) setSuccessMessage(null) + } finally { + setIsSubmitting(false) } - } catch (error) { - setErrorMessage({ - messageId: 'change-password.error', - defaultMessage: 'An error occurred while changing password' - }) - setSuccessMessage(null) - } finally { - setIsSubmitting(false) - } - }, [closeChangePasswordDialog]); + }, + [closeChangePasswordDialog], + ) return (
@@ -148,32 +146,38 @@ const AdminPage = () => { onBoardTemplateSelectorClose={closeBoardTemplateSelector} />
- {boardTemplateSelectorOpen && - - } -

Team Users

+ {boardTemplateSelectorOpen && ( + + )} +

{'Team Users'}

{users.length > 0 && (

- {searchTerm - ? `Showing ${filteredUsers.length} of ${users.length} users` - : `Total users: ${users.length}` - } + {searchTerm ? `Showing ${filteredUsers.length} of ${users.length} users` : `Total users: ${users.length}`}

)}
    {filteredUsers.map((user) => ( -
  • +
  • openChangePasswordDialog(user)} + onChangePassword={() => + openChangePasswordDialog(user) + } />
  • ))} @@ -181,8 +185,10 @@ const AdminPage = () => { {searchTerm && filteredUsers.length === 0 && (
    -

    No users found matching "{searchTerm}"

    -

    Try searching by email, nickname, username, or name.

    +

    {'No users found matching \'' + searchTerm + '\''}

    +

    + {'Try searching by email, nickname, username, or name.'} +

    )} @@ -198,7 +204,7 @@ const AdminPage = () => { )}
- ); -}; + ) +} export default React.memo(AdminPage) From f7f7205c988b4becaca6d3f9932f4ee7def0542b Mon Sep 17 00:00:00 2001 From: Nastasia Date: Mon, 25 Aug 2025 21:11:32 +0300 Subject: [PATCH 11/19] feat: set dev or prod by env in Docker --- docker/Dockerfile | 8 ++++++-- docker/Dockerfile.front.dev | 5 +++++ docker/docker-compose-db-nginx-dev.yml | 2 ++ docker/docker-compose-db-nginx.yml | 6 ++++-- docker/docker-compose.yml | 2 ++ webapp/.eslintrc.json | 5 +++-- .../components/sidebar/sidebarUserMenu.tsx | 4 +--- webapp/src/main.tsx | 20 ++++++++----------- webapp/src/styles/shared-variables.scss | 1 - webapp/webpack.common.js | 6 ++++++ 10 files changed, 37 insertions(+), 22 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9c98ab5d2bd..95d1e554cf2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,12 @@ ### Webapp build FROM node:16.3.0@sha256:ca6daf1543242acb0ca59ff425509eab7defb9452f6ae07c156893db06c7a9a4 as nodebuild + +ARG FOCALBOARD_ENVIRONMENT WORKDIR /webapp ENV NODE_OPTIONS=--max_old_space_size=4096 +ENV FOCALBOARD_ENVIRONMENT=$FOCALBOARD_ENVIRONMENT + COPY webapp/package.json webapp/package-lock.json* ./ @@ -29,9 +33,9 @@ RUN cd server && go mod download # Copy all code COPY . /go/src/focalboard -# Get target architecture +# Get target architecture ARG TARGETOS -ARG TARGETARCH +ARG TARGETARCH RUN --mount=type=cache,target="/root/.cache/go-build" EXCLUDE_PLUGIN=true EXCLUDE_SERVER=true EXCLUDE_ENTERPRISE=true make server-docker os=${TARGETOS} arch=${TARGETARCH} diff --git a/docker/Dockerfile.front.dev b/docker/Dockerfile.front.dev index 6aceecca489..674a0b99feb 100644 --- a/docker/Dockerfile.front.dev +++ b/docker/Dockerfile.front.dev @@ -1,6 +1,11 @@ ### Webapp build FROM node:16.3.0@sha256:ca6daf1543242acb0ca59ff425509eab7defb9452f6ae07c156893db06c7a9a4 as nodebuild +# Accept build argument +ARG FOCALBOARD_ENVIRONMENT=dev +# Make it available to the build process and runtime +ENV FOCALBOARD_ENVIRONMENT=$FOCALBOARD_ENVIRONMENT + WORKDIR /webapp # package.json is required by npm at build stage # need additional setup for node_modules diff --git a/docker/docker-compose-db-nginx-dev.yml b/docker/docker-compose-db-nginx-dev.yml index 0c432668ce5..8caa66172ac 100644 --- a/docker/docker-compose-db-nginx-dev.yml +++ b/docker/docker-compose-db-nginx-dev.yml @@ -31,6 +31,8 @@ services: build: context: ../ dockerfile: docker/Dockerfile.front.dev + args: + FOCALBOARD_ENVIRONMENT: ${FOCALBOARD_ENVIRONMENT:-dev} container_name: focalboard-front volumes: - ../webapp/src:/webapp/src diff --git a/docker/docker-compose-db-nginx.yml b/docker/docker-compose-db-nginx.yml index beb855a6840..742c0d2b252 100644 --- a/docker/docker-compose-db-nginx.yml +++ b/docker/docker-compose-db-nginx.yml @@ -5,6 +5,8 @@ services: build: context: ../ dockerfile: docker/Dockerfile + args: + FOCALBOARD_ENVIRONMENT: ${FOCALBOARD_ENVIRONMENT:-prod} container_name: focalboard depends_on: - focalboard-db @@ -29,7 +31,7 @@ services: container_name: focalboard-proxy restart: always ports: - - "80:80" + - "8081:80" - "443:443" volumes: - /var/run/docker.sock:/tmp/docker.sock:ro @@ -58,7 +60,7 @@ services: restart: "always" depends_on: - "proxy" - networks: + networks: - proxy focalboard-db: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ed0780b8268..ba775aa47c6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,6 +4,8 @@ services: build: context: ../ dockerfile: docker/Dockerfile + args: + FOCALBOARD_ENVIRONMENT: ${FOCALBOARD_ENVIRONMENT:-prod} container_name: focalboard volumes: - fbdata:/opt/focalboard/data diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index e7b0749d999..c2fd8ffec3c 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -16,7 +16,8 @@ "parser": "@typescript-eslint/parser", "env": { "jest": true, - "cypress/globals": true + "cypress/globals": true, + "node": true }, "settings": { "import/resolver": "webpack", @@ -63,7 +64,7 @@ "no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}], "max-nested-callbacks": ["error", {"max": 5}], "no-shadow": "off", - "@typescript-eslint/no-shadow": "error" + "@typescript-eslint/no-shadow": "error" }, "overrides": [ { diff --git a/webapp/src/components/sidebar/sidebarUserMenu.tsx b/webapp/src/components/sidebar/sidebarUserMenu.tsx index 7affa5dceba..6938d7c1fc3 100644 --- a/webapp/src/components/sidebar/sidebarUserMenu.tsx +++ b/webapp/src/components/sidebar/sidebarUserMenu.tsx @@ -14,7 +14,6 @@ import MenuWrapper from '../../widgets/menuWrapper' import {getMe, isAdmin, setMe} from '../../store/users' import {useAppSelector, useAppDispatch} from '../../store/hooks' import {Utils} from '../../utils' -import {getClientConfig} from '../../store/clientConfig' import ModalWrapper from '../modalWrapper' @@ -33,7 +32,6 @@ const SidebarUserMenu = () => { const user = useAppSelector(getMe) const isUserAdmin = useAppSelector(isAdmin); const intl = useIntl() - const clientConfig = useAppSelector(getClientConfig) if (Utils.isFocalboardPlugin()) { return <> @@ -45,7 +43,7 @@ const SidebarUserMenu = () => {
- {clientConfig.featureFlags['FOCALBOARD_ENVIRONMENT'] == 'prod' ? 'Борда' : 'Focalboard'} + {process.env.FOCALBOARD_ENVIRONMENT == 'prod' ? 'Борда' : 'Focalboard'}
{ const me = useAppSelector(getMe) - const clientConfig = useAppSelector(getClientConfig) - - useEffect(() => { - if (clientConfig.featureFlags['FOCALBOARD_ENVIRONMENT'] != 'prod') { - // TODO also set this when changing a theme - // for some reason useAppSelector doesn't work in theme.ts - document.documentElement.style.setProperty('--sidebar-bg-rgb', '92, 50, 30') - } else { - document.documentElement.style.setProperty('--sidebar-bg-rgb', '30, 50, 92') - } - }, [clientConfig.featureFlags['FOCALBOARD_ENVIRONMENT']]) + + const environment = process.env.FOCALBOARD_ENVIRONMENT + + if (environment !== 'prod') { + document.documentElement.style.setProperty('--sidebar-bg-rgb', '92, 50, 30') + } else { + document.documentElement.style.setProperty('--sidebar-bg-rgb', '30, 50, 92') + } return ( diff --git a/webapp/src/styles/shared-variables.scss b/webapp/src/styles/shared-variables.scss index 8b5c9a4e5a7..c87908a645f 100644 --- a/webapp/src/styles/shared-variables.scss +++ b/webapp/src/styles/shared-variables.scss @@ -2,7 +2,6 @@ --center-channel-bg-rgb: 255, 255, 255; --center-channel-color-rgb: 63, 67, 80; --sidebar-bg-rgb: 30, 50, 92; - --sidebar-bg-dev: 92, 50, 30; --sidebar-text-rgb: 255, 255, 255; --button-color-rgb: 255, 255, 255; --button-bg-rgb: 28, 88, 217; diff --git a/webapp/webpack.common.js b/webapp/webpack.common.js index b003265cec6..b74cbd449dc 100644 --- a/webapp/webpack.common.js +++ b/webapp/webpack.common.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. const path = require('path'); +const webpack = require('webpack'); const tsTransformer = require('@formatjs/ts-transformer'); const CopyPlugin = require('copy-webpack-plugin'); @@ -8,6 +9,8 @@ var HtmlWebpackPlugin = require('html-webpack-plugin'); const outpath = path.resolve(__dirname, 'pack'); +console.log('🔧 FOCALBOARD_ENVIRONMENT from Docker:', process.env.FOCALBOARD_ENVIRONMENT); + function makeCommonConfig() { const commonConfig = { target: 'web', @@ -99,6 +102,9 @@ function makeCommonConfig() { publicPath: '{{.BaseURL}}/', hash: true, }), + new webpack.DefinePlugin({ + 'process.env.FOCALBOARD_ENVIRONMENT': JSON.stringify(process.env.FOCALBOARD_ENVIRONMENT || 'dev'), + }), ], entry: ['./src/main.tsx', './src/userSettings.ts'], output: { From 8f0aeef04b946b41f68f6aaf3133157fc6fafb98 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Mon, 25 Aug 2025 22:06:15 +0300 Subject: [PATCH 12/19] fix: separate config to env variables --- docker/docker-compose-db-nginx.yml | 2 +- webapp/src/components/sidebar/sidebarUserMenu.tsx | 3 ++- webapp/src/config/envConfig.ts | 2 ++ webapp/src/main.tsx | 6 ++---- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 webapp/src/config/envConfig.ts diff --git a/docker/docker-compose-db-nginx.yml b/docker/docker-compose-db-nginx.yml index 742c0d2b252..a2e08e344e7 100644 --- a/docker/docker-compose-db-nginx.yml +++ b/docker/docker-compose-db-nginx.yml @@ -31,7 +31,7 @@ services: container_name: focalboard-proxy restart: always ports: - - "8081:80" + - "80:80" - "443:443" volumes: - /var/run/docker.sock:/tmp/docker.sock:ro diff --git a/webapp/src/components/sidebar/sidebarUserMenu.tsx b/webapp/src/components/sidebar/sidebarUserMenu.tsx index 6938d7c1fc3..dafeb4ef872 100644 --- a/webapp/src/components/sidebar/sidebarUserMenu.tsx +++ b/webapp/src/components/sidebar/sidebarUserMenu.tsx @@ -14,6 +14,7 @@ import MenuWrapper from '../../widgets/menuWrapper' import {getMe, isAdmin, setMe} from '../../store/users' import {useAppSelector, useAppDispatch} from '../../store/hooks' import {Utils} from '../../utils' +import {isProduction} from '../../config/envConfig' import ModalWrapper from '../modalWrapper' @@ -43,7 +44,7 @@ const SidebarUserMenu = () => {
- {process.env.FOCALBOARD_ENVIRONMENT == 'prod' ? 'Борда' : 'Focalboard'} + {isProduction ? 'Борда' : 'Focalboard'}
{ const me = useAppSelector(getMe) - const environment = process.env.FOCALBOARD_ENVIRONMENT - - if (environment !== 'prod') { + if (!isProduction) { document.documentElement.style.setProperty('--sidebar-bg-rgb', '92, 50, 30') } else { document.documentElement.style.setProperty('--sidebar-bg-rgb', '30, 50, 92') } - return ( From 6b4325afc977c3d48007a8e27bc77a9b8313a0a0 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Mon, 25 Aug 2025 23:43:17 +0300 Subject: [PATCH 13/19] fix: do not send dev or prod from server --- server/services/config/config.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/server/services/config/config.go b/server/services/config/config.go index e5dcce103e6..54a0d3f4e46 100644 --- a/server/services/config/config.go +++ b/server/services/config/config.go @@ -121,11 +121,6 @@ func ReadConfigFile(configFilePath string) (*Configuration, error) { return nil, err } - env := os.Getenv("FOCALBOARD_ENVIRONMENT") - if env != "" { - configuration.FeatureFlags["FOCALBOARD_ENVIRONMENT"] = env - } - log.Println("readConfigFile") log.Printf("%+v", removeSecurityData(configuration)) From c177e5af4061c826833516e8a7c7f60487751292 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Tue, 26 Aug 2025 20:06:35 +0300 Subject: [PATCH 14/19] fix: unused import --- server/services/config/config.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/services/config/config.go b/server/services/config/config.go index 54a0d3f4e46..dbabe0e1ee5 100644 --- a/server/services/config/config.go +++ b/server/services/config/config.go @@ -2,7 +2,6 @@ package config import ( "log" - "os" "github.com/spf13/viper" ) From 60cf5cc48c6300be19e4fd307e0d305341491e0f Mon Sep 17 00:00:00 2001 From: Nastasia Date: Mon, 25 Aug 2025 23:36:36 +0300 Subject: [PATCH 15/19] fix: admins from frontend to env --- docker/Dockerfile | 2 ++ docker/Dockerfile.front.dev | 2 ++ docker/docker-compose-db-nginx-dev.yml | 1 + docker/docker-compose-db-nginx.yml | 1 + webapp/src/config/envConfig.ts | 5 ++++- webapp/src/store/users.ts | 13 ++----------- webapp/webpack.common.js | 1 + 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 95d1e554cf2..1eb82dbca2e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,9 +3,11 @@ FROM node:16.3.0@sha256:ca6daf1543242acb0ca59ff425509eab7defb9452f6ae07c156893db ARG FOCALBOARD_ENVIRONMENT +ARG FOCALBOARD_ADMINS WORKDIR /webapp ENV NODE_OPTIONS=--max_old_space_size=4096 ENV FOCALBOARD_ENVIRONMENT=$FOCALBOARD_ENVIRONMENT +ENV FOCALBOARD_ADMINS=$FOCALBOARD_ADMINS COPY webapp/package.json webapp/package-lock.json* ./ diff --git a/docker/Dockerfile.front.dev b/docker/Dockerfile.front.dev index 674a0b99feb..1ca5cd4bdab 100644 --- a/docker/Dockerfile.front.dev +++ b/docker/Dockerfile.front.dev @@ -3,8 +3,10 @@ FROM node:16.3.0@sha256:ca6daf1543242acb0ca59ff425509eab7defb9452f6ae07c156893db # Accept build argument ARG FOCALBOARD_ENVIRONMENT=dev +ARG FOCALBOARD_ADMINS # Make it available to the build process and runtime ENV FOCALBOARD_ENVIRONMENT=$FOCALBOARD_ENVIRONMENT +ENV FOCALBOARD_ADMINS=$FOCALBOARD_ADMINS WORKDIR /webapp # package.json is required by npm at build stage diff --git a/docker/docker-compose-db-nginx-dev.yml b/docker/docker-compose-db-nginx-dev.yml index 8caa66172ac..c50e3c83d06 100644 --- a/docker/docker-compose-db-nginx-dev.yml +++ b/docker/docker-compose-db-nginx-dev.yml @@ -33,6 +33,7 @@ services: dockerfile: docker/Dockerfile.front.dev args: FOCALBOARD_ENVIRONMENT: ${FOCALBOARD_ENVIRONMENT:-dev} + FOCALBOARD_ADMINS: ${FOCALBOARD_ADMINS:-} container_name: focalboard-front volumes: - ../webapp/src:/webapp/src diff --git a/docker/docker-compose-db-nginx.yml b/docker/docker-compose-db-nginx.yml index a2e08e344e7..f6d5682b334 100644 --- a/docker/docker-compose-db-nginx.yml +++ b/docker/docker-compose-db-nginx.yml @@ -7,6 +7,7 @@ services: dockerfile: docker/Dockerfile args: FOCALBOARD_ENVIRONMENT: ${FOCALBOARD_ENVIRONMENT:-prod} + FOCALBOARD_ADMINS: ${FOCALBOARD_ADMINS:-} container_name: focalboard depends_on: - focalboard-db diff --git a/webapp/src/config/envConfig.ts b/webapp/src/config/envConfig.ts index 85f507a018a..a82b7901474 100644 --- a/webapp/src/config/envConfig.ts +++ b/webapp/src/config/envConfig.ts @@ -1,2 +1,5 @@ -export const environment = process.env.FOCALBOARD_ENVIRONMENT || 'dev' +const environment = process.env.FOCALBOARD_ENVIRONMENT || 'dev' export const isProduction = environment === 'prod' + +export const adminsUsernamesString = process.env.FOCALBOARD_ADMINS || ''; +export const adminUsernames = adminsUsernamesString ? adminsUsernamesString.split(',').map(username => username.trim()) : []; diff --git a/webapp/src/store/users.ts b/webapp/src/store/users.ts index 9b1eed69499..efb383c6e8d 100644 --- a/webapp/src/store/users.ts +++ b/webapp/src/store/users.ts @@ -5,6 +5,7 @@ import {createSlice, createAsyncThunk, PayloadAction, createSelector} from '@red import {default as client} from '../octoClient' import {IUser, parseUserProps, UserPreference} from '../user' +import {adminUsernames} from '../config/envConfig' import {Utils} from '../utils' @@ -229,16 +230,6 @@ export const getCardHiddenWarningSnoozeUntil = createSelector( export const isAdmin = createSelector( getMe, (user): boolean => { - if (!user) { - return false - } - const adminUsernames = [ - 'admin', - 'bulgak0v', - 'nastasia75', - 'tam', - 'olya_dushkina', - ] - + if (!user) return false; return adminUsernames.includes(user.username) }) diff --git a/webapp/webpack.common.js b/webapp/webpack.common.js index b74cbd449dc..16ce7d6e313 100644 --- a/webapp/webpack.common.js +++ b/webapp/webpack.common.js @@ -104,6 +104,7 @@ function makeCommonConfig() { }), new webpack.DefinePlugin({ 'process.env.FOCALBOARD_ENVIRONMENT': JSON.stringify(process.env.FOCALBOARD_ENVIRONMENT || 'dev'), + 'process.env.FOCALBOARD_ADMINS': JSON.stringify(process.env.FOCALBOARD_ADMINS || ''), }), ], entry: ['./src/main.tsx', './src/userSettings.ts'], From ca3726b342af0212876735726255b27603ec8458 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Thu, 28 Aug 2025 20:56:58 +0300 Subject: [PATCH 16/19] fix: admins from back to env --- docker/Dockerfile.back.dev | 2 ++ docker/docker-compose-db-nginx-dev.yml | 3 +++ server/api/auth.go | 3 +-- server/services/config/config.go | 8 ++++++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile.back.dev b/docker/Dockerfile.back.dev index b1ead28205f..7aa4bbb920f 100644 --- a/docker/Dockerfile.back.dev +++ b/docker/Dockerfile.back.dev @@ -7,6 +7,8 @@ ADD . /go/src/focalboard # Get target architecture ARG TARGETOS ARG TARGETARCH +ARG FOCALBOARD_ADMINS +ENV FOCALBOARD_ADMINS=$FOCALBOARD_ADMINS RUN --mount=type=cache,target="/root/.cache/go-build" EXCLUDE_PLUGIN=true EXCLUDE_SERVER=true EXCLUDE_ENTERPRISE=true make server-docker os=${TARGETOS} arch=${TARGETARCH} diff --git a/docker/docker-compose-db-nginx-dev.yml b/docker/docker-compose-db-nginx-dev.yml index c50e3c83d06..13932e626d0 100644 --- a/docker/docker-compose-db-nginx-dev.yml +++ b/docker/docker-compose-db-nginx-dev.yml @@ -6,6 +6,8 @@ services: build: context: ../ dockerfile: docker/Dockerfile.back.dev + args: + FOCALBOARD_ADMINS: ${FOCALBOARD_ADMINS:-} container_name: focalboard-back depends_on: - focalboard-db @@ -16,6 +18,7 @@ services: - VIRTUAL_HOST=${FOCALBOARD_HOST} - LETSENCRYPT_HOST=${FOCALBOARD_HOST} - FOCALBOARD_ENVIRONMENT=${FOCALBOARD_ENVIRONMENT:-dev} + - FOCALBOARD_ADMINS=${FOCALBOARD_ADMINS:-} - VIRTUAL_PORT=8000 - VIRTUAL_PROTO=http volumes: diff --git a/server/api/auth.go b/server/api/auth.go index 4acd0a84944..c7f4d2b4943 100644 --- a/server/api/auth.go +++ b/server/api/auth.go @@ -765,8 +765,7 @@ func (a *API) isHardcodedAdmin(userID string) (bool, error) { return false, err } - hardcodedAdmins := []string{"admin", "bulgak0v", "nastasia75", "tam", - "olya_dushkina",} + hardcodedAdmins := a.app.GetConfig().Admins for _, adminUsername := range hardcodedAdmins { if user.Username == adminUsername { return true, nil diff --git a/server/services/config/config.go b/server/services/config/config.go index dbabe0e1ee5..b5b9a1f1fea 100644 --- a/server/services/config/config.go +++ b/server/services/config/config.go @@ -25,7 +25,7 @@ type AmazonS3Config struct { Timeout int64 } -// Configuration is the app configuration stored in a json file. +// Configuration is the app configuration stored in a json file and in env type Configuration struct { ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"` Port int `json:"port" mapstructure:"port"` @@ -67,6 +67,8 @@ type Configuration struct { NotifyFreqCardSeconds int `json:"notify_freq_card_seconds" mapstructure:"notify_freq_card_seconds"` NotifyFreqBoardSeconds int `json:"notify_freq_board_seconds" mapstructure:"notify_freq_board_seconds"` + + Admins []string `mapstructure:"admins"` } // ReadConfigFile read the configuration from the filesystem. @@ -107,6 +109,7 @@ func ReadConfigFile(configFilePath string) (*Configuration, error) { viper.SetDefault("TeammateNameDisplay", "username") viper.SetDefault("ShowEmailAddress", false) viper.SetDefault("ShowFullName", false) + viper.SetDefault("Admins", []string{}) err := viper.ReadInConfig() // Find and read the config file if err != nil { // Handle errors reading the config file @@ -120,7 +123,7 @@ func ReadConfigFile(configFilePath string) (*Configuration, error) { return nil, err } - log.Println("readConfigFile") + log.Println("readConfigFile (sensitive data removed)") log.Printf("%+v", removeSecurityData(configuration)) return &configuration, nil @@ -128,5 +131,6 @@ func ReadConfigFile(configFilePath string) (*Configuration, error) { func removeSecurityData(config Configuration) Configuration { clean := config + clean.Admins = nil return clean } From a51e879f521370a46038715e18eae065193f81ba Mon Sep 17 00:00:00 2001 From: Nastasia Date: Tue, 9 Sep 2025 23:45:22 +0300 Subject: [PATCH 17/19] fix: pass env admins to backend --- docker/docker-compose-db-nginx.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/docker-compose-db-nginx.yml b/docker/docker-compose-db-nginx.yml index f6d5682b334..99ddd779f5f 100644 --- a/docker/docker-compose-db-nginx.yml +++ b/docker/docker-compose-db-nginx.yml @@ -17,6 +17,7 @@ services: - VIRTUAL_HOST=${FOCALBOARD_HOST} - LETSENCRYPT_HOST=${FOCALBOARD_HOST} - FOCALBOARD_ENVIRONMENT=${FOCALBOARD_ENVIRONMENT:-dev} + - FOCALBOARD_ADMINS=${FOCALBOARD_ADMINS:-} - VIRTUAL_PORT=8000 - VIRTUAL_PROTO=http volumes: From b0ed5dd884e36217f06da7a9d2006a8cc1bc6e60 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Thu, 7 Aug 2025 21:04:48 +0300 Subject: [PATCH 18/19] fix: sticky users in card details --- webapp/src/components/personSelector.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/personSelector.tsx b/webapp/src/components/personSelector.tsx index 72aeaa8e87a..386f304677d 100644 --- a/webapp/src/components/personSelector.tsx +++ b/webapp/src/components/personSelector.tsx @@ -160,7 +160,16 @@ const PersonSelector = (props: Props): JSX.Element => { usersInsideBoard.push(u) } } - return usersInsideBoard; + + const stickedApiUsers = stickedUsers + .map(username => usersInsideBoard.find(user => user.username === username)) + .filter(Boolean) as IUser[] + + const remainingApiUsers = usersInsideBoard + .filter(user => !stickedUsers.includes(user.username)) + .sort((a, b) => a.username.localeCompare(b.username)) + + return [...stickedApiUsers, ...remainingApiUsers] }, [boardUsers, allowAddUsers, boardUsersById, me]) let primaryClass = 'Person' From 3ad7af78812277956afc3d8881e572ee2ee5a070 Mon Sep 17 00:00:00 2001 From: Nastasia Date: Thu, 7 Aug 2025 21:32:44 +0300 Subject: [PATCH 19/19] refactor: sticky users logic in one place --- webapp/src/components/personSelector.tsx | 34 +++++++++++++++--------- webapp/src/store/users.ts | 19 ------------- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/webapp/src/components/personSelector.tsx b/webapp/src/components/personSelector.tsx index 386f304677d..55b5e521958 100644 --- a/webapp/src/components/personSelector.tsx +++ b/webapp/src/components/personSelector.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react' +import React, {useCallback, useMemo} from 'react' import {useIntl} from 'react-intl' import Select from 'react-select/async' import {CSSObject} from '@emotion/serialize' @@ -12,7 +12,7 @@ import {getSelectBaseStyle} from '../theme' import {IUser} from '../user' import {Utils} from '../utils' import {useAppSelector} from '../store/hooks' -import {getBoardUsers, getBoardUsersListWithSticky, getMe} from '../store/users' +import {getBoardUsers, getBoardUsersList, getMe} from '../store/users' import {ClientConfig} from '../config/clientConfig' import {getClientConfig} from '../store/clientConfig' @@ -73,6 +73,22 @@ const selectStyles = { }), } +const applyStickyUserSorting = (users: IUser[], stickyUsernames: string[]): IUser[] => { + if (stickyUsernames.length === 0) { + return users.sort((a, b) => a.username.localeCompare(b.username)) + } + + const stickyUsers = stickyUsernames + .map(username => users.find(user => user.username === username)) + .filter(Boolean) as IUser[] + + const remainingUsers = users + .filter(user => !stickyUsernames.includes(user.username)) + .sort((a, b) => a.username.localeCompare(b.username)) + + return [...stickyUsers, ...remainingUsers] +} + const PersonSelector = (props: Props): JSX.Element => { const {readOnly, userIDs, allowAddUsers, isMulti, closeMenuOnSelect = true, emptyDisplayValue, showMe = false, onChange} = props @@ -81,10 +97,12 @@ const PersonSelector = (props: Props): JSX.Element => { const stickedUsers: string[] = ['bulgak0v', 'g.-ekaterina', 'alexeyqu', 'daria_u', 'danya_s', 'kolpashchikova', 'olya_dushkina', 'michael_deev', 'diidary', 'riyatriana.rivera', 'kimihail', 'affendi', 'annaoskina2'] const boardUsersById = useAppSelector<{[key: string]: IUser}>(getBoardUsers) - const boardUsers = useAppSelector((state) => getBoardUsersListWithSticky(state, stickedUsers)); + const boardUsersRaw = useAppSelector(getBoardUsersList) const boardUsersKey = Object.keys(boardUsersById) ? Utils.hashCode(JSON.stringify(Object.keys(boardUsersById))) : 0 const me = useAppSelector(getMe) + const boardUsers = useMemo(() => applyStickyUserSorting(boardUsersRaw, stickedUsers), [boardUsersRaw, stickedUsers]) + const formatOptionLabel = (user: any): JSX.Element => { if (!user) { return
@@ -161,15 +179,7 @@ const PersonSelector = (props: Props): JSX.Element => { } } - const stickedApiUsers = stickedUsers - .map(username => usersInsideBoard.find(user => user.username === username)) - .filter(Boolean) as IUser[] - - const remainingApiUsers = usersInsideBoard - .filter(user => !stickedUsers.includes(user.username)) - .sort((a, b) => a.username.localeCompare(b.username)) - - return [...stickedApiUsers, ...remainingApiUsers] + return applyStickyUserSorting(usersInsideBoard, stickedUsers) }, [boardUsers, allowAddUsers, boardUsersById, me]) let primaryClass = 'Person' diff --git a/webapp/src/store/users.ts b/webapp/src/store/users.ts index 9b1eed69499..ecb25e14b58 100644 --- a/webapp/src/store/users.ts +++ b/webapp/src/store/users.ts @@ -131,25 +131,6 @@ export const getBoardUsersList = createSelector( (boardUsers) => Object.values(boardUsers).sort((a, b) => a.username.localeCompare(b.username)), ) -export const getBoardUsersListWithSticky = createSelector( - [getBoardUsers, (state: RootState, stickedUsernames: string[]) => stickedUsernames], - (boardUsers, stickedUsernames) => { - const allUsers = Object.values(boardUsers) - - // Get sticked users in their fixed order - const stickedUsers = stickedUsernames. - map((username) => allUsers.find((user) => user.username === username)). - filter(Boolean) as IUser[] - - // Get remaining users and sort alphabetically - const remainingUsers = allUsers. - filter((user) => !stickedUsernames.includes(user.username)). - sort((a, b) => a.username.localeCompare(b.username)) - - return [...stickedUsers, ...remainingUsers] - }, -) - export const getUser = (userId: string): (state: RootState) => IUser|undefined => { return (state: RootState): IUser|undefined => { const users = getBoardUsers(state)