diff --git a/client/package-lock.json b/client/package-lock.json index 35fcc32c6..01bb0601f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "foodoasis-client", - "version": "1.0.82", + "version": "1.0.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "foodoasis-client", - "version": "1.0.82", + "version": "1.0.83", "license": "GPL-2.0", "dependencies": { "@craco/craco": "^7.0.0", diff --git a/client/package.json b/client/package.json index f31df4f18..99a9b551b 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "foodoasis-client", "description": "React Client for Food Oasis", - "version": "1.0.83", + "version": "1.0.84", "author": "Hack for LA", "license": "GPL-2.0", "private": true, diff --git a/client/src/components/Admin/Features.js b/client/src/components/Admin/Features.js index b94de9079..677a92778 100644 --- a/client/src/components/Admin/Features.js +++ b/client/src/components/Admin/Features.js @@ -17,6 +17,8 @@ import { TableRow, TextField, Typography, + FormControlLabel, + Switch, } from "@mui/material"; import { Delete as DeleteIcon, @@ -28,6 +30,7 @@ import { useFormik } from "formik"; import React, { useEffect, useState } from "react"; import * as Yup from "yup"; import { useFeatureToLogin } from "../../hooks/useFeatureToLogin"; +import { useFeatures } from "../../hooks/useFeatures"; import * as accountService from "../../services/account-service"; import * as featureService from "../../services/feature-service"; import * as featureToLoginService from "../../services/feature-to-login-service"; @@ -48,21 +51,33 @@ const Features = () => { refetch: featureToLoginRefetch, } = useFeatureToLogin(); + const { data: featuresData, loading: featuresLoading } = useFeatures(); + useEffect(() => { - const newRows = featureToLoginData.map((feature) => ({ - featureId: feature.feature_id, - name: feature.feature_name, - featureToLoginId: feature.ftl_id, - history: feature.users.map((user) => ({ - loginId: user.login_id, - firstName: user.first_name, - lastName: user.last_name, - email: user.email, - featureToLoginId: user.featureToLoginId || feature.ftl_id, - })), - })); - setRows(newRows); - }, [featureToLoginData]); + if (featuresData && featureToLoginData) { + const newRows = featuresData.map((feature) => { + const featureToLogin = featureToLoginData.find( + (ftl) => ftl.feature_id === feature.id + ); + return { + featureId: feature.id, + name: feature.name, + is_enabled: feature.is_enabled, + history: featureToLogin + ? featureToLogin.users.map((user) => ({ + loginId: user.login_id, + firstName: user.first_name, + lastName: user.last_name, + email: user.email, + featureToLoginId: + user.featureToLoginId || featureToLogin.ftl_id, + })) + : [], + }; + }); + setRows(newRows); + } + }, [featureToLoginData, featuresData]); const handleModalClose = () => { setFeatureModalOpen(false); @@ -84,6 +99,18 @@ const Features = () => { setSelectedRowId(rowName); } }; + const handleIsEnabled = async (featureId, isEnabled) => { + try { + await featureService.update(featureId, { is_enabled: isEnabled }); + setRows((prevRows) => + prevRows.map((row) => + row.featureId === featureId ? { ...row, is_enabled: isEnabled } : row + ) + ); + } catch (error) { + console.error("Error updating feature:", error); + } + }; const featureFormik = useFormik({ initialValues: { name: "", @@ -143,6 +170,23 @@ const Features = () => { setRowsPerPage(+event.target.value); setPage(0); }; + + if (featuresLoading || featureToLoginLoading || !featuresData) { + return ( + + + + ); + } return ( { Add New Feature - {featureToLoginLoading ? ( - - - - ) : ( - - - - - - Feature ID - Feature Name - - - - - {rows.map((row, index) => ( - - handleRowClick(row.name)} - sx={{ - "& > *": { - borderBottom: "unset", - cursor: "pointer", - backgroundColor: "#efefef", - }, - }} - hover - > - - handleRowClick(row.name)} - > - {selectedRowId === row.name ? ( - - ) : ( - - )} - - - - {row.featureId} - - +
+ + + + Feature ID + Feature Name + + + + + {rows.map((row, index) => ( + + handleRowClick(row.name)} + sx={{ + "& > *": { + borderBottom: "unset", + cursor: "pointer", + backgroundColor: "#efefef", + }, + }} + hover + > + + handleRowClick(row.name)} > - {row.name} - - - { - try { - await featureService.remove(row.featureId); - featureToLoginRefetch(); - } catch (error) { - console.error( - "Failed to remove user from feature:", - error - ); + {selectedRowId === row.name ? ( + + ) : ( + + )} + + + + + {row.featureId} + + + {row.name} + + + + handleIsEnabled(row.featureId, e.target.checked) } - }} - > - - - - - - + } + label={ + + Globally Enable + + } + /> + + + { + try { + await featureService.remove(row.featureId); + featureToLoginRefetch(); + } catch (error) { + console.error( + "Failed to remove user from feature:", + error + ); + } + }} + > + + + + + + + - - - - - Users - + + + + Users + - - handleUserModalOpen(row.name, row.featureId) - } - > - - - + + handleUserModalOpen(row.name, row.featureId) + } + > + + + -
- - - Login ID - First Name - Last Name - Email +
+ + + Login ID + First Name + Last Name + Email + + + + {row.history.map((historyRow) => ( + + + {historyRow.loginId} + + + {historyRow.firstName} + + + {historyRow.lastName} + + + {historyRow.email} + + + {row.history.length > 0 && ( + { + try { + await featureToLoginService.removeUserFromFeature( + historyRow.featureToLoginId + ); + featureToLoginRefetch(); + } catch (error) { + console.error( + "Failed to remove user from feature:", + error + ); + } + }} + > + + + )} + - - - {row.history.map((historyRow) => ( - - - {historyRow.loginId} - - - {historyRow.firstName} - - - {historyRow.lastName} - - - {historyRow.email} - - - {row.history.length > 0 && ( - { - try { - await featureToLoginService.removeUserFromFeature( - historyRow.featureToLoginId - ); - featureToLoginRefetch(); - } catch (error) { - console.error( - "Failed to remove user from feature:", - error - ); - } - }} - > - - - )} - - - ))} - -
- - - - - - ))} - - -
- )} + ))} + + + + + + + + ))} + + + diff --git a/client/src/components/FoodSeeker/SearchResults/ResultsMap/ResultsMap.js b/client/src/components/FoodSeeker/SearchResults/ResultsMap/ResultsMap.js index c4e2893a5..e08703cd4 100644 --- a/client/src/components/FoodSeeker/SearchResults/ResultsMap/ResultsMap.js +++ b/client/src/components/FoodSeeker/SearchResults/ResultsMap/ResultsMap.js @@ -160,7 +160,7 @@ const ResultsMap = ({ stakeholders, categoryIds, toggleCategory, loading }) => { latitude={startIconCoordinates.latitude} offsetTop={-50} offsetLeft={-25} - anchor="bottom" + anchor="center" > @@ -218,8 +218,8 @@ const StartIcon = () => { return ( diff --git a/client/src/components/FoodSeeker/SearchResults/layouts/Mobile.js b/client/src/components/FoodSeeker/SearchResults/layouts/Mobile.js index 698be8484..0fa38daed 100644 --- a/client/src/components/FoodSeeker/SearchResults/layouts/Mobile.js +++ b/client/src/components/FoodSeeker/SearchResults/layouts/Mobile.js @@ -41,8 +41,6 @@ const MobileLayout = ({ filters, map, list, showList }) => { let newY; if (filterPanelOpen) { newY = 100; - } else if (hasAdvancedFilterFeatureFlag) { - newY = showList ? (100 / window.innerHeight) * 60 : 54; } else { newY = showList ? 17 : 54; } diff --git a/client/src/hooks/useFeatureFlag.js b/client/src/hooks/useFeatureFlag.js index 3acd29b2e..b1e8c2f78 100644 --- a/client/src/hooks/useFeatureFlag.js +++ b/client/src/hooks/useFeatureFlag.js @@ -1,10 +1,16 @@ import { useUserContext } from "../contexts/userContext"; +import { useFeatures } from "../hooks/useFeatures"; export default function useFeatureFlag(flagName) { const { user } = useUserContext(); - if (!user || !user.features) { - return false; - } - const featureFlags = user.features; - return featureFlags.includes(flagName); + const { data: featureFlags } = useFeatures(); + + const featureFlag = featureFlags.find((feature) => feature.name === flagName); + + const isFeatureEnabled = featureFlag && featureFlag.is_enabled; + + const userHasFeature = + user && user.features && user.features.includes(flagName); + + return isFeatureEnabled || userHasFeature; } diff --git a/client/src/hooks/useMapbox.js b/client/src/hooks/useMapbox.js index 2aec16d54..33fccfa4a 100644 --- a/client/src/hooks/useMapbox.js +++ b/client/src/hooks/useMapbox.js @@ -6,7 +6,7 @@ import { useMap } from "react-map-gl"; export const useMapbox = () => { const mapRef = useRef(); const isListPanelOpen = useListPanel(); - const { isMobile } = useBreakpoints(); + const { isMobile, isDesktop } = useBreakpoints(); const mapbox = useMap(); const getViewport = () => { @@ -29,8 +29,8 @@ export const useMapbox = () => { } mapbox.default.flyTo({ center: [ - isListPanelOpen && !isMobile ? longitude - 0.08 : longitude, - isMobile ? latitude - 0.05 : latitude, + isListPanelOpen && isDesktop ? longitude - 0.08 : longitude, + isMobile ? latitude - 0.04 : latitude, ], duration: 2000, }); diff --git a/client/src/services/feature-service.js b/client/src/services/feature-service.js index 736d72c43..103a2e95b 100644 --- a/client/src/services/feature-service.js +++ b/client/src/services/feature-service.js @@ -22,3 +22,9 @@ export const remove = async (id) => { const response = await axios.delete(`${baseUrl}/${id}`); return response.data; }; + +export const update = async (id, feature) => { + const response = await axios.put(`${baseUrl}/${id}`, feature); + return response.data; +}; + diff --git a/server/app/controllers/feature-controller.ts b/server/app/controllers/feature-controller.ts index 50a017a33..da04999ee 100644 --- a/server/app/controllers/feature-controller.ts +++ b/server/app/controllers/feature-controller.ts @@ -45,8 +45,25 @@ const remove: RequestHandler< res.status(500).json({ error: "Internal server error" }); } }; +const update: RequestHandler< + { id: string }, + { error: string } | { message: string } | Feature, + { is_enabled: boolean } +> = async (req, res) => { + try { + const { id } = req.params; + const { is_enabled } = req.body; + const resp = await featureService.update(Number(id), is_enabled); + res.status(200).json(resp); + } catch (error) { + console.error(error); + res.status(500).json({ error: "Internal server error" }); + } +}; + export default { post, getAll, - remove -}; + remove, + update, +}; \ No newline at end of file diff --git a/server/app/routes/feature-router.ts b/server/app/routes/feature-router.ts index e4abd6a5f..183bdbd48 100644 --- a/server/app/routes/feature-router.ts +++ b/server/app/routes/feature-router.ts @@ -18,5 +18,10 @@ router.delete( jwtSession.validateUserHasRequiredRoles(["admin"]), featureController.remove ); +router.put( + "/:id", + jwtSession.validateUserHasRequiredRoles(["admin"]), + featureController.update +); export default router; diff --git a/server/app/services/feature-service.ts b/server/app/services/feature-service.ts index ad905991f..a24abd755 100644 --- a/server/app/services/feature-service.ts +++ b/server/app/services/feature-service.ts @@ -3,23 +3,26 @@ import { Feature } from "../../types/feature-types"; const getAll = async (): Promise => { const sql = ` - SELECT id, name FROM feature_flag + SELECT id, name, is_enabled + FROM feature_flag `; const result = await db.manyOrNone(sql); return result; }; -const insert = async (model: Feature): Promise<{ id: number }> => { +const insert = async (model: Feature): Promise => { const sql = ` - INSERT INTO feature_flag(name) - VALUES ($) - RETURNING id, name + INSERT INTO feature_flag(name, is_enabled) + VALUES ($, $) + RETURNING id, name, is_enabled `; const result = await db.one(sql, model); return result; }; -const remove = async (id: string) => { +const remove = async ( + id: string +): Promise<{ success: boolean; message: string }> => { try { await db.tx(async (t) => { const deleteAssociationsQuery = ` @@ -42,8 +45,21 @@ const remove = async (id: string) => { throw error; } }; + +const update = async (id: number, is_enabled: boolean): Promise => { + const sql = ` + UPDATE feature_flag + SET is_enabled = $ + WHERE id = $ + RETURNING id, name, is_enabled +`; + const result = await db.one(sql, { id, is_enabled }); + return result; +}; + export default { insert, getAll, remove, + update, }; diff --git a/server/app/validation-schema/feature-schema.ts b/server/app/validation-schema/feature-schema.ts index 69c2ba24a..91e0a00be 100644 --- a/server/app/validation-schema/feature-schema.ts +++ b/server/app/validation-schema/feature-schema.ts @@ -11,6 +11,9 @@ export const FeaturePostRequestSchema: JSONSchemaType = { name: { type: "string", }, + is_enabled: { + type: "boolean", + }, }, additionalProperties: false, }; diff --git a/server/migrations/1723942677565_add-isEnabled-to-feature-flag-table.js b/server/migrations/1723942677565_add-isEnabled-to-feature-flag-table.js new file mode 100644 index 000000000..a3ec03047 --- /dev/null +++ b/server/migrations/1723942677565_add-isEnabled-to-feature-flag-table.js @@ -0,0 +1,17 @@ +/* eslint-disable camelcase */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + ALTER TABLE public.feature_flag + ADD COLUMN is_enabled boolean NOT NULL DEFAULT false; + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + ALTER TABLE public.feature_flag + DROP COLUMN is_enabled; + `); +}; diff --git a/server/package.json b/server/package.json index ed1861fb4..5de2aa023 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "foodoasis-web-api", - "version": "1.0.83", + "version": "1.0.84", "author": "Hack for LA", "description": "Web API Server for Food Oasis", "main": "server.js", diff --git a/server/types/feature-types.ts b/server/types/feature-types.ts index 2f5e69737..af5bc8e14 100644 --- a/server/types/feature-types.ts +++ b/server/types/feature-types.ts @@ -1,4 +1,5 @@ export interface Feature { id: number; name: string; + is_enabled: boolean; }