Skip to content

Commit

Permalink
feat: add Flag and Feature components (#9)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: rename `useFlagQuery` to `useFlagQueryFn`
  • Loading branch information
rqbazan authored Dec 6, 2021
1 parent dea25e5 commit ae74c7c
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 105 deletions.
81 changes: 73 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,14 @@ It could be the feature slug or an flag queries array or more powerful, an objec
type FlagQuery =
| string
| FlagQuery[]
| {
$or: FlagQuery[]
}
| {
$and: FlagQuery[]
}
| {
[slug: string]: boolean
[operator: symbol]: FlagQuery[]
}
```
Expand Down Expand Up @@ -202,29 +207,29 @@ function App() {
}
```

### `useFlagQuery`
### `useFlagQueryFn`

Hook that is used to get the magic function that can process a _flag query_.

#### Specification

```ts
interface UseFlagQuery {
interface UseFlagQueryFn {
(): (query: FlagQuery) => boolean
}
```

#### Example

```tsx
import { useFlagQuery } from 'toggled'
import { useFlagQueryFn } from 'toggled'

export default function App() {
const flagQuery = useFlagQuery()
const flagQueryFn = useFlagQueryFn()

return (
<Layout designV2={flagQuery({ 'design-v2': true, 'design-v1': false })}>
{flagQuery('chat') && <ChatWidget>}
<Layout designV2={flagQueryFn({ 'design-v2': true, 'design-v1': false })}>
{flagQueryFn('chat') && <ChatWidget>}
</Layout>
)
}
Expand All @@ -236,7 +241,7 @@ export default function App() {

Hook that is used to get a binary output based on the existence of a feature in the context. So, if the feature is in the context then the flag will be `true`, otherwise `false`.

> The `useFlagQuery` hook is used internally.
> The `useFlagQueryFn` hook is used internally.

#### Specification

Expand Down Expand Up @@ -264,6 +269,66 @@ export default function App() {
}
```

### `<Flag />`

Component to apply conditional rendering using a `flagQuery`

#### Specification

```ts
interface FlagProps {
flagQuery: FlagQuery
children: React.ReactNode
}
```

#### Example

```tsx
import { Flag } from 'toggled'

export default function App() {
return (
<Flag flagQuery={{ 'design-v2': true, 'design-v1': false }}>
<Layout designV2={hasDesignV2Only}>
<Flag flagQuery="chat">
<ChatWidget>
</Flag>
</Layout>
</Flag>
)
}
```

### `<Feature />`

Component to consume a feature object declaratively instead of `useFeature`

#### Specification

```ts
export interface FeatureProps {
slug: string
children(feature: DefaultFeature): React.ReactElement
}
```

#### Example

```tsx
import { Feature } from 'toggled'

export default function App() {
return (
<Feature slug="chat">
{feature => {
return <ChatWidget settings={feature.settings}>
}}
</Feature>
)
}
```
## License
MIT © [Ricardo Q. Bazan](https://rcrd.space)
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"devDependencies": {
"@size-limit/preset-small-lib": "^4.11.0",
"@testing-library/react": "^12.1.2",
"@testing-library/react-hooks": "^7.0.2",
"@types/react": "^17.0.34",
"@types/react-dom": "^17.0.11",
Expand Down Expand Up @@ -81,11 +82,11 @@
"size-limit": [
{
"path": "dist/toggled.cjs.production.min.js",
"limit": "500 B"
"limit": "600 B"
},
{
"path": "dist/toggled.esm.js",
"limit": "550 B"
"limit": "650 B"
}
],
"resolutions": {
Expand Down
151 changes: 101 additions & 50 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,64 @@ export interface DefaultFeature {
slug: string
}

export interface FeatureProviderProps<F extends DefaultFeature = DefaultFeature> {
features: F[]
children: React.ReactNode
}
export type TCache = Record<string, DefaultFeature>

export type FeatureProviderProps =
| {
cache: TCache
children: React.ReactNode
}
| {
features: DefaultFeature[]
children: React.ReactNode
}

export interface FeatureContextValue<F extends DefaultFeature = DefaultFeature> {
cache: Map<string, F>
export interface FeatureContextValue {
cache: TCache
}

export const Op = {
OR: Symbol('$or'),
AND: Symbol('$and'),
OR: '$or' as const,
AND: '$and' as const,
}

export type FlagQuery =
| string
| FlagQuery[]
| {
[Op.OR]: FlagQuery[]
}
| {
[Op.AND]: FlagQuery[]
}
| {
[slug: string]: boolean
[operator: symbol]: FlagQuery[]
}

const NO_PROVIDER = Symbol('No provider')
const NO_PROVIDER = '__toggled_no_provder_id__'

// @ts-ignore
export const FeatureContext = React.createContext<FeatureContextValue>(NO_PROVIDER)

export function FeatureProvider<F extends DefaultFeature = DefaultFeature>(props: FeatureProviderProps<F>) {
const { features, children } = props
export function createCache<F extends DefaultFeature = DefaultFeature>(features: F[]) {
return features.reduce((cache, current) => {
cache[current.slug] = current
return cache
}, {} as Record<string, F>)
}

const contextValue = React.useMemo(() => {
const cache = new Map(features.map(feature => [feature.slug, feature]))
export function FeatureProvider(props: FeatureProviderProps) {
// @ts-expect-error
const { features, cache, children } = props

return { cache }
}, [features])
const contextValue = React.useMemo(() => {
return { cache: cache ?? createCache(features) }
}, [cache, features])

return <FeatureContext.Provider value={contextValue}>{children}</FeatureContext.Provider>
}

function useFFContext() {
function useToggledContext() {
const contextValue = React.useContext(FeatureContext)

// @ts-ignore
Expand All @@ -55,56 +73,53 @@ function useFFContext() {
}

export function useFeature(slug: string) {
const { cache } = useFFContext()
const { cache } = useToggledContext()

return cache.get(slug)
return cache[slug]
}

export function useFlagQuery() {
const { cache } = useFFContext()

return function fn(flagQuery: FlagQuery) {
export function createFlagQueryFn(cache: Record<string, DefaultFeature>) {
return function flagQueryFn(flagQuery: FlagQuery) {
if (typeof flagQuery === 'string') {
return cache.has(flagQuery)
return Boolean(cache[flagQuery])
}

if (Array.isArray(flagQuery)) {
for (const query of flagQuery) {
if (!fn(query)) {
if (!flagQueryFn(query)) {
return false
}
}

return true
}

for (let key of Reflect.ownKeys(flagQuery)) {
for (let key in flagQuery) {
let phase: boolean

if (typeof key === 'string') {
phase = cache.has(key) === flagQuery[key]
} else {
if (key === Op.OR) {
phase = false

for (const innerQuery of flagQuery[key]) {
if (fn(innerQuery)) {
phase = true
break
}
if (key === Op.OR) {
phase = false

// @ts-expect-error
for (const innerQuery of flagQuery[key]) {
if (flagQueryFn(innerQuery)) {
phase = true
break
}
} else if (key === Op.AND) {
phase = true

for (const innerQuery of flagQuery[key]) {
if (!fn(innerQuery)) {
phase = false
break
}
}
} else if (key === Op.AND) {
phase = true

// @ts-expect-error
for (const innerQuery of flagQuery[key]) {
if (!flagQueryFn(innerQuery)) {
phase = false
break
}
} else {
throw Error('Invalid Operator')
}
} else {
// @ts-expect-error
phase = Boolean(cache[key]) === flagQuery[key]
}

if (!phase) {
Expand All @@ -116,8 +131,44 @@ export function useFlagQuery() {
}
}

export function useFlag(query: FlagQuery) {
const flagQuery = useFlagQuery()
export function useFlagQueryFn() {
const { cache } = useToggledContext()

return React.useMemo(() => createFlagQueryFn(cache), [cache])
}

export function useFlag(flagQuery: FlagQuery) {
const flagQueryFn = useFlagQueryFn()

return flagQueryFn(flagQuery)
}

export interface FlagProps {
flagQuery: FlagQuery
children: React.ReactNode
}

export function Flag({ flagQuery, children }: FlagProps) {
const enabled = useFlag(flagQuery)

if (!enabled) {
return null
}

return children as React.ReactElement
}

export interface FeatureProps {
slug: string
children(feature: DefaultFeature): React.ReactElement
}

export function Feature({ slug, children }: FeatureProps) {
const feature = useFeature(slug)

if (!feature) {
return null
}

return flagQuery(query)
return children(feature)
}
Loading

0 comments on commit ae74c7c

Please sign in to comment.