Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add useCookie mixin #478

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ We got you covered 👉 [stimulus-use.github.io/stimulus-use](https://stimulus-u
|[`useApplication, ApplicationController`](./docs/application-controller.md)| supercharged controller for your application.|
|[`useDispatch`](./docs/use-dispatch.md)|Adds a dispatch helper function to emit custom events. Useful to communicate between different controllers.|
|[`useMeta`](./docs/use-meta.md)|Adds getters to easily access <head> meta values.|
|[`useCookie`](./docs/use-cookie.md)|Adds getters to read cookies, and a simple API to add, edit and clear cookies on the client.|

## Extend or compose

Expand Down
61 changes: 61 additions & 0 deletions docs/use-cookie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# useCookie

Adds getters and setters to easily read cookies and provides a simple API to set and clear cookies.

Simply list at the top of your controller the list of cookie names you need (static cookieNames = [])

Accessors are then automatically defined by the mixin.
- Getter/Setter name is a camelized conversion of the cookie name with the optional `Cookie` suffix. `user_id` becomes `userIdCookie`

**Options:**

| Option| Description | Default value |
|-----------------------|-------------|---------------------|
| `expires` | Expire date of a cookie in days. Can be overriden on the options object on a per cookie basis |30|
| `suffix` | prepend or not `Cookie` to the accessor's name. Default is true to remain consistent with `Value` and `Class` API |true|


**Example**
```js
useCookie(this, { expires: 30, suffix: true }) // Same as useCookie(this)
```

## Usage

**Composing**

```js
import { Controller } from '@hotwired/stimulus'
import { useCookie } from 'stimulus-use'

export default class extends Controller {
static cookieNames = ['dessert', 'colorScheme']

connect() {
useCookie(this)
// Or pass an object to change the expires value (default 30 days):
// useCookie(this, { expires: 30, suffix: true })

// Getters are initialized for the declared cookieNames.
// For a cookie with name 'colorScheme' and value 'dark'
this.colorSchemeCookie // dark

// You can save new cookies with the [name]Cookie setter
this.dessertCookie = "cake"

// Passing an object allows you to override the default expires value
this.dessertCookie = {
value: "pancakes",
expires: 12 // Days to expire
}

// Individual getters
this.dessertCookie // cake
this.foodCookie // pasta
this.breakfastCookie // pancakes

// To immediately expire a cookie:
this.dessertCookie = null
this.dessertCookie // undefined
}
}
27 changes: 27 additions & 0 deletions playground/controllers/cookie_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Controller } from '@hotwired/stimulus'
import { useCookie } from 'stimulus-use'

export default class extends Controller {
static targets = ['input']
static cookieNames = ['sample']

connect() {
useCookie(this)
this.inputTarget.value = this.sampleCookie ? this.sampleCookie : ''
}

read() {
alert(`${new Date().toUTCString()} Cookie name: 'sample' - Cookie value: ${this.sampleCookie}`)
}

set() {
this.sampleCookie = this.inputTarget.value
this.read()
}

clear() {
this.sampleCookie = null
alert(`Cookie cleared. Cookie name: 'sample' - Cookie value: ${this.sampleCookie}`)
this.inputTarget.value = ''
}
}
30 changes: 30 additions & 0 deletions playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,35 @@ <h2 class="text-2xl text-center text-green-500 leading-6 font-medium my-8">
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg" data-controller="cookie">
<div class="px-4 py-5 sm:p-6">
<div class="font-semibold text-green-500">useCookie</div>
<div class="text-sm text-gray-400 truncate">
Read, add, edit and clear cookies
</div>
<div class="mt-3">
<input type="text" data-cookie-target="input" placeholder="Sample cookie value" class="border bg-white rounded px-2.5 py-1 text-sm text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 w-1/2">
<button
data-action="click->cookie#set"
type="button"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save
</button>
<button
data-action="click->cookie#read"
type="button"
class="iw-1/2 inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Read
</button>
<button
data-action="click->cookie#clear"
type="button"
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Clear
</button>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg col-span-3">
<div class="px-4 py-5 sm:p-6" data-controller="memo">
<div class="font-semibold text-green-500">useMemo</div>
Expand All @@ -304,6 +333,7 @@ <h2 class="text-2xl text-center text-green-500 leading-6 font-medium my-8">




<div class="font-semibold text-green-500 mt-8">useIntersection</div>
<div class="text-sm text-gray-400 truncate mb-5">
Plays an animation when the element appears
Expand Down
1 change: 1 addition & 0 deletions spec/use-cookie/fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const fixtureBase = `<div data-controller="cookie"></div>`
40 changes: 40 additions & 0 deletions spec/use-cookie/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
function parseCookie(cookie) {
return cookie.trim().split('=')
}

export function getCookies() {
return document.cookie.split(';').map(cookie => {
return parseCookie(cookie)
})
}

export function getCookieValue(cookieName) {
const cookies = document.cookie
.split(';')
.map(cookie => cookie.trim())
.filter(cookie => cookie !== '')

for (const cookie of cookies) {
const [parsedName, parsedValue] = parseCookie(cookie)
if (parsedName === cookieName) {
return decodeURIComponent(parsedValue)
}
}

return undefined
}

export function setBrowserCookies(cookies) {
// Discard previous cookies
document.cookie.split(';').forEach(cookie => {
const [cookieName, _cookieValue] = parseCookie(cookie)
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
})

cookies
.split(';')
.map(cookie => cookie.trim())
.forEach(cookiePair => {
document.cookie = cookiePair
})
}
102 changes: 102 additions & 0 deletions spec/use-cookie/use-cookie_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Controller, Application } from '@hotwired/stimulus'
import { useCookie } from '../../src'
import { nextFrame } from '../helpers'
import { fixtureBase } from './fixtures'
import { getCookieValue, setBrowserCookies } from './helpers'

const defaultOptions = {
expires: 30,
suffix: true
}

const declaredCookieNames = ['lunch', 'breakfast']
class UseLogController extends Controller {
static cookieNames = declaredCookieNames
connect() {
useCookie(this, this.application.options)
}
}

const scenarios = [
{
name: 'default scenario',
fixture: fixtureBase,
options: undefined,
initialCookies: ''
},
{
name: 'scenario with options',
fixture: fixtureBase,
options: { expires: 100 },
initialCookies: ''
},
{
name: 'scenario with no suffix',
fixture: fixtureBase,
options: { suffix: false },
initialCookies: ''
},
{
name: 'scenario without options',
fixture: fixtureBase,
options: { suffix: true, expires: 100 },
initialCookies: `${declaredCookieNames[0]}=pasta; ${declaredCookieNames[1]}=pancakes`
},
{
name: 'scenario with previously set cookies',
fixture: fixtureBase,
options: undefined,
initialCookies: `${declaredCookieNames[0]}=pasta; ${declaredCookieNames[1]}=pancakes`
}
]

scenarios.forEach(scenario => {
describe(`useCookie tests scenario : ${scenario.name}`, function () {
let application
let controller
let suffixValue

beforeEach('initialize controller', async function () {
application = Application.start()
application.options = Object.assign({}, defaultOptions, scenario.options)

setBrowserCookies(scenario.initialCookies)

fixture.set(fixtureBase)
application.register('cookie', UseLogController)
await nextFrame()
controller = application.controllers[0]
suffixValue = application.options.suffix ? 'Cookie' : ''
})

describe(`test cookie getters in: ${scenario.name}}`, function () {
it('returns value from browsers cookies', function () {
UseLogController.cookieNames.forEach(cookieName => {
const cookieValue = controller[`${cookieName}${suffixValue}`]
expect(getCookieValue(cookieName)).to.equal(cookieValue)
})
})
})

describe(`test cookie setters in: ${scenario.name}}`, function () {
it('stores the cookie value on the browser with default assignation', function () {
UseLogController.cookieNames.forEach(cookieName => {
controller[`${cookieName}${suffixValue}`] = 'updatedValue'
expect(getCookieValue(cookieName)).to.equal('updatedValue')
})
})
it('stores the cookie value on the browser with object assignation', function () {
UseLogController.cookieNames.forEach(cookieName => {
controller[`${cookieName}${suffixValue}`] = { value: 'updatedValue' }
expect(getCookieValue(cookieName)).to.equal('updatedValue')
})
})
it('removes the cookie from the browser', function () {
UseLogController.cookieNames.forEach(cookieName => {
controller[`${cookieName}${suffixValue}`] = null
expect(getCookieValue(cookieName)).to.equal(undefined)
})
})
})
})
})
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './use-application'
export * from './use-click-outside'
export * from './use-cookie'
export * from './use-debounce'
export * from './use-dispatch'
export * from './use-hover'
Expand Down
1 change: 1 addition & 0 deletions src/use-cookie/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useCookie } from './use-cookie'
69 changes: 69 additions & 0 deletions src/use-cookie/use-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Controller } from '@hotwired/stimulus'
import { camelize } from '../support/index'

export interface CookieOptions {
expires?: number
suffix?: boolean
}

export interface Cookie extends CookieOptions {
name: string
value: string
}

const defaultOptions: CookieOptions = {
expires: 30,
suffix: true
}

export const useCookie = (controller: Controller, options: CookieOptions = {}) => {
const { expires, suffix } = Object.assign({}, defaultOptions, options)
const getAccessorName = (cookieName: string) => {
return suffix ? `${camelize(cookieName)}Cookie` : camelize(cookieName)
}

const defineCookieAccessors = (cookieName: string) => {
const accesorName = getAccessorName(cookieName)

Object.defineProperty(controller, accesorName, {
get(): any {
const cookies = document.cookie.split(';').map(cookie => cookie.trim())
const matchingCookie = cookies.find(c => c.startsWith(cookieName + '='))
return matchingCookie ? matchingCookie.split('=')[1] : undefined
},
set(newValue: any): any {
if (newValue === null) {
return clearCookie(cookieName)
}

const cookieValue = typeof newValue === 'string' ? newValue : newValue.value
const cookie = parseCookieString(cookieName + '=' + cookieValue) as Cookie
saveCookie(cookie, expires!)
}
})
}

const constructor = controller.constructor as any
constructor.cookieNames?.forEach((cookieName: string) => {
defineCookieAccessors(cookieName)
})
}

function parseCookieString(nameValue: string): Cookie {
let [cookieName, cookieValue] = nameValue.split('=')
return {
name: cookieName,
value: cookieValue
} as Cookie
}

function saveCookie(cookie: Cookie, daysToExpire: number) {
const { name, value, expires = daysToExpire } = cookie

const date = new Date(new Date().getTime() + expires * 24 * 60 * 60 * 1000).toUTCString()
document.cookie = `${name}=${value};expires=${date};`
}

function clearCookie(cookieName: string) {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC;`
}